OpenLogi HID++ Device Discovery and Pairing: A Complete Implementation Guide

OpenLogi implements Logitech HID++ device discovery and pairing entirely in Rust using a two-layer architecture that separates low-level protocol logic from high-level IPC management.

OpenLogi is an open-source implementation of Logitech's HID++ protocol stack written in Rust. The codebase handles wireless peripheral discovery and pairing by splitting responsibilities between the openlogi-device crate for raw HID++ register operations and the openlogi-agent crate for session management and GUI communication.

Architecture Overview

The system divides HID++ device discovery and pairing into two distinct layers:

  • Device-level (openlogi-device): Direct HID++ register access, discovery parsing, and pairing commands
  • Agent-level (openlogi-agent): Owns the HID++ channel, runs a background watcher, and exposes a clean IPC API to the GUI

This separation ensures that time-critical HID++ communication runs without interference from GUI operations while providing a safe, asynchronous interface for frontend applications.

Device-Level Discovery and Pairing

All low-level HID++ logic resides in crates/openlogi-device/src/pairing.rs. This module handles the protocol-specific details for both Bolt and Unifying receiver families.

Receiver Family Detection

The implementation distinguishes between receiver types using the ReceiverFamily enum. This determines which discovery registers and pairing sequences the driver executes.

// ReceiverFamily distinguishes Bolt vs Unifying receivers
pub enum ReceiverFamily {
    Bolt,
    Unifying,
}

Discovery Mechanism

For Bolt receivers, the driver initiates discovery by writing to the BOLT_DISCOVERY register and listening for notification frames. Each discovered device emits two distinct frames:

  1. DiscoveryInfo – Contains protocol and addressing information
  2. DiscoveryName – Contains the human-readable device name

A PartialDevice accumulator merges these frames into a complete DiscoveredDevice struct containing the BTLE address, authentication method bitfield, device kind, and human-readable name.

The Pairing Flow

The run_pairing function orchestrates the entire process:

  1. Opens the selected receiver via open_receiver
  2. Subscribes to the notification stream
  3. Writes the discovery or lock register via write_register
  4. Enters the event loop (drive)

Bolt pairing uses pair_bolt_device to send a long-register write (action 0x01 with auto-slot selection) containing the target address, authentication bits, and entropy parameters.

Unifying pairing opens a lock register; the next powered-on device automatically pairs without requiring explicit device selection from a list.

Event Streaming

The run_pairing function streams PairingEvent variants back to the caller:

  • Searching – Receiver opened and scanning
  • DeviceFound – New device discovered
  • Passkey – Authentication required
  • Paired – Successful completion with slot number
  • Failed – Error or cancellation

Agent-Level Session Management

The agent layer in crates/openlogi-agent/src/pairing.rs wraps the low-level device operations in a managed session that safely interacts with the GUI.

Background Watcher Thread

The pairing::spawn() function in crates/openlogi-agent-core/src/watchers/pairing.rs creates a dedicated background thread running a Tokio runtime. This isolates blocking HID++ operations from the main agent process and returns a Control sender and PairingEvent receiver to the caller.

PairingManager Implementation

PairingManager wraps the watcher and translates raw PairingEvent values into the IPC-safe PairingUpdate struct. It maintains a cache of full DiscoveredDevice objects indexed by BTLE address, allowing the GUI to request pairing by address without serializing complex types across the IPC boundary.

Construction occurs via PairingManager::new, which requires the shared runtime and observable state from the agent core.

Session Lifecycle Control

The manager provides atomic session control through three primary methods:

Start – PairingManager::start(selector) accepts a ReceiverSelector (such as ReceiverSelector::First), atomically marks the session as active using sessions.compare_exchange, and acquires an exclusive lease on the receiver via receiver_access.acquire_exclusive to pause normal HID++ capture. It sends Control::Start(selector) to the watcher and publishes PairingPhase::Searching to the observable state.

Pair – PairingManager::pair(address) retrieves the cached DiscoveredDevice for the given BTLE address and forwards it as Control::Pair(device) to the watcher, updating the phase to Pairing.

Cancel – PairingManager::cancel() sends Control::Cancel to the watcher, which aborts the underlying HID++ flow and emits a Failed(Cancelled) event, triggering the release of the receiver lease.

Event Translation and Delivery

A background translator receives raw PairingEvent values from the watcher, converts them to PairingUpdate structs, and pushes them onto an unbounded channel. The GUI polls for updates via next_update(), which implements a 20-second long-polling mechanism to reduce latency while maintaining an asynchronous architecture.

Complete Implementation Example

The following example demonstrates the end-to-end flow using the agent-level API:

use openlogi_agent::pairing::PairingManager;
use openlogi_core::hid::pairing::ReceiverSelector;
use openlogi_ipc::PairingUpdate;
use tokio::time::Duration;

// Initialize with agent core components
let manager = PairingManager::new(shared_runtime, observable_state);

// List available receivers
let receivers = openlogi_device::pairing::list_pairing_receivers(&backend).await?;

// Start pairing session on the first available receiver
manager.start(ReceiverSelector::First).await?;

// Poll for discovery events
while let Some(update) = manager.next_update().await {
    match update {
        PairingUpdate::Searching => println!("Receiver opened – searching…"),
        PairingUpdate::DeviceFound(dev) => {
            println!("Found {} ({:?})", dev.name, dev.kind);
            // Initiate pairing with the discovered device
            manager.pair(dev.address)?;
        }
        PairingUpdate::Passkey(method) => println!("Enter passkey: {:?}", method),
        PairingUpdate::Paired { slot } => {
            println!("Device paired in slot {slot}");
            break;
        }
        PairingUpdate::Failed(err) => {
            eprintln!("Pairing failed: {err:?}");
            break;
        }
    }
}

// Cancel if user aborts
manager.cancel()?;

Summary

  • OpenLogi splits HID++ operations between openlogi-device for protocol logic and openlogi-agent for session management and IPC
  • Discovery differs by receiver family: Bolt receivers use explicit BOLT_DISCOVERY register polling with PartialDevice accumulation, while Unifying receivers rely on lock-based automatic pairing
  • Pairing is event-driven through the PairingEvent enum, with Bolt-specific pair_bolt_device calls requiring address and entropy parameters
  • Session safety is enforced via PairingManager using atomic compare-exchange operations and exclusive receiver leases to prevent concurrent access
  • GUI communication uses 20-second long-polling via next_update() with PairingUpdate IPC structs

Frequently Asked Questions

What is the difference between Bolt and Unifying receiver pairing in OpenLogi?

Bolt receivers require explicit device selection via pair_bolt_device, which sends a long-register write containing the target BTLE address, authentication bits, and entropy to a specific slot. Unifying receivers use a simplified lock mechanism where opening the lock register allows the next powered-on device to pair automatically without presenting a device list to the user.

How does OpenLogi prevent concurrent pairing sessions?

The PairingManager uses sessions.compare_exchange for atomic session state management and acquires an exclusive lease on the receiver through receiver_access.acquire_exclusive. This mechanism pauses the normal HID++ capture loop during active pairing, ensuring only one pairing operation can execute at a time.

Which HID++ registers control device discovery?

For Bolt receivers, the BOLT_DISCOVERY register initiates the discovery process, while BOLT_PAIRING handles the actual pairing writes. Unifying receivers use UNIFYING_PAIRING registers. These constants and low-level write helpers are defined in crates/openlogi-hidpp/src/receiver/bolt.rs and unifying.rs respectively.

How long does the GUI have to respond to pairing events?

The IPC layer implements a 20-second long-polling window via the next_update() method, allowing the GUI sufficient time to handle user interactions like passkey entry while maintaining an asynchronous, event-driven architecture that does not block the HID++ communication channel.

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 →