Skip to content

Bluetooth BLE Module (`bluetooth_ble`)

The bluetooth_ble module provides a BLE GATT Client transport for the ESP32 CNC pendant. It allows the pendant to communicate with CNC controllers (e.g., BTT SKR Pico) via Bluetooth Low Energy, using a UART-over-BLE protocol. This is one of four independent transport options alongside Serial, BT Serial (SPP), and Socket Client — see connection_management.md for the shared connection model.

⚠️ Hardware constraint: Bluetooth and WiFi are mutually exclusive on supported boards (no PSRAM). Only one radio transport may be active at a time. See cmake/sanity_check.cmake.


PropertyValue
Source pathmain/modules/bt_ble/
Feature guardESP3D_BT_BLE_FEATURE
RoleGATT Client (pendant side), connects to a remote CNC BLE server
ProtocolUART-over-BLE via service UUID 0xFFF0
Radio mode enumESP3DRadioMode::bluetooth_ble (= 5)
Global singletonbtBleClient (ESP3DBTBleClient)
Scan limitESP3D_BT_MAX_SCAN_RESULTS = 20 devices
Default MTU247 bytes (BTT module maximum)
FileResponsibility
esp3d_bt_ble_client.hESP3DBTBleClient class declaration, public API
esp3d_bt_ble_client.cppGAP/GATTC callbacks, lifecycle, TX/RX data path
esp3d_bt_ble_config.hesp3d_bt_ble_config_t configuration structure
boards/*/components/bsp/bt_ble_def.hBoard-specific default configuration instance

uses

discovers via scan()

ESP3DClient

-_rx_queue deque

-_tx_queue deque

-_rx_mutex pthread_mutex_t

-_tx_mutex pthread_mutex_t

+begin()

+handle()

+end()

+flush()

+process(msg)

+addRxData(msg)

+addTxData(msg)

+popRx() : ESP3DMessage

+popTx() : ESP3DMessage

+clearRxQueue()

+clearTxQueue()

+enqueueTxByPriority(msg)

ESP3DBTBleClient

-_config esp3d_bt_ble_config_t

-_gattc_if esp_gatt_if_t

-_conn_id uint16_t

-_mtu uint16_t

-_uart_ready bool

-_rxBuffer uint8_t

-_rxbuf_mutex pthread_mutex_t

-_autoconnect bool

-_reconnect_attempts uint8_t

-_init_command_sent bool

+begin() : bool

+handle()

+end()

+process(msg)

+scan(devices) : bool

+connect() : bool

+disconnect() : bool

+isConnected() : bool

+canSendData() : bool

+clearTargetDevice() : bool

+clearBondedDevices() : bool

+pushMsgToRxQueue(msg, size) : bool

+esp_ble_gap_cb(event, param)

+esp_ble_gattc_cb(event, gattc_if, param)

esp3d_bt_ble_config_t

+device_name char[32]

+scan_duration uint16_t

+rx_buffer_size size_t

+tx_buffer_size size_t

+mtu uint16_t

+service_uuid esp_bt_uuid_t

+char_uuid esp_bt_uuid_t

BTDevice

+addr esp_bd_addr_t

+name string

+rssi int8_t

GAP / GATTC events

BLE RF

popRx → process

set_value

readString / writeString

getInitCommand

btBleClient.scan()

disconnect / clearTargetDevice

clearBondedDevices

main.cpp

ESP3DX::begin()

ESP3DNetwork

(startBtBleMode / stopBtBleMode)

ESP3DBTBleClient

(btBleClient)

ESP-IDF Bluedroid Stack

(BT Controller + GAP + GATTC)

Remote CNC Controller

(BTT SKR Pico / BLE UART server

UUID 0xFFF0)

esp3dCommands.process()

ESP3DValues

(connection_status, server_status)

ESP3DSettings / NVS

(btble_address, btble_name, btble_passkey)

ESP3DGCodeHandlerService

(getInitCommand)

scan_bt_screen

(BLE device discovery UI)

settings_list_screen

(Forget BT Host)


VALUESCNC BLE ServerBluedroid StackESP3DBTBleClientESP3DNetworkVALUESCNC BLE ServerBluedroid StackESP3DBTBleClientESP3DNetworkbegin()configure(&esp3dBTBleConfig)malloc(_rxBuffer, 1024 bytes)pthread_mutex_init (tx, rx, rxbuf)esp_bt_controller_mem_release(CLASSIC_BT)esp_bt_controller_init(BLE mode)esp_bt_controller_enable(BLE)esp_bluedroid_init()esp_bluedroid_enable()esp_ble_gap_set_security_param(...) x6esp_ble_gatt_set_local_mtu(247)esp_ble_gap_register_callback()esp_ble_gattc_register_callback()esp_ble_gattc_app_register(0)ESP_GATTC_REG_EVT → _gattc_if savedclearBondedDevices()connect()esp_ble_gattc_open(_gattc_if, addr, PUBLIC, true)set_value(connection_status, "T")BLE connection requestBLE connection responseESP_GATTC_OPEN_EVT (status OK)esp_ble_gattc_send_mtu_req()ESP_GATTC_CFG_MTU_EVT → _mtu savedesp_ble_gattc_search_service(NULL)ESP_GATTC_SEARCH_RES_EVT (UUID 0xFFF0 found)_service_start/end_handle savedESP_GATTC_SEARCH_CMPL_EVT_tx_char_handle = start+2, _rx_char_handle = start+4esp_ble_gattc_register_for_notify(_rx_char_handle)ESP_GATTC_REG_FOR_NOTIFY_EVTesp_ble_gattc_write_char_descr(CCCD, notify_en=1)ESP_GATTC_WRITE_DESCR_EVT (OK)_uart_ready = trueset_value(connection_status, "C")sendInitCommand() Bluedroid StackESP3DBTBleClientESP3DNetworkBluedroid StackESP3DBTBleClientESP3DNetworkStack fully stopped - no more callbacks can fireend()flush(), clearRxQueue(), clearTxQueue()esp_ble_gattc_app_unregister(_gattc_if)esp_bluedroid_disable()esp_bluedroid_deinit()esp_bt_controller_disable()esp_bt_controller_deinit()pthread_mutex_destroy (tx, rx)setTxMutex(NULL), setRxMutex(NULL)free(_rxBuffer)

Design note: The Bluedroid stack is shut down before destroying mutexes. This prevents a race where an in-flight ESP_GATTC_NOTIFY_EVT (writing _rxBuffer and locking _rxbuf_mutex on the BTC task) races against mutex destruction on the network task.


All transports share the same five-state connection model. See connection_management.md for the full cross-transport description.

module not started

connect() called, address valid

GATT setup complete\n(OPEN + MTU + discovery + CCCD write)

OPEN_EVT failed

DISCONNECT_EVT or CLOSE_EVT\n(link lost)

AUTH_CMPL_EVT failed\n(wrong passkey)

autoconnect disabled\nmanual re-pair required

auto-reconnect attempt\nexponential backoff (up to 5 tries)

max retries exhausted\nautoconnect disabled

U

T

C

QM

A

_uart_ready = true

sendInitCommand() triggered

_conn_id = 0xFFFF

_autoconnect = false

status stays A, NOT overwritten by QM

Status Values (ESP3DValuesIndex::connection_status)

Section titled “Status Values (ESP3DValuesIndex::connection_status)”
ValueMeaningSet by
"U"Unknown / not started—
"T"Trying — connect() initiatedconnect()
"C"Connected — GATT fully ready, notifications activeWRITE_DESCR_EVT (success)
"?"Disconnected or connection failedOPEN_EVT failure, CLOSE/DISCONNECT_EVT, max retries
"A"Authentication failed (wrong passkey)AUTH_CMPL_EVT failure

The module targets a UART-over-BLE service following the convention used by BTT CNC modules:

BLE GATT Server (CNC controller side)

Service: 0xFFF0

UART Service

Characteristic 0xFFF2

TX from pendant

handle = start+2

Write No Response

Characteristic 0xFFF1

RX to pendant

handle = start+4

Notify

CCCD Descriptor

handle = start+5

Enable notifications: 0x0001

Handle assignment note: TX and RX handles are estimated as _service_start_handle + 2 and _service_start_handle + 4 from the service discovery result (SEARCH_RES_EVT). This matches the standard BTT module UART service layout and avoids a full per-characteristic discovery walk.


enqueueTxByPriority

BLE RF

esp3dCommands

GCode or ESP3D command

btBleClient.process(msg)

TX Queue

priority-aware deque

btBleClient.handle()

network task loop

Fragment by MTU-3

max payload = MTU - 3 bytes

esp_ble_gattc_write_char()

WRITE_NO_RSP

CNC Controller

  • Messages are fragmented into chunks of _mtu - 3 bytes (ATT protocol overhead = 3 bytes).
  • A ESP3D_MINIMAL_WAIT delay is inserted between consecutive chunks to avoid receiver buffer overflow.
  • If canSendData() returns false (GATT setup not complete), the message is discarded with a warning log.

BLE Notification packet

lock _rxbuf_mutex

complete line

popRx

incomplete line after 1500 ms

CNC Controller

ESP_GATTC_NOTIFY_EVT

Bluedroid BTC task

Accumulation Buffer

_rxBuffer 1024 bytes

protected by _rxbuf_mutex

End-char detection

newline or carriage return

RX Queue

ESP3DMessage*

btBleClient.handle()

network task

RX Flush Timer

1500 ms inactivity

processRxMsg(msg)

Buffer overflow policy: If the 1024-byte accumulation buffer fills before a line terminator arrives, the buffer is flushed immediately (same policy as the serial client). If the RX message queue is full, the oldest enqueued message is evicted to make room. Eviction runs on the BTC task, so it only drops the old message — calling esp3dCommands.process() from the BTC task could re-enter the non-reentrant BT API or stall the stack.


a14 Transport Changes (shared with the other clients)

Section titled “a14 Transport Changes (shared with the other clients)”
  • Own answers sent at once. A command received on this link ([ESP400], [ESP0], [ESP740]…) runs in the task that also drains this client’s TX queue, so its long answer, queued line after line, overflowed the TX size limit and was cut. Received messages now go through processRxMsg() (instead of esp3dCommands.process()), and process() first calls sendOwnAnswer(): an answer produced in that context is written at once (after the queued messages, order kept). Logs and messages from other tasks keep the queued path. The transport write is factored into writeTxMsg(), shared by the handle() TX drain and this direct path.
  • Low-memory admission. Each received line first goes through rxAdmit(); a refused line is dropped on purpose (reported as handled, no error log). Allocation failures in the RX path call esp3d_rx_line_lost().
  • Lost TX lines. A write error (or a full TX queue) calls esp3d_tx_line_lost(): on the CNC link the ok of that line will never come (S21), so the gcode host resets its flow and stops the streams.

See CNC_Firmware_Integration.md, “Lost Lines, Input Overflow and Resets”, and esp3d_core.md (low-memory admission guard).


The module implements Secure Connections pairing with MITM protection and automatic bonding:

CNC BLE ServerESP32 Pendant\nIO cap: NONECNC BLE ServerESP32 Pendant\nIO cap: NONEalt[Passkey requested(ESP_GAP_BLE_PASSKEY_REQ_EVT)]GATT sequence continues normallySubsequent CLOSE_EVT is suppressed\nstatus A is not overwritten by ?alt[Auth success (ESP_GAP_BLE_AUTH_CMPL_EVT)][Auth failure]Connection request via esp_ble_gattc_openPairing requestPairing responsePasskey requiredRead from NVS (esp3d_btble_passkey)\nDefault: 123456esp_ble_passkey_reply(passkey, true)Auth complete OKAuth complete FAILEDconnection_status = "A"_autoconnect = false_conn_id = 0xFFFFesp_ble_gattc_close()

Security parameters summary:

ParameterValueNotes
Auth requirementESP_LE_AUTH_REQ_SC_MITM_BONDSecure Connections + MITM + Bonding
IO capabilityESP_IO_CAP_NONEPendant has no display or keyboard
Key size16 bytes128-bit AES encryption
Init / Rsp key masksENC_KEY_MASK | ID_KEY_MASKBoth sides exchange encryption and identity keys
Bonding persistenceCleared on every begin()Ensures clean pairing state on each start
Passkey storageNVS esp3d_btble_passkey6 digits (000000–999999), default 123456

ESP_IO_CAP_NONE explained: The pendant has no display capable of showing a passkey and no numeric input. With NONE, “Just Works” mode is attempted by default. If the CNC module requires a passkey (fires PASSKEY_REQ_EVT), the firmware automatically reads and replies with the value stored in NVS — no user interaction is needed during reconnects after the initial setup.


Yes

No

Success

Failure

No

Yes

Connection Lost or OPEN_EVT failed

_autoconnect enabled?

Init reconnect state:

attempts = 0, delay = 5000 ms

Wait _reconnect_delay_ms

connect()

Connected

status C

attempts++

delay x= 2

attempts >= 5?

Stop reconnecting:

autoconnect = false

status = ?

Attempt #Delay before attempt
15 000 ms
210 000 ms
320 000 ms
440 000 ms
580 000 ms
—Auto-reconnect disabled, status → "?"

The reconnect loop runs entirely within handle() on the network task — no dedicated reconnect task. It checks elapsed time against _last_reconnect_attempt_ms on each handle() call.


All BLE-specific settings are persisted in NVS via ESP3DSettings. Sizes include the null terminator.

Setting IndexMax SizeDefaultDescription
esp3d_btble_address18 bytes""Target MAC address (AA:BB:CC:DD:EE:FF)
esp3d_btble_name32 bytes""Human-readable device name (display only)
esp3d_btble_passkey16 bytes"123456"6-digit BLE passkey (000000–999999)

Address parsing: str2bda() accepts three formats — AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, and AABBCCDDEEFF (case-insensitive). If esp3d_btble_address is empty or cannot be parsed, connect() immediately sets _autoconnect = false and returns without attempting a connection.


The entire module is gated by a compile-time feature flag:

# CMakeLists.txt
option(BT_BLE_SERVICE "Enable Bluetooth BLE service" OFF)

This is converted to ESP3D_BT_BLE_FEATURE by cmake/features.cmake and validated by cmake/sanity_check.cmake, which enforces the mutual exclusion with WiFi transports (no-PSRAM boards cannot run both radio stacks simultaneously).

Default Board Configuration (bt_ble_def.h)

Section titled “Default Board Configuration (bt_ble_def.h)”
esp3d_bt_ble_config_t esp3dBTBleConfig = {
.device_name = "PibotCNC", // Pendant's own BLE device name
.discoverable = true,
.connectable = true,
.scan_duration = 10, // Active scan duration in seconds
.service_uuid = { .len = ESP_UUID_LEN_16, .uuid = { .uuid16 = 0x1800 } },
.char_uuid = { .len = ESP_UUID_LEN_16, .uuid = { .uuid16 = 0x2A00 } },
.advertise = true,
.rx_buffer_size = 1024, // Accumulation buffer (bytes)
.tx_buffer_size = 1024, // TX buffer (bytes)
.mtu = 247 // Requested MTU (BTT module maximum)
};

scan() performs an active BLE scan and blocks the calling task until complete or timed out. It is always invoked from the dedicated bt_scan_task FreeRTOS task spawned by scan_bt_screen, keeping the LVGL thread unblocked.

Bluedroid GAPESP3DBTBleClientscan_bt_screen\nbt_scan_taskBluedroid GAPESP3DBTBleClientscan_bt_screen\nbt_scan_taskloop[For each advertising packet received]btBleClient.scan(devices)esp_ble_gap_set_scan_params\nACTIVE, PUBLIC, interval=0x50, window=0x30esp_ble_gap_start_scanning(10 s)SCAN_START_COMPLETE_EVT\n_discovery_started = trueSCAN_RESULT_EVT\naddr + name from AD data + RSSIAdd or update discovered_ble_devices[]\nmax 20 entriesSEARCH_INQ_CMPL_EVT\n_scan_completed = truedevices = move(discovered_ble_devices)

Post-processing in scan_bt_screen:

  • Only named devices are included (empty-name entries are filtered out).
  • Results sorted by RSSI descending (strongest signal first).
  • The currently connected device (from btBleClient.getCurrentAddress()) is always prepended even if absent from the scan.
  • RSSI → signal percentage: (rssi + 100) × 1.43, clamped to [0, 100].

  • Spawns bt_scan_task to call btBleClient.scan() without blocking LVGL.
  • Dispatches to btBleClient or btSerialClient based on esp3dNetwork.getMode().
  • On device selection: saves address and name to NVS via setCurrentAddress() / setCurrentName(), then navigates back to settings.

The Forget BT Host settings action (only shown when BLE mode is active):

btBleClient.disconnect();
btBleClient.clearTargetDevice(); // clears NVS address + name, disables autoconnect
btBleClient.clearBondedDevices(); // removes all Bluedroid bonding records from NVS
esp3dXsettings.writeString(ESP3DSettingIndex::esp3d_btble_passkey, "");

This returns the pendant to a clean, unpaired state — the user must scan and re-select a device next time.


Three mutexes protect three distinct data structures across two execution contexts (network task and BTC task):

MutexProtectsWritersReaders
_tx_mutexTX message deque (base class)process() any taskhandle() network task
_rx_mutexRX message deque (base class)pushMsgToRxQueue() BTC taskhandle() network task
_rxbuf_mutex_rxBuffer[] accumulation bufferNOTIFY_EVT callback BTC taskhandle() RX-flush path network task

Critical execution path (BTC task → network task):

NOTIFY_EVT (BTC task)
→ lock _rxbuf_mutex
→ append bytes to _rxBuffer
→ if end-char: lock _rx_mutex → pushMsgToRxQueue → unlock _rx_mutex
→ unlock _rxbuf_mutex
handle() (network task)
→ lock _rxbuf_mutex → check 1500 ms flush → unlock _rxbuf_mutex
→ lock _rx_mutex → popRx → unlock _rx_mutex
→ esp3dCommands.process(msg)

UAF prevention: end() calls esp_bluedroid_disable() and esp_bt_controller_deinit() synchronously before touching any mutex or buffer. Once those calls return, the BTC task cannot fire any more callbacks, making it safe to destroy mutexes and free _rxBuffer.

Re-entrancy guard in handle(): After esp3dCommands.process(msg), handle() checks _started before proceeding. A command (e.g., [ESP110] to change transport mode) may call end() during processing, leaving the BLE stack torn down. The guard prevents subsequent TX drain or reconnect logic from running against a destroyed stack.


A Python reference client is provided at tools/bt_client/fluidnc_ble.py:

Terminal window
# Connect to a CNC controller by MAC address and send commands interactively
python tools/bt_client/fluidnc_ble.py --address AA:BB:CC:DD:EE:FF
class FluidNcBle:
# BLE GATT client targeting UUID 0xFFF0 — same service as the firmware.
# Useful for verifying the CNC controller's BLE advertising and
# GCode command/response behaviour independently of the pendant.

See tools.md for the full list of communication test clients.


ESP-IDF

UI

Network

CNC Integration

Core Platform

bluetooth_ble module

esp3d_bt_ble_config_t

bt_ble_def.h

ESP3DBTBleClient

btBleClient

ESP3DClient

base class

ESP3DSettings

NVS

ESP3DValues

observable

ESP3DCommands

ESP3DGCodeHandlerService

getInitCommand

ESP3DNetwork

orchestrator

scan_bt_screen

settings_list_screen

Bluedroid Stack

GAP + GATTC


TopicFile
Shared connection model (status codes, init sequence, all transports)architecture/connection_management.md
BT Serial (SPP alternative transport)bluetooth_serial.md
Serial transportserial_client.md
Socket client transport (WiFi TCP)socket_client.md
Network orchestrator (ESP3DNetwork)network.md
GCode host and init commandarchitecture/gcode_host_architecture.md
Memory constraints (BLE mode: ~10 KB free heap)guides/esp32_memory_constraints.md
Feature compatibility matrix (BT vs WiFi exclusions)features/feature_resource_matrix.md
Build system and feature flagsguides/board_build_guidelines.md
Communication test toolsguides/tools.md