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

> Master OpenLogi HID++ device discovery and pairing with this Rust implementation guide. Explore its two-layer architecture for robust low-level protocol and IPC management. Get the code from AprilNEA/OpenLogi.

- Repository: [Xuan Zhang/OpenLogi](https://github.com/AprilNEA/OpenLogi)
- Tags: how-to-guide
- Published: 2026-08-24

---

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

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

```rust
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`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-hidpp/src/receiver/bolt.rs) and [`unifying.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/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.