Back to Technology

USB Part 7: HID Devices

March 31, 2026Wasil Zafar20 min read

Master USB HID from report descriptor authoring to multi-report composite devices — keyboard, mouse, gamepad, and custom sensor HID, boot protocol vs report protocol, and TinyUSB HID implementation.

Table of Contents

  1. HID Class Architecture
  2. Report Descriptor Language
  3. Keyboard Implementation
  4. Mouse Implementation
  5. Gamepad Implementation
  6. Custom Sensor HID
  7. Boot Protocol
  8. OUT & Feature Reports
  9. Multi-Report Devices
  10. HID Tools & Debugging
  11. Exercises
  12. Conclusion & Next Steps
Series Context: This is Part 7 of the 17-part USB Development Mastery series. Part 6 covered CDC Virtual COM Port. Now we tackle HID — the device class that needs no custom driver on any major operating system, making it the fastest path from firmware to usable device on a host PC.

USB Development Mastery

Your 17-step learning path • Currently on Step 7

HID Class Architecture

The Human Interface Device (HID) class was defined by USB-IF to handle input and output for devices that humans interact with directly. Despite the name, HID is used for a remarkably wide range of purposes: traditional keyboards and mice, game controllers, uninterruptible power supply panels, barcode scanners, biometric sensors, and custom vendor devices that need a zero-driver-install path to the host.

Endpoint Configuration

HID uses interrupt endpoints, not bulk or isochronous:

  • Interrupt IN endpoint (mandatory): Device sends reports to the host. The host polls this endpoint at the interval specified by bInterval in the endpoint descriptor. For Full-Speed USB, bInterval is in milliseconds (minimum 1ms, maximum 255ms). For High-Speed, it is in units of 125µs (1=125µs, 4=1ms, 8=1ms, etc.).
  • Interrupt OUT endpoint (optional): Host sends reports to the device. Used for keyboard LED control, feature configuration, or device feedback. If omitted, host-to-device reports go through EP0 with SET_REPORT control requests.

HID Descriptor Chain

Unlike CDC, HID uses a single interface. The descriptor chain within that interface is: Interface Descriptor → HID Descriptor → Endpoint Descriptor(s). The HID descriptor (bDescriptorType=0x21) sits between the interface and endpoint descriptors and references the report descriptor:

FieldValueMeaning
bLength9Size of HID descriptor itself
bDescriptorType0x21HID descriptor type code
bcdHID0x0111HID spec version 1.11 (use 0x0111)
bCountryCode0x00Country code for localized keyboards (0=not localized)
bNumDescriptors0x01Number of subordinate descriptors (always 1 for report descriptor)
bDescriptorType[0]0x22Type of subordinate descriptor = Report Descriptor
wDescriptorLength[0]NByte length of the report descriptor (critical — must be exact)

The host fetches the report descriptor via a GET_DESCRIPTOR request for type 0x22. The report descriptor is where all the magic lives — it is a byte stream that defines the exact format and meaning of every byte in every report your device sends or receives.

Why No Driver Is Needed

Every major OS ships with a generic HID driver that can parse any compliant report descriptor and expose the device through a standardized interface. Windows uses hid.dll and hidclass.sys. Linux uses the usbhid kernel module. macOS uses IOHIDFamily. The report descriptor tells the driver what the data means — the driver does not need to know the vendor ID or device type ahead of time.

Report Descriptor Language

The HID report descriptor is a compact bytecode language. Each item is 1 to 5 bytes: a 1-byte prefix (containing item type, tag, and size) followed by 0, 1, 2, or 4 data bytes. The prefix byte is: bits[7:4]=tag, bits[3:2]=type, bits[1:0]=size.

Item Types

TypeCodeItems
Main0Input, Output, Feature, Collection, End Collection
Global1Usage Page, Logical Min/Max, Physical Min/Max, Unit, Report Size, Report ID, Report Count, Push, Pop
Local2Usage, Usage Min/Max, Designator, String Index

Collection Hierarchy

Every HID device wraps its reports in a Collection hierarchy. The top-level collection is always Application (0x01). Inside you may have Physical (0x00) collections to group related fields, and Logical (0x02) collections for abstract groupings.

Annotated 6-Key Keyboard + Mouse Descriptor

/* Combined keyboard (6-key rollover) + mouse report descriptor.
   Two Report IDs: 1=keyboard, 2=mouse.
   Total: ~68 bytes. Comments show byte values in hex. */

uint8_t const desc_hid_report[] = {

    /* ── KEYBOARD (Report ID 1) ─────────────────────────────── */
    0x05, 0x01,  /* Usage Page (Generic Desktop)         tag=0,type=1 */
    0x09, 0x06,  /* Usage (Keyboard)                     tag=0,type=2 */
    0xA1, 0x01,  /* Collection (Application)             tag=A,type=0 */
    0x85, 0x01,  /*   Report ID (1)                      tag=8,type=1 */

    /* Modifier byte: 8 bits, one per modifier key */
    0x05, 0x07,  /*   Usage Page (Key Codes)              */
    0x19, 0xE0,  /*   Usage Minimum (0xE0 = Left Control) */
    0x29, 0xE7,  /*   Usage Maximum (0xE7 = Right GUI)    */
    0x15, 0x00,  /*   Logical Minimum (0)                 */
    0x25, 0x01,  /*   Logical Maximum (1)                 */
    0x75, 0x01,  /*   Report Size (1 bit)                 */
    0x95, 0x08,  /*   Report Count (8 — 8 modifier bits)  */
    0x81, 0x02,  /*   Input (Data, Variable, Absolute)    */

    /* Reserved byte (required by boot protocol) */
    0x95, 0x01,  /*   Report Count (1)                    */
    0x75, 0x08,  /*   Report Size (8 bits)                */
    0x81, 0x03,  /*   Input (Constant) — reserved byte    */

    /* 6 key code slots: each 0x00-0xDD */
    0x95, 0x06,  /*   Report Count (6 key slots)          */
    0x75, 0x08,  /*   Report Size (8 bits each)           */
    0x15, 0x00,  /*   Logical Minimum (0)                 */
    0x25, 0xDD,  /*   Logical Maximum (0xDD = max keycode) */
    0x05, 0x07,  /*   Usage Page (Key Codes)              */
    0x19, 0x00,  /*   Usage Minimum (0)                   */
    0x29, 0xDD,  /*   Usage Maximum (0xDD)                */
    0x81, 0x00,  /*   Input (Data, Array, Absolute)       */
    0xC0,        /* End Collection                        */

    /* ── MOUSE (Report ID 2) ────────────────────────────────── */
    0x05, 0x01,  /* Usage Page (Generic Desktop)          */
    0x09, 0x02,  /* Usage (Mouse)                         */
    0xA1, 0x01,  /* Collection (Application)              */
    0x85, 0x02,  /*   Report ID (2)                       */
    0x09, 0x01,  /*   Usage (Pointer)                     */
    0xA1, 0x00,  /*   Collection (Physical)               */

    /* 3 buttons (left, right, middle) */
    0x05, 0x09,  /*     Usage Page (Buttons)              */
    0x19, 0x01,  /*     Usage Minimum (Button 1)          */
    0x29, 0x03,  /*     Usage Maximum (Button 3)          */
    0x15, 0x00,  /*     Logical Minimum (0)               */
    0x25, 0x01,  /*     Logical Maximum (1)               */
    0x95, 0x03,  /*     Report Count (3 buttons)          */
    0x75, 0x01,  /*     Report Size (1 bit each)          */
    0x81, 0x02,  /*     Input (Data, Variable, Absolute)  */

    /* 5 padding bits to align to byte boundary */
    0x95, 0x01,  /*     Report Count (1)                  */
    0x75, 0x05,  /*     Report Size (5 bits padding)      */
    0x81, 0x03,  /*     Input (Constant)                  */

    /* X and Y relative movement */
    0x05, 0x01,  /*     Usage Page (Generic Desktop)      */
    0x09, 0x30,  /*     Usage (X)                         */
    0x09, 0x31,  /*     Usage (Y)                         */
    0x09, 0x38,  /*     Usage (Wheel)                     */
    0x15, 0x81,  /*     Logical Minimum (-127)            */
    0x25, 0x7F,  /*     Logical Maximum (127)             */
    0x75, 0x08,  /*     Report Size (8 bits)              */
    0x95, 0x03,  /*     Report Count (3: X, Y, wheel)    */
    0x81, 0x06,  /*     Input (Data, Variable, Relative)  */

    0xC0,        /*   End Collection (Physical)           */
    0xC0         /* End Collection (Application)          */
};

Keyboard Report

The 8-byte boot keyboard report is the most important HID format to understand thoroughly — it is the format that BIOS, pre-boot environments, and many simple HID drivers expect. Even if your device uses report protocol, understanding the boot format clarifies the field layout.

Boot Keyboard Report Format

ByteFieldDescription
0ModifierBit mask: [7]R-GUI [6]R-Alt [5]R-Shift [4]R-Ctrl [3]L-GUI [2]L-Alt [1]L-Shift [0]L-Ctrl
1ReservedAlways 0x00 (required by boot protocol for padding)
2Keycode[0]First held key's HID keycode (0x00 = no key)
3Keycode[1]Second held key
4Keycode[2]Third held key
5Keycode[3]Fourth held key
6Keycode[4]Fifth held key
7Keycode[5]Sixth held key (6-key rollover limit)

Selected HID Keycodes

Keycode (hex)KeyKeycode (hex)Key
0x04a0x270 (zero)
0x05b0x28Enter / Return
0x1E10x29Escape
0x1F20x2ABackspace
0x2030x2BTab
0x2140x2CSpace
0x2250x3AF1
0x2360x3BF2
0x2470x4FRight Arrow
0x2580x50Left Arrow
0x2690x51Down Arrow

Typing "Hello" One Character Per Frame

/* hid_keyboard.c — type a string one character per USB polling interval */
#include "tusb.h"

/* ASCII to HID keycode conversion for uppercase A-Z and basic symbols.
   Returns {modifier, keycode} pair. modifier=0x02 means Left Shift. */
typedef struct { uint8_t mod; uint8_t key; } hid_key_t;

static hid_key_t ascii_to_hid(char c) {
    /* Lowercase a-z */
    if (c >= 'a' && c <= 'z') return (hid_key_t){0x00, (uint8_t)(0x04 + c - 'a')};
    /* Uppercase A-Z (shift + lowercase keycode) */
    if (c >= 'A' && c <= 'Z') return (hid_key_t){0x02, (uint8_t)(0x04 + c - 'A')};
    /* Digits */
    if (c >= '1' && c <= '9') return (hid_key_t){0x00, (uint8_t)(0x1E + c - '1')};
    if (c == '0') return (hid_key_t){0x00, 0x27};
    /* Punctuation */
    if (c == ' ')  return (hid_key_t){0x00, 0x2C};
    if (c == '\n') return (hid_key_t){0x00, 0x28};  /* Enter */
    if (c == '!')  return (hid_key_t){0x02, 0x1E};  /* Shift+1 */
    return (hid_key_t){0x00, 0x00};  /* unsupported character */
}

static const char *s_type_string = "Hello World!\n";
static int   s_type_index  = 0;
static bool  s_key_released = true;  /* alternate between key-down and key-up */

/* Call this from the main loop whenever tud_hid_ready() returns true */
void keyboard_task(void) {
    if (!tud_hid_ready()) return;

    if (s_type_string[s_type_index] == '\0') return;  /* done */

    if (s_key_released) {
        /* Send key-down report */
        hid_key_t k = ascii_to_hid(s_type_string[s_type_index]);
        uint8_t keycodes[6] = {k.key, 0, 0, 0, 0, 0};
        tud_hid_keyboard_report(HID_ITF_PROTOCOL_KEYBOARD, k.mod, keycodes);
        s_key_released = false;
    } else {
        /* Send key-up (all zeros) before next key */
        uint8_t keycodes[6] = {0, 0, 0, 0, 0, 0};
        tud_hid_keyboard_report(HID_ITF_PROTOCOL_KEYBOARD, 0, keycodes);
        s_key_released = true;
        s_type_index++;
    }
}

/* Required callback — host calls GET_REPORT via EP0 */
uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t report_id,
                                 hid_report_type_t report_type,
                                 uint8_t *buffer, uint16_t reqlen) {
    (void)itf; (void)report_id; (void)report_type; (void)reqlen;
    buffer[0] = 0;  /* no key currently pressed */
    return 1;
}

/* Required callback — host sends SET_REPORT (e.g. LED state) */
void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id,
                             hid_report_type_t report_type,
                             uint8_t const *buffer, uint16_t bufsize) {
    (void)itf; (void)report_id; (void)report_type; (void)buffer; (void)bufsize;
}

Mouse Report

The standard HID mouse uses relative movement — X and Y deltas tell the host how many units the mouse moved since the last report, not where on the screen the cursor is. Signed 8-bit integers are typically used, giving a range of -127 to +127 units per report.

Mouse Report Structure

ByteFieldDescription
0ButtonsBits [0]=Left, [1]=Right, [2]=Middle, [3-7]=padding (set to 0)
1XSigned 8-bit relative X movement (negative = left)
2YSigned 8-bit relative Y movement (negative = up)
3WheelSigned 8-bit scroll wheel delta (negative = scroll down)

USB Mouse Drawing a Circle

/* mouse_circle.c — USB mouse traces a circle using sine/cosine */
#include "tusb.h"
#include <math.h>   /* sinf(), cosf() — link with -lm */

#define CIRCLE_RADIUS  50    /* pixels (host DPI dependent) */
#define CIRCLE_STEPS   128   /* steps per full revolution */

static int   s_step    = 0;
static float s_prev_x  = (float)CIRCLE_RADIUS;
static float s_prev_y  = 0.0f;

void mouse_task(void) {
    if (!tud_hid_ready()) return;

    float angle  = (2.0f * 3.14159265f * (float)s_step) / (float)CIRCLE_STEPS;
    float curr_x = (float)CIRCLE_RADIUS * cosf(angle);
    float curr_y = (float)CIRCLE_RADIUS * sinf(angle);

    /* Delta from previous position (relative mouse movement) */
    int8_t dx = (int8_t)(curr_x - s_prev_x);
    int8_t dy = (int8_t)(curr_y - s_prev_y);

    s_prev_x = curr_x;
    s_prev_y = curr_y;

    /* hid_mouse_report_t: buttons, x, y, wheel, pan */
    tud_hid_mouse_report(HID_ITF_PROTOCOL_MOUSE,
                         0x00,  /* no buttons */
                         dx, dy,
                         0,     /* no wheel */
                         0);    /* no pan */

    s_step = (s_step + 1) % CIRCLE_STEPS;
}

Gamepad / Custom HID

TinyUSB ships with a pre-built HID_REPORT_DESC_GAMEPAD macro that defines a standard gamepad: two analog sticks (X/Y/Z/Rz axes), 32 buttons, and a D-pad hat switch. This is sufficient for most game controller applications and is compatible with the browser's GamepadAPI and WebHID API.

Gamepad Descriptor and Report

/* gamepad_hid.c — USB gamepad with 2 sticks, 16 buttons, hat switch */
#include "tusb.h"

/* tusb_config.h — enable HID */
/* #define CFG_TUD_HID  1 */

uint8_t const desc_hid_report[] = {
    HID_REPORT_DESC_GAMEPAD()   /* TinyUSB built-in gamepad descriptor macro */
};

/* hid_gamepad_report_t (from TinyUSB hid.h):
   typedef struct TU_ATTR_PACKED {
       int8_t   x;           // Left stick X: -127 to 127
       int8_t   y;           // Left stick Y
       int8_t   z;           // Right stick X (Z axis)
       int8_t   rz;          // Right stick Y (Rz axis)
       uint8_t  rx;          // Left trigger: 0-255
       uint8_t  ry;          // Right trigger: 0-255
       uint8_t  hat;         // D-pad: 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW,8=center
       uint32_t buttons;     // 32 button bits
   } hid_gamepad_report_t; */

static hid_gamepad_report_t s_gamepad = {0};

void gamepad_update(int8_t lx, int8_t ly, int8_t rx, int8_t ry,
                    uint32_t buttons, uint8_t hat) {
    s_gamepad.x       = lx;
    s_gamepad.y       = ly;
    s_gamepad.z       = rx;
    s_gamepad.rz      = ry;
    s_gamepad.buttons = buttons;
    s_gamepad.hat     = hat;
}

void gamepad_task(void) {
    if (tud_hid_ready()) {
        tud_hid_report(0, &s_gamepad, sizeof(s_gamepad));
    }
}

Three-Report HID (Keyboard + Mouse + Gamepad)

/* Multi-report HID: Report ID 1=keyboard, 2=mouse, 3=gamepad.
   One HID interface, three report IDs — avoids composite device complexity. */

uint8_t const desc_hid_report[] = {
    /* Report ID 1: keyboard (6KRO boot format) */
    HID_REPORT_DESC_KEYBOARD( HID_REPORT_ID(1) ),
    /* Report ID 2: mouse */
    HID_REPORT_DESC_MOUSE( HID_REPORT_ID(2) ),
    /* Report ID 3: gamepad */
    HID_REPORT_DESC_GAMEPAD( HID_REPORT_ID(3) )
};

/* Dispatch to correct report type based on which device is active */
void hid_multi_task(void) {
    if (!tud_hid_ready()) return;

    /* Send the report for whichever device has new data */
    /* (In a real implementation, use flags to track which report changed) */

    /* Keyboard example: send 'A' key press */
    uint8_t keycodes[6] = {HID_KEY_A, 0, 0, 0, 0, 0};
    tud_hid_keyboard_report(1, 0, keycodes);  /* Report ID 1 */
}

Custom Sensor HID (Vendor Usage Page)

For devices that do not fit any standard HID usage (sensor, measurement instrument, custom control panel), use the vendor-defined usage page (0xFF00–0xFFFF). The host's generic HID driver will still load — your application reads raw reports using the hid library or hidapi.

IMU Sensor HID Descriptor

/* Custom IMU sensor: 6-axis (3×int16 accelerometer + 3×int16 gyroscope).
   IN report: 12 bytes of sensor data (1 report ID + 12 data = 13 bytes total).
   Feature report: sensor configuration (sample rate, range). */

uint8_t const desc_hid_report_imu[] = {
    /* ── IN Report: sensor data (Report ID 1) ─── */
    0x06, 0x00, 0xFF,  /* Usage Page (Vendor Defined 0xFF00)  */
    0x09, 0x01,        /* Usage (Vendor Usage 0x01)           */
    0xA1, 0x01,        /* Collection (Application)            */
    0x85, 0x01,        /*   Report ID (1) — IN report         */

    /* 6 signed 16-bit values: Ax, Ay, Az, Gx, Gy, Gz */
    0x09, 0x02,        /*   Usage (Vendor: Accel X)           */
    0x09, 0x03,        /*   Usage (Vendor: Accel Y)           */
    0x09, 0x04,        /*   Usage (Vendor: Accel Z)           */
    0x09, 0x05,        /*   Usage (Vendor: Gyro X)            */
    0x09, 0x06,        /*   Usage (Vendor: Gyro Y)            */
    0x09, 0x07,        /*   Usage (Vendor: Gyro Z)            */
    0x15, 0x80,        /*   Logical Minimum (-32768 low byte) */
    0x26, 0xFF, 0x7F,  /*   Logical Maximum (32767)           */
    0x75, 0x10,        /*   Report Size (16 bits)             */
    0x95, 0x06,        /*   Report Count (6 values)           */
    0x81, 0x02,        /*   Input (Data, Variable, Absolute)  */

    /* ── Feature Report: config (Report ID 2) ─── */
    0x85, 0x02,        /*   Report ID (2) — Feature report    */
    0x09, 0x10,        /*   Usage (Vendor: Sample Rate)       */
    0x09, 0x11,        /*   Usage (Vendor: Accel Range)       */
    0x09, 0x12,        /*   Usage (Vendor: Gyro Range)        */
    0x15, 0x00,        /*   Logical Minimum (0)               */
    0x26, 0xFF, 0x00,  /*   Logical Maximum (255)             */
    0x75, 0x08,        /*   Report Size (8 bits)              */
    0x95, 0x03,        /*   Report Count (3 config bytes)     */
    0xB1, 0x02,        /*   Feature (Data, Variable, Absolute)*/

    0xC0               /* End Collection                      */
};

/* IMU report structure */
typedef struct __attribute__((packed)) {
    int16_t ax, ay, az;   /* Accelerometer: mg (milli-g) */
    int16_t gx, gy, gz;   /* Gyroscope: mdps (milli-degrees/sec) */
} imu_data_t;

/* Send IMU data at 100Hz (call from 10ms periodic task) */
void imu_hid_task(void) {
    if (!tud_hid_ready()) return;

    /* Read from your IMU sensor (MPU6050, ICM42688, etc.) */
    imu_data_t imu;
    imu_sensor_read(&imu);  /* your BSP function */

    /* Send as HID IN report with Report ID 1 */
    tud_hid_report(1, &imu, sizeof(imu));
}

Python Host: Reading the IMU via hidapi

pip install hid
/* Python code — imu_reader.py */
/*
import hid
import struct
import time

VID = 0xCafe
PID = 0x4002

dev = hid.device()
dev.open(VID, PID)
dev.set_nonblocking(0)  # blocking read

print(f"Manufacturer: {dev.get_manufacturer_string()}")
print(f"Product: {dev.get_product_string()}")

# Configure sensor: sample_rate=100Hz, accel_range=4g, gyro_range=500dps
# Feature report: [report_id, sample_rate, accel_range, gyro_range]
dev.send_feature_report([0x02, 100, 1, 1])

print("Reading IMU data (Ctrl+C to stop)...")
try:
    while True:
        # Read 13 bytes: 1 report_id + 12 data bytes (6 × int16)
        data = dev.read(13)
        if data and data[0] == 0x01:
            ax, ay, az, gx, gy, gz = struct.unpack_from('<6h', bytes(data), 1)
            print(f"Accel: X={ax:6d} Y={ay:6d} Z={az:6d} mg  "
                  f"Gyro: X={gx:6d} Y={gy:6d} Z={gz:6d} mdps")
        time.sleep(0.01)
except KeyboardInterrupt:
    pass
finally:
    dev.close()
*/

Boot Protocol vs Report Protocol

BIOS and UEFI firmware need to handle keyboard and mouse input before the OS loads. They cannot parse arbitrary report descriptors, so the HID spec defines a boot protocol with a fixed, hardcoded report format that all compliant boot-capable keyboards and mice must support.

PropertyBoot ProtocolReport Protocol
Report formatFixed 8-byte keyboard / 3-byte mouseDefined by report descriptor
Parser requiredNone — format is hardcodedFull descriptor parser
Who uses itBIOS, UEFI, pre-boot screensOS after boot
Activated bySET_PROTOCOL(0) requestSET_PROTOCOL(1) or default
bInterfaceSubClassMust be 0x010x00 (no boot support)
bInterfaceProtocol0x01=keyboard, 0x02=mouse0x00=none

Handling Protocol Switching

/* boot_keyboard.c — keyboard supporting both boot and report protocol */
#include "tusb.h"

static uint8_t s_protocol = HID_PROTOCOL_REPORT;  /* start in report protocol */

/* TinyUSB calls this when host sends SET_PROTOCOL */
uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t report_id,
                                 hid_report_type_t report_type,
                                 uint8_t *buffer, uint16_t reqlen) {
    /* In boot protocol, report_id is 0 (no report IDs used).
       In report protocol, report_id matches your descriptor. */
    (void)itf; (void)report_id; (void)report_type; (void)reqlen;

    if (s_protocol == HID_PROTOCOL_BOOT) {
        /* Return 8-byte boot format */
        memset(buffer, 0, 8);
        return 8;
    } else {
        /* Return current report-protocol data */
        memset(buffer, 0, reqlen);
        return reqlen;
    }
}

/* TinyUSB calls this when protocol changes (BIOS switch to boot, OS switch to report) */
void tud_hid_set_protocol_cb(uint8_t itf, uint8_t protocol) {
    (void)itf;
    s_protocol = protocol;
    /* protocol: 0 = HID_PROTOCOL_BOOT, 1 = HID_PROTOCOL_REPORT */
}

/* Send keyboard report in the correct format for current protocol */
void keyboard_send(uint8_t modifier, uint8_t keycode) {
    if (!tud_hid_ready()) return;

    if (s_protocol == HID_PROTOCOL_BOOT) {
        /* Boot format: 8 bytes, no report ID */
        uint8_t buf[8] = {modifier, 0, keycode, 0, 0, 0, 0, 0};
        tud_hid_report(0, buf, 8);
    } else {
        /* Report format with Report ID */
        uint8_t keycodes[6] = {keycode, 0, 0, 0, 0, 0};
        tud_hid_keyboard_report(1, modifier, keycodes);
    }
}

/* Interface must declare boot support in its descriptor:
   bInterfaceSubClass = 0x01   (supports boot interface)
   bInterfaceProtocol = 0x01   (keyboard)
   These values go in the USB interface descriptor. */

OUT & Feature Reports

Host-to-device communication in HID uses either OUT reports (through the interrupt OUT endpoint or EP0 SET_REPORT) or Feature reports (always through EP0 GET/SET_REPORT). The most common example is keyboard LED control.

Keyboard LED Output Report

/* Keyboard LED output report format (standard HID):
   Byte 0: LED bits
     bit 0: Num Lock
     bit 1: Caps Lock
     bit 2: Scroll Lock
     bit 3: Compose
     bit 4: Kana
     bits 5-7: padding (constant 0)
*/

/* GPIO-connected LEDs */
#define LED_PIN_NUMLOCK    GPIO_PIN_0
#define LED_PIN_CAPSLOCK   GPIO_PIN_1
#define LED_PIN_SCROLLLOCK GPIO_PIN_2
#define LED_GPIO_PORT      GPIOB

void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id,
                             hid_report_type_t report_type,
                             uint8_t const *buffer, uint16_t bufsize) {
    (void)itf; (void)report_id;

    if (report_type == HID_REPORT_TYPE_OUTPUT && bufsize >= 1) {
        /* buffer[0] contains the LED bitmask */
        uint8_t led_state = buffer[0];

        HAL_GPIO_WritePin(LED_GPIO_PORT, LED_PIN_NUMLOCK,
            (led_state & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET);
        HAL_GPIO_WritePin(LED_GPIO_PORT, LED_PIN_CAPSLOCK,
            (led_state & 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET);
        HAL_GPIO_WritePin(LED_GPIO_PORT, LED_PIN_SCROLLLOCK,
            (led_state & 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET);
    }

    if (report_type == HID_REPORT_TYPE_FEATURE && bufsize >= 1) {
        /* Feature report received — save configuration to flash */
        uint8_t config_byte = buffer[0];
        flash_config_write(CONFIG_KEY_HID, &config_byte, 1);
    }
}

/* Host can read back Feature report via GET_REPORT */
uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t report_id,
                                 hid_report_type_t report_type,
                                 uint8_t *buffer, uint16_t reqlen) {
    (void)itf; (void)report_id;

    if (report_type == HID_REPORT_TYPE_FEATURE && reqlen >= 1) {
        /* Read config from flash and return it */
        flash_config_read(CONFIG_KEY_HID, buffer, 1);
        return 1;
    }
    return 0;
}

Multi-Report HID with Consumer Control

Using multiple Report IDs in a single HID interface is a powerful technique: you get keyboard, mouse, and media key functionality all from one USB interface, without needing a composite device or multiple endpoints. The host driver routes each report to the appropriate OS subsystem based on the Report ID and usage pages.

Three-Report Descriptor with Consumer Control

/* Three reports: 1=keyboard, 2=mouse, 3=consumer control (media keys) */

uint8_t const desc_hid_report_triple[] = {
    /* ── Report ID 1: Keyboard ─────────────────────────────── */
    HID_REPORT_DESC_KEYBOARD( HID_REPORT_ID(1) ),

    /* ── Report ID 2: Mouse ─────────────────────────────────── */
    HID_REPORT_DESC_MOUSE( HID_REPORT_ID(2) ),

    /* ── Report ID 3: Consumer Control (media keys) ──────────── */
    0x05, 0x0C,        /* Usage Page (Consumer)                  */
    0x09, 0x01,        /* Usage (Consumer Control)               */
    0xA1, 0x01,        /* Collection (Application)               */
    0x85, 0x03,        /*   Report ID (3)                        */
    0x15, 0x00,        /*   Logical Minimum (0)                  */
    0x25, 0x01,        /*   Logical Maximum (1)                  */
    0x75, 0x01,        /*   Report Size (1 bit)                  */
    0x95, 0x10,        /*   Report Count (16 keys)               */
    /* Media key usages */
    0x09, 0xB5,        /*   Usage (Scan Next Track)              */
    0x09, 0xB6,        /*   Usage (Scan Previous Track)          */
    0x09, 0xB7,        /*   Usage (Stop)                         */
    0x09, 0xCD,        /*   Usage (Play/Pause)                   */
    0x09, 0xE2,        /*   Usage (Mute)                         */
    0x09, 0xE9,        /*   Usage (Volume Increment)             */
    0x09, 0xEA,        /*   Usage (Volume Decrement)             */
    0x09, 0x23,        /*   Usage (WWW Home)                     */
    0x09, 0x94,        /*   Usage (My Computer)                  */
    0x09, 0x92,        /*   Usage (Calculator)                   */
    0x09, 0x2A,        /*   Usage (WWW Favorites)                */
    0x09, 0x21,        /*   Usage (WWW Search)                   */
    0x09, 0x26,        /*   Usage (WWW Stop)                     */
    0x09, 0x24,        /*   Usage (WWW Back)                     */
    0x09, 0x31,        /*   Usage (WWW Forward)                  */
    0x09, 0x27,        /*   Usage (WWW Refresh)                  */
    0x81, 0x02,        /*   Input (Data, Variable, Absolute)     */
    0xC0               /* End Collection                         */
};

/* Media key bitmask (matches usage order above) */
typedef enum {
    MEDIA_NEXT_TRACK   = (1 << 0),
    MEDIA_PREV_TRACK   = (1 << 1),
    MEDIA_STOP         = (1 << 2),
    MEDIA_PLAY_PAUSE   = (1 << 3),
    MEDIA_MUTE         = (1 << 4),
    MEDIA_VOL_UP       = (1 << 5),
    MEDIA_VOL_DOWN     = (1 << 6),
    MEDIA_WWW_HOME     = (1 << 7),
} media_key_t;

/* Send a media key press and release */
void media_key_press(media_key_t key) {
    if (!tud_hid_ready()) return;
    /* Key down */
    uint16_t report = (uint16_t)key;
    tud_hid_report(3, &report, sizeof(report));
    /* Small delay, then key up */
    board_delay(10);
    report = 0;
    tud_hid_report(3, &report, sizeof(report));
}

/* Dispatch reports from main loop based on which device has new data */
static uint8_t s_active_report = 0;  /* 1=kb, 2=mouse, 3=media */

void hid_triple_task(void) {
    if (!tud_hid_ready()) return;

    switch (s_active_report) {
        case 1: {
            /* Send any pending keyboard data */
            uint8_t keycodes[6] = {0};
            tud_hid_keyboard_report(1, 0, keycodes);
            break;
        }
        case 2:
            tud_hid_mouse_report(2, 0, 0, 0, 0, 0);
            break;
        case 3: {
            uint16_t report = 0;
            tud_hid_report(3, &report, sizeof(report));
            break;
        }
    }
}

HID Descriptor Tools & Debugging

Writing report descriptors by hand is error-prone. The following tools are essential for authoring, validating, and debugging HID descriptors.

Tool Reference

ToolPlatformPurpose
HID Descriptor Tool (dt2_4.exe)WindowsOfficial USB.org GUI tool for authoring descriptors. Validates syntax and generates C arrays. Download from usb.org/document-library.
eleccelerator.com HID parserWeb (any)Paste a C array or hex string; it parses and annotates every item. Excellent for understanding existing descriptors.
usbhid-dumpLinuxReads back the actual report descriptor from a connected device: sudo usbhid-dump -d cafe:4002
hidtest.exe / HIDTestAppWindowsMicrosoft HID test tool — sends/receives reports, displays descriptor parse tree.
Wireshark + USBPcapWindows/LinuxCaptures USB traffic including HID reports. Filter: usb.transfer_type == 0x01 for interrupt transfers.

Common Report Descriptor Mistakes

  • Missing End Collection: Every Collection must have a matching End Collection (0xC0). The host parser will reject the descriptor silently if these are mismatched.
  • Wrong Report Size × Count: The total bits described by Report Size × Report Count must equal the actual report payload. If your report is 8 bytes (64 bits) but the descriptor sums to 63 bits, the host may reject or misparse it.
  • Logical Min > Logical Max: For signed values, Logical Minimum must be expressed correctly. For -127 to 127: use 0x15, 0x81 (signed byte representation) not 0x15, 0xFF.
  • Wrong wDescriptorLength: The HID descriptor field wDescriptorLength must exactly match sizeof(desc_hid_report). Off-by-one errors here cause the host to read garbage or fail silently.
  • Report ID in boot protocol: If bInterfaceProtocol is keyboard (0x01) or mouse (0x02) and the device supports boot protocol, the boot-mode report must NOT include a Report ID byte prefix — the 8-byte or 3-byte format is fixed.

Reading Back Your Descriptor with usbhid-dump

# Install usbhid-dump (part of usbutils on most distros)
sudo apt install usbutils   # Debian/Ubuntu

# List connected HID devices
sudo usbhid-dump -l

# Dump the report descriptor for a specific device (replace VID:PID)
sudo usbhid-dump -d cafe:4002 -e descriptor

# Stream live HID reports (shows raw bytes of every IN report)
sudo usbhid-dump -d cafe:4002 -e stream -t 10

Practical Exercises

Beginner: Button-Triggered Keyboard

Wire a tactile button to a GPIO pin. When the button is pressed, your device sends the HID keyboard report for "Hello World\n" — one keycode per polling interval. Requirements: debounce the button in firmware (10ms debounce window); send key-up between each key-down; verify in a text editor that the typed string appears correctly with proper capitalization of 'H', 'W' (shift modifier).

/* Skeleton: debounced button → keyboard output */
static bool s_btn_prev = false;
static bool s_typing   = false;

void button_keyboard_task(void) {
    bool btn_now = (HAL_GPIO_ReadPin(BTN_GPIO, BTN_PIN) == GPIO_PIN_RESET);
    if (btn_now && !s_btn_prev) {
        /* Rising edge detected after debounce */
        s_typing   = true;
        s_type_index = 0;
        s_key_released = true;
    }
    s_btn_prev = btn_now;

    if (s_typing) {
        keyboard_task();  /* from Section 3 */
        if (s_type_string[s_type_index] == '\0') s_typing = false;
    }
}

Intermediate: WebHID Gamepad

Build a USB gamepad with two analog axes (connected to ADC channels) and 8 digital buttons (connected to GPIO pins). Implement the TinyUSB gamepad descriptor. Verify using the browser's Gamepad API at gamepad-tester.com. Requirements: ADC values must be scaled to int8_t range (-127 to 127); button debounce of 5ms; send a report every polling interval even if no state changed (prevents host timeout on some OSes).

Advanced: IMU Sensor with Real-Time Python Visualization

Connect an I2C IMU (e.g., MPU6050, ICM42688, or LSM6DS3) to your MCU. Implement the vendor-defined IMU HID descriptor from Section 6. On the device side: read the sensor via I2C DMA at 100Hz; send HID IN reports at 100Hz; implement the Feature report to allow the host to configure sample rate and measurement range. On the host side: write a Python script using the hid library that reads quaternion data (computed by a simple Mahony or Madgwick filter running on the MCU) and displays a 3D orientation visualization using matplotlib or OpenGL.

Generate Your HID Design Document

Document your USB HID device design — report descriptors, polling interval, MCU target, and implementation notes — and export a structured design specification for sharing with your team.

USB HID Design Document Generator

Capture your HID configuration, report descriptor decisions, and implementation notes in a shareable document.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Next Steps

USB HID is the most powerful zero-driver-install class in the USB ecosystem. Here is a summary of what we covered:

  • Architecture: HID uses interrupt endpoints with a host-defined polling interval. A single interface contains the HID descriptor (which references the report descriptor) and one or two interrupt endpoints. No custom driver is ever needed on Windows, Linux, or macOS.
  • Report descriptor language: A compact bytecode format defining every bit of every report. Global items set context (usage page, logical range, report size/count). Local items assign usage. Main items commit fields (Input, Output, Feature, Collection).
  • Keyboard: 8-byte boot format with modifier byte + reserved byte + 6 keycodes. Use tud_hid_keyboard_report() in TinyUSB. Retarget typed string output by sending key-down / key-up pairs.
  • Mouse: Relative X/Y/wheel deltas. Use tud_hid_mouse_report(). Absolute positioning requires declaring Usage (X/Y) as Physical with absolute flag.
  • Gamepad: TinyUSB's HID_REPORT_DESC_GAMEPAD macro handles the descriptor. Works with browser WebHID API and native game input APIs.
  • Custom sensor HID: Vendor usage page (0xFF00) enables arbitrary data formats. Host reads raw bytes using hidapi / Python hid library. Feature reports allow bi-directional configuration.
  • Boot protocol: Required for BIOS/UEFI support. Declare bInterfaceSubClass=0x01 and handle SET_PROTOCOL switching in tud_hid_set_protocol_cb().
  • Multi-report: Multiple Report IDs in one interface give you keyboard + mouse + media keys from a single HID descriptor — no composite device needed.
Next Article: Part 8 — USB Mass Storage. We implement a USB MSC device from scratch: SCSI command parsing, integrating FATFS, implementing a RAM disk for rapid prototyping, and connecting to real flash or SD card storage.
Technology