openlogi-hidpp 0.8.14

OpenLogi's hard fork of the `hidpp` crate (Logitech HID++ protocol).
Documentation
//! Implements the `ExtendedAdjustableDpi` feature (ID `0x2202`).
//!
//! This is the modern successor to [`AdjustableDpi`](super::adjustable_dpi)
//! (`0x2201`). On top of a single per-sensor DPI it adds independent X/Y DPI,
//! lift-off distance, DPI-status LED control and a DPI calibration flow, and it
//! describes the supported DPI as fixed values and stepped ranges rather than a
//! flat list.

pub mod event;
pub mod types;

#[cfg(test)]
mod tests;

use openlogi_hidpp_derive::Feature;

pub use event::{DpiCalibrationCompleted, DpiParametersChanged, ExtendedDpiEvent};
pub use types::{
    CalibrationType, DpiCalibrationCorrection, DpiCalibrationInfo, DpiDirection, DpiParameters,
    DpiRange, LedHoldType, Lod, SensorCapabilities, SensorCapabilitiesInfo, SetDpiParameters,
    ShowDpiStatus, StartDpiCalibration,
};

use self::types::{parse_dpi_list, parse_dpi_ranges, parse_lod_list, terminated_word_len};
use crate::{
    feature::{EventSource, FeatureEndpoint},
    protocol::v20::{self, Hidpp20Error},
};

/// Upper bound on the number of `getSensorDpiRanges` pages fetched before the
/// device is considered to be returning a malformed, unterminated list.
const MAX_RANGE_PAGES: u8 = 16;

/// Byte 0 of every per-sensor reply echoes the request's sensor index. Logi
/// Options+ rejects a reply naming another sensor in each of these calls
/// (`devio::Feature2202ExtendedAdjustableDPI`), and so does this: such a reply
/// is not an answer for the sensor asked about, nor the result of its write.
fn expect_sensor(payload: &[u8; 16], sensor_index: u8) -> Result<(), Hidpp20Error> {
    if payload[0] == sensor_index {
        Ok(())
    } else {
        Err(Hidpp20Error::UnsupportedResponse)
    }
}

/// Implements the `ExtendedAdjustableDpi` / `0x2202` feature.
#[derive(Feature)]
#[creatable(id = 0x2202, version = 0)]
pub struct ExtendedDpiFeature {
    /// The endpoint this feature talks to.
    endpoint: FeatureEndpoint,

    /// Publishes decoded events to listeners.
    events: EventSource<ExtendedDpiEvent>,
}

impl ExtendedDpiFeature {
    /// Whether an unsolicited report with `function_id` is the device's
    /// DPI-parameters broadcast (event `0`, decoded as
    /// [`ExtendedDpiEvent::ParametersChanged`]), sent when the sensor's DPI
    /// changes on the device — a DPI button, say.
    ///
    /// Deliberately independent of the payload, like
    /// `BatteryStatusFeature::is_status_broadcast`: a listener that only needs
    /// to know *that* the DPI changed re-reads it with
    /// [`Self::get_sensor_dpi_parameters`]. A calibration result (event `1`)
    /// changes no setting and is not one.
    #[must_use]
    pub const fn is_parameters_broadcast(function_id: u8) -> bool {
        function_id == 0
    }

    /// Retrieves the number of motion 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 capabilities and DPI-level count of `sensor_index`.
    ///
    /// A reply echoing a different sensor yields
    /// [`Hidpp20Error::UnsupportedResponse`]: these bits decide which fields a
    /// [`SetDpiParameters`] write may carry, so they must belong to the sensor
    /// being written.
    pub async fn get_sensor_capabilities(
        &self,
        sensor_index: u8,
    ) -> Result<SensorCapabilitiesInfo, Hidpp20Error> {
        let payload = self
            .endpoint
            .call(1, [sensor_index, 0, 0])
            .await?
            .extend_payload();
        expect_sensor(&payload, sensor_index)?;
        Ok(SensorCapabilitiesInfo {
            sensor_index: payload[0],
            dpi_level_count: payload[1],
            capabilities: SensorCapabilities::from_bits_retain(payload[2]),
        })
    }

    /// Retrieves the supported DPI of `sensor_index` along `direction` as a mix
    /// of fixed values and stepped ranges.
    ///
    /// The device may split the description across several pages; this fetches
    /// them until the `0x0000` end-of-list terminator is seen, then decodes the
    /// accumulated stream. A device that never terminates the list within a
    /// bounded number of pages yields [`Hidpp20Error::UnsupportedResponse`].
    pub async fn get_sensor_dpi_ranges(
        &self,
        sensor_index: u8,
        direction: DpiDirection,
    ) -> Result<Vec<DpiRange>, Hidpp20Error> {
        let mut stream = Vec::new();
        for page in 0..MAX_RANGE_PAGES {
            let payload = self
                .endpoint
                .call(2, [sensor_index, direction.into(), page])
                .await?
                .extend_payload();
            // Validate the echoed addressing (sensor, direction, page) before
            // trusting the page body, so a mismatched page cannot corrupt the
            // accumulated stream.
            if payload[0] != sensor_index || payload[1] != u8::from(direction) || payload[2] != page
            {
                return Err(Hidpp20Error::UnsupportedResponse);
            }
            stream.extend_from_slice(&payload[3..16]);
            if terminated_word_len(&stream).is_some() {
                return parse_dpi_ranges(&stream);
            }
        }
        Err(Hidpp20Error::UnsupportedResponse)
    }

    /// Retrieves the current profile's DPI list for `sensor_index` along
    /// `direction`.
    ///
    /// Only meaningful when the sensor supports profiles
    /// ([`SensorCapabilities::PROFILE`]); otherwise the device returns an error.
    /// A reply echoing another sensor or direction yields
    /// [`Hidpp20Error::UnsupportedResponse`].
    pub async fn get_sensor_dpi_list(
        &self,
        sensor_index: u8,
        direction: DpiDirection,
    ) -> Result<Vec<u16>, Hidpp20Error> {
        let payload = self
            .endpoint
            .call(3, [sensor_index, direction.into(), 0])
            .await?
            .extend_payload();
        expect_sensor(&payload, sensor_index)?;
        if payload[1] != u8::from(direction) {
            return Err(Hidpp20Error::UnsupportedResponse);
        }
        Ok(parse_dpi_list(&payload[2..]))
    }

    /// Retrieves the current profile's lift-off-distance list for
    /// `sensor_index`.
    ///
    /// The list length is the sensor's DPI-level count
    /// ([`SensorCapabilitiesInfo::dpi_level_count`]), which the caller passes as
    /// `dpi_level_count`; the device does not delimit the list.
    pub async fn get_sensor_lod_list(
        &self,
        sensor_index: u8,
        dpi_level_count: u8,
    ) -> Result<Vec<Lod>, Hidpp20Error> {
        let payload = self
            .endpoint
            .call(4, [sensor_index, 0, 0])
            .await?
            .extend_payload();
        expect_sensor(&payload, sensor_index)?;
        parse_lod_list(&payload[1..], usize::from(dpi_level_count))
    }

    /// Retrieves the current and default DPI parameters of `sensor_index`.
    ///
    /// The ten parameter bytes need a long reply: a short one, zero-filled,
    /// would read the default DPI, the Y DPI and the lift-off as zero.
    /// Options+ refuses a reply under ten bytes too.
    pub async fn get_sensor_dpi_parameters(
        &self,
        sensor_index: u8,
    ) -> Result<DpiParameters, Hidpp20Error> {
        let v20::Message::Long(_, payload) = self.endpoint.call(5, [sensor_index, 0, 0]).await?
        else {
            return Err(Hidpp20Error::UnsupportedResponse);
        };
        expect_sensor(&payload, sensor_index)?;
        Ok(DpiParameters {
            sensor_index: payload[0],
            dpi_x: u16::from_be_bytes([payload[1], payload[2]]),
            default_dpi_x: u16::from_be_bytes([payload[3], payload[4]]),
            dpi_y: u16::from_be_bytes([payload[5], payload[6]]),
            default_dpi_y: u16::from_be_bytes([payload[7], payload[8]]),
            lod: Lod::try_from(payload[9]).map_err(|_| Hidpp20Error::UnsupportedResponse)?,
        })
    }

    /// Sets the DPI and lift-off distance of `sensor_index`.
    ///
    /// `params.dpi_y` must be `0` when the sensor has no independent Y axis and
    /// `params.lod` [`Lod::NotSupported`] when it has no lift-off control;
    /// [`SetDpiParameters::for_sensor`] builds parameters that satisfy both.
    /// A reply echoing another sensor yields
    /// [`Hidpp20Error::UnsupportedResponse`].
    pub async fn set_sensor_dpi_parameters(
        &self,
        sensor_index: u8,
        params: SetDpiParameters,
    ) -> Result<(), Hidpp20Error> {
        let mut args = [0; 16];
        args[0] = sensor_index;
        args[1..3].copy_from_slice(&params.dpi_x.to_be_bytes());
        args[3..5].copy_from_slice(&params.dpi_y.to_be_bytes());
        args[5] = params.lod.into();
        let reply = self.endpoint.call_long(6, args).await?;
        expect_sensor(&reply.extend_payload(), sensor_index)
    }

    /// Asks the device to show `params.dpi_level` on its DPI status LED.
    ///
    /// Valid only while the device is in host mode.
    pub async fn show_sensor_dpi_status(
        &self,
        sensor_index: u8,
        params: ShowDpiStatus,
    ) -> Result<(), Hidpp20Error> {
        let mut args = [0; 16];
        args[..4].copy_from_slice(&[
            sensor_index,
            params.dpi_level,
            params.led_hold_type.into(),
            params.button_num,
        ]);
        let reply = self.endpoint.call_long(7, args).await?;
        expect_sensor(&reply.extend_payload(), sensor_index)
    }

    /// Retrieves the reference information needed to start a calibration of
    /// `sensor_index`. Like the DPI parameters, its eight bytes need a long
    /// reply.
    pub async fn get_dpi_calibration_info(
        &self,
        sensor_index: u8,
    ) -> Result<DpiCalibrationInfo, Hidpp20Error> {
        let v20::Message::Long(_, payload) = self.endpoint.call(8, [sensor_index, 0, 0]).await?
        else {
            return Err(Hidpp20Error::UnsupportedResponse);
        };
        expect_sensor(&payload, sensor_index)?;
        Ok(DpiCalibrationInfo {
            sensor_index: payload[0],
            mouse_width: payload[1],
            mouse_length: u16::from_be_bytes([payload[2], payload[3]]),
            calib_dpi_x: u16::from_be_bytes([payload[4], payload[5]]),
            calib_dpi_y: u16::from_be_bytes([payload[6], payload[7]]),
        })
    }

    /// Starts a DPI calibration of `sensor_index`.
    ///
    /// Requires [`SensorCapabilities::CALIBRATION`]. The device reports the
    /// outcome through an [`ExtendedDpiEvent::CalibrationCompleted`] event; for a
    /// [`CalibrationType::Software`] calibration the result is then applied with
    /// [`Self::set_dpi_calibration`].
    pub async fn start_dpi_calibration(
        &self,
        sensor_index: u8,
        params: StartDpiCalibration,
    ) -> Result<(), Hidpp20Error> {
        let [count_hi, count_lo] = params.expected_count.to_be_bytes();
        let mut args = [0; 16];
        args[..8].copy_from_slice(&[
            sensor_index,
            params.direction.into(),
            count_hi,
            count_lo,
            params.calib_type.into(),
            params.start_timeout,
            params.hw_process_timeout,
            params.sw_process_timeout,
        ]);
        let reply = self.endpoint.call_long(9, args).await?;
        expect_sensor(&reply.extend_payload(), sensor_index)
    }

    /// Applies a calibration correction to `sensor_index` along `direction`.
    ///
    /// Allowed only while a calibration started by [`Self::start_dpi_calibration`]
    /// is in progress (or to revert, see [`DpiCalibrationCorrection`]).
    pub async fn set_dpi_calibration(
        &self,
        sensor_index: u8,
        direction: DpiDirection,
        correction: DpiCalibrationCorrection,
    ) -> Result<(), Hidpp20Error> {
        let [cor_hi, cor_lo] = correction.to_wire()?.to_be_bytes();
        let mut args = [0; 16];
        args[..4].copy_from_slice(&[sensor_index, direction.into(), cor_hi, cor_lo]);
        let reply = self.endpoint.call_long(10, args).await?;
        expect_sensor(&reply.extend_payload(), sensor_index)
    }
}