mcbluetooth-esp32/docs/protocol-spec.md
Ryan Malloy 6398a5223a ESP32 Bluetooth test harness MCP server
UART-controlled ESP32 peripheral for automated E2E Bluetooth testing.
Dual-mode (Classic BT + BLE) via Bluedroid on original ESP32.

Firmware (ESP-IDF v5.x, 2511 lines C):
- NDJSON protocol over UART1 (115200 baud)
- System commands: ping, reset, get_info, get_status
- Classic BT: GAP, SPP, all 4 SSP pairing modes
- BLE: GATTS, advertising, GATT service/characteristic management
- 6 device personas: headset, speaker, keyboard, sensor, phone, bare
- Event reporter: thread-safe async event queue to host

Python MCP server (FastMCP, 1626 lines):
- Async serial client with command/response correlation
- Event queue with wait_for pattern matching
- Tools: connection, configure, classic, ble, persona, events
- MCP resources: esp32://status, esp32://events, esp32://personas

Tests: 74 unit tests passing, 5 integration test stubs (skip without hardware)
2026-02-02 15:12:28 -07:00

13 KiB

UART Protocol Specification

Authoritative reference for the JSON-over-UART protocol used between the Python MCP server (mcbluetooth-esp32) and the ESP32 firmware.

Overview

  • Format: NDJSON (newline-delimited JSON) -- one JSON object per line, terminated by \n
  • Baud rate: 115200
  • Frame format: 8N1 (8 data bits, no parity, 1 stop bit)
  • Max line length: 2048 bytes (lines exceeding this are silently dropped)
  • UART peripheral: ESP32 UART1 on GPIO4 (TX) and GPIO5 (RX), keeping UART0 free for ESP-IDF console logging
  • Encoding: UTF-8
  • Newlines: \n only. Carriage returns (\r) are stripped by the firmware reader.

There are three message types flowing over the link:

Direction Type Purpose
Host --> ESP32 cmd Request an action or query
ESP32 --> Host resp Direct reply to a command (correlated by id)
ESP32 --> Host event Asynchronous notification (no correlation ID)

Message Formats

Command (host --> ESP32)

{"type":"cmd","id":"1","cmd":"ping","params":{}}
Field Type Required Description
type string yes Always "cmd"
id string yes Monotonically increasing request ID. The ESP32 echoes this in the response.
cmd string yes Command name (see Command Reference below)
params object no Command-specific parameters. Omitted or {} if the command takes none.

Response (ESP32 --> host)

{"type":"resp","id":"1","status":"ok","data":{"pong":true}}
Field Type Required Description
type string yes Always "resp"
id string yes Echoed from the command that triggered this response
status string yes "ok" on success, "error" on failure
data object yes Command-specific payload. On error, contains {"error":"description"}.

Event (ESP32 --> host, unsolicited)

{"type":"event","event":"pair_request","data":{"address":"AA:BB:CC:DD:EE:FF","type":"numeric_comparison","passkey":123456},"ts":12345}
Field Type Required Description
type string yes Always "event"
event string yes Event name (see Event Reference below)
data object yes Event-specific payload
ts number yes Millisecond timestamp from esp_timer_get_time() / 1000 (time since boot)

Command Reference

System Commands

Command Parameters Response Data Description
ping none {"pong":true} Heartbeat check. Verifies the serial link is alive.
reset none {} Reboots the ESP32. The response may not arrive before the link drops.
get_info none {"chip_model":"ESP32","features":["wifi","bt","ble"],"revision":3,"cores":2,"fw_version":"0.1.0","free_heap":240000,"bt_mac":"AA:BB:CC:DD:EE:FF"} Hardware and firmware details.
get_status none {"uptime_ms":12345,"free_heap":230000,"bt_enabled":false,"ble_enabled":false} Current device state.

Configuration Commands

Command Parameters Response Data Description
configure name (string, optional), io_cap (string, optional), device_class (int, optional), pin_code (string, optional) Applied config values Set one or more Bluetooth properties atomically. Any parameter not sent retains its current value.
load_persona persona (string) {"persona":"headset","device_name":"BT Headset","io_cap":"no_io","classic":true,"ble":true,"device_class":"0x200404","services":[...]} Load a preset device profile. Sets name, IO capability, Class of Device, and advertised services in one operation.
list_personas none {"personas":[...]} List all available persona presets and their configurations.
classic_set_ssp_mode mode (string) Applied mode Set the SSP association model. See SSP Modes below.

Valid mode values for classic_set_ssp_mode:

Mode Description
just_works No user interaction. Lowest security. Both sides auto-accept.
numeric_comparison Both sides display a 6-digit passkey. User confirms they match.
passkey_entry Remote device must type a passkey displayed on the ESP32.
passkey_display ESP32 displays a passkey that the remote device enters.

Classic Bluetooth Commands

Command Parameters Response Data Description
classic_enable none BT state Enable the BR/EDR (Classic Bluetooth) stack.
classic_disable none BT state Disable Classic Bluetooth. Device becomes invisible to inquiry scans.
classic_set_discoverable discoverable (bool), timeout (int, seconds, 0=forever) Discoverable state Control whether the ESP32 responds to Bluetooth inquiry scans.
classic_pair_respond address (string), accept (bool), passkey (int, optional), pin (string, optional) Pairing result Accept or reject an incoming pairing request from the given address.

BLE Commands

Command Parameters Response Data Description
ble_enable none BLE state Initialize the BLE stack. Required before advertising or GATT operations.
ble_disable none BLE state Shut down BLE. Stops advertising and tears down GATT services.
ble_advertise enable (bool), interval_ms (int, default 100) Advertising state Start or stop BLE advertisement broadcasts.
ble_set_adv_data name (string, optional), service_uuids (string[], optional), manufacturer_data (hex string, optional) Applied adv data Configure the contents of BLE advertisement packets. Call before starting advertising.

GATT Commands

Command Parameters Response Data Description
gatt_add_service uuid (string), primary (bool, default true) {"handle":N} Create a GATT service. Returns a handle used when adding characteristics.
gatt_add_characteristic service_handle (int), uuid (string), properties (string[]), value (hex string, optional) {"handle":N} Add a characteristic to a service. Properties: "read", "write", "notify", "indicate".
gatt_set_value char_handle (int), value (hex string) {} Update a characteristic's stored value.
gatt_notify char_handle (int) {} Push the current value to all subscribed BLE clients as a notification.
gatt_clear none {} Remove all services and characteristics, resetting the GATT server to blank.

Hex string encoding: Values are represented as lowercase hex strings without a prefix. For example, a temperature of 25.00 C encoded as a little-endian int16 in hundredths of a degree would be "c409" (2500 = 0x09C4, little-endian = c4 09).


Event Reference

Event Data Fields Trigger
boot fw_version (string), chip_model (string), cores (int), revision (int), free_heap (int) Emitted once immediately after firmware startup.
pair_request address (string), type (string: "just_works", "numeric_comparison", "passkey_entry", "legacy_pin"), passkey (int) A remote device has initiated pairing. Requires a classic_pair_respond to proceed.
pair_complete address (string), success (bool) Pairing attempt finished (accepted, rejected, or timed out).
connect address (string), transport (string: "classic" or "ble") A remote device has established a connection.
disconnect address (string), transport (string: "classic" or "ble") A remote device has disconnected.
gatt_read handle (int), address (string) A BLE client read a characteristic value.
gatt_write handle (int), address (string), value (hex string), length (int) A BLE client wrote to a characteristic.
gatt_subscribe handle (int), subscribed (bool) A BLE client enabled or disabled notifications on a characteristic.

IO Capabilities

The io_cap parameter controls which Secure Simple Pairing (SSP) association model the Bluetooth stack negotiates. The pairing model is determined by the capabilities of both devices.

io_cap string ESP-IDF Constant Typical SSP Outcome
display_only ESP_IO_CAP_OUT Passkey Display (ESP32 shows, remote enters)
display_yesno ESP_IO_CAP_IO Numeric Comparison (both display 6-digit code, user confirms)
keyboard_only ESP_IO_CAP_IN Passkey Entry (remote displays, ESP32 enters)
no_io ESP_IO_CAP_NONE Just Works (no user interaction, no MITM protection)
keyboard_display ESP_IO_CAP_KBDISP Numeric Comparison or Passkey Entry depending on remote

SSP negotiation matrix (simplified, both sides must agree):

Local \ Remote no_io display_only display_yesno keyboard_only keyboard_display
no_io Just Works Just Works Just Works Just Works Just Works
display_only Just Works Just Works Just Works Passkey Entry Passkey Entry
display_yesno Just Works Just Works Numeric Comp Passkey Entry Numeric Comp
keyboard_only Just Works Passkey Entry Passkey Entry Passkey Entry Passkey Entry
keyboard_display Just Works Passkey Entry Numeric Comp Passkey Entry Numeric Comp

Error Handling

Unknown commands

An unrecognized command returns an error response with the original command echoed back:

{"type":"resp","id":"5","status":"error","data":{"error":"unknown_command","cmd":"foobar"}}

Invalid JSON

If the host sends a line that does not parse as valid JSON, the firmware responds with id set to "?":

{"type":"resp","id":"?","status":"error","data":"invalid JSON"}

Missing required fields

If a command is missing required parameters, the firmware returns:

{"type":"resp","id":"3","status":"error","data":{"error":"missing 'persona' param"}}

Timeouts

The host should use a 5-second default timeout when waiting for a response. The Python SerialClient implements this via asyncio.wait_for and raises CommandTimeout on expiry.

Exceptions:

  • The reset command may not produce a response at all (the device reboots before the reply flushes). Treat the link drop as success.
  • classic_pair_respond may take longer if the remote device is slow. Consider a 10-second timeout for pairing operations.

Line overflow

Lines exceeding 2048 bytes are discarded by both sides without any error response. The firmware resets its read buffer; the Python client logs a warning and drops the line.


Wire Examples

Full ping/pong exchange:

Host TX:  {"type":"cmd","id":"1","cmd":"ping"}\n
ESP32 TX: {"type":"resp","id":"1","status":"ok","data":{"pong":true}}\n

Configure with IO capability:

Host TX:  {"type":"cmd","id":"2","cmd":"configure","params":{"name":"MyDevice","io_cap":"display_yesno"}}\n
ESP32 TX: {"type":"resp","id":"2","status":"ok","data":{"name":"MyDevice","io_cap":"display_yesno"}}\n

Boot event (ESP32 sends immediately after power-on):

ESP32 TX: {"type":"event","event":"boot","data":{"fw_version":"0.1.0","chip_model":"ESP32","cores":2,"revision":3,"free_heap":283648},"ts":42}\n

Pair request event followed by host response:

ESP32 TX: {"type":"event","event":"pair_request","data":{"address":"AA:BB:CC:DD:EE:FF","type":"numeric_comparison","passkey":482901},"ts":15234}\n
Host TX:  {"type":"cmd","id":"7","cmd":"classic_pair_respond","params":{"address":"AA:BB:CC:DD:EE:FF","accept":true,"passkey":482901}}\n
ESP32 TX: {"type":"resp","id":"7","status":"ok","data":{}}\n
ESP32 TX: {"type":"event","event":"pair_complete","data":{"address":"AA:BB:CC:DD:EE:FF","success":true},"ts":15891}\n

Personas

Personas are bundled device presets stored in the firmware. Loading one configures the device name, IO capability, Class of Device, and returns the list of GATT service UUIDs the persona expects.

Persona Device Name IO Capability Class of Device Classic BLE Services
headset BT Headset no_io 0x200404 (Audio, Headset) yes yes Battery (0x180F), Device Info (0x180A)
speaker BT Speaker no_io 0x200414 (Audio, Loudspeaker) yes yes Battery (0x180F), Device Info (0x180A)
keyboard BT Keyboard keyboard_only 0x002540 (Peripheral, Keyboard) yes yes HID (0x1812), Battery (0x180F)
sensor Environment Sensor no_io none (BLE only) no yes Env Sensing (0x181A), Battery (0x180F)
phone Test Phone keyboard_display 0x5A020C (Phone) yes yes Phonebook (0x1130), Device Info (0x180A)
bare ESP32-Test display_yesno 0x1F00 (Uncategorized) yes yes none