Add SPP (Serial Port Profile) support for bidirectional data transfer

Firmware:
- Add spp_connect, spp_disconnect, spp_data events
- Add spp_send, spp_disconnect, spp_status commands
- Track remote address for connected SPP peer
- Report received data as hex + optional text decode

Python MCP:
- esp32_spp_send(data/data_hex) - send text or binary
- esp32_spp_disconnect() - close SPP connection
- esp32_spp_status() - query connection state

Tested: Linux rfcomm connect → ESP32, bidirectional data transfer works
This commit is contained in:
Ryan Malloy 2026-02-03 13:39:17 -07:00
parent 5dcacc23ab
commit 61da375a0c
7 changed files with 297 additions and 7 deletions

View File

@ -34,10 +34,11 @@ The ESP32 can emulate various Bluetooth devices for testing:
| Capability | Description | | Capability | Description |
|------------|-------------| |------------|-------------|
| **Classic BT Pairing** | All 4 SSP modes: Just Works, Numeric Comparison, Passkey Entry, Legacy PIN | | **Classic BT Pairing** | All 4 SSP modes: Just Works, Numeric Comparison, Passkey Entry, Legacy PIN |
| **SPP (Serial Port)** | Bidirectional data transfer over Classic Bluetooth virtual serial port |
| **BLE GATT Server** | Dynamic service/characteristic creation at runtime | | **BLE GATT Server** | Dynamic service/characteristic creation at runtime |
| **Device Personas** | Presets for headset, speaker, keyboard, sensor, phone | | **Device Personas** | Presets for headset, speaker, keyboard, sensor, phone |
| **IO Capabilities** | Configurable: `no_io`, `display_only`, `display_yesno`, `keyboard_only`, `keyboard_display` | | **IO Capabilities** | Configurable: `no_io`, `display_only`, `display_yesno`, `keyboard_only`, `keyboard_display` |
| **Event Reporting** | Real-time events: pair_request, pair_complete, gatt_read, gatt_write, gatt_subscribe | | **Event Reporting** | Real-time events: pair_request, pair_complete, spp_data, gatt_read, gatt_write, gatt_subscribe |
### Hardware Requirements ### Hardware Requirements
@ -116,6 +117,13 @@ esp32_get_info() # → chip, fw_version, bt_mac
| `esp32_classic_set_discoverable` | Make device visible for pairing | | `esp32_classic_set_discoverable` | Make device visible for pairing |
| `esp32_classic_pair_respond` | Accept/reject incoming pairing | | `esp32_classic_pair_respond` | Accept/reject incoming pairing |
### SPP (Serial Port Profile)
| Tool | Description |
|------|-------------|
| `esp32_spp_send` | Send data (text or hex) over SPP connection |
| `esp32_spp_disconnect` | Close the active SPP connection |
| `esp32_spp_status` | Get SPP connection status and remote address |
### BLE / GATT ### BLE / GATT
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|

View File

@ -54,6 +54,7 @@ static struct {
pair_type_t pair_type; pair_type_t pair_type;
/* SPP handle for the listening server */ /* SPP handle for the listening server */
uint32_t spp_handle; uint32_t spp_handle;
char spp_remote_addr[18]; /* Connected peer address */
} classic_state = { } classic_state = {
.io_cap = ESP_BT_IO_CAP_IO, /* display+yesno (numeric comparison) */ .io_cap = ESP_BT_IO_CAP_IO, /* display+yesno (numeric comparison) */
.ssp_enabled = true, .ssp_enabled = true,
@ -243,24 +244,37 @@ static void spp_cb(esp_spp_cb_event_t event, esp_spp_cb_param_t *param)
case ESP_SPP_SRV_OPEN_EVT: case ESP_SPP_SRV_OPEN_EVT:
bd_addr_to_str(param->srv_open.rem_bda, addr_str); bd_addr_to_str(param->srv_open.rem_bda, addr_str);
classic_state.spp_handle = param->srv_open.handle; classic_state.spp_handle = param->srv_open.handle;
strncpy(classic_state.spp_remote_addr, addr_str,
sizeof(classic_state.spp_remote_addr) - 1);
classic_state.spp_remote_addr[sizeof(classic_state.spp_remote_addr) - 1] = '\0';
ESP_LOGI(SPP_TAG, "SPP client connected: %s (handle=%" PRIu32 ")", ESP_LOGI(SPP_TAG, "SPP client connected: %s (handle=%" PRIu32 ")",
addr_str, param->srv_open.handle); addr_str, param->srv_open.handle);
event_report_connect(addr_str, "classic"); /* Report specific SPP connect event with more detail */
{
cJSON *d = cJSON_CreateObject();
cJSON_AddStringToObject(d, "address", addr_str);
cJSON_AddNumberToObject(d, "handle", (double)param->srv_open.handle);
cJSON_AddStringToObject(d, "transport", "spp");
event_report(EVT_SPP_CONNECT, d);
}
break; break;
case ESP_SPP_CLOSE_EVT: case ESP_SPP_CLOSE_EVT:
ESP_LOGI(SPP_TAG, "SPP disconnected (handle=%" PRIu32 ")", ESP_LOGI(SPP_TAG, "SPP disconnected (handle=%" PRIu32 ")",
param->close.handle); param->close.handle);
/* We don't have the remote address in CLOSE_EVT on all IDF versions, /* Report SPP disconnect with saved remote address */
* so report with handle info. */
{ {
cJSON *d = cJSON_CreateObject(); cJSON *d = cJSON_CreateObject();
cJSON_AddNumberToObject(d, "handle", (double)param->close.handle); cJSON_AddNumberToObject(d, "handle", (double)param->close.handle);
cJSON_AddStringToObject(d, "transport", "classic"); cJSON_AddStringToObject(d, "transport", "spp");
event_report(EVT_DISCONNECT, d); if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(d, "address", classic_state.spp_remote_addr);
}
event_report(EVT_SPP_DISCONNECT, d);
} }
if (classic_state.spp_handle == param->close.handle) { if (classic_state.spp_handle == param->close.handle) {
classic_state.spp_handle = 0; classic_state.spp_handle = 0;
classic_state.spp_remote_addr[0] = '\0';
} }
break; break;
@ -274,9 +288,46 @@ static void spp_cb(esp_spp_cb_event_t event, esp_spp_cb_param_t *param)
break; break;
case ESP_SPP_DATA_IND_EVT: case ESP_SPP_DATA_IND_EVT:
/* Data received over SPP -- log but don't process for now. */ /* Data received over SPP -- report as event */
ESP_LOGI(SPP_TAG, "SPP data: %d bytes on handle %" PRIu32, ESP_LOGI(SPP_TAG, "SPP data: %d bytes on handle %" PRIu32,
param->data_ind.len, param->data_ind.handle); param->data_ind.len, param->data_ind.handle);
{
cJSON *d = cJSON_CreateObject();
cJSON_AddNumberToObject(d, "handle", (double)param->data_ind.handle);
cJSON_AddNumberToObject(d, "length", param->data_ind.len);
if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(d, "address", classic_state.spp_remote_addr);
}
/* Encode data as hex string */
size_t hex_len = param->data_ind.len * 2 + 1;
char *hex_str = malloc(hex_len);
if (hex_str) {
for (int i = 0; i < param->data_ind.len; i++) {
sprintf(hex_str + i * 2, "%02x", param->data_ind.data[i]);
}
hex_str[param->data_ind.len * 2] = '\0';
cJSON_AddStringToObject(d, "data_hex", hex_str);
free(hex_str);
}
/* Also try UTF-8 if printable */
bool printable = true;
for (int i = 0; i < param->data_ind.len && printable; i++) {
uint8_t c = param->data_ind.data[i];
if (c < 0x20 && c != '\n' && c != '\r' && c != '\t') {
printable = false;
}
}
if (printable && param->data_ind.len < 256) {
char *text = malloc(param->data_ind.len + 1);
if (text) {
memcpy(text, param->data_ind.data, param->data_ind.len);
text[param->data_ind.len] = '\0';
cJSON_AddStringToObject(d, "data_text", text);
free(text);
}
}
event_report(EVT_SPP_DATA, d);
}
break; break;
default: default:
@ -598,6 +649,126 @@ void cmd_classic_set_ssp_mode(const char *id, cJSON *params)
uart_send_response(id, STATUS_OK, data); uart_send_response(id, STATUS_OK, data);
} }
/* ------------------------------------------------------------------ */
/* SPP command handlers */
/* ------------------------------------------------------------------ */
static bool hex_to_bytes(const char *hex, uint8_t *out, size_t *out_len, size_t max_len)
{
size_t hex_len = strlen(hex);
if (hex_len % 2 != 0) return false;
size_t bytes = hex_len / 2;
if (bytes > max_len) return false;
for (size_t i = 0; i < bytes; i++) {
unsigned int b;
if (sscanf(hex + i * 2, "%2x", &b) != 1) return false;
out[i] = (uint8_t)b;
}
*out_len = bytes;
return true;
}
void cmd_spp_send(const char *id, cJSON *params)
{
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
if (classic_state.spp_handle == 0) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("no SPP connection"));
return;
}
const cJSON *j_data = cJSON_GetObjectItem(params, "data");
const cJSON *j_hex = cJSON_GetObjectItem(params, "data_hex");
uint8_t buf[512];
size_t len = 0;
if (cJSON_IsString(j_hex)) {
/* Hex-encoded data */
if (!hex_to_bytes(j_hex->valuestring, buf, &len, sizeof(buf))) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("invalid hex data"));
return;
}
} else if (cJSON_IsString(j_data)) {
/* Plain text data */
len = strlen(j_data->valuestring);
if (len > sizeof(buf)) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("data too long (max 512 bytes)"));
return;
}
memcpy(buf, j_data->valuestring, len);
} else {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("missing 'data' or 'data_hex'"));
return;
}
esp_err_t err = esp_spp_write(classic_state.spp_handle, len, buf);
if (err != ESP_OK) {
ESP_LOGE(SPP_TAG, "spp_write failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
ESP_LOGI(SPP_TAG, "SPP sent %zu bytes", len);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "bytes_sent", (double)len);
uart_send_response(id, STATUS_OK, data);
}
void cmd_spp_disconnect(const char *id, cJSON *params)
{
(void)params;
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
if (classic_state.spp_handle == 0) {
uart_send_response(id, STATUS_OK,
cJSON_CreateString("no active connection"));
return;
}
uint32_t handle = classic_state.spp_handle;
esp_err_t err = esp_spp_disconnect(handle);
if (err != ESP_OK) {
ESP_LOGE(SPP_TAG, "spp_disconnect failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
ESP_LOGI(SPP_TAG, "SPP disconnect initiated (handle=%" PRIu32 ")", handle);
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_spp_status(const char *id, cJSON *params)
{
(void)params;
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "connected", classic_state.spp_handle != 0);
if (classic_state.spp_handle != 0) {
cJSON_AddNumberToObject(data, "handle", (double)classic_state.spp_handle);
if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(data, "remote_address", classic_state.spp_remote_addr);
}
}
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* State query */ /* State query */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View File

@ -17,6 +17,11 @@ void cmd_classic_set_discoverable(const char *id, cJSON *params);
void cmd_classic_pair_respond(const char *id, cJSON *params); void cmd_classic_pair_respond(const char *id, cJSON *params);
void cmd_classic_set_ssp_mode(const char *id, cJSON *params); void cmd_classic_set_ssp_mode(const char *id, cJSON *params);
/* SPP command handlers */
void cmd_spp_send(const char *id, cJSON *params);
void cmd_spp_disconnect(const char *id, cJSON *params);
void cmd_spp_status(const char *id, cJSON *params);
/* State query */ /* State query */
bool bt_classic_is_enabled(void); bool bt_classic_is_enabled(void);

View File

@ -156,6 +156,11 @@ static const cmd_entry_t cmd_table[] = {
{ CMD_CLASSIC_PAIR_RESPOND, cmd_classic_pair_respond }, { CMD_CLASSIC_PAIR_RESPOND, cmd_classic_pair_respond },
{ CMD_CLASSIC_SET_SSP_MODE, cmd_classic_set_ssp_mode }, { CMD_CLASSIC_SET_SSP_MODE, cmd_classic_set_ssp_mode },
/* SPP */
{ CMD_SPP_SEND, cmd_spp_send },
{ CMD_SPP_DISCONNECT, cmd_spp_disconnect },
{ CMD_SPP_STATUS, cmd_spp_status },
/* BLE */ /* BLE */
{ CMD_BLE_ENABLE, cmd_ble_enable }, { CMD_BLE_ENABLE, cmd_ble_enable },
{ CMD_BLE_DISABLE, cmd_ble_disable }, { CMD_BLE_DISABLE, cmd_ble_disable },

View File

@ -36,6 +36,11 @@
#define CMD_CLASSIC_SET_DISCOVERABLE "classic_set_discoverable" #define CMD_CLASSIC_SET_DISCOVERABLE "classic_set_discoverable"
#define CMD_CLASSIC_PAIR_RESPOND "classic_pair_respond" #define CMD_CLASSIC_PAIR_RESPOND "classic_pair_respond"
/* SPP commands */
#define CMD_SPP_SEND "spp_send"
#define CMD_SPP_DISCONNECT "spp_disconnect"
#define CMD_SPP_STATUS "spp_status"
/* BLE commands */ /* BLE commands */
#define CMD_BLE_ENABLE "ble_enable" #define CMD_BLE_ENABLE "ble_enable"
#define CMD_BLE_DISABLE "ble_disable" #define CMD_BLE_DISABLE "ble_disable"
@ -59,6 +64,11 @@
#define EVT_GATT_WRITE "gatt_write" #define EVT_GATT_WRITE "gatt_write"
#define EVT_GATT_SUBSCRIBE "gatt_subscribe" #define EVT_GATT_SUBSCRIBE "gatt_subscribe"
/* SPP events */
#define EVT_SPP_DATA "spp_data"
#define EVT_SPP_CONNECT "spp_connect"
#define EVT_SPP_DISCONNECT "spp_disconnect"
/* SSP IO capabilities */ /* SSP IO capabilities */
#define IO_CAP_DISPLAY_ONLY "display_only" #define IO_CAP_DISPLAY_ONLY "display_only"
#define IO_CAP_DISPLAY_YESNO "display_yesno" #define IO_CAP_DISPLAY_YESNO "display_yesno"

View File

@ -60,6 +60,11 @@ CMD_CLASSIC_DISABLE = "classic_disable"
CMD_CLASSIC_SET_DISCOVERABLE = "classic_set_discoverable" CMD_CLASSIC_SET_DISCOVERABLE = "classic_set_discoverable"
CMD_CLASSIC_PAIR_RESPOND = "classic_pair_respond" CMD_CLASSIC_PAIR_RESPOND = "classic_pair_respond"
# SPP (Serial Port Profile)
CMD_SPP_SEND = "spp_send"
CMD_SPP_DISCONNECT = "spp_disconnect"
CMD_SPP_STATUS = "spp_status"
# BLE # BLE
CMD_BLE_ENABLE = "ble_enable" CMD_BLE_ENABLE = "ble_enable"
CMD_BLE_DISABLE = "ble_disable" CMD_BLE_DISABLE = "ble_disable"
@ -83,6 +88,11 @@ EVT_GATT_READ = "gatt_read"
EVT_GATT_WRITE = "gatt_write" EVT_GATT_WRITE = "gatt_write"
EVT_GATT_SUBSCRIBE = "gatt_subscribe" EVT_GATT_SUBSCRIBE = "gatt_subscribe"
# SPP Events
EVT_SPP_DATA = "spp_data"
EVT_SPP_CONNECT = "spp_connect"
EVT_SPP_DISCONNECT = "spp_disconnect"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Protocol constants # Protocol constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -11,6 +11,9 @@ from ..protocol import (
CMD_CLASSIC_ENABLE, CMD_CLASSIC_ENABLE,
CMD_CLASSIC_PAIR_RESPOND, CMD_CLASSIC_PAIR_RESPOND,
CMD_CLASSIC_SET_DISCOVERABLE, CMD_CLASSIC_SET_DISCOVERABLE,
CMD_SPP_DISCONNECT,
CMD_SPP_SEND,
CMD_SPP_STATUS,
Status, Status,
) )
from ..serial_client import get_client from ..serial_client import get_client
@ -122,3 +125,81 @@ def register_tools(mcp: FastMCP) -> None:
return {"status": "error", "error": response.data.get("error", "unknown error")} return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc: except Exception as exc:
return {"error": str(exc)} return {"error": str(exc)}
# -------------------------------------------------------------------------
# SPP (Serial Port Profile) tools
# -------------------------------------------------------------------------
@mcp.tool()
async def esp32_spp_send(
data: str | None = None,
data_hex: str | None = None,
) -> dict[str, Any]:
"""Send data over the SPP (Serial Port Profile) connection.
SPP provides a virtual serial port over Bluetooth. After a Linux host
connects via rfcomm or similar, this tool sends data to that connection.
Args:
data: Plain text data to send (UTF-8 encoded).
data_hex: Hex-encoded binary data to send (e.g., "48656c6c6f" for "Hello").
Use this for binary protocols. Takes precedence over 'data'.
Returns:
Response with bytes_sent count on success.
"""
if data is None and data_hex is None:
return {"error": "Either 'data' or 'data_hex' must be provided"}
try:
client = get_client()
params: dict[str, Any] = {}
if data_hex is not None:
params["data_hex"] = data_hex
elif data is not None:
params["data"] = data
response = await client.send_command(CMD_SPP_SEND, params)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_spp_disconnect() -> dict[str, Any]:
"""Disconnect the current SPP connection.
Terminates the active SPP (Serial Port Profile) connection if one exists.
The SPP server will continue listening for new connections.
Returns:
Response confirming the disconnection.
"""
try:
client = get_client()
response = await client.send_command(CMD_SPP_DISCONNECT)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_spp_status() -> dict[str, Any]:
"""Get the current SPP connection status.
Returns information about whether an SPP connection is active,
the connection handle, and the remote device address.
Returns:
Response with connected (bool), handle, and remote_address if connected.
"""
try:
client = get_client()
response = await client.send_command(CMD_SPP_STATUS)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}