Skip to main content

openlogi_hid/write/
dpi.rs

1use std::sync::Arc;
2
3use hidpp::{
4    device::Device,
5    feature::{CreatableFeature, adjustable_dpi::AdjustableDpiFeature},
6    protocol::v20::{ErrorType, Hidpp20Error},
7};
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11use crate::route::DeviceRoute;
12
13use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
14
15/// Supported DPI values reported by a device's HID++ AdjustableDpi feature.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct DpiCapabilities {
18    values: Vec<u16>,
19}
20
21impl DpiCapabilities {
22    /// Build capabilities from a device-reported DPI list. Values are sorted
23    /// and deduplicated so callers can rely on stable ordering.
24    pub fn new(mut values: Vec<u16>) -> Result<Self, WriteError> {
25        values.sort_unstable();
26        values.dedup();
27        if values.is_empty() {
28            return Err(WriteError::EmptyDpiList);
29        }
30        Ok(Self { values })
31    }
32
33    /// All supported DPI values, sorted ascending.
34    #[must_use]
35    pub fn values(&self) -> &[u16] {
36        &self.values
37    }
38
39    /// Minimum supported DPI.
40    #[must_use]
41    pub fn min(&self) -> u16 {
42        self.values[0]
43    }
44
45    /// Maximum supported DPI.
46    #[must_use]
47    pub fn max(&self) -> u16 {
48        self.values[self.values.len() - 1]
49    }
50
51    /// Whether `dpi` is exactly supported by the device.
52    #[must_use]
53    pub fn contains(&self, dpi: u16) -> bool {
54        self.values.binary_search(&dpi).is_ok()
55    }
56
57    /// The supported DPI nearest to `dpi`.
58    #[must_use]
59    pub fn nearest(&self, dpi: u32) -> u16 {
60        let mut nearest = self.values[0];
61        let mut best_delta = u32::from(nearest).abs_diff(dpi);
62        for &candidate in &self.values[1..] {
63            let delta = u32::from(candidate).abs_diff(dpi);
64            if delta < best_delta {
65                nearest = candidate;
66                best_delta = delta;
67            }
68        }
69        nearest
70    }
71
72    /// Snap `dpi` to the nearest supported value, widened to `u32` for UI math.
73    /// The single home for "round a DPI onto this device's grid" — callers that
74    /// hold an `Option<DpiCapabilities>` should `map_or(dpi, |c| c.snap(dpi))`.
75    #[must_use]
76    pub fn snap(&self, dpi: u32) -> u32 {
77        u32::from(self.nearest(dpi))
78    }
79
80    /// Best-effort step size for UI widgets that need a single increment.
81    /// Returns the smallest positive gap between adjacent reported values.
82    #[must_use]
83    pub fn step_hint(&self) -> u16 {
84        self.values
85            .array_windows::<2>()
86            .filter_map(|&[low, high]| high.checked_sub(low))
87            .filter(|step| *step > 0)
88            .min()
89            .unwrap_or(1)
90    }
91
92    /// A supported value different from `current`, for diagnostic write tests.
93    #[must_use]
94    pub fn adjacent_test_target(&self, current: u16) -> Option<u16> {
95        if self.values.len() < 2 {
96            return None;
97        }
98        match self.values.binary_search(&current) {
99            Ok(index) if index + 1 < self.values.len() => Some(self.values[index + 1]),
100            Ok(index) if index > 0 => Some(self.values[index - 1]),
101            Ok(_) => None,
102            Err(index) if index < self.values.len() => Some(self.values[index]),
103            Err(_) => self.values.last().copied(),
104        }
105        .filter(|target| *target != current)
106    }
107}
108
109/// Current DPI plus the supported values reported by the device.
110///
111/// Crosses the agent↔GUI IPC (`read_dpi`, [`DpiCapabilities`] included), so
112/// field order is wire format — changes require a `PROTOCOL_VERSION` bump
113/// (guarded by `openlogi-agent-core/tests/wire_format.rs`).
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct DpiInfo {
116    /// DPI currently configured on sensor 0.
117    pub current: u16,
118    /// Supported values reported by the device for sensor 0.
119    pub capabilities: DpiCapabilities,
120}
121
122/// Read the device's current DPI on sensor 0 — companion to [`set_dpi`].
123/// Used by `openlogi diag dpi` and any future Settings → Diagnostics
124/// surface that wants to display the current value without writing.
125pub async fn get_dpi(route: &DeviceRoute) -> Result<u16, WriteError> {
126    let index = route.device_index();
127    with_route(route, move |channel| async move {
128        let mut device = Device::new(Arc::clone(&channel), index)
129            .await
130            .map_err(|_| WriteError::DeviceUnreachable { index })?;
131        let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
132        feature
133            .get_sensor_dpi(0)
134            .await
135            .map_err(|e| classify_hidpp_error(e, HidppOperation::ReadDpi, AdjustableDpiFeature::ID))
136    })
137    .await
138}
139
140/// Classify a HID++ error from the AdjustableDpi functions. A device that
141/// announces `0x2201` but rejects a function (`Unsupported` /
142/// `InvalidFunctionId`) or returns a structurally invalid DPI list
143/// (`UnsupportedResponse`) will keep doing so, so these map to the permanent
144/// [`WriteError::FeatureUnsupported`]; channel/timeout and other errors are
145/// forwarded through [`classify_hidpp_error`] as transient so callers may retry.
146fn classify_dpi_error(error: Hidpp20Error) -> WriteError {
147    match error {
148        Hidpp20Error::Feature(ErrorType::Unsupported | ErrorType::InvalidFunctionId)
149        | Hidpp20Error::UnsupportedResponse => WriteError::FeatureUnsupported {
150            feature_hex: AdjustableDpiFeature::ID,
151        },
152        other => classify_hidpp_error(
153            other,
154            HidppOperation::ReadDpiCapabilities,
155            AdjustableDpiFeature::ID,
156        ),
157    }
158}
159
160/// Read the current DPI and the supported DPI values for sensor 0 in one
161/// route/channel session.
162pub async fn get_dpi_info(route: &DeviceRoute) -> Result<DpiInfo, WriteError> {
163    let index = route.device_index();
164    with_route(route, move |channel| async move {
165        let mut device = Device::new(Arc::clone(&channel), index)
166            .await
167            .map_err(|_| WriteError::DeviceUnreachable { index })?;
168        let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
169        let sensor_count = feature
170            .get_sensor_count()
171            .await
172            .map_err(classify_dpi_error)?;
173        if sensor_count == 0 {
174            // The device claims AdjustableDpi but exposes no sensor — it cannot
175            // report DPI, and that won't change on retry.
176            return Err(WriteError::FeatureUnsupported {
177                feature_hex: AdjustableDpiFeature::ID,
178            });
179        }
180        let current = feature
181            .get_sensor_dpi(0)
182            .await
183            .map_err(classify_dpi_error)?;
184        let values = feature
185            .get_sensor_dpi_list(0)
186            .await
187            .map_err(classify_dpi_error)?;
188        Ok(DpiInfo {
189            current,
190            capabilities: DpiCapabilities::new(values)?,
191        })
192    })
193    .await
194}
195
196/// Set sensor 0's DPI for the device addressed by `route`.
197pub async fn set_dpi(route: &DeviceRoute, dpi: u16) -> Result<(), WriteError> {
198    let index = route.device_index();
199    with_route(route, move |channel| async move {
200        set_dpi_on_channel(&channel, index, dpi).await
201    })
202    .await
203}
204
205/// The DPI write itself, on an already-open channel at HID++ `index`. Shared by
206/// [`set_dpi`] (which opens a fresh channel) and [`set_dpi_on`](super::set_dpi_on)
207/// (which reuses one).
208pub(super) async fn set_dpi_on_channel(
209    channel: &Arc<hidpp::channel::HidppChannel>,
210    index: u8,
211    dpi: u16,
212) -> Result<(), WriteError> {
213    let mut device = Device::new(Arc::clone(channel), index)
214        .await
215        .map_err(|_| WriteError::DeviceUnreachable { index })?;
216    let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
217    feature
218        .set_sensor_dpi(0, dpi)
219        .await
220        .map_err(|e| classify_hidpp_error(e, HidppOperation::WriteDpi, AdjustableDpiFeature::ID))?;
221    // Read back to confirm the firmware accepted the value. A mismatch is a
222    // silent failure mode that's otherwise invisible — devices in low-power
223    // states or with unsupported DPI ranges can ACK the write yet keep the old
224    // value. We log a warning but still return Ok because the request reached
225    // the device.
226    if let Ok(actual) = feature.get_sensor_dpi(0).await {
227        if actual == dpi {
228            debug!(index, dpi, "wrote DPI (verified)");
229        } else {
230            tracing::warn!(
231                index,
232                requested = dpi,
233                actual,
234                "DPI write accepted but device reports a different value — \
235                 likely out of the device's supported range"
236            );
237        }
238    } else {
239        debug!(index, dpi, "wrote DPI (read-back skipped)");
240    }
241    Ok(())
242}