Skip to main content

hidpp/feature/
adjustable_dpi.rs

1//! Implements the `AdjustableDpi` feature (ID `0x2201`) that allows reading
2//! and changing a mouse sensor's DPI.
3
4use openlogi_hidpp_derive::Feature;
5
6use crate::{feature::FeatureEndpoint, protocol::v20::Hidpp20Error};
7
8/// Implements the `AdjustableDpi` / `0x2201` feature.
9#[derive(Clone, Feature)]
10#[creatable(id = 0x2201, version = 0)]
11pub struct AdjustableDpiFeature {
12    /// The endpoint this feature talks to.
13    endpoint: FeatureEndpoint,
14}
15
16impl AdjustableDpiFeature {
17    /// Retrieves the number of sensors the device exposes.
18    pub async fn get_sensor_count(&self) -> Result<u8, Hidpp20Error> {
19        Ok(self.endpoint.call(0, [0; 3]).await?.extend_payload()[0])
20    }
21
22    /// Retrieves the supported DPI values for `sensor_index`.
23    ///
24    /// `getSensorDpiList` takes the sensor index in the first parameter byte and
25    /// returns the whole list in a single long response: the echoed sensor index
26    /// followed by up to seven big-endian values, terminated by `0x0000` (the
27    /// terminator is absent when the values fill the response). Each value is
28    /// either an explicit DPI or a compact range marker (`0xe000 | step`) whose
29    /// start is the previous value and whose end is the next value. The returned
30    /// list is sorted and deduplicated.
31    pub async fn get_sensor_dpi_list(&self, sensor_index: u8) -> Result<Vec<u16>, Hidpp20Error> {
32        // Skip the echoed sensor index in byte 0; the DPI values follow.
33        let payload = self
34            .endpoint
35            .call(1, [sensor_index, 0x00, 0x00])
36            .await?
37            .extend_payload();
38        parse_dpi_list_payload(&payload[1..])
39    }
40
41    /// Retrieves the currently configured DPI for `sensor_index`.
42    pub async fn get_sensor_dpi(&self, sensor_index: u8) -> Result<u16, Hidpp20Error> {
43        let payload = self
44            .endpoint
45            .call(2, [sensor_index, 0x00, 0x00])
46            .await?
47            .extend_payload();
48
49        Ok(u16::from_be_bytes([payload[1], payload[2]]))
50    }
51
52    /// Sets the DPI for `sensor_index`.
53    pub async fn set_sensor_dpi(&self, sensor_index: u8, dpi: u16) -> Result<(), Hidpp20Error> {
54        let [dpi_hi, dpi_lo] = dpi.to_be_bytes();
55        self.endpoint
56            .call(3, [sensor_index, dpi_hi, dpi_lo])
57            .await?;
58
59        Ok(())
60    }
61}
62
63fn parse_dpi_list_payload(bytes: &[u8]) -> Result<Vec<u16>, Hidpp20Error> {
64    let mut values = Vec::new();
65    let mut offset = 0;
66
67    while offset + 1 < bytes.len() {
68        let value = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]);
69        // `0x0000` terminates the list. A list that fills the whole response
70        // has no room for it, so absence of a terminator is not an error — we
71        // simply stop when the buffer runs out below.
72        if value == 0 {
73            break;
74        }
75
76        if value >> 13 == 0b111 {
77            let step = value & 0x1fff;
78            if step == 0 || offset + 3 >= bytes.len() {
79                return Err(Hidpp20Error::UnsupportedResponse);
80            }
81            // A range marker's start is the preceding explicit value; a leading
82            // marker with no predecessor is malformed.
83            let start = u32::from(*values.last().ok_or(Hidpp20Error::UnsupportedResponse)?);
84            let last = u16::from_be_bytes([bytes[offset + 2], bytes[offset + 3]]);
85            if u32::from(last) < start {
86                return Err(Hidpp20Error::UnsupportedResponse);
87            }
88            let mut next = start + u32::from(step);
89            while next < u32::from(last) {
90                values.push(u16::try_from(next).map_err(|_| Hidpp20Error::UnsupportedResponse)?);
91                next += u32::from(step);
92            }
93            // The high endpoint is always supported, even when it is not an
94            // exact multiple of `step` from the low endpoint.
95            values.push(last);
96            offset += 4;
97        } else {
98            values.push(value);
99            offset += 2;
100        }
101    }
102
103    if values.is_empty() {
104        return Err(Hidpp20Error::UnsupportedResponse);
105    }
106    values.sort_unstable();
107    values.dedup();
108    Ok(values)
109}
110
111#[cfg(test)]
112#[allow(clippy::unwrap_used, reason = "expect/unwrap are idiomatic in tests")]
113mod tests {
114    use std::assert_matches;
115
116    use super::parse_dpi_list_payload;
117    use crate::protocol::v20::Hidpp20Error;
118
119    #[test]
120    fn parses_explicit_dpi_list() {
121        let payload = [0x01, 0x90, 0x03, 0x20, 0x06, 0x40, 0x00, 0x00];
122
123        assert_eq!(parse_dpi_list_payload(&payload).unwrap(), [400, 800, 1600]);
124    }
125
126    #[test]
127    fn expands_range_encoded_dpi_list() {
128        let payload = [0x01, 0x90, 0xe1, 0x90, 0x06, 0x40, 0x00, 0x00];
129
130        assert_eq!(
131            parse_dpi_list_payload(&payload).unwrap(),
132            [400, 800, 1200, 1600]
133        );
134    }
135
136    #[test]
137    fn sorts_and_deduplicates_values() {
138        let payload = [0x06, 0x40, 0x03, 0x20, 0x03, 0x20, 0x00, 0x00];
139
140        assert_eq!(parse_dpi_list_payload(&payload).unwrap(), [800, 1600]);
141    }
142
143    #[test]
144    fn rejects_range_marker_without_previous_value() {
145        let payload = [0xe0, 0x32, 0x1f, 0x40, 0x00, 0x00];
146
147        assert_matches!(
148            parse_dpi_list_payload(&payload),
149            Err(Hidpp20Error::UnsupportedResponse)
150        );
151    }
152
153    #[test]
154    fn rejects_range_marker_without_end_value() {
155        let payload = [0x01, 0x90, 0xe0, 0x32];
156
157        assert_matches!(
158            parse_dpi_list_payload(&payload),
159            Err(Hidpp20Error::UnsupportedResponse)
160        );
161    }
162
163    #[test]
164    fn rejects_zero_step_range_marker() {
165        let payload = [0x01, 0x90, 0xe0, 0x00, 0x06, 0x40, 0x00, 0x00];
166
167        assert_matches!(
168            parse_dpi_list_payload(&payload),
169            Err(Hidpp20Error::UnsupportedResponse)
170        );
171    }
172
173    #[test]
174    fn rejects_descending_range_marker() {
175        let payload = [0x06, 0x40, 0xe0, 0x32, 0x01, 0x90, 0x00, 0x00];
176
177        assert_matches!(
178            parse_dpi_list_payload(&payload),
179            Err(Hidpp20Error::UnsupportedResponse)
180        );
181    }
182
183    #[test]
184    fn range_keeps_off_grid_high_endpoint() {
185        // min 400, step 400, max 1500 — 1500 is not on the 400 grid but is a
186        // supported value and must be kept.
187        let payload = [0x01, 0x90, 0xe1, 0x90, 0x05, 0xdc, 0x00, 0x00];
188
189        assert_eq!(
190            parse_dpi_list_payload(&payload).unwrap(),
191            [400, 800, 1200, 1500]
192        );
193    }
194
195    #[test]
196    fn parses_full_list_without_terminator() {
197        // A list that fills the response leaves no room for a 0x0000
198        // terminator; the values are still valid.
199        let payload = [0x01, 0x90, 0x03, 0x20, 0x06, 0x40];
200
201        assert_eq!(parse_dpi_list_payload(&payload).unwrap(), [400, 800, 1600]);
202    }
203
204    #[test]
205    fn rejects_payload_with_no_values() {
206        assert_matches!(
207            parse_dpi_list_payload(&[0x00, 0x00]),
208            Err(Hidpp20Error::UnsupportedResponse)
209        );
210    }
211}