The firmware uses UART0 (via USB bridge) with ESP-IDF console disabled, not UART1 on GPIO4/GPIO5 as originally documented. Updated both docs to reflect the actual hardware-verified configuration: - protocol-spec.md: UART peripheral description - hardware-setup.md: wiring section, monitor section, sdkconfig table, troubleshooting steps
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 UART0 via the dev board's USB-to-UART bridge (TX=GPIO1, RX=GPIO3). ESP-IDF console is disabled (
CONFIG_ESP_CONSOLE_NONE=y) so the firmware owns UART0 exclusively. - Encoding: UTF-8
- Newlines:
\nonly. 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
resetcommand may not produce a response at all (the device reboots before the reply flushes). Treat the link drop as success. classic_pair_respondmay 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 |