# How DPI Control with Presets and Cycle/Set-Preset Actions Works at the HID++ Level

> Discover how OpenLogi manages DPI control at the HID++ level. Learn about presets, cycle actions, and TOML config for seamless Logitech device integration. Optimize your mouse settings today.

- Repository: [Xuan Zhang/OpenLogi](https://github.com/AprilNEA/OpenLogi)
- Tags: internals
- Published: 2026-08-23

---

**OpenLogi manages DPI control through a three-layer pipeline that stores user presets in TOML configuration, cycles them via thread-safe runtime state machines, and commits values to Logitech devices using HID++ features 0x2201 or 0x2202 with automatic clamping and verification.**

Managing mouse sensitivity at the hardware level requires precise communication with the device's firmware. OpenLogi, an open-source Rust application for Logitech peripherals, implements **DPI control with presets and Cycle/Set-preset actions at the HID++ level** through a clean separation between user configuration, runtime state management, and low-level protocol drivers.

## Preset Configuration and Storage

User-defined DPI values originate in the configuration layer. The system stores an ordered list of presets per device in the [`config.toml`](https://github.com/AprilNEA/OpenLogi/blob/main/config.toml) file under the `dpi_presets` array for each mouse key (e.g., `"2b042"`).

The TOML parser uses a custom deserializer, `deserialize_dpi_presets`, located in [`crates/openlogi-core/src/config/device.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-core/src/config/device.rs) at lines 136-145. This ensures empty arrays are omitted gracefully during serialization. When the UI loads, `AppState::dpi_presets()` in [`crates/openlogi-desktop/src/state/dpi.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-desktop/src/state/dpi.rs) (lines 34-40) reads these values into a runtime snapshot.

Adding, removing, or reordering presets updates the in-memory configuration and triggers a persist/reload cycle. However, these presets remain purely logical lists—they are **not** written back to the device automatically until the user triggers a cycle or set action.

## Runtime DPI State Management

The runtime layer maintains mutable state shared between the OS-hook thread and the desktop UI. The `DpiCycles` structure, defined in [`crates/openlogi-agent-core/src/dpi.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-agent-core/src/dpi.rs) (lines 7-49), acts as a global registry protected by `Arc<RwLock<DpiCycles>>`.

This structure holds a map from device keys to `DpiCycleState` instances. Each state contains:

- The preset list (`Vec<Dpi>`)
- The current index (`usize`)
- A `DeviceRoute` (the HID++ address of the mouse)
- Optional `DpiCapabilities` (to clamp values to hardware limits)

### Cycling Through Presets

When the user triggers **Action::CycleDpiPresets**, the system calls `DpiCycleState::cycle()` (lines 52-64). This method increments the index with wrapping arithmetic and returns a tuple containing the **normalized DPI** and the target `DeviceRoute`. Normalization uses the device's reported capabilities to select the nearest supported value.

### Setting Specific Presets

For **Action::SetDpiPreset(i)**, the dispatch logic invokes `DpiCycleState::set(i)` (lines 66-75). This clamps the provided index to the preset list length, then produces the same normalized DPI/target tuple. Both methods return `Option<(Dpi, Option<DeviceRoute>)>`, yielding `None` if the preset list is empty.

## Action Dispatch and Background Processing

The `dispatch_action` function in [`crates/openlogi-agent-core/src/hook_runtime.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-agent-core/src/hook_runtime.rs) serves as the central hub translating high-level actions into concrete work:

```rust
pub fn dispatch_action(
    action: &Action,
    dpi_cycle: &Arc<RwLock<DpiCycles>>,
    device_key: Option<&str>,
    capture: &CaptureChannel,
    registry: Option<&ChannelRegistry>,
    receiver_access: &ReceiverAccess,
) {
    let next = match action {
        Action::CycleDpiPresets => {
            match dpi_cycle.write() {
                Ok(mut guard) => guard.state_for(device_key).and_then(DpiCycleState::cycle),
                Err(e) => { warn!(error = %e, "dpi_cycle lock poisoned — cycle skipped"); None }
            }
        }
        Action::SetDpiPreset(i) => {
            match dpi_cycle.write() {
                Ok(mut guard) => guard.state_for(device_key).and_then(|s| s.set(*i as usize)),
                Err(e) => { warn!(error = %e, "dpi_cycle lock poisoned — set skipped"); None }
            }
        }
        // … other actions omitted …
    };
    
    if let Some((dpi, target)) = next {
        info!(%dpi, "DPI action → writing to device");
        if let Some(registry) = registry {
            write_dpi_in_background(capture, registry, receiver_access, target, dpi);
        } else {
            warn!("no inventory registry – DPI action skipped");
        }
    }
}

```

This design keeps the hot input callback non-blocking. After resolving the next DPI value, the function spawns `write_dpi_in_background` to handle the HID++ communication on a separate thread.

## HID++ Protocol Implementation

The background task ultimately calls `set_dpi` in [`crates/openlogi-device/src/write/dpi.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/write/dpi.rs). This async function accepts a `HidBackend` reference, a `DeviceRoute`, and the target `Dpi` value.

### Feature Detection (0x2201 vs 0x2202)

Inside `set_dpi_on_channel`, the driver creates a `hidpp::Device` and calls `DpiFeature::open(&mut device)` (lines 46-66). This probes for protocol support in the following order:

1. **Feature 0x2201** (`AdjustableDpiFeature::ID`) — The legacy adjustable DPI interface
2. **Feature 0x2202** (`ExtendedDpiFeature::ID`) — The extended DPI interface with additional parameters

The function returns an enum variant `DpiFeature::Adjustable` or `DpiFeature::Extended` depending on what the hardware reports.

### Writing and Verifying DPI Values

Once the feature is identified, the driver commits the value:

- **For 0x2201**: Invokes `set_sensor_dpi` directly
- **For 0x2202**: Reads the current `SetDpiParameters` to preserve lift-off distance and Y-axis settings, then calls `set_sensor_dpi_parameters`

After writing, the driver performs a **read-back verification** (lines 100-110). If the device reports a different DPI than requested, the system logs a warning, catching hardware that silently ignores unsupported values. All errors map to `WriteError` variants (e.g., `FeatureUnsupported`) for proper upstream handling.

## Practical Implementation Examples

### Triggering a Preset from Rust Code

```rust
use openlogi_core::binding::Action;
use openlogi_agent_core::hook_runtime::ActionDispatcher;
use std::sync::{Arc, RwLock};

fn apply_preset(dispatcher: &ActionDispatcher, preset_index: u8, device_key: Option<&str>) {
    let action = Action::SetDpiPreset(preset_index);
    dispatcher.dispatch(&action, device_key);
}

```

### Manual DPI Write (Bypassing Presets)

```rust
use openlogi_device::write::dpi::{set_dpi, get_dpi};
use openlogi_hid::backend::HidBackend;
use openlogi_hid::DeviceRoute;
use openlogi_core::hid::dpi::Dpi;

async fn write_custom_dpi(backend: &dyn HidBackend, route: &DeviceRoute, value: u16) {
    let dpi = Dpi::new(value);
    set_dpi(backend, route, dpi).await.expect("Failed to set DPI");
    let current = get_dpi(backend, route).await.unwrap();
    println!("Device now reports DPI = {}", current.raw());
}

```

### Cycling DPI in a UI Callback

```rust
use openlogi_core::binding::Action;
use openlogi_agent_core::hook_runtime::ActionDispatcher;

fn on_cycle_button_press(dispatcher: &ActionDispatcher) {
    dispatcher.dispatch(&Action::CycleDpiPresets, None);
}

```

## Summary

- **Configuration Layer**: Stores ordered DPI presets in [`config.toml`](https://github.com/AprilNEA/OpenLogi/blob/main/config.toml) via [`crates/openlogi-core/src/config/device.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-core/src/config/device.rs), deserialized through `deserialize_dpi_presets`.
- **Runtime State**: `DpiCycles` and `DpiCycleState` in [`crates/openlogi-agent-core/src/dpi.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-agent-core/src/dpi.rs) manage thread-safe preset cycling and hardware-specific normalization.
- **Action Dispatch**: The `dispatch_action` function in [`crates/openlogi-agent-core/src/hook_runtime.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-agent-core/src/hook_runtime.rs) handles both `CycleDpiPresets` and `SetDpiPreset(i)` actions, spawning background tasks to avoid blocking input hooks.
- **HID++ Protocol**: [`crates/openlogi-device/src/write/dpi.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/write/dpi.rs) implements feature detection (0x2201 vs 0x2202), value clamping, and read-back verification to ensure reliable hardware communication.

## Frequently Asked Questions

### What HID++ features does OpenLogi use for DPI control?

OpenLogi uses feature **0x2201** (`AdjustableDpiFeature`) as the primary interface, falling back to **0x2202** (`ExtendedDpiFeature`) when the legacy feature is unavailable. The driver automatically detects support during `DpiFeature::open` in [`crates/openlogi-device/src/write/dpi.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/write/dpi.rs).

### How does OpenLogi handle DPI values outside the device's supported range?

The normalization logic in `DpiCycleState` clamps DPI values to the nearest supported step reported by the device's `DpiCapabilities`. This ensures the hardware never receives an invalid value, preventing silent failures or undefined behavior.

### Why does DPI writing occur in a background thread rather than on the main thread?

The `dispatch_action` function spawns `write_dpi_in_background` to prevent blocking the OS input hook callback. HID++ communication involves I/O operations that could stall the hotkey handler, so the system defers the actual device write to a background thread using channels.

### Where does OpenLogi store user-defined DPI presets?

Presets persist in the user's [`config.toml`](https://github.com/AprilNEA/OpenLogi/blob/main/config.toml) file within the `dpi_presets` array for each device key. The TOML deserializer at [`crates/openlogi-core/src/config/device.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-core/src/config/device.rs) handles empty arrays gracefully, while the runtime state mirrors these values in `DpiCycles` during application execution.