Skip to content

Migrate ESP32 BLE from Bluedroid to NimBLE-Arduino#104

Merged
jonasniesner merged 4 commits into
OpenDisplay:mainfrom
davelee98:nimble-arduino-esp32-migration
Jul 17, 2026
Merged

Migrate ESP32 BLE from Bluedroid to NimBLE-Arduino#104
jonasniesner merged 4 commits into
OpenDisplay:mainfrom
davelee98:nimble-arduino-esp32-migration

Conversation

@davelee98

Copy link
Copy Markdown
Contributor

Summary

Replaces the arduino-esp32 built-in Bluedroid BLE stack (BLEDevice.h family) with h2zero/NimBLE-Arduino 2.5.0 for all ESP32 targets. The nRF52840 (Adafruit Bluefruit) path is untouched.

Primary motivation: Bluedroid's BLEServer::getConnectedCount() returns incorrect values, and every ESP32 power/sleep and advertising decision keys off that count (9 call sites). NimBLEServer::getConnectedCount() is reliable — verified in the installed source that the peer is added to m_connectedPeers before onConnect fires — so all call sites are fixed by the stack swap alone, with no logic changes.

Secondary benefit: ~36 KB flash reduction on esp32-s3-N16R8 (Bluedroid no longer linked).

Approach (minimal diff)

A single #include <NimBLEDevice.h> plus using BLEServer = NimBLEServer; (etc.) alias block in ble_init.h lets the ~30 existing BLE* spellings compile unchanged. Only the genuinely-different NimBLE 2.x call sites were edited:

  • NIMBLE_PROPERTY:: characteristic properties (no BLECharacteristic::PROPERTY_*)
  • 2.x callbacks take NimBLEConnInfo& (onConnect/onDisconnect/onWrite/onSubscribe)
  • CCCD auto-created for NOTIFY; BLE2902 removed, subscription tracked via onSubscribe
  • setCallbacks(&cb, false) so static callback objects aren't deleted on deinit()
  • advertising: enableScanResponse / setPreferredParams; setAdvertisementData() kept last before start()
  • dropped the deprecated no-op pService->start()

platformio.ini pulls NimBLE-Arduino into the shared [env] and excludes it from the nRF env via lib_ignore.

Commits

  1. Migrate ESP32 BLE from Bluedroid to NimBLE-Arduino — the core swap.
  2. Advertising payload fixenableScanResponse()/setPreferredParams() reset NimBLE's custom-data flag; calling them after setAdvertisementData() made start() discard the name + manufacturer-id-9286 payload, leaving the device undiscoverable. Reordered so setAdvertisementData() is last.
  3. Binary write fixonWrite no longer converts getValue() to Arduino String (the strlen-based conversion truncated pipe frames at their leading 0x00); holds a NimBLEAttValue instead.
  4. Reliability — USB idle path uses idleDelay(5) instead of idleDelay(2000) (removes up-to-2s first-exchange stall); response notify uses notify(data,len) (snapshots into an mbuf, race-free on the shared RX/TX characteristic) and stops advancing the ring on notify() failure (no dropped pipe ACKs).

Validation

  • All 9 ESP32 envs + the nRF env build clean (sources force-recompiled); dependency graph shows NimBLE-Arduino @ 2.5.0 and no Bluedroid BLE library for ESP32; nRF Bluefruit path unaffected.
  • On-hardware (esp32-s3, ODB8B185): connect, subscribe, binary write, command processing, and notify response all verified end-to-end; advertising restarts on disconnect.

🤖 Generated with Claude Code

davelee98 and others added 4 commits July 17, 2026 00:59
Replace the arduino-esp32 built-in Bluedroid BLE stack (BLEDevice.h family)
with h2zero/NimBLE-Arduino (2.5.0) for all ESP32 targets. The nRF52840
(Bluefruit) path is untouched.

Primary motivation: Bluedroid's BLEServer::getConnectedCount() returns
incorrect values, and every ESP32 power/sleep and advertising decision keys
off that count. NimBLEServer::getConnectedCount() is reliable, so all nine
call sites are fixed by the stack swap alone with no logic changes.
Secondary benefit: ~36 KB flash reduction (esp32-s3-N16R8).

Approach keeps the diff minimal: a single NimBLE include plus BLE* -> NimBLE*
type aliases in ble_init.h lets the ~30 existing BLE* spellings compile
unchanged; only the genuinely different NimBLE 2.x call sites are edited:
- NIMBLE_PROPERTY:: characteristic properties (no BLECharacteristic::PROPERTY_*)
- 2.x callbacks take NimBLEConnInfo& (onConnect/onDisconnect/onWrite/onSubscribe)
- CCCD auto-created for NOTIFY; BLE2902 removed, subscription tracked via onSubscribe
- setCallbacks(&cb, false) so static callback objects are not deleted on deinit
- advertising: enableScanResponse / setPreferredParams; setManufacturerData(bytes,16)
- dropped deprecated no-op pService->start()

platformio.ini pulls NimBLE-Arduino into the shared [env] and excludes it from
the nRF env via lib_ignore. Verified: all 9 ESP32 envs build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPcsPnhpVnAeA3zHaaQ7eE
NimBLEAdvertising tracks whether a custom advertisement payload was set via an
internal m_advDataSet flag. setAdvertisementData() sets it true, but
enableScanResponse() and setPreferredParams() reset it to false. The port
called those AFTER setAdvertisementData(), so at start() NimBLE discarded the
custom payload (name + manufacturer data) and re-advertised the piecemeal
builder instead — a 128-bit service UUID with no manufacturer id 9286 and no
name. Clients discover tags by manufacturer id 9286, so the device became
undiscoverable and could not be connected to.

Make setAdvertisementData() the last advertising-data call before start() in
both ble_init_esp32() and updatemsdata(). Drop enableScanResponse(false)
(scan response is off by default in NimBLE 2.x) and setPreferredParams()
(only affects the overridden builder); this matches Bluedroid's prior on-air
behavior, which advertised name + flags + 16-byte manufacturer data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPcsPnhpVnAeA3zHaaQ7eE
onWrite read the characteristic via `String value = getValue()`. NimBLE's
NimBLEAttValue -> Arduino String conversion uses the C-string (strlen)
constructor, which truncates at the first 0x00 byte. Pipe-write frames begin
with 0x00 (00 70 / 00 71 / 00 81), so every write was seen as length 0
("Empty data received") and dropped.

Hold the raw NimBLEAttValue instead; its length()/c_str() preserve the full
binary payload including embedded and leading NUL bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPcsPnhpVnAeA3zHaaQ7eE
Three reliability fixes surfaced while validating the NimBLE migration (all
pre-existing behavior; NimBLE lets us fix the notify ones cleanly):

- Idle loop: the non-battery (USB) idle path used idleDelay(2000), which
  stalled BLE command/response servicing for up to 2 s when a client connected
  mid-delay — a sluggish, unreliable-feeling first exchange. Use idleDelay(5),
  matching the battery idle-hold cadence.

- Notify payload race: RX and TX are the same characteristic. setValue(data,len)
  followed by no-arg notify() sends whatever value is currently stored, so a
  client streaming WRITE_NR pipe frames could overwrite it between the two calls
  and corrupt the outgoing ACK. notify(data,len) snapshots the payload into an
  mbuf immediately, eliminating the window.

- Dropped ACKs: the drain advanced the response ring even when notify() failed
  (BLE_HS_ENOMEM under mbuf exhaustion), permanently losing a pipe ACK and
  stalling the client's window. On failure, break and keep the entry queued to
  retry next pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPcsPnhpVnAeA3zHaaQ7eE
@davelee98
davelee98 requested a review from jonasniesner as a code owner July 17, 2026 05:44
@jonasniesner
jonasniesner merged commit d4da951 into OpenDisplay:main Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants