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:
- discover the HID service (
0x1812),
- read the Report Map characteristic (
0x2A4B) into dev->config.report_maps[],
- register the Report (
0x2A4D) and Boot (0x2A22 / 0x2A32 / 0x2A33) characteristics into
dev->reports, incrementing dev->reports_len,
- subscribe to the corresponding CCCDs in
attach_report_listeners(),
- 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
- 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).
- Pair and connect any HOGP peripheral (a BLE keyboard).
- 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:
-
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.
-
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.
-
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.
Environment
masterandrelease/v5.5)esp-hosted)CONFIG_BT_NIMBLE_ENABLED=y)esp_hid, filecomponents/esp_hid/src/nimble_hidh.cTK-KB032(public address), HOGP / HID over GATTLine numbers below refer to
origin/masterat commite9da155a72624fce88b8ef2cf3cde9aee2e6067f(2026-07-02). The last commit touching this file on
masteriscce63cb7("fix(nimble): Always read initial BAS level", 2026-05-15).
The affected region of
read_device_services()is byte-identical onorigin/master,origin/release/v5.5and tagv6.0.2(checked withgit show <ref>:components/esp_hid/src/nimble_hidh.c;v6.0.2differs frommasteronly by the recursive-mutex change and the initial BAS read, bothoutside this function).
Expected behaviour
After a BLE HID device is connected and encrypted,
esp_hidhshould:0x1812),0x2A4B) intodev->config.report_maps[],0x2A4D) and Boot (0x2A22/0x2A32/0x2A33) characteristics intodev->reports, incrementingdev->reports_len,attach_report_listeners(),ESP_HIDH_INPUT_EVENTfor 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_lenstays0, nothing is subscribed, andESP_HIDH_INPUT_EVENTis 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:
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
esp_hidhwith theNimBLE backend (the
bluetooth/esp_hid_hostexample is sufficient).esp_hidh_dev_dump()reportsReport Maps: 1but an empty map, thatdev->reports_len == 0, and that noESP_HIDH_INPUT_EVENTis ever delivered when keys arepressed.
Root cause
read_device_services()walks the discovered services and, for each service, walks itscharacteristics. The dispatch on the service UUID is an
if / else ifchain that covers GAP,BAS and DIS only — there is no branch for the HID service
BLE_SVC_HID_UUID16(0x1812):Three points:
No HID branch. The
suuidchain never testsBLE_SVC_HID_UUID16. When the current serviceis 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.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 thatif/elseend withcontinue, so line 530 is unreachable for any characteristic. These are exactly the lines thatread the Report Map (0x2A4B) and that allocate/populate the
esp_hidh_dev_report_tentriesfor the Report and Boot characteristics.
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 DeviceInformation 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 itselfunreachable for a second reason: it sits in the
elseofif (properties & READ)(line 493) yetimmediately re-tests
properties & BLE_GATT_CHR_PROP_READat line 520, a condition that is falseby construction in that branch.
Consequence
reportis declaredesp_hidh_dev_report_t *report = NULL;at line 387 and is assigned onlyat line 543, inside the dead region. Every downstream action is guarded by
report != NULL:dev->reportsand incrementingdev->reports_len;Since
reportis never assigned, all of these are skipped.dev->reports_lenstays0, so theloop at line 681 (
if (dev->reports_len && ...)) never parses a report map either, andattach_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 HIDreport, 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):
Two further points that would make the fix complete:
reportshould be reset toNULLafter it has been linked intodev->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.
if (suuid == BLE_SVC_HID_UUID16) { hidindex++; }atline 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 theelseof theif (char_result[c].properties & BLE_GATT_CHR_PROP_READ)at line531, itself nested inside
if (cuuid == BLE_SVC_HID_CHR_UUID16_REPORT_MAP)at line 530. Reachingit would require
cuuidto be0x2A4Band one of0x2A22 / 0x2A32 / 0x2A33 / 0x2A4Dat thesame time. Even if the branch became reachable, no report would ever be allocated.
Unguarded NULL dereference, lines 575-580. The final
elseof that same chain writesreport->protocol_mode,report->report_type,report->usageandreport->value_lenwith noreport != NULLcheck, while every other use ofreportin the function is guarded. On the firstcharacteristic of a device this pointer is still the
NULLfrom line 387, so making the regionreachable without fixing this would turn the bug into a crash.
Impact
The BLE HID host role of
esp_hidon the NimBLE backend is non-functional: report descriptors arenever read and input reports are never delivered, for any HOGP peripheral. Applications using
esp_hidhwith NimBLE (as opposed to Bluedroid) currently have to bypass the component and driveGATT discovery / CCCD subscription themselves.