Skip to main content

openlogi_hid/write/
dpi.rs

1use std::sync::Arc;
2
3use hidpp::{
4    device::Device,
5    feature::{
6        CreatableFeature,
7        adjustable_dpi::AdjustableDpiFeature,
8        extended_dpi::{DpiDirection, DpiRange, ExtendedDpiFeature, SetDpiParameters},
9    },
10    protocol::v20::{ErrorType, Hidpp20Error},
11};
12use tracing::debug;
13
14use crate::SharedChannel;
15use crate::channel::route::DeviceRoute;
16
17use super::{HidppOperation, WriteError, classify_hidpp_error, with_route};
18
19// DpiCapabilities and DpiInfo are pure IPC wire data with no HID++ I/O, so
20// they live in `openlogi_core::hid::dpi`; re-exported here unchanged so this
21// module's own API surface doesn't churn.
22pub use openlogi_core::hid::dpi::{Dpi, DpiCapabilities, DpiInfo};
23
24/// Sensor 0 is the only sensor OpenLogi drives: the UI exposes one DPI value
25/// per device, and every Logitech pointing device reports its pointer sensor
26/// first.
27const SENSOR: u8 = 0;
28
29/// Whichever DPI feature a device actually exposes.
30///
31/// `0x2201 AdjustableDpi` is the original; `0x2202 ExtendedAdjustableDpi` is
32/// its successor, and some mice expose only the latter (`openlogi diag
33/// features` shows which). `Capabilities::from_feature_ids` turns the DPI panel
34/// on for *either* ID, so both have to be drivable from here — otherwise a
35/// `0x2202`-only mouse gets a panel that cannot read or write anything.
36enum DpiFeature {
37    /// `0x2201` — one DPI per sensor, described as a flat list of values.
38    Adjustable(Arc<AdjustableDpiFeature>),
39
40    /// `0x2202` — independent X/Y DPI plus lift-off distance, described as a
41    /// mix of fixed values and stepped ranges.
42    Extended(Arc<ExtendedDpiFeature>),
43}
44
45impl DpiFeature {
46    /// Opens whichever DPI feature `device` exposes, preferring `0x2201`.
47    ///
48    /// The preference is deliberate and not protocol-driven: `0x2201` is the
49    /// path every device that works today already takes, so trying it first
50    /// keeps `0x2202` support purely additive. A device exposing both behaves
51    /// exactly as it did before.
52    async fn open(device: &mut Device) -> Result<Self, WriteError> {
53        if let Some(index) = feature_index(device, AdjustableDpiFeature::ID).await? {
54            return Ok(Self::Adjustable(device.add_feature(index)));
55        }
56        if let Some(index) = feature_index(device, ExtendedDpiFeature::ID).await? {
57            return Ok(Self::Extended(device.add_feature(index)));
58        }
59        // Neither ID is present. Name the canonical one in the error: a caller
60        // reading "0x2201 unsupported" is being told this device has no DPI
61        // feature at all, which is what happened.
62        Err(WriteError::FeatureUnsupported {
63            feature_hex: AdjustableDpiFeature::ID,
64        })
65    }
66
67    /// The HID++ feature ID being driven, for error reporting.
68    const fn id(&self) -> u16 {
69        match self {
70            Self::Adjustable(_) => AdjustableDpiFeature::ID,
71            Self::Extended(_) => ExtendedDpiFeature::ID,
72        }
73    }
74
75    /// The number of motion sensors the device reports.
76    async fn sensor_count(&self) -> Result<u8, Hidpp20Error> {
77        match self {
78            Self::Adjustable(feature) => feature.get_sensor_count().await,
79            Self::Extended(feature) => feature.get_sensor_count().await,
80        }
81    }
82
83    /// The DPI currently configured on [`SENSOR`].
84    async fn current_dpi(&self) -> Result<Dpi, Hidpp20Error> {
85        match self {
86            Self::Adjustable(feature) => feature.get_sensor_dpi(SENSOR).await.map(Dpi::from),
87            Self::Extended(feature) => Ok(feature
88                .get_sensor_dpi_parameters(SENSOR)
89                .await?
90                .dpi_x
91                .into()),
92        }
93    }
94
95    /// Every DPI value [`SENSOR`] accepts, as a flat list.
96    async fn supported_dpi(&self) -> Result<Vec<u16>, Hidpp20Error> {
97        match self {
98            Self::Adjustable(feature) => feature.get_sensor_dpi_list(SENSOR).await,
99            Self::Extended(feature) => {
100                // `getSensorDpiList` (function 3) only answers on sensors that
101                // support profiles; the range description is the one every
102                // 0x2202 sensor reports. X is the axis the UI drives.
103                let ranges = feature
104                    .get_sensor_dpi_ranges(SENSOR, DpiDirection::X)
105                    .await?;
106                Ok(expand_dpi_ranges(&ranges))
107            }
108        }
109    }
110
111    /// Sets [`SENSOR`]'s DPI.
112    async fn set_dpi(&self, dpi: Dpi) -> Result<(), Hidpp20Error> {
113        let dpi = dpi.into();
114        match self {
115            Self::Adjustable(feature) => feature.set_sensor_dpi(SENSOR, dpi).await,
116            Self::Extended(feature) => {
117                // `setSensorDpiParameters` writes DPI X, DPI Y and lift-off
118                // distance in one packet with no "leave unchanged" encoding, so
119                // read the current parameters first and put back what we are
120                // not asked to change. Writing a bare `lod` would silently
121                // retune the sensor's lift-off height.
122                let current = feature.get_sensor_dpi_parameters(SENSOR).await?;
123                feature
124                    .set_sensor_dpi_parameters(
125                        SENSOR,
126                        SetDpiParameters {
127                            dpi_x: dpi,
128                            // The spec has the host send 0 for dpiY when the
129                            // sensor has no independent Y axis, and reports 0
130                            // on read in exactly that case. When it does have
131                            // one, keep the axes locked together — the UI
132                            // exposes a single DPI.
133                            dpi_y: if current.dpi_y == 0 { 0 } else { dpi },
134                            lod: current.lod,
135                        },
136                    )
137                    .await
138            }
139        }
140    }
141}
142
143/// Resolves `feature_hex` to its runtime index, or `None` when the device does
144/// not expose it.
145///
146/// Unlike [`open_feature`](super::open_feature) an absent feature is not an
147/// error here — [`DpiFeature::open`] uses absence to fall through to the next
148/// candidate, and only a transport failure should abort the probe.
149async fn feature_index(device: &mut Device, feature_hex: u16) -> Result<Option<u8>, WriteError> {
150    Ok(device
151        .root()
152        .get_feature(feature_hex)
153        .await
154        .map_err(|e| classify_hidpp_error(e, HidppOperation::ResolveFeature, feature_hex))?
155        .map(|info| info.index))
156}
157
158/// Flattens `0x2202`'s fixed-value / stepped-range description into the flat
159/// list [`DpiCapabilities`] is built from.
160///
161/// A stepped range's endpoints are inclusive and the high endpoint is always
162/// selectable even when it is not an exact multiple of `step` from the low one.
163/// Adjacent ranges may share an endpoint; `DpiCapabilities::new` deduplicates.
164pub(super) fn expand_dpi_ranges(ranges: &[DpiRange]) -> Vec<u16> {
165    let mut values = Vec::new();
166    for range in ranges {
167        match *range {
168            DpiRange::Fixed(value) => values.push(value),
169            DpiRange::Stepped { from, to, step } => {
170                // `step` is never 0 and `to >= from` — the decoder rejects both
171                // as a malformed response — so this terminates.
172                let mut value = u32::from(from);
173                while value < u32::from(to) {
174                    if let Ok(value) = u16::try_from(value) {
175                        values.push(value);
176                    }
177                    value += u32::from(step);
178                }
179                values.push(to);
180            }
181        }
182    }
183    values
184}
185
186/// Read the device's current DPI on sensor 0 — companion to [`set_dpi`].
187/// Used by `openlogi diag dpi` and any future Settings → Diagnostics
188/// surface that wants to display the current value without writing.
189pub async fn get_dpi(route: &DeviceRoute) -> Result<Dpi, WriteError> {
190    let index = route.device_index();
191    with_route(route, move |channel| async move {
192        get_dpi_on_channel(&channel, index).await
193    })
194    .await
195}
196
197async fn get_dpi_on_channel(
198    channel: &Arc<hidpp::channel::HidppChannel>,
199    index: u8,
200) -> Result<Dpi, WriteError> {
201    let mut device = Device::new(Arc::clone(channel), index)
202        .await
203        .map_err(|_| WriteError::DeviceUnreachable { index })?;
204    let feature = DpiFeature::open(&mut device).await?;
205    feature
206        .current_dpi()
207        .await
208        .map_err(|e| classify_hidpp_error(e, HidppOperation::ReadDpi, feature.id()))
209}
210
211/// Classify a HID++ error from the DPI functions of `feature_hex`. A device
212/// that announces the feature but rejects a function (`Unsupported` /
213/// `InvalidFunctionId`) or returns a structurally invalid DPI description
214/// (`UnsupportedResponse`) will keep doing so, so these map to the permanent
215/// [`WriteError::FeatureUnsupported`]; channel/timeout and other errors are
216/// forwarded through [`classify_hidpp_error`] as transient so callers may retry.
217fn classify_dpi_error(feature_hex: u16, error: Hidpp20Error) -> WriteError {
218    match error {
219        Hidpp20Error::Feature(ErrorType::Unsupported | ErrorType::InvalidFunctionId)
220        | Hidpp20Error::UnsupportedResponse => WriteError::FeatureUnsupported { feature_hex },
221        other => classify_hidpp_error(other, HidppOperation::ReadDpiCapabilities, feature_hex),
222    }
223}
224
225/// Read the current DPI and the supported DPI values for sensor 0 in one
226/// route/channel session.
227pub async fn get_dpi_info(route: &DeviceRoute) -> Result<DpiInfo, WriteError> {
228    let index = route.device_index();
229    with_route(route, move |channel| async move {
230        get_dpi_info_on_channel(&channel, index).await
231    })
232    .await
233}
234
235pub(super) async fn get_dpi_info_on_channel(
236    channel: &Arc<hidpp::channel::HidppChannel>,
237    index: u8,
238) -> Result<DpiInfo, WriteError> {
239    let mut device = Device::new(Arc::clone(channel), index)
240        .await
241        .map_err(|_| WriteError::DeviceUnreachable { index })?;
242    let feature = DpiFeature::open(&mut device).await?;
243    let feature_hex = feature.id();
244    let sensor_count = feature
245        .sensor_count()
246        .await
247        .map_err(|e| classify_dpi_error(feature_hex, e))?;
248    if sensor_count == 0 {
249        // The device claims a DPI feature but exposes no sensor — it cannot
250        // report DPI, and that won't change on retry.
251        return Err(WriteError::FeatureUnsupported { feature_hex });
252    }
253    let current = feature
254        .current_dpi()
255        .await
256        .map_err(|e| classify_dpi_error(feature_hex, e))?;
257    let values = feature
258        .supported_dpi()
259        .await
260        .map_err(|e| classify_dpi_error(feature_hex, e))?;
261    Ok(DpiInfo {
262        current,
263        capabilities: DpiCapabilities::new(values)?,
264    })
265}
266
267/// Set sensor 0's DPI for the device addressed by `route`.
268pub async fn set_dpi(route: &DeviceRoute, dpi: Dpi) -> Result<(), WriteError> {
269    let index = route.device_index();
270    with_route(route, move |channel| async move {
271        set_dpi_on_channel(&channel, index, dpi).await
272    })
273    .await
274}
275
276/// The DPI write itself, on an already-open channel at HID++ `index`. Shared by
277/// [`set_dpi`] (which opens a fresh channel) and [`set_dpi_on`]
278/// (which reuses one).
279pub(super) async fn set_dpi_on_channel(
280    channel: &Arc<hidpp::channel::HidppChannel>,
281    index: u8,
282    dpi: Dpi,
283) -> Result<(), WriteError> {
284    let mut device = Device::new(Arc::clone(channel), index)
285        .await
286        .map_err(|_| WriteError::DeviceUnreachable { index })?;
287    let feature = DpiFeature::open(&mut device).await?;
288    feature
289        .set_dpi(dpi)
290        .await
291        .map_err(|e| classify_hidpp_error(e, HidppOperation::WriteDpi, feature.id()))?;
292    // Read back to confirm the firmware accepted the value. A mismatch is a
293    // silent failure mode that's otherwise invisible — devices in low-power
294    // states or with unsupported DPI ranges can ACK the write yet keep the old
295    // value. We log a warning but still return Ok because the request reached
296    // the device.
297    if let Ok(actual) = feature.current_dpi().await {
298        if actual == dpi {
299            debug!(index, %dpi, "wrote DPI (verified)");
300        } else {
301            tracing::warn!(
302                index,
303                requested = %dpi,
304                %actual,
305                "DPI write accepted but device reports a different value — \
306                 likely out of the device's supported range"
307            );
308        }
309    } else {
310        debug!(index, %dpi, "wrote DPI (read-back skipped)");
311    }
312    Ok(())
313}
314
315/// Write DPI on an already-open [`SharedChannel`] — the fast path that skips
316/// enumeration and channel setup.
317pub async fn set_dpi_on(shared: &SharedChannel, dpi: Dpi) -> Result<(), WriteError> {
318    set_dpi_on_channel(shared.channel(), shared.device_index(), dpi).await
319}
320
321/// Read current DPI and supported values on an already-open [`SharedChannel`].
322pub async fn get_dpi_info_on(shared: &SharedChannel) -> Result<DpiInfo, WriteError> {
323    get_dpi_info_on_channel(shared.channel(), shared.device_index()).await
324}