openlogi-hidpp 0.8.14

OpenLogi's hard fork of the `hidpp` crate (Logitech HID++ protocol).
Documentation
//! Implements the `AdjustableDpi` feature (ID `0x2201`) that allows reading
//! and changing a mouse sensor's DPI.

use openlogi_hidpp_derive::Feature;

use crate::{feature::FeatureEndpoint, protocol::v20::Hidpp20Error};

/// Implements the `AdjustableDpi` / `0x2201` feature.
#[derive(Clone, Feature)]
#[creatable(id = 0x2201, version = 0)]
pub struct AdjustableDpiFeature {
    /// The endpoint this feature talks to.
    endpoint: FeatureEndpoint,
}

impl AdjustableDpiFeature {
    /// Retrieves the number of sensors the device exposes.
    pub async fn get_sensor_count(&self) -> Result<u8, Hidpp20Error> {
        Ok(self.endpoint.call(0, [0; 3]).await?.extend_payload()[0])
    }

    /// Retrieves the supported DPI values for `sensor_index`.
    ///
    /// `getSensorDpiList` takes the sensor index in the first parameter byte and
    /// returns the whole list in a single long response: the echoed sensor index
    /// followed by up to seven big-endian values, terminated by `0x0000` (the
    /// terminator is absent when the values fill the response). Each value is
    /// either an explicit DPI or a compact range marker (`0xe000 | step`) whose
    /// start is the previous value and whose end is the next value. The returned
    /// list is sorted and deduplicated.
    ///
    /// A reply echoing another sensor yields
    /// [`Hidpp20Error::UnsupportedResponse`], as in Logi Options+
    /// (`devio::Feature2201AdjustableDPI::GetSensorDPIList`): its values are
    /// another sensor's.
    pub async fn get_sensor_dpi_list(&self, sensor_index: u8) -> Result<Vec<u16>, Hidpp20Error> {
        let payload = self
            .endpoint
            .call(1, [sensor_index, 0x00, 0x00])
            .await?
            .extend_payload();
        if payload[0] != sensor_index {
            return Err(Hidpp20Error::UnsupportedResponse);
        }
        parse_dpi_list_payload(&payload[1..])
    }

    /// Retrieves the currently configured DPI for `sensor_index`.
    ///
    /// Unlike the list, the echo is not checked: Options+ reads this value
    /// and accepts a write without comparing it either, so a firmware that
    /// answers these with another byte 0 keeps working.
    pub async fn get_sensor_dpi(&self, sensor_index: u8) -> Result<u16, Hidpp20Error> {
        let payload = self
            .endpoint
            .call(2, [sensor_index, 0x00, 0x00])
            .await?
            .extend_payload();

        Ok(u16::from_be_bytes([payload[1], payload[2]]))
    }

    /// Sets the DPI for `sensor_index`.
    pub async fn set_sensor_dpi(&self, sensor_index: u8, dpi: u16) -> Result<(), Hidpp20Error> {
        let [dpi_hi, dpi_lo] = dpi.to_be_bytes();
        self.endpoint
            .call(3, [sensor_index, dpi_hi, dpi_lo])
            .await?;

        Ok(())
    }
}

fn parse_dpi_list_payload(bytes: &[u8]) -> Result<Vec<u16>, Hidpp20Error> {
    let mut values = Vec::new();
    let mut offset = 0;

    while offset + 1 < bytes.len() {
        let value = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]);
        // `0x0000` terminates the list. A list that fills the whole response
        // has no room for it, so absence of a terminator is not an error — we
        // simply stop when the buffer runs out below.
        if value == 0 {
            break;
        }

        if value >> 13 == 0b111 {
            let step = value & 0x1fff;
            if step == 0 || offset + 3 >= bytes.len() {
                return Err(Hidpp20Error::UnsupportedResponse);
            }
            // A range marker's start is the preceding explicit value; a leading
            // marker with no predecessor is malformed.
            let start = u32::from(*values.last().ok_or(Hidpp20Error::UnsupportedResponse)?);
            let last = u16::from_be_bytes([bytes[offset + 2], bytes[offset + 3]]);
            if u32::from(last) < start {
                return Err(Hidpp20Error::UnsupportedResponse);
            }
            let mut next = start + u32::from(step);
            while next < u32::from(last) {
                values.push(u16::try_from(next).map_err(|_| Hidpp20Error::UnsupportedResponse)?);
                next += u32::from(step);
            }
            // The high endpoint is always supported, even when it is not an
            // exact multiple of `step` from the low endpoint.
            values.push(last);
            offset += 4;
        } else {
            values.push(value);
            offset += 2;
        }
    }

    if values.is_empty() {
        return Err(Hidpp20Error::UnsupportedResponse);
    }
    values.sort_unstable();
    values.dedup();
    Ok(values)
}

#[cfg(test)]
mod tests {
    use std::assert_matches;

    use super::parse_dpi_list_payload;
    use crate::protocol::v20::Hidpp20Error;

    /// The result of `call` when the device answers its one request, to
    /// function `function`, with the long payload `reply`.
    fn answer<T, F>(
        function: u8,
        call: impl FnOnce(super::AdjustableDpiFeature) -> F,
        reply: [u8; 16],
    ) -> Result<T, Hidpp20Error>
    where
        F: Future<Output = Result<T, Hidpp20Error>>,
    {
        use std::sync::Arc;

        use crate::channel::mock::{MockRawHidChannel, channel_with_reader};
        use crate::feature::CreatableFeature;
        use crate::nibble::U4;
        use crate::protocol::v20::{Message, MessageHeader};

        futures::executor::block_on(async {
            let (raw, handle) = MockRawHidChannel::new();
            let channel = Arc::new(channel_with_reader(raw).await);
            let mut pending = Box::pin(call(super::AdjustableDpiFeature::new(channel, 3, 8)));
            assert!(futures::poll!(pending.as_mut()).is_pending());
            let header = MessageHeader {
                device_index: 3,
                feature_index: 8,
                function_id: U4::from_lo(function),
                software_id: U4::from_lo(1),
            };
            handle
                .send_incoming(Message::Long(header, reply).into())
                .await;
            pending.await
        })
    }

    /// A `getSensorDpiList` reply for `sensor` listing 800 and 1600 DPI.
    fn dpi_list_reply(sensor: u8) -> [u8; 16] {
        let mut reply = [0; 16];
        reply[0] = sensor;
        reply[1..5].copy_from_slice(&[0x03, 0x20, 0x06, 0x40]);
        reply
    }

    #[test]
    fn dpi_list_takes_a_reply_for_its_sensor() {
        let list = answer(
            1,
            |dpi| async move { dpi.get_sensor_dpi_list(1).await },
            dpi_list_reply(1),
        );
        assert_matches!(list, Ok(values) if values == [800, 1600]);
    }

    #[test]
    fn dpi_list_refuses_a_reply_for_another_sensor() {
        let list = answer(
            1,
            |dpi| async move { dpi.get_sensor_dpi_list(1).await },
            dpi_list_reply(0),
        );
        assert_matches!(list, Err(Hidpp20Error::UnsupportedResponse));
    }

    #[test]
    fn current_dpi_read_does_not_check_the_echo() {
        // Options+ reads the current DPI without comparing byte 0; a firmware
        // that answers it with another byte keeps a readable DPI.
        let mut reply = [0; 16];
        reply[1..3].copy_from_slice(&1600u16.to_be_bytes());
        let dpi = answer(2, |dpi| async move { dpi.get_sensor_dpi(1).await }, reply);
        assert_matches!(dpi, Ok(1600));
    }

    #[test]
    fn parses_explicit_dpi_list() {
        let payload = [0x01, 0x90, 0x03, 0x20, 0x06, 0x40, 0x00, 0x00];

        assert_eq!(parse_dpi_list_payload(&payload).unwrap(), [400, 800, 1600]);
    }

    #[test]
    fn expands_range_encoded_dpi_list() {
        let payload = [0x01, 0x90, 0xe1, 0x90, 0x06, 0x40, 0x00, 0x00];

        assert_eq!(
            parse_dpi_list_payload(&payload).unwrap(),
            [400, 800, 1200, 1600]
        );
    }

    #[test]
    fn sorts_and_deduplicates_values() {
        let payload = [0x06, 0x40, 0x03, 0x20, 0x03, 0x20, 0x00, 0x00];

        assert_eq!(parse_dpi_list_payload(&payload).unwrap(), [800, 1600]);
    }

    #[test]
    fn rejects_range_marker_without_previous_value() {
        let payload = [0xe0, 0x32, 0x1f, 0x40, 0x00, 0x00];

        assert_matches!(
            parse_dpi_list_payload(&payload),
            Err(Hidpp20Error::UnsupportedResponse)
        );
    }

    #[test]
    fn rejects_range_marker_without_end_value() {
        let payload = [0x01, 0x90, 0xe0, 0x32];

        assert_matches!(
            parse_dpi_list_payload(&payload),
            Err(Hidpp20Error::UnsupportedResponse)
        );
    }

    #[test]
    fn rejects_zero_step_range_marker() {
        let payload = [0x01, 0x90, 0xe0, 0x00, 0x06, 0x40, 0x00, 0x00];

        assert_matches!(
            parse_dpi_list_payload(&payload),
            Err(Hidpp20Error::UnsupportedResponse)
        );
    }

    #[test]
    fn rejects_descending_range_marker() {
        let payload = [0x06, 0x40, 0xe0, 0x32, 0x01, 0x90, 0x00, 0x00];

        assert_matches!(
            parse_dpi_list_payload(&payload),
            Err(Hidpp20Error::UnsupportedResponse)
        );
    }

    #[test]
    fn range_keeps_off_grid_high_endpoint() {
        // min 400, step 400, max 1500 — 1500 is not on the 400 grid but is a
        // supported value and must be kept.
        let payload = [0x01, 0x90, 0xe1, 0x90, 0x05, 0xdc, 0x00, 0x00];

        assert_eq!(
            parse_dpi_list_payload(&payload).unwrap(),
            [400, 800, 1200, 1500]
        );
    }

    #[test]
    fn parses_full_list_without_terminator() {
        // A list that fills the response leaves no room for a 0x0000
        // terminator; the values are still valid.
        let payload = [0x01, 0x90, 0x03, 0x20, 0x06, 0x40];

        assert_eq!(parse_dpi_list_payload(&payload).unwrap(), [400, 800, 1600]);
    }

    #[test]
    fn rejects_payload_with_no_values() {
        assert_matches!(
            parse_dpi_list_payload(&[0x00, 0x00]),
            Err(Hidpp20Error::UnsupportedResponse)
        );
    }
}