Skip to content

Navigation Menu

Sign in
Sign up

HID++ Protocol

Mina Maher edited this page Apr 1, 2026 · 6 revisions

πŸ”Œ 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.

Note

For how this protocol layer fits into the overall system, see Architecture.

πŸ“¦ Report Format

HID++ 2.0 uses two report types:

πŸ“„ Short Report (7 bytes)

Byte: [0] [1] [2] [3] [4..6]
 reportId deviceIndex featureIndex funcId|swId params (3 bytes)
 0x10

πŸ“„ Long Report (20 bytes)

Byte: [0] [1] [2] [3] [4..19]
 reportId deviceIndex featureIndex funcId|swId params (16 bytes)
 0x11

πŸ“‹ Field Details

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] Encoding

Byte[3] packs two 4-bit fields:

Byte[3] = (functionId << 4) | softwareId

Tip

Example: functionId=2, softwareId=5 -> byte[3] = 0x25

❌ Error Report

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

πŸ’» Logitune Implementation

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;
};

πŸ” Feature Discovery

🏠 Root Feature (0x0000)

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

πŸ”„ Enumeration Flow

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
Loading

Note

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.

πŸ”§ Key Features

πŸ”‹ Battery Unified (0x1004)

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=charged

Tip

The device sends battery notifications when the level changes or charging state changes. Same format as GetStatus response.

πŸ”€ ReprogControlsV4 (0x1b04)

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)

Important

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.

βš™οΈ SmartShift (0x2110)

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)

Tip

autoDisengage: Threshold (1-255) at which the wheel switches from ratchet to freespin during fast scrolling. Higher = more force needed to trigger freespin.

πŸ“œ HiResWheel (0x2121)

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)

Note

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.

πŸ”„ ThumbWheel (0x2150)

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)

🧭 Direction Normalization

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 = positive

🎚️ AdjustableDPI (0x2201)

Functions:

FunctionId Name Params Response
0 GetSensorDpiList sensorIdx minDPI, maxDPI, stepDPI
1 GetSensorDpi sensorIdx currentDPI
2 SetSensorDpi sensorIdx, dpi_hi, dpi_lo (none significant)

Note

The MX Master 3S has one sensor (index 0) with range 200-8000 and step 50.

πŸ”Œ ChangeHost (0x1814)

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.

🏷️ DeviceName (0x0005)

Functions:

FunctionId Name Params Response
0 GetNameLength (none) name length in bytes
1 GetName offset_hi, offset_lo up to 13 bytes of name

Tip

The device name is read in 13-byte chunks. For the MX Master 3S, the name is "MX Master 3S" (12 bytes, one chunk).

πŸ“‘ Bolt Receiver Protocol

πŸ”— DeviceConnection Notifications (Register 0x41)

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)

πŸ”„ Handling in Logitune

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
Loading

Key behavior:

Behavior Details
πŸ”— 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.

πŸ“‘ Receiver Slot Probing

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
}

πŸ”— softwareId for Async Response Matching

❓ The Problem

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.

Caution

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). This was the "delta=256 bug".

βœ… The Solution

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;
}

πŸ›‘οΈ Error Handling and Retry

πŸ“‘ Transport Layer

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]
Loading

πŸ“¨ CommandQueue Layer

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

⚠️ Common Error Scenarios

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

πŸ“š References

These open-source projects were invaluable references for the HID++ protocol:

Project Description
🐍 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.
πŸ“– HID++ 2.0 specification Unofficial protocol documentation collected from various sources.

πŸ”§ Useful Commands for Protocol Debugging

# 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 Wiki


🏠 Home

πŸ“š User Guide

πŸ—οΈ Architecture

πŸ”§ Extending

πŸ§ͺ Quality

Clone this wiki locally

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /