Skip to main content

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