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, 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}