How OpenLogi Manages Device Enumeration and Probing: HID++ Discovery in Rust

OpenLogi discovers Logitech devices through a two-stage pipeline that enumerates HID++ nodes and probes each node to build a complete DeviceInventory, using time-budgeted concurrent operations and a NodeLedger to ensure stability across transient failures.

OpenLogi (available at AprilNEA/OpenLogi) is a Rust-based library for interacting with Logitech HID++ devices. The library implements a robust device enumeration and probing system that can distinguish between Bolt receivers, Unifying receivers, and direct-connected devices while maintaining persistent channels and resilient failure handling.

Overview of the Enumeration Architecture

The core of OpenLogi's discovery mechanism resides in the Enumerator type defined in crates/openlogi-device/src/inventory.rs. This type orchestrates the entire discovery process by coordinating OS-level HID++ enumeration, persistent channel management, and concurrent device probing.

The Enumerator Type and Channel Cache

The Enumerator maintains a ChannelCache to reuse already-opened HidppChannel instances across enumeration ticks. According to the source in inventory.rs, the prepare_nodes function (around line 44) handles the logic of reusing existing channels or opening new ones via backend.open_hidpp. This caching prevents redundant device initialization and reduces latency in long-running surveillance loops.

NodeLedger and Failure Resilience

To prevent transient USB or HID++ communication glitches from causing devices to disappear from the user interface, OpenLogi employs a NodeLedger implemented in crates/openlogi-device/src/inventory/ledger.rs. The ledger tracks consecutive failures per node and replays the last-good snapshot when a probe fails, only evicting a node after a configurable failure threshold is reached.

The Two-Stage Discovery Pipeline

OpenLogi separates device discovery into distinct enumeration and probing phases, allowing for efficient concurrent processing while respecting strict time budgets.

Stage 1: Collecting OS HID++ Candidates

The pipeline begins when the backend (provided by openlogi-hid) lists all available HID++ interfaces via backend.enumerate_hidpp(). The enumerate function (line 55 in inventory.rs) collects these candidates and prepares them for probing by associating each with a cached or new channel.

Stage 2: Concurrent Node Probing

For every active channel, the system executes probe_one concurrently, respecting per-node timeouts defined by PROBE_BUDGET (25 seconds for direct devices) and RECEIVER_PROBE_BUDGET (13 seconds for receivers). The enumerate_reporting_completeness function (line 70) coordinates this parallel probing and aggregates results into a Vec<DeviceInventory>.

Probe Implementations by Device Type

The probe_one function in crates/openlogi-device/src/inventory/probe.rs detects the node type and delegates to specialized routines that understand the specific HID++ dialects used by different Logitech receiver families.

Bolt Receiver Probing

For Bolt receivers, probe_bolt_receiver executes a three-step process:

  1. Drain pending device-arrival events using drain_device_arrival
  2. Sequentially read each occupied slot's identity via read_bolt_slot_identity
  3. Concurrently walk the feature table of each slot using walk_bolt_slot

This process operates under a BOLT_SLOT_PROBE budget of 10 seconds per slot.

Unifying Receiver Probing

The probe_unifying_receiver function handles legacy Unifying receivers by:

  1. Reading the pairing count via unifying.count_pairings
  2. Triggering an arrival drain with drain_device_arrival_unifying
  3. Probing each discovered slot via probe_unifying_slot

Unifying slots operate under a UNIFYING_SLOT_PROBE budget of 3.5 seconds, or UNIFYING_CACHED_SLOT_PROBE (0.75 seconds) when utilizing cached data.

Direct Device Probing

For Bluetooth-direct or USB-C connected devices, probe_direct immediately walks the HID++ feature table at index 0xff. If successful, it creates a single PairedDevice entry without receiver mediation.

Time Budgeting and Concurrency Controls

OpenLogi implements strict time budgeting to prevent enumeration from hanging on unresponsive devices.

Per-Node and Per-Slot Timeouts

The system defines several timeout constants in inventory.rs:

  • PROBE_BUDGET: 25 seconds for direct devices
  • RECEIVER_PROBE_BUDGET: 13 seconds for receiver hubs
  • BOLT_SLOT_PROBE: 10 seconds per Bolt slot
  • UNIFYING_SLOT_PROBE: 3.5 seconds per Unifying slot
  • UNIFYING_CACHED_SLOT_PROBE: 0.75 seconds for cached Unifying slots

If any probe exceeds its budget, the node is marked as failed via NodeProbe::failed() (defined in probe.rs at line 82), and the NodeLedger falls back to the previous snapshot.

Cache Management and Grace Periods

Probe results are stored in a HashMap<CacheKey, Cached> structure. The apply_outcomes function (line 86 in inventory.rs) manages cache eviction using a CACHE_MISS_GRACE period, ensuring that immutable device data (such as feature tables) does not require redundant probing across enumeration ticks.

API Usage Patterns

OpenLogi exposes two primary patterns for consuming the enumeration API: one-shot scans suitable for CLI tools and persistent enumeration for GUI applications.

One-Shot Enumeration for CLI Tools

The public enumerate function creates a fresh Enumerator and retries until the inventory stabilizes or reaches an attempt limit:

use openlogi_device::inventory::enumerate;
use openlogi_hid::backend::HidBackend;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let backend = openlogi_hid::backend::new_backend().await?;
    let inventories = enumerate(backend).await?;
    
    for inv in inventories {
        println!("Receiver: {}", inv.receiver.name);
        for dev in inv.paired {
            println!("  Slot {} – {}", dev.slot, dev.codename.unwrap_or("<unnamed>".into()));
        }
    }
    Ok(())
}

Continuous Enumeration for Long-Running Agents

For persistent applications, the Enumerator type can be reused across ticks to maintain open channels and cached probe data:

use openlogi_device::inventory::Enumerator;
use std::sync::Arc;

async fn run_watcher(backend: Arc<dyn HidBackend>) {
    let mut enumerator = Enumerator::with_backend(backend);
    loop {
        match enumerator.enumerate().await {
            Ok(inventories) => {
                // Publish to UI via IPC
            }
            Err(e) => eprintln!("Enumeration error: {:?}", e),
        }
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
    }
}

Summary

  • OpenLogi implements a two-stage pipeline in crates/openlogi-device/src/inventory.rs that first enumerates HID++ nodes then probes them concurrently.
  • The Enumerator type maintains a ChannelCache to reuse HidppChannel instances and minimize device initialization overhead.
  • Specialized probe functions in probe.rs handle Bolt receivers, Unifying receivers, and direct devices with protocol-specific logic.
  • Strict time budgets (PROBE_BUDGET, RECEIVER_PROBE_BUDGET, and slot-specific limits) prevent enumeration hangs on unresponsive hardware.
  • The NodeLedger in ledger.rs provides failure resilience by tracking consecutive failures and replaying last-good snapshots when probes timeout.
  • Results are cached using CacheKey entries with grace periods to avoid redundant feature table walks across enumeration ticks.

Frequently Asked Questions

What is the difference between enumerate and Enumerator::enumerate in OpenLogi?

The standalone enumerate function in crates/openlogi-device/src/inventory.rs provides a one-shot API that creates a fresh Enumerator, retries until stable, and returns the final inventory. In contrast, Enumerator::enumerate performs a single tick of the enumeration loop and is designed for long-running watchers that reuse the same instance to maintain persistent channels and caches across multiple calls.

How does OpenLogi handle temporary USB communication failures during device probing?

OpenLogi uses the NodeLedger struct defined in ledger.rs to track consecutive failures per node. When a probe exceeds its time budget or returns an error, the ledger replays the last successful snapshot instead of immediately removing the device from the inventory. The node is only evicted after crossing a configurable failure threshold, ensuring transient glitches do not cause UI flicker or device disappearance.

Why are there different time budgets for Bolt and Unifying receiver slots?

Bolt and Unifying receivers use different HID++ protocols with varying response characteristics. According to the constants defined in inventory.rs, Bolt slots receive a BOLT_SLOT_PROBE budget of 10 seconds due to their more complex feature table walks, while Unifying slots use UNIFYING_SLOT_PROBE (3.5 seconds) or UNIFYING_CACHED_SLOT_PROBE (0.75 seconds) because their pairing protocol is more predictable and supports faster cached lookups.

Can OpenLogi discover Logitech devices connected via Bluetooth without a USB receiver?

Yes. The probe_direct function in probe.rs handles Bluetooth-direct and USB-C connected devices by walking the HID++ feature table at index 0xff directly. This allows OpenLogi to enumerate paired devices that communicate without a Bolt or Unifying receiver, creating a single PairedDevice entry in the resulting DeviceInventory.

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 →