How OpenLogi Identifies and Differentiates Devices by Receiver Serial and Unit ID

OpenLogi uses a DeviceIdentity enum that prefers receiver serial numbers when available, falling back to 4-byte unit IDs for devices like lights that lack unique hardware serials.

OpenLogi is an open-source Rust implementation for managing Logitech HID++ devices. According to the AprilNEA/OpenLogi source code, the project identifies each physical device through a dual-strategy system that handles both traditional peripherals with burned-in serial numbers and accessories that rely on receiver-assigned unit IDs.

The DeviceIdentity Enum: Serial vs. Unit ID

At the core of the identification system lies the DeviceIdentity enum defined in crates/openlogi-core/src/device_order.rs. This enum represents the two possible ways a device can be uniquely tracked:

pub enum DeviceIdentity {
    Serial(String),
    Unit([u8; 4]),
}

The Serial variant stores a human-readable hexadecimal string extracted directly from the device's hardware, while the Unit variant holds a raw 4-byte array representing the pairing slot identifier assigned by the receiver. This design allows OpenLogi to treat both identification schemes interchangeably throughout the application layer.

Extracting Identifiers from HID++ Receivers

OpenLogi retrieves device identifiers by issuing the HID++ GetReceiverInfo command to both Unifying and Bolt receivers.

Unifying and Bolt Receiver Implementation

In crates/openlogi-hidpp/src/receiver/unifying.rs and the corresponding bolt.rs file, the code parses the receiver response to extract two critical fields:

  • Serial number: A 4-byte value encoded as an uppercase hex string using hex::encode_upper(&response[1..=4])
  • Unit ID: A 4-byte array taken from response bytes 4 through 7 ([response[4], response[5], response[6], response[7]])

The serial provides global uniqueness across all manufactured devices, whereas the unit ID remains stable only as long as the device maintains its pairing slot with the current receiver.

Constructing Identity with from_parts

The DeviceIdentity implementation provides a single constructor method from_parts that encapsulates the precedence logic:

impl DeviceIdentity {
    pub fn from_parts(serial: Option<&str>, unit_id: [u8; 4]) -> Self {
        serial.map(|s| DeviceIdentity::Serial(s.to_string()))
            .unwrap_or(DeviceIdentity::Unit(unit_id))
    }
}

Serial numbers take precedence. When the hardware exposes a serial string, OpenLogi creates a DeviceIdentity::Serial instance. If the serial is None—common with lighting accessories and certain haptic devices—the system falls back to DeviceIdentity::Unit using the provided 4-byte unit ID.

use openlogi_core::device_order::DeviceIdentity;

// Device with hardware serial
let id = DeviceIdentity::from_parts(Some("ABCD1234"), [0, 0, 0, 0]);
// Returns: DeviceIdentity::Serial("ABCD1234")

// Device without serial (e.g., RGB light)
let id = DeviceIdentity::from_parts(None, [0x01, 0x02, 0x03, 0x04]);
// Returns: DeviceIdentity::Unit([1, 2, 3, 4])

Persistent Identification in the Inventory System

The device inventory layer converts raw receiver data into persistent DeviceIdentity values. In crates/openlogi-device/src/inventory/standalone.rs, the code constructs DeviceModelInfo structures that store both the optional serial number and the raw unit ID, then canonicalizes them through DeviceIdentity::from_parts.

Cache Stability and Lookup

To prevent cache collisions when multiple receivers share the same serial or when multiple devices connect through a single receiver, crates/openlogi-device/src/inventory/probe.rs uses CacheKey::Bolt { unit_id } as the unique lookup key. This guarantees distinct cache entries for each physical receiver slot while maintaining stable identifiers across application restarts.

Why OpenLogi Uses Both Identifier Types

Serial numbers provide global uniqueness and survive device reboots, making them ideal for storing persistent user configurations like DPI settings or button remappings. Unit IDs provide a deterministic fallback for devices that lack hardware serials—particularly common in Logitech's lighting and haptic product lines—while remaining stable as long as the pairing relationship persists.

The orchestrator layer in crates/openlogi-agent-core/src/orchestrator.rs relies on this dual system to consistently route commands to the correct physical device, regardless of whether the underlying hardware exposes a traditional serial number.

Summary

  • OpenLogi identifies devices through the DeviceIdentity enum with Serial(String) and Unit([u8; 4]) variants defined in crates/openlogi-core/src/device_order.rs.
  • Receiver implementations in unifying.rs and bolt.rs extract 4-byte serial numbers and unit IDs via the HID++ GetReceiverInfo command.
  • The from_parts constructor prioritizes serial numbers, falling back to unit IDs only when hardware serials are absent.
  • The inventory system uses these identities to maintain stable cache keys in probe.rs and persistent device records in standalone.rs.
  • This dual approach ensures unique identification for both standard peripherals with burned-in serials and accessory devices that rely on receiver-assigned unit IDs.

Frequently Asked Questions

What is the difference between serial number and unit ID in OpenLogi?

The serial number is a hardware-burned identifier extracted as a 4-byte hex string from the device itself, providing global uniqueness. The unit ID is a 4-byte receiver-assigned identifier based on the pairing slot, used when the device lacks a hardware serial. Serials are preferred for persistence, while unit IDs act as a stable fallback.

How does OpenLogi handle devices without serial numbers?

When from_parts receives None for the serial parameter, it constructs a DeviceIdentity::Unit variant using the 4-byte unit ID. This commonly occurs with Logitech lights and haptic accessories, allowing the inventory system to track these devices reliably via their receiver pairing slot.

Where is the device identification logic implemented in the OpenLogi codebase?

The DeviceIdentity enum and its construction logic reside in crates/openlogi-core/src/device_order.rs. The extraction of raw identifiers from receivers happens in crates/openlogi-hidpp/src/receiver/unifying.rs and bolt.rs, while the inventory persistence layer in crates/openlogi-device/src/inventory/standalone.rs bridges these into the application's device model.

How does the inventory cache distinguish between multiple receivers?

The probe implementation in crates/openlogi-device/src/inventory/probe.rs uses CacheKey::Bolt { unit_id } to generate unique cache entries. This ensures that two receivers with identical serial numbers—or multiple devices connected through a single receiver—maintain separate cache entries based on their distinct unit IDs.

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 →