mcbluetooth-esp32/docs/test-scenarios.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

6.9 KiB

E2E Test Scenarios

Overview

Tests require both MCP servers running simultaneously:

  • mcbluetooth — controls the Linux BlueZ stack (host side)
  • mcbluetooth-esp32 — controls the ESP32 peripheral (device side)

An LLM orchestrates both servers, issuing tool calls to each side to execute the test flow.

Prerequisites

# Terminal 1: Start ESP32 MCP server
ESP32_SERIAL_PORT=/dev/ttyUSB4 uvx mcbluetooth-esp32

# Terminal 2: mcbluetooth is already running (or add to Claude Code config)

Test 1: SSP Just Works

Both devices have no_io capability — pairing auto-completes without user interaction.

Flow

# ESP32 side: Configure as headset (no_io)
esp32_load_persona("headset")           # io_cap=no_io → Just Works
esp32_classic_enable()
esp32_classic_set_discoverable(True)

# Linux side: Discover and pair
bt_scan(adapter="hci0", mode="classic", timeout=10)
# → Find "BT Headset" in scan results

bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="auto")
# Just Works: auto-accepts on both sides

# Verify
esp32_wait_event("pair_complete", timeout=15)
# → {"address": "XX:XX:XX:XX:XX:XX", "success": true}

bt_device_info(adapter="hci0", address="D8:13:2A:7F:47:C0")
# → paired: true

Expected Result

  • Pairing completes without any passkey exchange
  • Both sides report success

Test 2: SSP Numeric Comparison

Both devices have display_yesno — both display a 6-digit passkey that must match.

Flow

# ESP32 side: Configure as phone (keyboard_display → numeric comparison)
esp32_load_persona("phone")
esp32_classic_enable()
esp32_classic_set_discoverable(True)

# Linux side: Scan and initiate pairing
bt_scan(adapter="hci0", mode="classic")
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")

# Both sides receive the passkey
esp32_wait_event("pair_request", timeout=15)
# → {"type": "numeric_comparison", "passkey": 123456, "address": "..."}

bt_pairing_status()
# → passkey: 123456 (should match ESP32's passkey!)

# Both sides confirm
esp32_classic_pair_respond(address="XX:XX:XX:XX:XX:XX", accept=True)
bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0", accept=True)

# Verify
esp32_wait_event("pair_complete")
# → {"success": true}

Expected Result

  • Both sides display the same 6-digit passkey
  • After both confirm, pairing succeeds

Test 3: SSP Passkey Entry

One side displays a passkey, the other must enter it.

Flow (ESP32 displays, Linux enters)

# ESP32: display_only → shows passkey
esp32_configure(io_cap="display_only")
esp32_classic_enable()
esp32_classic_set_discoverable(True)

# Linux: Initiate pairing
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")

# ESP32 displays passkey
esp32_wait_event("pair_request")
# → {"type": "passkey_display", "passkey": 654321}

# Linux enters the passkey
bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0",
                passkey=654321, accept=True)

# Verify
esp32_wait_event("pair_complete")
# → {"success": true}

Flow (Linux displays, ESP32 enters)

# ESP32: keyboard_only → must enter passkey
esp32_configure(io_cap="keyboard_only")
esp32_classic_enable()
esp32_classic_set_discoverable(True)

# Linux initiates pairing — Linux displays passkey
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")

bt_pairing_status()
# → passkey: 789012 (displayed on Linux)

# ESP32 receives passkey entry request
esp32_wait_event("pair_request")
# → {"type": "passkey_entry"}

# ESP32 enters the passkey shown on Linux
esp32_classic_pair_respond(address="XX:XX:XX:XX:XX:XX", accept=True, passkey=789012)

# Verify
esp32_wait_event("pair_complete")

Test 4: Legacy PIN

ESP32 configured with a legacy PIN code.

esp32_configure(pin_code="1234")
esp32_classic_enable()
esp32_classic_set_discoverable(True)

bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")

# Linux enters PIN
bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0",
                pin="1234", accept=True)

Test 5: BLE GATT Read/Write

ESP32 creates an Environmental Sensing service with a Temperature characteristic.

# ESP32: Set up as sensor
esp32_load_persona("sensor")
esp32_gatt_add_service(uuid="0000181a-0000-1000-8000-00805f9b34fb", primary=True)
# → {"service_handle": 40}

esp32_gatt_add_characteristic(
    service_handle=40,
    uuid="00002a6e-0000-1000-8000-00805f9b34fb",  # Temperature
    properties=["read", "notify"],
    value="e803"  # 25.0°C (0x03e8 = 1000 in little-endian → 10.00°C? or raw)
)
# → {"char_handle": 42}

esp32_ble_advertise(enable=True)

# Linux: Scan, connect, read
bt_ble_scan(adapter="hci0", timeout=5)
bt_connect(adapter="hci0", address="D8:13:2A:7F:47:C0")
bt_ble_read(adapter="hci0", address="D8:13:2A:7F:47:C0",
            char_uuid="00002a6e-0000-1000-8000-00805f9b34fb")
# → {"hex": "e803", ...}

# ESP32: Update value
esp32_gatt_set_value(char_handle=42, value="f003")  # 25.5°C

Test 6: BLE GATT Notifications

# Linux: Subscribe to temperature notifications
bt_ble_notify(adapter="hci0", address="D8:13:2A:7F:47:C0",
              char_uuid="00002a6e-0000-1000-8000-00805f9b34fb",
              enable=True)

# ESP32: Verify subscription
esp32_wait_event("gatt_subscribe")
# → {"char_handle": 42, "subscribed": true}

# ESP32: Update and notify
esp32_gatt_set_value(char_handle=42, value="0004")
esp32_gatt_notify(char_handle=42)

# Linux should receive the updated value
bt_ble_read(adapter="hci0", address="D8:13:2A:7F:47:C0",
            char_uuid="00002a6e-0000-1000-8000-00805f9b34fb")
# → {"hex": "0004"}

Test 7: Persona Switching

Verify device identity changes are visible from the Linux side.

# Load different personas and scan each time
for persona in ["headset", "speaker", "keyboard", "sensor", "phone", "bare"]:
    esp32_load_persona(persona)
    esp32_classic_enable()
    esp32_classic_set_discoverable(True)

    bt_scan(adapter="hci0", mode="both", timeout=5)
    # Verify device name and class match persona definition

    esp32_classic_disable()

Running Tests

# Unit tests only (no hardware needed)
make test-unit

# Integration tests (requires ESP32 on /dev/ttyUSB4)
ESP32_SERIAL_PORT=/dev/ttyUSB4 make test-integration

# Full suite
make test

Test Matrix

Test SSP Mode ESP32 IO Cap Linux IO Cap Auto?
Just Works NoInputNoOutput no_io no_io Yes
Numeric Comparison NumericComparison keyboard_display display_yesno No (confirm)
Passkey Entry (ESP32 displays) PasskeyEntry display_only keyboard_only No (enter)
Passkey Entry (Linux displays) PasskeyEntry keyboard_only display_only No (enter)
Legacy PIN LegacyPIN n/a n/a No (PIN)