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