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:
- Drain pending device-arrival events using
drain_device_arrival - Sequentially read each occupied slot's identity via
read_bolt_slot_identity - 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:
- Reading the pairing count via
unifying.count_pairings - Triggering an arrival drain with
drain_device_arrival_unifying - 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 devicesRECEIVER_PROBE_BUDGET: 13 seconds for receiver hubsBOLT_SLOT_PROBE: 10 seconds per Bolt slotUNIFYING_SLOT_PROBE: 3.5 seconds per Unifying slotUNIFYING_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.rsthat first enumerates HID++ nodes then probes them concurrently. - The
Enumeratortype maintains a ChannelCache to reuseHidppChannelinstances and minimize device initialization overhead. - Specialized probe functions in
probe.rshandle 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.rsprovides failure resilience by tracking consecutive failures and replaying last-good snapshots when probes timeout. - Results are cached using
CacheKeyentries 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →