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 completeVec<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:
- Protocol Handshake:
RawChannel::probeverifies the device speaks HID++ by attempting the initial protocol exchange. - Feature Table Walking:
device.enumerate_features()(defined incrates/openlogi-device/src/inventory/features.rs) collects all supported feature IDs by iterating through the device's feature table. - 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
HidBackendtrait inopenlogi-hidunifies USB, Bluetooth, and Unifying receiver enumeration across operating systems. - Intelligent caching:
ProbeCacheStorepersists probe results toprobe-cache.json, avoiding redundant HID++ feature-table walks on subsequent runs. - State reconciliation: The
Ledgerinopenlogi-devicetracks device lifecycles, enabling seamless handling of hotplug events without full re-enumeration. - Comprehensive probing: The
probe_nodefunction performs protocol verification, feature enumeration, and capability detection, caching results viaProbeVerdict. - 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →