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

> Learn how OpenLogi manages HID++ discovery and device enumeration. Explore its two-stage pipeline and NodeLedger for stable, concurrent operations in Rust.

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

---

**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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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:

```rust
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:

```rust
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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`](https://github.com/AprilNEA/OpenLogi/blob/main/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`.