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

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 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 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 (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 (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 serves as the central hub translating high-level actions into concrete work:

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. 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

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)

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

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

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.

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 file within the dpi_presets array for each device key. The TOML deserializer at crates/openlogi-core/src/config/device.rs handles empty arrays gracefully, while the runtime state mirrors these values in DpiCycles during application execution.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →