From 61da375a0c9e08f31eb8edba07de70174028e30e Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 3 Feb 2026 13:39:17 -0700 Subject: [PATCH] Add SPP (Serial Port Profile) support for bidirectional data transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 10 +- firmware/main/bt_classic.c | 183 ++++++++++++++++++++++++- firmware/main/bt_classic.h | 5 + firmware/main/cmd_dispatcher.c | 5 + firmware/main/protocol.h | 10 ++ src/mcbluetooth_esp32/protocol.py | 10 ++ src/mcbluetooth_esp32/tools/classic.py | 81 +++++++++++ 7 files changed, 297 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7ebdf93..7a2bd92 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,11 @@ The ESP32 can emulate various Bluetooth devices for testing: | Capability | Description | |------------|-------------| | **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 | | **Device Personas** | Presets for headset, speaker, keyboard, sensor, phone | | **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 @@ -116,6 +117,13 @@ esp32_get_info() # → chip, fw_version, bt_mac | `esp32_classic_set_discoverable` | Make device visible for 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 | Tool | Description | |------|-------------| diff --git a/firmware/main/bt_classic.c b/firmware/main/bt_classic.c index 45c4b5d..5e87605 100644 --- a/firmware/main/bt_classic.c +++ b/firmware/main/bt_classic.c @@ -54,6 +54,7 @@ static struct { pair_type_t pair_type; /* SPP handle for the listening server */ uint32_t spp_handle; + char spp_remote_addr[18]; /* Connected peer address */ } classic_state = { .io_cap = ESP_BT_IO_CAP_IO, /* display+yesno (numeric comparison) */ .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: bd_addr_to_str(param->srv_open.rem_bda, addr_str); 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 ")", 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; case ESP_SPP_CLOSE_EVT: ESP_LOGI(SPP_TAG, "SPP disconnected (handle=%" PRIu32 ")", param->close.handle); - /* We don't have the remote address in CLOSE_EVT on all IDF versions, - * so report with handle info. */ + /* Report SPP disconnect with saved remote address */ { cJSON *d = cJSON_CreateObject(); cJSON_AddNumberToObject(d, "handle", (double)param->close.handle); - cJSON_AddStringToObject(d, "transport", "classic"); - event_report(EVT_DISCONNECT, d); + cJSON_AddStringToObject(d, "transport", "spp"); + 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) { classic_state.spp_handle = 0; + classic_state.spp_remote_addr[0] = '\0'; } break; @@ -274,9 +288,46 @@ static void spp_cb(esp_spp_cb_event_t event, esp_spp_cb_param_t *param) break; 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, 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; default: @@ -598,6 +649,126 @@ void cmd_classic_set_ssp_mode(const char *id, cJSON *params) 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 */ /* ------------------------------------------------------------------ */ diff --git a/firmware/main/bt_classic.h b/firmware/main/bt_classic.h index 674471f..c72a601 100644 --- a/firmware/main/bt_classic.h +++ b/firmware/main/bt_classic.h @@ -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_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 */ bool bt_classic_is_enabled(void); diff --git a/firmware/main/cmd_dispatcher.c b/firmware/main/cmd_dispatcher.c index 12c49c0..fbfedff 100644 --- a/firmware/main/cmd_dispatcher.c +++ b/firmware/main/cmd_dispatcher.c @@ -156,6 +156,11 @@ static const cmd_entry_t cmd_table[] = { { CMD_CLASSIC_PAIR_RESPOND, cmd_classic_pair_respond }, { 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 */ { CMD_BLE_ENABLE, cmd_ble_enable }, { CMD_BLE_DISABLE, cmd_ble_disable }, diff --git a/firmware/main/protocol.h b/firmware/main/protocol.h index ba36bf8..a6e901b 100644 --- a/firmware/main/protocol.h +++ b/firmware/main/protocol.h @@ -36,6 +36,11 @@ #define CMD_CLASSIC_SET_DISCOVERABLE "classic_set_discoverable" #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 */ #define CMD_BLE_ENABLE "ble_enable" #define CMD_BLE_DISABLE "ble_disable" @@ -59,6 +64,11 @@ #define EVT_GATT_WRITE "gatt_write" #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 */ #define IO_CAP_DISPLAY_ONLY "display_only" #define IO_CAP_DISPLAY_YESNO "display_yesno" diff --git a/src/mcbluetooth_esp32/protocol.py b/src/mcbluetooth_esp32/protocol.py index f51deb1..a4253ff 100644 --- a/src/mcbluetooth_esp32/protocol.py +++ b/src/mcbluetooth_esp32/protocol.py @@ -60,6 +60,11 @@ CMD_CLASSIC_DISABLE = "classic_disable" CMD_CLASSIC_SET_DISCOVERABLE = "classic_set_discoverable" 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 CMD_BLE_ENABLE = "ble_enable" CMD_BLE_DISABLE = "ble_disable" @@ -83,6 +88,11 @@ EVT_GATT_READ = "gatt_read" EVT_GATT_WRITE = "gatt_write" EVT_GATT_SUBSCRIBE = "gatt_subscribe" +# SPP Events +EVT_SPP_DATA = "spp_data" +EVT_SPP_CONNECT = "spp_connect" +EVT_SPP_DISCONNECT = "spp_disconnect" + # --------------------------------------------------------------------------- # Protocol constants # --------------------------------------------------------------------------- diff --git a/src/mcbluetooth_esp32/tools/classic.py b/src/mcbluetooth_esp32/tools/classic.py index f85ed8a..1f8c38e 100644 --- a/src/mcbluetooth_esp32/tools/classic.py +++ b/src/mcbluetooth_esp32/tools/classic.py @@ -11,6 +11,9 @@ from ..protocol import ( CMD_CLASSIC_ENABLE, CMD_CLASSIC_PAIR_RESPOND, CMD_CLASSIC_SET_DISCOVERABLE, + CMD_SPP_DISCONNECT, + CMD_SPP_SEND, + CMD_SPP_STATUS, Status, ) 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")} except Exception as 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)}