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