-
Notifications
You must be signed in to change notification settings - Fork 37
HID++ Protocol
This page is a deep dive into the HID++ 2.0 protocol as implemented in Logitune. It covers report format, feature discovery, the features used, notification handling, and the Bolt receiver protocol.
HID++ 2.0 uses two report types:
Byte: [0] [1] [2] [3] [4..6]
reportId deviceIndex featureIndex funcId|swId params (3 bytes)
0x10
Byte: [0] [1] [2] [3] [4..19]
reportId deviceIndex featureIndex funcId|swId params (16 bytes)
0x11
| Field | Bits | Description |
|---|---|---|
reportId |
8 |
0x10 for short, 0x11 for long |
deviceIndex |
8 | Device slot: 0xFF for direct (USB/BT), 1-6 for receiver slots |
featureIndex |
8 | Device-assigned index (NOT the feature ID — resolved via Root feature) |
functionId |
4 (upper) | Function number within the feature (0-15) |
softwareId |
4 (lower) | Caller identifier for response matching (0 = notification, 1-15 = request) |
params |
3 or 16 | Function-specific parameters |
Byte[3] packs two 4-bit fields:
Byte[3] = (functionId << 4) | softwareId
Example: functionId=2, softwareId=5 -> byte[3] = 0x25
An error response has featureIndex = 0xFF:
Byte: [0] [1] [2] [3] [4] [5] [6]
0x11 deviceIndex 0xFF featureIndex funcId errorCode 0x00
Error codes (from HidppTypes.h):
| Code | Name | Description |
|---|---|---|
0x00 |
NoError | Success |
0x01 |
Unknown | Unknown error |
0x02 |
InvalidArgument | Bad parameter value |
0x03 |
OutOfRange | Value out of allowed range |
0x04 |
HwError | Hardware error (device busy, command too fast) |
0x05 |
Busy | Device is busy processing another command |
0x09 |
Unsupported | Feature or function not supported |
0x0B |
InvalidAddress | Invalid memory address |
In HidppTypes.h:
struct Report {
uint8_t reportId{};
uint8_t deviceIndex{};
uint8_t featureIndex{};
uint8_t functionId{}; // upper 4 bits of byte[3]
uint8_t softwareId{}; // lower 4 bits of byte[3]
std::array<uint8_t, 16> params{};
int paramLength{};
std::vector<uint8_t> serialize() const;
static std::optional<Report> parse(std::span<const uint8_t> data);
bool isError() const;
ErrorCode errorCode() const;
};Every HID++ 2.0 device supports the Root feature at index 0. It provides getFeatureID (function 0):
Request: featureIndex=0x00, functionId=0, params=[featureId_hi, featureId_lo]
Response: params[0] = featureIndex (device-assigned), params[1] = featureType
sequenceDiagram
participant FD as FeatureDispatcher
participant TR as Transport
participant Dev as Device
Note over FD: Known features: 0x0005, 0x1004, 0x1814, 0x1b04, 0x2110, 0x2121, 0x2150, 0x2201
FD->>TR: Root.getFeatureID(0x0005)
TR->>Dev: [0x11, devIdx, 0x00, 0x0A, 0x00, 0x05, ...]
Dev-->>TR: [0x11, devIdx, 0x00, 0x0A, 0x01, 0x00, ...]
Note over FD: DeviceName 0x0005 -> index 0x01
FD->>TR: Root.getFeatureID(0x1004)
Dev-->>TR: response with index 0x03
Note over FD: BatteryUnified 0x1004 -> index 0x03
FD->>TR: Root.getFeatureID(0x1b04)
Dev-->>TR: response with index 0x05
Note over FD: ReprogControlsV4 0x1b04 -> index 0x05
Note over FD: ... continues for all known features ...
Note over FD: If response index = 0, feature not supported
The feature table is stored as std::unordered_map<FeatureId, uint8_t>. All subsequent calls use FeatureDispatcher::call() or callAsync(), which look up the feature index automatically.
Functions:
| FunctionId | Name | Params | Response |
|---|---|---|---|
| 0 | GetStatus | (none) | level (%), charging status |
Response parsing:
// params[0] = battery level (0-100)
// params[1] = next level (predictive)
// params[2] = status: 0=discharging, 1-3=charging, 4=chargedNotifications: The device sends battery notifications when the level changes or charging state changes. Same format as GetStatus response.
This feature controls button remapping (diversion). When a button is "diverted," the device sends its press/release events to the host software instead of performing the default action.
Functions:
| FunctionId | Name | Params | Response |
|---|---|---|---|
| 3 | SetControlReporting | CID_hi, CID_lo, flags | (none significant) |
| 4 | GetControlReporting | CID_hi, CID_lo | CID, flags |
SetControlReporting flags byte:
Bit 0: divert (1 = send to software, 0 = native)
Bit 1: dvalid (1 = divert bit is valid)
Bit 2: rawXY (1 = also send raw XY deltas)
Bit 3: rvalid (1 = rawXY bit is valid)
Logitune's implementation:
// From hidpp/features/ReprogControls.cpp
auto ReprogControls::buildSetDivert(uint16_t controlId, bool divert, bool rawXY)
-> std::array<uint8_t, 4>
{
uint8_t flags = 0x02; // dvalid=1
if (divert) flags |= 0x01;
if (rawXY) flags |= 0x0C; // rawXY=1, rvalid=1
return {
static_cast<uint8_t>(controlId >> 8),
static_cast<uint8_t>(controlId & 0xFF),
flags,
0x00
};
}Notifications:
| FunctionId | Event | Params |
|---|---|---|
| 0 | DivertedButtonEvent | CID_hi, CID_lo (0x0000 = all released) |
| 1 | DivertedRawXYEvent | dx_hi, dx_lo, dy_hi, dy_lo (int16, big-endian) |
The release event sends CID=0 (all buttons released), not the CID of the released button. This is important for gesture resolution — the gesture completes when controlId == 0.
Functions:
| FunctionId | Name | Params | Response |
|---|---|---|---|
| 0 | GetStatus | (none) | mode, autoDisengage, default |
| 1 | SetStatus | mode, autoDisengage | (none significant) |
Mode values:
| Mode | Meaning |
|---|---|
| 1 | Freespin (free-spinning scroll wheel) |
| 2 | Ratchet / SmartShift active (click-by-click scroll with auto-disengage) |
autoDisengage: Threshold (1-255) at which the wheel switches from ratchet to freespin during fast scrolling. Higher = more force needed to trigger freespin.
Functions:
| FunctionId | Name | Params | Response |
|---|---|---|---|
| 1 | GetWheelMode | (none) | mode byte |
| 2 | SetWheelMode | mode byte | (none significant) |
| 3 | GetRatchetSwitch | (none) | ratchet state |
Mode byte bits:
Bit 0: target (0=HID, 1=HID++)
Bit 1: resolution (0=low-res, 1=hi-res)
Bit 2: invert (0=standard, 1=inverted/natural)
Bit 3: analytics (reserved)
Logitune reads the full mode byte and only modifies bits 1 (hiRes) and 2 (invert), preserving the rest.
Notifications:
| FunctionId | Event | Params |
|---|---|---|
| 1 | RatchetSwitch | params[0]: 0=freespin, 1=ratchet |
This notification fires when the physical SmartShift button on the mouse is pressed.
Functions:
| FunctionId | Name | Params | Response |
|---|---|---|---|
| 0 | GetInfo | (none) | resolution, capabilities, defaultDirection |
| 1 | GetStatus | (none) | divert, invert flags |
| 2 | SetReporting | divert, invert | (confirmed) |
GetInfo response:
params[0-1]: nativeResolution (uint16, big-endian)
params[2-3]: divertedResolution (uint16, big-endian)
params[4]: bit 0 = defaultDirection (0=positive when left, 1=positive when right)
SetReporting params:
params[0]: divert (0x00=native, 0x01=diverted to software)
params[1]: invert (0x00=normal, 0x01=inverted direction)
Notifications (when diverted):
params[0-1]: rotation delta (int16, signed, big-endian)
The MX Master 3S has defaultDirection = 0, meaning positive deltas correspond to leftward/backward rotation. Logitune normalizes this:
// In AppController::onThumbWheelRotation:
int normalized = delta * m_deviceManager.thumbWheelDefaultDirection();
// defaultDirection=0 -> thumbWheelDefaultDirection=-1
// Multiplying by -1 makes clockwise = positiveFunctions:
| FunctionId | Name | Params | Response |
|---|---|---|---|
| 0 | GetSensorDpiList | sensorIdx | minDPI, maxDPI, stepDPI |
| 1 | GetSensorDpi | sensorIdx | currentDPI |
| 2 | SetSensorDpi | sensorIdx, dpi_hi, dpi_lo | (none significant) |
The MX Master 3S has one sensor (index 0) with range 200-8000 and step 50.
Functions:
| FunctionId | Name | Params | Response |
|---|---|---|---|
| 0 | GetHostInfo | (none) | hostCount, currentHost |
| 2 | GetCookies | (none) | cookie bytes (non-zero = paired) |
Used to display Easy-Switch host info. GetCookies returns one byte per host slot — a non-zero value indicates the slot is paired with a host.
Functions:
| FunctionId | Name | Params | Response |
|---|---|---|---|
| 0 | GetNameLength | (none) | name length in bytes |
| 1 | GetName | offset_hi, offset_lo | up to 13 bytes of name |
The device name is read in 13-byte chunks. For the MX Master 3S, the name is "MX Master 3S" (12 bytes, one chunk).
When a device connects or disconnects from a Bolt or Unifying receiver, the receiver sends a HID++ 1.0 notification with register 0x41:
Byte: [0] [1] [2] [3] [4] [5] [6]
0x10 deviceIndex 0x41 ?? flags ?? ??
flags (params[0]):
Bit 6: link (0 = established, 1 = not established)
stateDiagram-v2
[*] --> Connected : Device on receiver slot
Connected --> SoftDisconnect : Register 0x41, bit 6 = 1
Note right of SoftDisconnect : Keep hidraw fd open\nClear CommandQueue\nReset features\nEmit deviceDisconnected
SoftDisconnect --> Reconnecting : Register 0x41, bit 6 = 0
Note right of Reconnecting : Start 1500ms debounce timer\nCancel any pending timer
Reconnecting --> Connected : Timer fires\nenumerateAndSetup()
Reconnecting --> Reconnecting : Another 0x41 (link=1)\nrestart timer
Key behavior:
- Soft disconnect: The hidraw fd stays open. Only logical state is reset. This allows detecting when the device reconnects on the same receiver.
- 1500ms debounce: Multiple DeviceConnection notifications arrive during device boot. Sending HID++ calls too early results in HwError. The 1500ms delay ensures the device is ready.
- Timer cancellation: If multiple "link established" notifications arrive, only the last one triggers re-enumeration.
When a Bolt receiver is found but no device is on any slot, Logitune keeps the receiver fd open and watches for incoming traffic:
// QSocketNotifier on receiver fd
// Any HID++ traffic from device index 1-6 means a device appeared
if (bytes.size() >= 3 && bytes[1] >= 1 && bytes[1] <= 6) {
// Device arrived — disconnect current transport, probe receiver again
}When using callAsync() (fire-and-forget write), the response arrives later via the QSocketNotifier on the hidraw fd. Without a way to distinguish responses from notifications, the response gets misinterpreted.
Example bug: thumb wheel SetReporting (function 0x02) sends a response with featureIndex matching ThumbWheel. If softwareId is 0, this looks like a thumb wheel rotation notification with delta = 256 (the SetReporting confirmation bytes interpreted as rotation).
FeatureDispatcher::callAsync() assigns a rotating softwareId (1-15):
uint8_t FeatureDispatcher::nextSoftwareId() {
uint8_t id = m_nextSwId;
m_nextSwId = (m_nextSwId % 15) + 1; // rotate 1-15
return id;
}In DeviceManager::handleNotification(), the first check is:
if (report.softwareId != 0) {
if (m_features)
m_features->handleResponse(report);
return; // Not a notification — don't process as input event
}handleResponse() looks up the pending callback by softwareId and invokes it:
bool FeatureDispatcher::handleResponse(const Report &report) {
auto it = m_pendingCallbacks.find(report.softwareId);
if (it != m_pendingCallbacks.end()) {
if (it->second)
it->second(report);
m_pendingCallbacks.erase(it);
return true;
}
return false;
}Transport::sendRequest() implements synchronous send+receive with retry:
flowchart TD
Send[Write report to hidraw fd] --> Read[Read response with timeout]
Read --> Parse{Parse response}
Parse -->|Valid response| CheckErr{Is error?}
Parse -->|Timeout| Retry{Retries left?}
Parse -->|Wrong device/feature| Discard[Discard, read again]
CheckErr -->|HwError or Busy| Retry
CheckErr -->|Other error| ReturnErr[Return error report]
CheckErr -->|No error| ReturnOk[Return response]
Retry -->|Yes| Wait[Wait 50ms] --> Send
Retry -->|No| ReturnNone[Return nullopt]
The CommandQueue adds its own retry logic on top:
-
3 retries per command (
kMaxRetries = 3) -
50ms retry delay (
kRetryDelayMs = 50) - If all retries fail, the command is dropped and the queue moves to the next command
| Scenario | Error | Solution |
|---|---|---|
| Commands sent too fast | HwError (0x04) | CommandQueue 10ms pacing |
| Device sleeping | Timeout / HwError | Sleep/wake detection + re-enumeration |
| Device disconnected from receiver | Timeout | DeviceConnection notification handling |
| Wrong hidraw interface | Timeout | sysfs report descriptor check before opening |
| Feature not supported | Unsupported (0x09) |
hasFeature() check before calling |
These open-source projects were invaluable references for the HID++ protocol:
- Solaar — Python-based Logitech device manager. Comprehensive HID++ implementation with excellent documentation.
- logiops — C++ Logitech device daemon. Good reference for ReprogControls, SmartShift, and gesture handling.
- libratbag — C library for configuring gaming mice. Covers HID++ 1.0 and 2.0.
- Logitech HID++ 2.0 specification — Unofficial protocol documentation collected from various sources.
# Watch raw HID++ traffic
sudo cat /dev/hidrawN | xxd
# Run Logitune with full protocol logging
./build/src/app/logitune --debug 2>&1 | grep lcHidpp
# Identify hidraw interfaces for a device
ls -la /sys/class/hidraw/*/device/
# Read report descriptor (check for HID++ report IDs)
xxd /sys/class/hidraw/hidrawN/device/report_descriptor
# Look for 0x85 0x11 (long report) or 0x85 0x10 (short report)Logitune — Linux configurator for Logitech peripherals · Source · GPL-3.0