Skip to content

esp_hid: NimBLE HID host never reads the HID Report Map — Report Map / Report characteristic handling is dead code inside the DIS branch of read_device_services() (IDFGH-18187) #19011

Description

@raoulh

Environment

Item Value
IDF version v6.0.2 (also present on master and release/v5.5)
Host target ESP32-P4 (application CPU, BLE host role: central)
Controller ESP32-C6 running as a radio co-processor, HCI over SDIO (esp-hosted)
Bluetooth host stack NimBLE (CONFIG_BT_NIMBLE_ENABLED=y)
Component esp_hid, file components/esp_hid/src/nimble_hidh.c
Peripheral under test BLE keyboard TK-KB032 (public address), HOGP / HID over GATT
Build system idf.py, CMake

Line numbers below refer to origin/master at commit e9da155a72624fce88b8ef2cf3cde9aee2e6067f
(2026-07-02). The last commit touching this file on master is cce63cb7
("fix(nimble): Always read initial BAS level", 2026-05-15).

The affected region of read_device_services() is byte-identical on origin/master,
origin/release/v5.5 and tag v6.0.2 (checked with git show <ref>:components/esp_hid/src/nimble_hidh.c;
v6.0.2 differs from master only by the recursive-mutex change and the initial BAS read, both
outside this function).

Expected behaviour

After a BLE HID device is connected and encrypted, esp_hidh should:

  1. discover the HID service (0x1812),
  2. read the Report Map characteristic (0x2A4B) into dev->config.report_maps[],
  3. register the Report (0x2A4D) and Boot (0x2A22 / 0x2A32 / 0x2A33) characteristics into
    dev->reports, incrementing dev->reports_len,
  4. subscribe to the corresponding CCCDs in attach_report_listeners(),
  5. emit ESP_HIDH_INPUT_EVENT for every notification received.

Observed behaviour

Connection, pairing, bonding and encryption all succeed. GAP, DIS and BAS characteristics are read
correctly. However, no HID service handle is ever read, the report map stays empty,
dev->reports_len stays 0, nothing is subscribed, and ESP_HIDH_INPUT_EVENT is never emitted.
The link is then dropped by supervision timeout (HCI reason 0x08) ~28 s later for lack of traffic,
although the discovery itself had completed normally after 3.4 s.

Relevant log excerpts:

NIMBLE_HIDH: ENC_CHANGE status=0 encrypted=1 authenticated=0 bonded=1
NIMBLE_HIDH: failed to find chars for service : 0
NIMBLE_HIDH: read_char failed
esp_hidh_dev_reports_get() failed / report map 0 empty

DIS reads succeed (VID/PID via PnP ID, manufacturer name "Beijing OnMicro") and the BAS battery
level is notified, so GATT itself is working — only the HID part of the discovery produces nothing.

Steps to reproduce

  1. Build an ESP-IDF application in the BLE central / HID host role using esp_hidh with the
    NimBLE backend (the bluetooth/esp_hid_host example is sufficient).
  2. Pair and connect any HOGP peripheral (a BLE keyboard).
  3. Observe that esp_hidh_dev_dump() reports Report Maps: 1 but an empty map, that
    dev->reports_len == 0, and that no ESP_HIDH_INPUT_EVENT is ever delivered when keys are
    pressed.

Root cause

read_device_services() walks the discovered services and, for each service, walks its
characteristics. The dispatch on the service UUID is an if / else if chain that covers GAP,
BAS and DIS only — there is no branch for the HID service BLE_SVC_HID_UUID16 (0x1812)
:

472                 if (suuid == BLE_SVC_GAP_UUID16) {
             ...
485                 } else if (suuid == BLE_SVC_BAS_UUID16) {
             ...
492                 } else if (suuid == BLE_SVC_DIS_UUID16) {
493                     if (char_result[c].properties & BLE_GATT_CHR_PROP_READ) {
494                         if (cuuid == BLE_SVC_DIS_CHR_UUID16_PNP_ID) {            /* DIS */
500                         } else if (cuuid == ..._MANUFACTURER_NAME) {             /* DIS */
508                         } else if (cuuid == ..._SERIAL_NUMBER) {                 /* DIS */
516                         }
517                         continue;                    /* <-- exit 1 */
518                     } else {
519                         if (cuuid == BLE_SVC_HID_CHR_UUID16_PROTOCOL_MODE) {     /* HID (!) */
             ...
527                         }
528                         continue;                    /* <-- exit 2 */
529                     }
530                     if (cuuid == BLE_SVC_HID_CHR_UUID16_REPORT_MAP) {   /* UNREACHABLE */
             ...        /* reads 0x2A4B, allocates and fills `report` for 0x2A4D / boot chars */
583                     }
584                 }

Three points:

  1. No HID branch. The suuid chain never tests BLE_SVC_HID_UUID16. When the current service
    is the HID service, none of the three branches is taken, control falls straight through to the
    descriptor-discovery code that follows (line 585 onwards) with report == NULL.

  2. Lines 530-583 are dead code. They sit inside the DIS branch, after the if (READ) { ... continue; } else { ... continue; } at lines 493-529: both paths of that if/else end with
    continue, so line 530 is unreachable for any characteristic. These are exactly the lines that
    read the Report Map (0x2A4B) and that allocate/populate the esp_hidh_dev_report_t entries
    for the Report and Boot characteristics.

  3. The intent is clearly a misplaced brace, not deliberate logic. Line 519 tests
    BLE_SVC_HID_CHR_UUID16_PROTOCOL_MODE — a HID characteristic — from inside the Device
    Information Service
    branch. The whole region from line 519 to line 583 is evidently meant to be
    the body of an else if (suuid == BLE_SVC_HID_UUID16) branch. That Protocol Mode read is itself
    unreachable for a second reason: it sits in the else of if (properties & READ) (line 493) yet
    immediately re-tests properties & BLE_GATT_CHR_PROP_READ at line 520, a condition that is false
    by construction in that branch.

Consequence

report is declared esp_hidh_dev_report_t *report = NULL; at line 387 and is assigned only
at line 543, inside the dead region. Every downstream action is guarded by report != NULL:

  • lines 596, 613, 624, 635, 667 — appending the report to dev->reports and incrementing
    dev->reports_len;
  • line 654 — recording the CCCD handle of a report characteristic;
  • line 544 — the allocation guard itself.

Since report is never assigned, all of these are skipped. dev->reports_len stays 0, so the
loop at line 681 (if (dev->reports_len && ...)) never parses a report map either, and
attach_report_listeners() (line 1291) has nothing to subscribe to.

Net effect: with the NimBLE backend, esp_hid's HID host (HOGP) role cannot receive a single HID
report, with any peripheral.

Suggested fix

Close the DIS branch after its own characteristics and open a real
else if (suuid == BLE_SVC_HID_UUID16) branch around the HID handling. Minimal patch (indicative —
it also removes the two impossible conditions described in the next section):

--- a/components/esp_hid/src/nimble_hidh.c
+++ b/components/esp_hid/src/nimble_hidh.c
@@ -492,60 +492,51 @@ static void read_device_services(esp_hidh_dev_t *dev)
                 } else if (suuid == BLE_SVC_DIS_UUID16) {
                     if (char_result[c].properties & BLE_GATT_CHR_PROP_READ) {
                         if (cuuid == BLE_SVC_DIS_CHR_UUID16_PNP_ID) {
                             /* ... unchanged ... */
                         } else if (cuuid == BLE_SVC_DIS_CHR_UUID16_MANUFACTURER_NAME) {
                             /* ... unchanged ... */
                         } else if (cuuid == BLE_SVC_DIS_CHR_UUID16_SERIAL_NUMBER) {
                             /* ... unchanged ... */
                         }
-                        continue;
-                    } else {
-                        if (cuuid == BLE_SVC_HID_CHR_UUID16_PROTOCOL_MODE) {
-                            if (char_result[c].properties & BLE_GATT_CHR_PROP_READ) {
-                                if (read_char(dev->ble.conn_id, chandle, &rdata, &rlen) == 0 && rlen) {
-                                    dev->protocol_mode[hidindex] = *((uint8_t *)rdata);
-                                    free(rdata);
-                                    rdata = NULL;
-                                }
-                            }
-                        }
-                        continue;
                     }
+                    continue;
+                } else if (suuid == BLE_SVC_HID_UUID16) {
+                    if (cuuid == BLE_SVC_HID_CHR_UUID16_PROTOCOL_MODE) {
+                        if (char_result[c].properties & BLE_GATT_CHR_PROP_READ) {
+                            if (read_char(dev->ble.conn_id, chandle, &rdata, &rlen) == 0 && rlen) {
+                                dev->protocol_mode[hidindex] = *((uint8_t *)rdata);
+                                free(rdata);
+                                rdata = NULL;
+                            }
+                        }
+                        continue;
+                    }
                     if (cuuid == BLE_SVC_HID_CHR_UUID16_REPORT_MAP) {
                         if (char_result[c].properties & BLE_GATT_CHR_PROP_READ) {
                             if (read_char(dev->ble.conn_id, chandle, &rdata, &rlen) == 0 && rlen) {
                                 uint8_t *copy = nimble_hidh_dup_bytes(rdata, rlen);
                                 if (copy) {
                                     free((void *)dev->config.report_maps[hidindex].data);
                                     dev->config.report_maps[hidindex].data = copy;
                                     dev->config.report_maps[hidindex].len = rlen;
                                 }
                             }
-                            continue;
-                        } else if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_INP || cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_OUT
-                                   || cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_MOUSE_INP || cuuid == BLE_SVC_HID_CHR_UUID16_RPT) {
-                            report = (esp_hidh_dev_report_t *)malloc(sizeof(esp_hidh_dev_report_t));
-                            if (report == NULL) {
-                                ESP_LOGE(TAG, "malloc esp_hidh_dev_report_t failed");
-                                goto done;
-                            }
-                            report->next = NULL;
-                            report->permissions = char_result[c].properties;
-                            report->handle = chandle;
-                            report->ccc_handle = 0;
-                            report->report_id = 0;
-                            report->map_index = hidindex;
-                            if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_INP) {
-                                /* ... unchanged ... */
-                            } else if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_OUT) {
-                                /* ... unchanged ... */
-                            } else if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_MOUSE_INP) {
-                                /* ... unchanged ... */
-                            } else {
-                                /* ... unchanged ... */
-                            }
-                        } else {
-                            report->protocol_mode = ESP_HID_PROTOCOL_MODE_REPORT;
-                            report->report_type = 0;
-                            report->usage = ESP_HID_USAGE_GENERIC;
-                            report->value_len = 0;
                         }
+                        continue;
+                    }
+                    if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_INP || cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_OUT
+                            || cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_MOUSE_INP || cuuid == BLE_SVC_HID_CHR_UUID16_RPT) {
+                        report = (esp_hidh_dev_report_t *)malloc(sizeof(esp_hidh_dev_report_t));
+                        if (report == NULL) {
+                            ESP_LOGE(TAG, "malloc esp_hidh_dev_report_t failed");
+                            goto done;
+                        }
+                        report->next = NULL;
+                        report->permissions = char_result[c].properties;
+                        report->handle = chandle;
+                        report->ccc_handle = 0;
+                        report->report_id = 0;
+                        report->map_index = hidindex;
+                        if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_INP) {
+                            report->protocol_mode = ESP_HID_PROTOCOL_MODE_BOOT;
+                            report->report_type = ESP_HID_REPORT_TYPE_INPUT;
+                            report->usage = ESP_HID_USAGE_KEYBOARD;
+                            report->value_len = 8;
+                        } else if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_OUT) {
+                            report->protocol_mode = ESP_HID_PROTOCOL_MODE_BOOT;
+                            report->report_type = ESP_HID_REPORT_TYPE_OUTPUT;
+                            report->usage = ESP_HID_USAGE_KEYBOARD;
+                            report->value_len = 8;
+                        } else if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_MOUSE_INP) {
+                            report->protocol_mode = ESP_HID_PROTOCOL_MODE_BOOT;
+                            report->report_type = ESP_HID_REPORT_TYPE_INPUT;
+                            report->usage = ESP_HID_USAGE_MOUSE;
+                            report->value_len = 8;
+                        } else {
+                            report->protocol_mode = ESP_HID_PROTOCOL_MODE_REPORT;
+                            report->report_type = 0;
+                            report->usage = ESP_HID_USAGE_GENERIC;
+                            report->value_len = 0;
+                        }
                     } else {
                         continue;
                     }
                 }

Two further points that would make the fix complete:

  • report should be reset to NULL after it has been linked into dev->reports (lines 596-600,
    613-617, 624-628, 635-639, 667-671), or at the top of each characteristic iteration, so that a
    later characteristic can never re-link an already-inserted node.
  • With the branch restored, the existing if (suuid == BLE_SVC_HID_UUID16) { hidindex++; } at
    line 674 keeps working as intended for devices exposing several HID service instances.

Secondary defects in the same region

Both are consequences of the same misplaced brace and disappear with the patch above, but they are
worth listing explicitly:

  • Impossible condition, lines 541-542. The else if (cuuid == BLE_SVC_HID_CHR_UUID16_BOOT_KBD_INP || ...) is the else of the if (char_result[c].properties & BLE_GATT_CHR_PROP_READ) at line
    531, itself nested inside if (cuuid == BLE_SVC_HID_CHR_UUID16_REPORT_MAP) at line 530. Reaching
    it would require cuuid to be 0x2A4B and one of 0x2A22 / 0x2A32 / 0x2A33 / 0x2A4D at the
    same time. Even if the branch became reachable, no report would ever be allocated.

  • Unguarded NULL dereference, lines 575-580. The final else of that same chain writes
    report->protocol_mode, report->report_type, report->usage and report->value_len with no
    report != NULL check, while every other use of report in the function is guarded. On the first
    characteristic of a device this pointer is still the NULL from line 387, so making the region
    reachable without fixing this would turn the bug into a crash.

Impact

The BLE HID host role of esp_hid on the NimBLE backend is non-functional: report descriptors are
never read and input reports are never delivered, for any HOGP peripheral. Applications using
esp_hidh with NimBLE (as opposed to Bluedroid) currently have to bypass the component and drive
GATT discovery / CCCD subscription themselves.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions