Skip to content

Bluetooth Serial (BT SPP) Transport Module

Bluetooth Serial (BT SPP) Transport Module

Section titled “Bluetooth Serial (BT SPP) Transport Module”

The bluetooth_serial module implements a Bluetooth Classic Serial Port Profile (SPP) transport for the ESP3D pendant firmware. It provides a wireless serial link to a CNC machine controller (FluidNC, grbl, grblHAL) without any cable, using the same message pipeline as the Serial and Socket Client transports.

Build gate: the entire module is compiled only when ESP3D_BT_SERIAL_FEATURE is defined. Bluetooth and WiFi are mutually exclusive on the ESP32 (no PSRAM); see Build Constraints.


  1. Architecture Overview
  2. Component Structure
  3. Connection State Machine
  4. Data Flow
  5. Initialization Sequence
  6. Reconnection Logic
  7. Device Discovery (Scan)
  8. Authentication & PIN Handling
  9. Configuration
  10. NVS Settings
  11. Connection Status Values
  12. API Reference
  13. Thread Safety Model
  14. Memory Constraints
  15. Build Constraints
  16. Related Modules

The module sits in the Communication Transports layer, alongside the serial, BLE, USB-serial, socket, and WebSocket clients. All transports share the same ESP3DClient base class and feed into the same GCode pipeline.

UI Layer

Bluetooth Stack (ESP-IDF)

Communication Transports

Core Platform

process() / handle()

set_value(connection_status)

readString / writeString

getInitCommand()

esp_bt_gap_register_callback

esp_spp_enhanced_init

esp_spp_connect

esp_spp_write

GAP callbacks

SPP callbacks

btSerialClient.scan()

setCurrentAddress/Name

subscribes connection_status

esp3dCommands

(command dispatcher)

esp3dXValues

(observable values)

esp3dXSettings

(NVS settings)

esp3dGcodeHandler

(CNC protocol)

Serial Client

(UART)

ESP3DBTSerialClient

(BT SPP) ← current module

BT BLE Client

(GATT)

Socket Client

(WiFi TCP)

GAP Layer

esp_bt_gap_*

SPP Layer

esp_spp_*

BT Controller

esp_bt_controller_*

Bluedroid Stack

esp_bluedroid_*

scan_bt_screen

(device picker)

settings_list_screen

(address / PIN config)

connection_status_screen

(live status)


inherits

uses

discovers / stores

configured by

«abstract base»

ESP3DClient

-_rx_queue deque

-_tx_queue deque

-_rx_mutex pthread_mutex_t

-_tx_mutex pthread_mutex_t

+begin() : bool

+handle()

+end()

+flush()

+process(msg) : void

+addRxData(msg) : bool

+addTxData(msg) : bool

+popRx() : ESP3DMessage

+popTx() : ESP3DMessage

+enqueueTxByPriority(msg) : bool

+clearRxQueue() : bool

+clearTxQueue() : bool

+getRxMsgsCount() : size_t

+getTxMsgsCount() : size_t

ESP3DBTSerialClient

-_connection_state ConnectionState

-_spp_handle int

-_rxBuffer uint8_t

-_rxBufferPos size_t

-_autoconnect bool

-_reconnect_attempts uint8_t

-_reconnect_delay_ms uint32_t

-_init_command_sent bool

-auth_failed volatile bool

-_connection_closing volatile bool

-_connection_mutex pthread_mutex_t

-_rxbuf_mutex pthread_mutex_t

-_tx_mutex pthread_mutex_t

-_rx_mutex pthread_mutex_t

+begin() : bool

+handle()

+end()

+process(msg) : void

+flush()

+connect() : bool

+disconnect() : bool

+scan(devices) : bool

+isConnected() : bool

+canSendData() : bool

+clearBondedDevices() : bool

+clearTargetDevice() : bool

+setCurrentName(name) : bool

+setCurrentAddress(addr) : bool

+getCurrentName() : const char

+getCurrentAddress() : const char

+sppWriteSafe(data, size) : esp_err_t

+resetInitCommand()

+isReconnectActive() : bool

-sendInitCommand()

-setConnectionState(state)

-sppCallback(event, param)

-esp_bt_gap_cb(event, param)

«enumeration»

ConnectionState

DISCONNECTED

CONNECTING

STABILIZING

CONNECTED

BTDevice

+addr esp_bd_addr_t

+name std::string

+rssi int8_t

esp3d_bt_serial_config_t

+spp_channel uint32_t

+device_name char[32]

+discoverable bool

+connectable bool

+scan_duration uint16_t

+rx_buffer_size size_t

+tx_buffer_size size_t

FilePurpose
esp3d_bt_serial_client.hESP3DBTSerialClient class declaration
esp3d_bt_serial_client.cppFull implementation – lifecycle, GAP/SPP callbacks, queue management
esp3d_bt_serial_config.hesp3d_bt_serial_config_t struct + extern esp3dBTSerialConfig
esp3d_bt_device.hBTDevice struct (scan result: address, name, RSSI)

The client tracks the link through four states. Transitions are driven by SPP and GAP callbacks arriving on the Bluedroid BTC task.

begin() / end()

connect() called\naddress valid in NVS

ESP_SPP_OPEN_EVT success\nhandle = valid

ESP_SPP_OPEN_EVT failure\nor ESP_SPP_CLOSE_EVT while CONNECTING\n(stale bond cleared)

CONNECTION_STABILIZATION_DELAY_MS elapsed (500 ms)\ncanSendData() triggers sendInitCommand()

ESP_SPP_CLOSE_EVT

ESP_SPP_CLOSE_EVT\nor ESP_SPP_WRITE_EVT failure (no connection)\nor Auth failure (ESP_BT_GAP_AUTH_CMPL_EVT)

Auto-reconnect\n(up to 5 attempts, exponential back-off)

DISCONNECTED

CONNECTING

STABILIZING

CONNECTED

State effects:

TransitionSide effect
→ DISCONNECTEDTX queue cleared immediately; _spp_handle = -1
→ CONNECTINGTimestamps zeroed; connection_status = "T"
→ STABILIZINGConnection timestamp saved; connection_status = "C"
→ CONNECTEDsendInitCommand() called (GCode handler init string)
Auth fail → DISCONNECTEDautoconnect = false; connection_status = "A"

esp3dCommandshandle()(main loop task)RX Queue(ESP3DClient)_rxBuffer(accumulation)sppCallback()ESP_SPP_DATA_IND_EVTESP-IDF SPP Stack(BTC task)CNC Machineesp3dCommandshandle()(main loop task)RX Queue(ESP3DClient)_rxBuffer(accumulation)sppCallback()ESP_SPP_DATA_IND_EVTESP-IDF SPP Stack(BTC task)CNC Machine_rxBufferPos resetalt[end char detected (\n or \r)][buffer full (overflow)]loop[handle() polling]Bluetooth frameDATA_IND_EVT callbacklock _rxbuf_mutexbyte-by-byte appendpushMsgToRxQueue()flush partial buffer to pushMsgToRxQueue()unlock _rxbuf_mutexRX_FLUSH_TIMEOUT_MS check (1500 ms)flush stale partial linegetRxMsgsCount() > 0?msgesp3dCommands.process(msg)

RX queue eviction policy: when the queue is full, the oldest messages are dropped to make room—never processed from inside the BTC callback, which avoids re-entrant BT API calls and stack stalls from heavy [ESP] commands.

CNC MachinesppWriteSafe()esp_spp_write()handle()(main loop task)TX Queue(ESP3DClient)process()esp3dCommands/ GCode hostCNC MachinesppWriteSafe()esp_spp_write()handle()(main loop task)TX Queue(ESP3DClient)process()esp3dCommands/ GCode hostalt[not ready][ready]holds _connection_mutexverifies state atomicallyloop[handle() main loop]ESP3DMessage (normal or high-priority)canSendData()?deleteMsg() - discardenqueueTxByPriority()high priority → front, normal → backcanSendData()?popTx()msgsppWriteSafe(data, size)Bluetooth SPP frame

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).


esp3dGcodeHandlerSPP callbackGAP callbackESP-IDF BT APIsbtSerialClient.begin()ESP3DX::begin()esp3dGcodeHandlerSPP callbackGAP callbackESP-IDF BT APIsbtSerialClient.begin()ESP3DX::begin()connection_status = 'T'connection_status = 'C'wait 500 ms in STABILIZING state_init_command_sent = truebegin()configure(esp3dBTSerialConfig)malloc(_rxBuffer)pthread_mutex_init x4esp_bt_controller_mem_release(BLE)free BLE RAM - not neededesp_bt_controller_init(CLASSIC_BT)esp_bt_controller_enable(CLASSIC_BT)esp_bluedroid_init_with_cfg(ssp_en=false)esp_bluedroid_enable()esp_bt_gap_register_callback()esp_bt_gap_set_device_name(hostname)esp_spp_register_callback()esp_spp_enhanced_init(MODE_CB)connect() calls esp_spp_connect()ESP_SPP_OPEN_EVT successsetConnectionState(STABILIZING)canSendData() triggers CONNECTEDgetInitCommand()esp_spp_write(initCmd)resetStartupCommandsSent()

The module implements an exponential back-off reconnection strategy, driven entirely by handle() polling — no background reconnection task.

No

Yes

Yes

No

No

Yes

No

Yes

No

Yes

No

Yes

No

Yes

handle called

_started?

return

canSendData?

Process RX and TX queues

_autoconnect?

state == DISCONNECTED?

_reconnect_active?

Init: attempts=0

delay=5000 ms

record timestamp

attempts less than 5

AND delay elapsed?

connect()

attempts++

delay times 2

attempts >= 5?

Reset counters

autoconnect = false

connection_status = '?'

Back-off schedule:

AttemptDelay before attempt
15 s
210 s
320 s
440 s
580 s
—Auto-reconnect disabled, connection_status = "?"

_autoconnect is permanently disabled after an authentication failure (ESP_BT_GAP_AUTH_CMPL_EVT non-success). The user must reconfigure the PIN and manually initiate a new connection.


The scan() method is called by the scan_bt_screen UI screen. It is blocking for the duration of the inquiry (scan duration + 10 s safety margin).

esp_bt_gap_callbackesp_bt_gap_*btSerialClient.scan()scan_bt_screenbt_scan_taskesp_bt_gap_callbackesp_bt_gap_*btSerialClient.scan()scan_bt_screenbt_scan_taskalt[name not in EIR]loop[Until scan_completed OR timeout]scan(devices)clear discovered_devicesesp_bt_gap_register_callback()esp_bt_gap_start_discovery(GENERAL_INQUIRY, duration)wait 100 msDISC_RES_EVT (device found)parse EIR for name and RSSIupdate or append to discovered_devices[]esp_bt_gap_read_remote_name()READ_REMOTE_NAME_EVTupdate name in discovered_devices[]DISC_STATE_CHANGED_EVT STOPPED_scan_completed = truedevices via move semantics

Scan result deduplication: devices are matched by esp_bd_addr_t. Duplicate reports update the name and RSSI of the existing entry. The list is capped at ESP3D_BT_MAX_SCAN_RESULTS entries.


The module uses legacy PIN-based authentication (SSP disabled: ssp_en = false in the Bluedroid config):

esp3dXSettingsesp_bt_gap_callbackBT Controlleresp3dXSettingsesp_bt_gap_callbackBT Controlleralt[PIN empty or unset][PIN configured]alt[success][failure]ESP_BT_GAP_PIN_REQ_EVTreadString(esp3d_btserial_pin)empty stringdefault to "1234"configured PIN stringesp_bt_gap_pin_reply(bda, true, len, pin_code)ESP_BT_GAP_AUTH_CMPL_EVTlog authentication success_auth_failed_ = true_autoconnect = falseconnection_status = "A"setConnectionState(DISCONNECTED)

Auth-fail / CLOSE_EVT race: _auth_failed_ is a volatile bool written by the GAP task before ESP_SPP_CLOSE_EVT fires on the BTC task. sppCallback() reads it to suppress the duplicate "?" status update and to skip bond-clearing when the auth failure was already handled.


The module is configured via the esp3d_bt_serial_config_t struct. The board-specific default instance esp3dBTSerialConfig is defined in the board’s bt_services_def.h.

typedef struct {
uint32_t spp_channel; // SPP channel number (typically 1)
char device_name[32]; // Pendant's own BT name (overridden by NVS hostname at begin())
bool discoverable; // Whether the pendant is visible to other devices
bool connectable; // Whether the pendant accepts incoming connections
uint16_t scan_duration; // Inquiry scan length in seconds
size_t rx_buffer_size; // Line accumulation buffer in bytes
size_t tx_buffer_size; // TX buffer size in bytes (reference only)
} esp3d_bt_serial_config_t;

Runtime overrides applied in begin():

  • device_name is overwritten from the NVS setting esp3d_hostname (e.g., "PibotCNC").
  • rx_buffer_size determines the heap allocation for _rxBuffer; the RX queue max is hard-coded to 4096 bytes via setRxMaxSize(4096).

The module reads and writes the following NVS-backed settings via ESP3DSettings:

Setting IndexPurposeRead byWritten by
esp3d_btserial_addressTarget device MAC ("AA:BB:CC:DD:EE:FF")connect()setCurrentAddress()
esp3d_btserial_nameTarget device friendly nameconnect()setCurrentName(), clearTargetDevice()
esp3d_btserial_pinPIN code for authenticationesp_bt_gap_cb() PIN_REQ_EVTUI settings screen
esp3d_hostnamePendant’s own Bluetooth advertised namebegin()—

MAC address parsing is tolerant of three formats: AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, and AABBCCDDEEFF.


The module sets ESP3DValuesIndex::connection_status via esp3dXValues.set_value(). These values are consumed by the UI connection_status_screen and the ConnectionStatusComponent.

ValueMeaning
"T"Trying – SPP connection initiated (connect() called or SPP_CL_INIT_EVT)
"C"Connected – SPP link open (ESP_SPP_OPEN_EVT success)
"?"Disconnected – Link dropped (ESP_SPP_CLOSE_EVT) or max reconnects exhausted
"A"Auth failed – Wrong PIN (ESP_BT_GAP_AUTH_CMPL_EVT failure)

server_status (set to "?" on disconnect, then set to "C" by the GCode handler after the init command is acknowledged) follows the same model as the serial and socket transports — see CNC_Firmware_Integration.


MethodDescription
begin()Initializes BT stack (controller → Bluedroid → GAP → SPP), allocates buffers, starts connection. Returns false on any stack init failure.
handle()Drains RX/TX queues, flushes partial lines on timeout, drives reconnect state machine. Must be called frequently from the main loop task.
end()Orderly shutdown: deinits SPP → Bluedroid → BT controller, then destroys mutexes and frees _rxBuffer. Safe to call even if not started.
flush()No-op (deprecated). TX is driven entirely by handle().
MethodDescription
connect()Reads MAC address from NVS, calls esp_spp_connect(). Disconnects first if already connected.
disconnect()Disables auto-reconnect, calls esp_spp_disconnect().
isConnected()Returns true if _spp_handle != -1 (handle assigned by ESP_SPP_OPEN_EVT).
canSendData()Thread-safe check: returns true only in CONNECTED, or when STABILIZING delay has elapsed. Triggers CONNECTED transition when delay passes.
clearTargetDevice()Clears NVS address and name, disables auto-reconnect. Used by the “Forget BT host” settings action.
clearBondedDevices()Removes all bonded devices from the BT controller bond list. Called automatically on CLOSE without OPEN while CONNECTING (stale bond recovery).
MethodDescription
scan(devices)Blocking inquiry scan. Populates devices vector with BTDevice entries. Returns false on timeout or stack error.
getScanMaxDuration()Returns total timeout in ms for scan(): (scan_duration + 10) * 1000.
MethodDescription
process(msg)Entry point from esp3dCommands. Checks canSendData(), enqueues to TX with priority. Discards message if not ready.
pushMsgToRxQueue(buf, size)Wraps a raw buffer into an ESP3DMessage and enqueues to the RX queue. Evicts oldest entries on overflow.
isEndChar(ch)Returns true for '\n' or '\r' — line terminator detection for message framing.
MethodDescription
bda2str(bda, str, size)Converts binary BT address to "XX:XX:XX:XX:XX:XX" string.
str2bda(str, bda)Parses MAC string (colon, dash, or bare hex formats) to binary esp_bd_addr_t.
rssi_to_percentage(rssi)Maps RSSI dBm (−100 to −30) to 0–100 % signal strength.
setCurrentName(name)Persists friendly name to NVS and updates _current_name.
setCurrentAddress(addr)Persists MAC address to NVS and updates _current_address.

The module runs across two execution contexts simultaneously:

ContextRuns onAccesses
Main loop taskhandle(), process()TX/RX queues, reconnect state, _rxBuffer flush
Bluedroid BTC taskGAP & SPP callbacks_spp_handle, _rxBuffer, _connection_state, queues

Four pthread mutexes coordinate concurrent access:

BTC task (callbacks)

Main loop task

Mutexes

_tx_mutex

base class TX queue

_rx_mutex

base class RX queue

_connection_mutex

_spp_handle, _connection_state

_connection_closing, timestamps

_rxbuf_mutex

_rxBuffer and _rxBufferPos

accumulation only

handle()

sppWriteSafe()

sppCallback()

esp_bt_gap_cb()

Shutdown ordering (end()): the BT stack (esp_spp_deinit() → esp_bluedroid_disable() → esp_bt_controller_deinit()) is torn down before mutexes are destroyed and _rxBuffer is freed. This prevents in-flight callbacks from writing to freed memory — replacing the previous blind 1000 ms wait that neither stopped callbacks nor waited for their completion.


This module runs on a memory-constrained ESP32 with approximately 10 KB usable heap in Bluetooth mode.

ResourceSize / Notes
_rxBufferrx_buffer_size bytes — heap-allocated in begin(), freed in end()
RX queue max4096 bytes, set via setRxMaxSize(4096)
discovered_devicesstd::vector<BTDevice>, capped at ESP3D_BT_MAX_SCAN_RESULTS; released after scan via move semantics and shrink_to_fit()
Bond-list bufferTemporary malloc(sizeof(esp_bd_addr_t) * N) in clearBondedDevices(), freed immediately after use
BLE memory reclaimesp_bt_controller_mem_release(ESP_BT_MODE_BLE) in begin() recovers approximately 60 KB

Allocation guard pattern (enforced throughout):

uint8_t* buf = (uint8_t*)malloc(size);
if (!buf) {
esp3d_log_e("alloc failed (free=%u largest=%u)",
heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
return false;
}

See docs/guides/esp32_memory_constraints.md for the general fragmentation playbook.


mutually exclusive

no PSRAM shared radio

mutually exclusive

no PSRAM shared radio

mutually exclusive

both require BT radio

mutually exclusive

WiFi required for TCP

ESP3D_BT_SERIAL_FEATURE ON

ESP3D_BT_BLE_FEATURE ON

ESP3D_WIFI_FEATURE ON

SOCKET_CLIENT_SERVICE ON

  • BT Serial + WiFi: cannot be active simultaneously on the standard ESP32 (no PSRAM). Enforced by cmake/sanity_check.cmake.
  • BT Serial + BT BLE: only one BT mode can be active at a time. BLE memory is explicitly released in begin() via esp_bt_controller_mem_release(ESP_BT_MODE_BLE).
  • BT Serial + Socket Client: Socket Client requires WiFi, which is incompatible with BT.
  • Memory reclaimed at begin(): releasing BLE mode RAM is the prerequisite that makes the Classic BT stack fit in available DRAM.

Refer to docs/features/feature_resource_matrix.md for the full feature compatibility matrix and resource ticket accounting.


ModuleRelationship
serial_clientSibling UART transport — same ESP3DClient base, same queue and init-command model
bluetooth_bleSibling BLE/GATT transport — mutually exclusive with this module at build time
socket_clientSibling WiFi TCP transport — mutually exclusive with this module at build time
Core_Platform_&_InfrastructureESP3DClient base class, ESP3DMessage, ESP3DSettings, ESP3DValues
CNC_Firmware_Integrationesp3dGcodeHandler.getInitCommand() and resetStartupCommandsSent() used by sendInitCommand()
UI_Framework_&_Screensscan_bt_screen (device picker), settings_list_screen (PIN/address), connection_status_screen (live status)
docs/features/feature_resource_matrix.mdBT vs. WiFi resource trade-offs and feature flag combinations
docs/guides/esp32_memory_constraints.mdHeap fragmentation guidance for BT mode (~10 KB available heap)
docs/architecture/connection_management.mdShared initialization sequence and "U"/"T"/"C"/"?" status model across all transports