How OpenLogi Manages HID++ Device Enumeration and Probing

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 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, 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. 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. 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 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. 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) 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, 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:

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:

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

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 →