Skip to main content

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, BCD-decoded by the protocol layer.
229    pub number: u8,
230    /// Firmware revision, BCD-decoded by the protocol layer.
231    pub revision: u8,
232    /// Firmware build, BCD-decoded by the protocol layer.
233    pub build: u16,
234    /// Whether this is the entity currently running.
235    pub active: bool,
236    /// USB or wireless product ID the entity runs under. A bootloader entity
237    /// reports the PID the device enumerates as while in DFU mode; only the
238    /// active entity is required to report a real value, so an inactive one
239    /// may be zero.
240    pub transport_pid: u16,
241    /// Optional extra versioning bytes. Device-specific and usually all zero,
242    /// carried verbatim because a device that does populate them is exactly
243    /// the device a report is being collected for.
244    pub extra_version: [u8; 5],
245}
246
247impl From<DeviceEntityFirmwareInfo> for FirmwareEntityInfo {
248    fn from(info: DeviceEntityFirmwareInfo) -> Self {
249        Self {
250            kind: info.entity_type,
251            prefix: info.firmware_prefix,
252            number: info.firmware_number,
253            revision: info.revision,
254            build: info.build,
255            active: info.active,
256            transport_pid: info.transport_pid,
257            extra_version: info.extra_version,
258        }
259    }
260}
261
262/// One firmware entity a device reports through HID++ `0x0003` function 1.
263/// Returned by [`dump_firmware_entities`] so a device report can name the
264/// exact firmware it is running.
265///
266/// There are two states and only two: the device answered with a record that
267/// decoded, or it did not. An enum makes "a version with no kind" and "an
268/// error alongside a version" unrepresentable rather than merely unreachable.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum FirmwareEntity {
271    /// The entity's record was read and decoded.
272    Readable {
273        /// Index of the entity in the device's own table.
274        index: u8,
275        /// The decoded firmware record.
276        info: FirmwareEntityInfo,
277    },
278    /// The device declared the entity, but its record could not be read.
279    ///
280    /// Reported rather than dropped: omitting the row would claim the device
281    /// has fewer firmware images than it says it has, and a device that cannot
282    /// describe one of its own images is what a bug report needs to say.
283    Unreadable {
284        /// Index of the entity in the device's own table.
285        index: u8,
286        /// Why the record could not be read.
287        error: WriteError,
288    },
289}
290
291/// Read every firmware entity the device on `route` reports.
292///
293/// A device lists its main application firmware alongside its bootloader and,
294/// on many models, a separate radio stack. `openlogi diag features` prints
295/// them so a bug report names the firmware that produced the behaviour rather
296/// than just the model.
297///
298/// A single entity the *device* declined or answered unparseably does not fail
299/// the call — see [`FirmwareEntity::Unreadable`]. A channel failure does: the
300/// route is gone, not the entity.
301pub async fn dump_firmware_entities(
302    backend: &dyn HidBackend,
303    route: &DeviceRoute,
304) -> Result<Vec<FirmwareEntity>, WriteError> {
305    let index = route.device_index();
306    with_route(backend, route, move |channel| async move {
307        dump_firmware_entities_on_channel(&channel, index).await
308    })
309    .await
310}
311
312/// [`dump_firmware_entities`] against an already-open channel, the shape the
313/// tests drive a scripted device through.
314pub(crate) async fn dump_firmware_entities_on_channel(
315    channel: &Arc<HidppChannel>,
316    index: u8,
317) -> Result<Vec<FirmwareEntity>, WriteError> {
318    let mut device = Device::new(Arc::clone(channel), index)
319        .await
320        .map_err(|_| WriteError::DeviceUnreachable { index })?;
321    let feature = open_feature::<DeviceInformationFeature>(&mut device).await?;
322    let info = feature.get_device_info().await.map_err(|e| {
323        classify_hidpp_error(
324            e,
325            HidppOperation::DumpFeatures,
326            DeviceInformationFeature::ID,
327        )
328    })?;
329
330    let mut entries = Vec::with_capacity(usize::from(info.entity_count));
331    for entity in 0..info.entity_count {
332        match feature.get_fw_info(entity).await {
333            Ok(fw) => entries.push(FirmwareEntity::Readable {
334                index: entity,
335                info: fw.into(),
336            }),
337            // The device answered about *this* entity and the answer was no:
338            // it refused the read, or it sent a record this parser cannot
339            // decode (a G502's radio stack reports a build field that is not
340            // valid BCD). The rest of the table is still worth reading.
341            Err(e @ (Hidpp20Error::Feature(_) | Hidpp20Error::UnsupportedResponse)) => {
342                entries.push(FirmwareEntity::Unreadable {
343                    index: entity,
344                    error: classify_hidpp_error(
345                        e,
346                        HidppOperation::DumpFeatures,
347                        DeviceInformationFeature::ID,
348                    ),
349                });
350            }
351            // A channel failure says nothing about the entity — the route
352            // disappeared. Carrying on would spend a timeout per remaining
353            // entity and then print malformed-firmware rows for a disconnect.
354            Err(e) => {
355                return Err(classify_hidpp_error(
356                    e,
357                    HidppOperation::DumpFeatures,
358                    DeviceInformationFeature::ID,
359                ));
360            }
361        }
362    }
363    Ok(entries)
364}