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}