How OpenLogi Handles Device Enumeration and Probing for Receivers and Stand-Alone Devices

OpenLogi discovers Logitech HID++ hardware through a two-phase inventory subsystem that first detects receiver types (Bolt, Unifying, or direct) and then probes each slot to build a complete device inventory with cached feature tables.

The openlogi-device crate in the AprilNEA/OpenLogi repository implements a robust, platform-agnostic enumeration pipeline. It abstracts platform-specific HID transport details to provide a unified view of both receiver-based and stand-alone Logitech peripherals across Linux, macOS, and Windows.

Overview of the Inventory Subsystem

Device enumeration begins in crates/openlogi-device/src/inventory/mod.rs (or inventory.rs), where the public enumerate() function serves as the entry point. This asynchronous orchestrator iterates over available HID backends, identifies potential Logitech receivers, and delegates to specialized probing logic based on the detected hardware type.

The subsystem distinguishes between three physical configurations:

  • Bolt receivers – Logi Bolt USB dongles using HID++ 2.0+
  • Unifying receivers – Legacy Unifying receivers
  • Stand-alone devices – Bluetooth-direct or wired devices without a receiver

Receiver Detection Phase

Detection starts when probe_one in crates/openlogi-device/src/inventory/probe.rs opens a raw HID++ channel. The code calls receiver::detect from the hidpp crate to inspect the product ID and classify the node.

If the channel identifies a known receiver, the system branches to probe_bolt_receiver or probe_unifying_receiver. When no receiver signature matches, execution falls back to probe_direct, treating the node as a stand-alone device.

Device Probing Mechanisms

Each receiver type requires distinct probing strategies to map paired slots and extract device identities.

Bolt Receiver Probing

For Bolt hardware, probe_bolt_receiver follows this sequence:

  1. Reads the pairing-count register to determine active slots
  2. Drains pending device-arrival events from the queue
  3. Sequentially reads each occupied slot's identity via registers 0x41 and 0x42
  4. Concurrently walks the per-slot feature table

The operation respects the BOLT_SLOT_PROBE time budget to prevent blocking the inventory loop.

Unifying Receiver Probing

The probe_unifying_receiver function handles legacy receivers differently:

  1. Reads the pairing-count register
  2. Triggers a broadcast-drain to collect one event per paired slot
  3. Concurrently walks each slot's feature table using the UNIFYING_SLOT_PROBE timeout

Stand-Alone Device Probing

When probe_direct is invoked (defined in crates/openlogi-device/src/inventory/standalone.rs), it queries the device at slot 0xff (represented by the constant DIRECT_DEVICE_INDEX). This slot address corresponds to the device's own HID++ interface rather than a receiver proxy. The function walks the feature set directly and returns a single PairedDevice entry representing the physical hardware.

Result Assembly and Caching

After probing completes, assemble_bolt_probe or assemble_unifying_probe constructs a DeviceInventory struct containing:

  • A ReceiverInfo metadata object
  • A vector of PairedDevice entries for each populated slot

The system generates a ProbeVerdict enum variant:

  • Healthy { complete } – Indicates the pairing-count register responded and every expected slot was processed
  • Failed – Triggers fallback to the last known good snapshot

Feature-walk results are cached in inventory::cache. When is_stale returns false for a slot, the probe skips HID++ traffic and reuses cached data, significantly reducing latency for stable devices.

Hot-Plug Handling

The inventory subsystem maintains responsiveness to physical changes through inventory::hotplug::watch_hotplug. This parallel watcher monitors udev (Linux) or IOKit (macOS) for arrival and removal events, pushing notifications onto an async channel.

When the inventory loop receives an event, it re-runs probe_one for the affected node, ensuring the GUI reflects devices added or removed during runtime without requiring a full rescan.

Code Example: Enumerating Devices

The following example demonstrates the public API for consuming device inventories:

use openlogi_device::inventory::{enumerate, InventoryError};

#[tokio::main]
async fn main() -> Result<(), InventoryError> {
    // Initialize the platform-specific HID backend
    let backend = openlogi_hid::native::NativeBackend::new();

    // Fetch complete inventory including all receivers and stand-alone devices
    let inventories = enumerate(&backend).await?;
    
    for inv in inventories {
        println!("Receiver: {}", inv.receiver.name);
        for dev in inv.paired {
            println!("  • Device slot {} – kind: {:?}", dev.slot, dev.kind);
        }
    }
    Ok(())
}

This same implementation works across operating systems because the openlogi_hid::Backend trait abstracts platform-specific transport details.

Summary

  • Two-phase detection – probe_one classifies hardware as Bolt, Unifying, or direct, then delegates to specialized probe functions in probe.rs.
  • Slot-based enumeration – Receivers are probed slot-by-slot with time-bounded operations (BOLT_SLOT_PROBE, UNIFYING_SLOT_PROBE), while stand-alone devices use the fixed index 0xff.
  • Resilient caching – The inventory::cache module stores feature tables, avoiding redundant HID++ register reads when data remains fresh.
  • Runtime hot-plug – The hotplug.rs watcher triggers selective re-probing when devices connect or disconnect during operation.
  • Unified abstraction – The enumerate() function in inventory.rs provides a cross-platform interface hiding OS-specific HID implementations.

Frequently Asked Questions

How does OpenLogi differentiate between Bolt and Unifying receivers?

OpenLogi uses the receiver::detect function from the internal hidpp crate to inspect the product ID of the opened HID++ channel. Based on this identifier, probe_one in probe.rs dispatches to either probe_bolt_receiver or probe_unifying_receiver, each implementing protocol-specific probing sequences optimized for the respective hardware generation.

What happens when a device is connected without a receiver?

When probe_one cannot identify a known receiver signature, it falls back to probe_direct in standalone.rs. This function treats the HID node as a stand-alone device and queries slot 0xff (DIRECT_DEVICE_INDEX), which represents the device's native HID++ interface. The result is a single PairedDevice entry returned in a DeviceInventory with no receiver metadata.

How does the inventory system handle devices being plugged in while the application is running?

The inventory::hotplug::watch_hotplug module runs a platform-specific watcher (using udev on Linux or IOKit on macOS) that monitors kernel events for device arrival and removal. When the watcher detects a change, it pushes an event to the inventory loop, which triggers a targeted re-probe of only the affected node, updating the published DeviceInventory snapshot without requiring a full system rescan.

Why does probing use time budgets for each slot?

The constants BOLT_SLOT_PROBE and UNIFYING_SLOT_PROBE define per-slot timeouts to prevent a single unresponsive device from blocking the entire enumeration process. Since HID++ operations involve synchronous register reads, these budgets ensure the inventory system remains responsive even when individual paired devices are sleeping or disconnected but still occupying logical slots in the receiver.

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 →