# How OpenLogi Manages HID++ Device Enumeration and Probing

> OpenLogi enhances HID++ device enumeration and probing by caching results, tracking state across hotplug events, and abstracting OS-specific HID stacks. Learn how OpenLogi optimizes performance.

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

---

**OpenLogi prevents expensive HID++ protocol round-trips by caching probe results to disk, using a reconciling ledger to track device state across hotplug events, and abstracting OS-specific HID stacks through a unified backend interface.**

OpenLogi is an open-source Rust framework for managing Logitech HID++ peripherals. The `openlogi-device` crate implements a robust enumeration system that discovers devices across USB, Bluetooth, and Unifying receivers while avoiding redundant feature-table walks through intelligent caching.

## Backend Abstraction and HID++ Filtering

The enumeration pipeline begins in [`crates/openlogi-hid/src/backend.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-hid/src/backend.rs) with the **`HidBackend`** trait. This abstraction encapsulates platform-specific implementations—using `async-hid` on Linux and macOS and `winapi` on Windows—to provide a unified interface for device discovery.

The backend exposes two critical async methods:

- **`enumerate()`**: Returns raw HID node identifiers for all connected devices regardless of protocol.
- **`enumerate_hidpp()`**: Filters the raw node list to include only devices that respond to the HID++ handshake protocol.

## The Enumerator Engine

In [`crates/openlogi-device/src/inventory.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/inventory.rs), the **`Enumerator`** struct orchestrates the discovery process. The public entry point **`enumerate()`** creates an `Enumerator` instance and delegates to **`enumerate_reporting_completeness()`**, which manages the reconciliation between live OS nodes and cached device state.

The enumerator supports two operational modes:

- **One-shot enumeration**: Used by CLI commands and tests via the free function `openlogi_device::enumerate()`, which creates a temporary enumerator with an in-memory cache and returns a complete `Vec<DeviceInventory>`.
- **Persistent enumeration**: Long-running agents use `Enumerator::with_backend()` to continuously monitor for device plug/unplug events, refreshing the ledger every 5 seconds by default.

## Persistent Probe Caching

To avoid the performance cost of repeated HID++ feature-table walks—which require multiple round-trip transactions—the system implements **`ProbeCacheStore`** in [`crates/openlogi-hid/src/probe_cache.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-hid/src/probe_cache.rs). This on-disk JSON cache stores the results of previous successful probes indexed by a stable **`CacheKey`** derived from device identity.

When the enumerator initializes via **`Enumerator::with_probe_cache()`**, it loads existing entries from [`probe-cache.json`](https://github.com/AprilNEA/OpenLogi/blob/main/probe-cache.json). During enumeration, the system reuses fresh cache entries instead of re-probing hardware, significantly reducing startup latency for previously seen devices.

## Ledger-Based State Reconciliation

Device management relies on a **`Ledger`** implementation in [`crates/openlogi-device/src/inventory/ledger.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/inventory/ledger.rs) to track the lifecycle of HID nodes. The ledger maintains three critical states:

- **Live nodes**: Currently connected devices reported by the OS via `backend.enumerate_hidpp()`.
- **Cached channels**: Previously probed devices that may have temporarily disconnected.
- **Stale entries**: Nodes that vanished from the OS and should be evicted after timeout.

During each enumeration pass, the system reconciles the live node set with the ledger's cached channels. This handles transient USB disconnections without requiring full re-probing when devices reconnect.

## Deep Device Probing

When a device lacks a valid cache entry or the cache has expired, the enumerator triggers **`probe_node`** in [`crates/openlogi-device/src/inventory/probe.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/inventory/probe.rs). This multi-stage process produces a **`ProbeVerdict`** that classifies the device capability.

The probe sequence includes:

1. **Protocol Handshake**: **`RawChannel::probe`** verifies the device speaks HID++ by attempting the initial protocol exchange.
2. **Feature Table Walking**: **`device.enumerate_features()`** (defined in [`crates/openlogi-device/src/inventory/features.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/inventory/features.rs)) collects all supported feature IDs by iterating through the device's feature table.
3. **Capability Extraction**: Specific handlers probe for batteries, DPI settings, SmartShift, and other Logitech-specific features to populate `ProbedFeatures`.

## Error Handling and Retry Logic

The enumeration system distinguishes between transient transport failures and permanent device errors. Defined in [`crates/openlogi-device/src/inventory/enumerate.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/inventory/enumerate.rs), **`RETRY_DELAY`** and **`MAX_RETRIES`** govern the back-off strategy for failed probes.

Transient failures—such as temporary USB communication errors—trigger a retry attempt. Permanent failures, marked as **`ProbeVerdict::Failed`**, result in the device being added to the ledger's failed list and eventually evicted when the OS no longer reports the node.

## Practical Implementation Examples

For CLI utilities or testing scenarios, use the one-shot enumeration API:

```rust
use openlogi_device::{enumerate, InventoryError};

#[tokio::main]
async fn main() -> Result<(), InventoryError> {
    // Returns Vec<DeviceInventory> for all discovered HID++ devices
    let inventories = enumerate().await?;
    
    for inv in inventories {
        println!("Device {}: {:?}", inv.serial, inv.features);
    }
    Ok(())
}

```

For persistent monitoring applications, maintain a long-running enumerator:

```rust
use openlogi_device::inventory::Enumerator;
use openlogi_hid::backend::HidBackend;

async fn run_agent(backend: impl HidBackend) {
    let mut enumerator = Enumerator::with_backend(backend);
    
    loop {
        match enumerator.enumerate().await {
            Ok((inventories, _complete, _healthy)) => {
                // Process DeviceInventory structs and update UI
            }
            Err(e) => {
                // Handle backend failure
                eprintln!("Enumeration failed: {}", e);
            }
        }
        
        // Configurable scan interval (default 5s)
        tokio::time::sleep(std::time::Duration::from_secs(5)).await;
    }
}

```

## Summary

- **Backend abstraction**: The `HidBackend` trait in `openlogi-hid` unifies USB, Bluetooth, and Unifying receiver enumeration across operating systems.
- **Intelligent caching**: `ProbeCacheStore` persists probe results to [`probe-cache.json`](https://github.com/AprilNEA/OpenLogi/blob/main/probe-cache.json), avoiding redundant HID++ feature-table walks on subsequent runs.
- **State reconciliation**: The `Ledger` in `openlogi-device` tracks device lifecycles, enabling seamless handling of hotplug events without full re-enumeration.
- **Comprehensive probing**: The `probe_node` function performs protocol verification, feature enumeration, and capability detection, caching results via `ProbeVerdict`.
- **Error resilience**: Transient failures trigger retries with configurable delays, while permanent failures are logged and eventually evicted from the device list.

## Frequently Asked Questions

### How does OpenLogi distinguish HID++ devices from standard HID peripherals?

OpenLogi relies on the **`enumerate_hidpp()`** method of the `HidBackend` trait, which filters raw HID nodes by attempting the HID++ protocol handshake via **`RawChannel::probe`**. Only devices responding with valid HID++ protocol identifiers are passed to the `Enumerator` for feature probing, while standard HID devices are silently excluded from the inventory.

### What prevents OpenLogi from re-probing devices every time an application starts?

The **`ProbeCacheStore`** in [`crates/openlogi-hid/src/probe_cache.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-hid/src/probe_cache.rs) serializes successful probe results—including feature tables and device capabilities—to a JSON file on disk. When the `Enumerator` initializes with `with_probe_cache()`, it loads these entries using stable **`CacheKey`** identifiers. Fresh cache entries bypass the expensive `enumerate_features()` walk entirely, reducing startup time to milliseconds for previously seen hardware.

### How does OpenLogi handle devices that disconnect and reconnect during operation?

The **`Ledger`** implementation in [`crates/openlogi-device/src/inventory/ledger.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-device/src/inventory/ledger.rs) maintains a reconciled view of device state between enumeration passes. When `enumerate_reporting_completeness()` detects a previously cached device has vanished from `backend.enumerate_hidpp()`, the ledger marks it as stale but retains the channel information. If the device reconnects before the cache expires, OpenLogi restores the cached capabilities without requiring a new probe, handling transient USB disconnections transparently.

### What is the difference between one-shot and persistent enumeration modes?

**One-shot enumeration** uses the free function `openlogi_device::enumerate()` to create a temporary `Enumerator` with an in-memory cache, returning a complete `Vec<DeviceInventory>` before dropping all state—ideal for CLI commands and testing. **Persistent enumeration** creates a long-lived `Enumerator` instance via `with_backend()` that runs in a loop, continuously calling `enumerate()` to watch for hotplug events and incrementally updating the `Ledger` and on-disk cache for daemon-style applications.