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_FEATUREis defined. Bluetooth and WiFi are mutually exclusive on the ESP32 (no PSRAM); see Build Constraints.
Table of Contents
Section titled “Table of Contents”- Architecture Overview
- Component Structure
- Connection State Machine
- Data Flow
- Initialization Sequence
- Reconnection Logic
- Device Discovery (Scan)
- Authentication & PIN Handling
- Configuration
- NVS Settings
- Connection Status Values
- API Reference
- Thread Safety Model
- Memory Constraints
- Build Constraints
- Related Modules
Architecture Overview
Section titled “Architecture Overview”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.
Component Structure
Section titled “Component Structure”Key Files
Section titled “Key Files”| File | Purpose |
|---|---|
esp3d_bt_serial_client.h | ESP3DBTSerialClient class declaration |
esp3d_bt_serial_client.cpp | Full implementation – lifecycle, GAP/SPP callbacks, queue management |
esp3d_bt_serial_config.h | esp3d_bt_serial_config_t struct + extern esp3dBTSerialConfig |
esp3d_bt_device.h | BTDevice struct (scan result: address, name, RSSI) |
Connection State Machine
Section titled “Connection State Machine”The client tracks the link through four states. Transitions are driven by SPP and GAP callbacks arriving on the Bluedroid BTC task.
State effects:
| Transition | Side effect |
|---|---|
→ DISCONNECTED | TX queue cleared immediately; _spp_handle = -1 |
→ CONNECTING | Timestamps zeroed; connection_status = "T" |
→ STABILIZING | Connection timestamp saved; connection_status = "C" |
→ CONNECTED | sendInitCommand() called (GCode handler init string) |
Auth fail → DISCONNECTED | autoconnect = false; connection_status = "A" |
Data Flow
Section titled “Data Flow”Receive Path (CNC → Pendant)
Section titled “Receive Path (CNC → Pendant)”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.
Transmit Path (Pendant → CNC)
Section titled “Transmit Path (Pendant → CNC)”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 throughprocessRxMsg()(instead ofesp3dCommands.process()), andprocess()first callssendOwnAnswer(): 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 intowriteTxMsg(), shared by thehandle()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 callesp3d_rx_line_lost(). - Lost TX lines. A write error (or a full TX queue) calls
esp3d_tx_line_lost(): on the CNC link theokof 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).
Initialization Sequence
Section titled “Initialization Sequence”Reconnection Logic
Section titled “Reconnection Logic”The module implements an exponential back-off reconnection strategy, driven entirely by handle() polling — no background reconnection task.
Back-off schedule:
| Attempt | Delay before attempt |
|---|---|
| 1 | 5 s |
| 2 | 10 s |
| 3 | 20 s |
| 4 | 40 s |
| 5 | 80 s |
| — | Auto-reconnect disabled, connection_status = "?" |
_autoconnectis permanently disabled after an authentication failure (ESP_BT_GAP_AUTH_CMPL_EVTnon-success). The user must reconfigure the PIN and manually initiate a new connection.
Device Discovery (Scan)
Section titled “Device Discovery (Scan)”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).
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.
Authentication & PIN Handling
Section titled “Authentication & PIN Handling”The module uses legacy PIN-based authentication (SSP disabled: ssp_en = false in the Bluedroid config):
Auth-fail / CLOSE_EVT race:
_auth_failed_is avolatile boolwritten by the GAP task beforeESP_SPP_CLOSE_EVTfires 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.
Configuration
Section titled “Configuration”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_nameis overwritten from the NVS settingesp3d_hostname(e.g.,"PibotCNC").rx_buffer_sizedetermines the heap allocation for_rxBuffer; the RX queue max is hard-coded to 4096 bytes viasetRxMaxSize(4096).
NVS Settings
Section titled “NVS Settings”The module reads and writes the following NVS-backed settings via ESP3DSettings:
| Setting Index | Purpose | Read by | Written by |
|---|---|---|---|
esp3d_btserial_address | Target device MAC ("AA:BB:CC:DD:EE:FF") | connect() | setCurrentAddress() |
esp3d_btserial_name | Target device friendly name | connect() | setCurrentName(), clearTargetDevice() |
esp3d_btserial_pin | PIN code for authentication | esp_bt_gap_cb() PIN_REQ_EVT | UI settings screen |
esp3d_hostname | Pendant’s own Bluetooth advertised name | begin() | — |
MAC address parsing is tolerant of three formats: AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, and AABBCCDDEEFF.
Connection Status Values
Section titled “Connection Status Values”The module sets ESP3DValuesIndex::connection_status via esp3dXValues.set_value(). These values are consumed by the UI connection_status_screen and the ConnectionStatusComponent.
| Value | Meaning |
|---|---|
"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.
API Reference
Section titled “API Reference”Lifecycle
Section titled “Lifecycle”| Method | Description |
|---|---|
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(). |
Connection Control
Section titled “Connection Control”| Method | Description |
|---|---|
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). |
Device Discovery
Section titled “Device Discovery”| Method | Description |
|---|---|
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. |
Message Handling
Section titled “Message Handling”| Method | Description |
|---|---|
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. |
Utility
Section titled “Utility”| Method | Description |
|---|---|
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. |
Thread Safety Model
Section titled “Thread Safety Model”The module runs across two execution contexts simultaneously:
| Context | Runs on | Accesses |
|---|---|---|
| Main loop task | handle(), process() | TX/RX queues, reconnect state, _rxBuffer flush |
| Bluedroid BTC task | GAP & SPP callbacks | _spp_handle, _rxBuffer, _connection_state, queues |
Four pthread mutexes coordinate concurrent access:
Shutdown ordering (
end()): the BT stack (esp_spp_deinit()→esp_bluedroid_disable()→esp_bt_controller_deinit()) is torn down before mutexes are destroyed and_rxBufferis 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.
Memory Constraints
Section titled “Memory Constraints”This module runs on a memory-constrained ESP32 with approximately 10 KB usable heap in Bluetooth mode.
| Resource | Size / Notes |
|---|---|
_rxBuffer | rx_buffer_size bytes — heap-allocated in begin(), freed in end() |
| RX queue max | 4096 bytes, set via setRxMaxSize(4096) |
discovered_devices | std::vector<BTDevice>, capped at ESP3D_BT_MAX_SCAN_RESULTS; released after scan via move semantics and shrink_to_fit() |
| Bond-list buffer | Temporary malloc(sizeof(esp_bd_addr_t) * N) in clearBondedDevices(), freed immediately after use |
| BLE memory reclaim | esp_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.
Build Constraints
Section titled “Build Constraints”- 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()viaesp_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.
Related Modules
Section titled “Related Modules”| Module | Relationship |
|---|---|
serial_client | Sibling UART transport — same ESP3DClient base, same queue and init-command model |
bluetooth_ble | Sibling BLE/GATT transport — mutually exclusive with this module at build time |
socket_client | Sibling WiFi TCP transport — mutually exclusive with this module at build time |
Core_Platform_&_Infrastructure | ESP3DClient base class, ESP3DMessage, ESP3DSettings, ESP3DValues |
CNC_Firmware_Integration | esp3dGcodeHandler.getInitCommand() and resetStartupCommandsSent() used by sendInitCommand() |
UI_Framework_&_Screens | scan_bt_screen (device picker), settings_list_screen (PIN/address), connection_status_screen (live status) |
docs/features/feature_resource_matrix.md | BT vs. WiFi resource trade-offs and feature flag combinations |
docs/guides/esp32_memory_constraints.md | Heap fragmentation guidance for BT mode (~10 KB available heap) |
docs/architecture/connection_management.md | Shared initialization sequence and "U"/"T"/"C"/"?" status model across all transports |