mcbluetooth-esp32/docs/test-scenarios.md
Ryan Malloy 73d3d438a2 Expand test scenarios with step-by-step procedures
Rewrite test-scenarios.md with detailed per-step instructions
including exact tool calls, expected responses, negative test
cases, teardown procedures, and an environment variable reference.
2026-02-02 15:13:36 -07:00

16 KiB

End-to-End Test Scenarios

Tests that exercise the full Bluetooth stack across two devices: a Linux host running mcbluetooth (BlueZ) and an ESP32 running the mcbluetooth-esp32 firmware.

Overview

Each test requires two MCP servers running simultaneously:

Server Controls Tool prefix
mcbluetooth Linux BlueZ stack (hci0) bt_*
mcbluetooth-esp32 ESP32 peripheral via UART esp32_*

The LLM orchestrates both sides, acting as the test conductor. It issues commands to one side, observes events on the other, and verifies that the protocol exchange matches expectations.

Prerequisites

  • ESP32 connected and reachable at ESP32_SERIAL_PORT (default /dev/ttyUSB4)
  • BlueZ adapter powered on (bt_adapter_power(adapter="hci0", on=true))
  • Both MCP servers registered in the Claude Code session

Test 1: SSP Just Works

Both devices have no_io capability. Pairing should auto-complete with no user interaction.

Setup

Step 1 -- Connect to the ESP32 and configure it as a no-IO device:

esp32_connect(port="/dev/ttyUSB4")
esp32_configure(name="JustWorks-Test", io_cap="no_io")
esp32_classic_enable()
esp32_classic_set_discoverable(discoverable=true)

Step 2 -- Power on the Linux adapter:

bt_adapter_power(adapter="hci0", on=true)

Execute

Step 3 -- Scan from Linux to discover the ESP32:

bt_scan(adapter="hci0", timeout=10, mode="classic")

Verify that a device named "JustWorks-Test" appears in the scan results. Note its Bluetooth address (e.g., "AA:BB:CC:DD:EE:FF").

Step 4 -- Initiate pairing from Linux:

bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="auto")

Since both sides have no_io, SSP Just Works is negotiated. No passkey exchange occurs.

Step 5 -- Wait for the pair_request event on the ESP32 side:

esp32_wait_event(event_name="pair_request", timeout=10)

Expected: {"event":"pair_request","data":{"address":"...","type":"just_works","passkey":0},...}

Step 6 -- Accept the pairing on the ESP32:

esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true)

Step 7 -- Verify pairing completed on both sides:

esp32_wait_event(event_name="pair_complete", timeout=10)

Expected: {"event":"pair_complete","data":{"address":"AA:BB:CC:DD:EE:FF","success":true},...}

bt_list_devices(adapter="hci0", filter="paired")

Verify the ESP32 address appears in the paired devices list.

Teardown

bt_unpair(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
esp32_classic_set_discoverable(discoverable=false)
esp32_classic_disable()

Test 2: SSP Numeric Comparison

Both devices have display_yesno capability. Both display a 6-digit passkey that the user (or LLM) must confirm matches.

Setup

Step 1 -- Configure the ESP32 with display+yesno:

esp32_connect(port="/dev/ttyUSB4")
esp32_configure(name="NumComp-Test", io_cap="display_yesno")
esp32_classic_enable()
esp32_classic_set_discoverable(discoverable=true)

Step 2 -- Ensure Linux adapter is powered:

bt_adapter_power(adapter="hci0", on=true)

Execute

Step 3 -- Scan and discover:

bt_scan(adapter="hci0", timeout=10, mode="classic")

Locate "NumComp-Test" in results.

Step 4 -- Initiate pairing from Linux in interactive mode:

bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="interactive")

This returns a pairing status indicating it is awaiting confirmation with a passkey.

Step 5 -- Capture the passkey from the ESP32:

esp32_wait_event(event_name="pair_request", timeout=10)

Expected: {"event":"pair_request","data":{"address":"...","type":"numeric_comparison","passkey":482901},...}

Note the passkey value (e.g., 482901).

Step 6 -- Verify the passkeys match and confirm on both sides:

Check that the passkey from the Linux pairing status matches the ESP32 event.

Accept on ESP32:

esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true, passkey=482901)

Confirm on Linux:

bt_pair_confirm(adapter="hci0", address="AA:BB:CC:DD:EE:FF", accept=true)

Step 7 -- Verify completion:

esp32_wait_event(event_name="pair_complete", timeout=10)

Expected: {"data":{"address":"AA:BB:CC:DD:EE:FF","success":true}}

Negative case

Repeat the flow but provide mismatched confirmation: accept on one side, reject on the other. Verify pair_complete reports "success":false.

Teardown

bt_unpair(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
esp32_classic_set_discoverable(discoverable=false)

Test 3: SSP Passkey Entry

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

Direction A: ESP32 displays, Linux enters

Setup:

esp32_configure(name="PasskeyDisp-Test", io_cap="display_only")
esp32_classic_enable()
esp32_classic_set_discoverable(discoverable=true)

Execute:

bt_scan(adapter="hci0", timeout=10, mode="classic")
bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="interactive")

The ESP32 receives a pair_request event with "type":"passkey_entry" and a passkey value.

esp32_wait_event(event_name="pair_request", timeout=10)

Extract the passkey (e.g., 731205). Then send it from the Linux side:

bt_pair_confirm(adapter="hci0", address="AA:BB:CC:DD:EE:FF", passkey=731205, accept=true)

Accept on ESP32:

esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true)

Verify pair_complete succeeds.

Direction B: Linux displays, ESP32 enters

Setup:

esp32_configure(name="PasskeyEntry-Test", io_cap="keyboard_only")
esp32_classic_enable()
esp32_classic_set_discoverable(discoverable=true)

Execute:

bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="interactive")

The Linux side generates a passkey for the ESP32 to enter. The ESP32 receives a pair_request event. Forward the passkey from the Linux pairing status to the ESP32 response:

esp32_wait_event(event_name="pair_request", timeout=10)
esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true, passkey=<passkey_from_linux>)

Verify pair_complete succeeds.


Test 4: Legacy PIN

ESP32 configured with a PIN code. Linux initiates pairing with that PIN. This tests pre-SSP pairing mode.

Setup

esp32_connect(port="/dev/ttyUSB4")
esp32_configure(name="LegacyPIN-Test", io_cap="no_io", pin_code="1234")
esp32_classic_enable()
esp32_classic_set_discoverable(discoverable=true)

Execute

Step 1 -- Scan and pair from Linux with the PIN:

bt_scan(adapter="hci0", timeout=10, mode="classic")
bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="interactive")

Step 2 -- Handle the pair request on ESP32:

esp32_wait_event(event_name="pair_request", timeout=10)

Expected: "type":"legacy_pin"

Step 3 -- Respond with the PIN on the ESP32:

esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true, pin="1234")

Step 4 -- Provide the PIN from Linux:

bt_pair_confirm(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pin="1234", accept=true)

Step 5 -- Verify:

esp32_wait_event(event_name="pair_complete", timeout=10)

Negative case

Provide a wrong PIN from the Linux side (e.g., "0000"). Verify pairing fails with "success":false.

Teardown

bt_unpair(adapter="hci0", address="AA:BB:CC:DD:EE:FF")

Test 5: BLE GATT Read/Write

ESP32 creates an Environmental Sensing service with a Temperature characteristic. Linux reads the value and verifies correctness. ESP32 updates the value and Linux reads again.

Setup

Step 1 -- Prepare the ESP32 as a BLE sensor:

esp32_connect(port="/dev/ttyUSB4")
esp32_configure(name="Temp-Sensor", io_cap="no_io")
esp32_ble_enable()

Step 2 -- Create the GATT service and characteristic:

esp32_gatt_add_service(uuid="181A", primary=true)

Returns: {"handle": 40} (example handle)

esp32_gatt_add_characteristic(
    service_handle=40,
    uuid="2A6E",
    properties=["read", "notify"],
    value="c409"
)

Returns: {"handle": 42} (example handle)

The value "c409" encodes 25.00 C as a little-endian int16 in hundredths of a degree (2500 = 0x09C4, LE = c4 09).

Step 3 -- Start advertising:

esp32_ble_set_adv_data(name="Temp-Sensor", service_uuids=["181A"])
esp32_ble_advertise(enable=true, interval_ms=100)

Read

Step 4 -- Scan from Linux:

bt_ble_scan(adapter="hci0", timeout=10, name_filter="Temp-Sensor")

Locate the device and note its address.

Step 5 -- Connect and read the characteristic:

bt_connect(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
bt_ble_read(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E")

Expected: hex value "c409", decoded as temperature 25.00 C.

Step 6 -- Verify the ESP32 received a GATT read event:

esp32_get_events(event_name="gatt_read")

Expected: an event with {"handle":42,"address":"..."}.

Write (update from ESP32, re-read from Linux)

Step 7 -- Update the value on the ESP32:

esp32_gatt_set_value(char_handle=42, value="d007")

The value "d007" encodes 20.00 C (2000 = 0x07D0, LE = d0 07).

Step 8 -- Re-read from Linux:

bt_ble_read(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E")

Expected: "d007" (20.00 C).

Teardown

bt_disconnect(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
esp32_ble_advertise(enable=false)
esp32_gatt_clear()

Test 6: BLE GATT Subscribe

Test notification subscription and delivery. The ESP32 pushes a value update to a subscribed Linux client.

Setup

Use the same GATT service structure from Test 5 (Environmental Sensing, Temperature characteristic with notify property).

esp32_connect(port="/dev/ttyUSB4")
esp32_ble_enable()
esp32_gatt_add_service(uuid="181A", primary=true)
# -> handle: 40
esp32_gatt_add_characteristic(
    service_handle=40,
    uuid="2A6E",
    properties=["read", "notify"],
    value="c409"
)
# -> handle: 42
esp32_ble_set_adv_data(name="Notify-Sensor", service_uuids=["181A"])
esp32_ble_advertise(enable=true)

Execute

Step 1 -- Connect from Linux and subscribe to notifications:

bt_ble_scan(adapter="hci0", timeout=10, name_filter="Notify-Sensor")
bt_connect(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
bt_ble_notify(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E", enable=true)

Step 2 -- Verify the ESP32 received a subscribe event:

esp32_wait_event(event_name="gatt_subscribe", timeout=5)

Expected: {"handle":42,"subscribed":true}

Step 3 -- Update the value on the ESP32 and send a notification:

esp32_gatt_set_value(char_handle=42, value="0c0a")
esp32_gatt_notify(char_handle=42)

The value "0c0a" encodes 25.72 C (2572 = 0x0A0C, LE = 0c 0a).

Step 4 -- Verify the Linux client received the notification:

The BLE notification should arrive as an updated characteristic value on the Linux side. Read the cached value:

bt_ble_read(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E")

Expected: "0c0a".

Step 5 -- Unsubscribe:

bt_ble_notify(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E", enable=false)
esp32_wait_event(event_name="gatt_subscribe", timeout=5)

Expected: {"handle":42,"subscribed":false}

Teardown

bt_disconnect(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
esp32_ble_advertise(enable=false)
esp32_gatt_clear()

Test 7: Persona Switching

Load different device personas on the ESP32 and verify that the device name and Bluetooth class are visible from a Linux scan.

Execute

Step 1 -- List available personas:

esp32_connect(port="/dev/ttyUSB4")
esp32_list_personas()

Verify the response contains all six personas: headset, speaker, keyboard, sensor, phone, bare.

Step 2 -- Load the headset persona:

esp32_load_persona(persona="headset")

Expected response:

{
  "persona": "headset",
  "device_name": "BT Headset",
  "io_cap": "no_io",
  "classic": true,
  "ble": true,
  "device_class": "0x200404",
  "services": ["0000180f-...", "0000180a-..."]
}

Step 3 -- Enable Classic BT and make discoverable:

esp32_classic_enable()
esp32_classic_set_discoverable(discoverable=true)

Step 4 -- Scan from Linux and verify the device appears as "BT Headset":

bt_scan(adapter="hci0", timeout=10, mode="classic")

Check that the scan results include a device named "BT Headset".

Step 5 -- Switch to the keyboard persona:

esp32_load_persona(persona="keyboard")
esp32_classic_set_discoverable(discoverable=true)

Step 6 -- Scan again and verify the name changed:

bt_scan(adapter="hci0", timeout=10, mode="classic")

Check that the device now appears as "BT Keyboard".

Step 7 -- Load the sensor persona (BLE only):

esp32_classic_disable()
esp32_load_persona(persona="sensor")
esp32_ble_enable()
esp32_ble_set_adv_data(name="Environment Sensor", service_uuids=["181A"])
esp32_ble_advertise(enable=true)

Step 8 -- Scan via BLE from Linux:

bt_ble_scan(adapter="hci0", timeout=10, name_filter="Environment Sensor")

Verify the device appears in the BLE scan results.

Teardown

esp32_ble_advertise(enable=false)
esp32_ble_disable()

Running Tests

Unit tests (no hardware required)

Unit tests exercise the Python protocol layer, event queue, and MCP tool registration using mock serial connections.

make test-unit

Or directly:

uv run pytest tests/ -v --ignore=tests/integration

Integration tests (requires ESP32 on serial port)

Integration tests require a physical ESP32 connected and flashed with the firmware.

ESP32_SERIAL_PORT=/dev/ttyUSB4 make test-integration

Or directly:

ESP32_SERIAL_PORT=/dev/ttyUSB4 uv run pytest tests/integration/ -v

Full E2E tests (requires both MCP servers)

The test scenarios in this document are designed to be executed by an LLM with both MCP servers available. Configure your Claude Code session with:

# Add the ESP32 MCP server
claude mcp add mcbluetooth-esp32 -- uvx mcbluetooth-esp32

# The mcbluetooth server (Linux BlueZ) should already be available

Set the serial port via environment variable:

export ESP32_SERIAL_PORT=/dev/ttyUSB4

Then instruct the LLM to run through the test scenarios, e.g.:

"Run Test 1 (SSP Just Works) from the test scenarios document. Both mcbluetooth and mcbluetooth-esp32 MCP servers are available."

Test environment variables

Variable Default Description
ESP32_SERIAL_PORT /dev/ttyUSB0 Serial device path for the ESP32
ESP32_SERIAL_BAUD 115200 Baud rate (should not need changing)
BT_ADAPTER hci0 Linux Bluetooth adapter for mcbluetooth

Test Matrix

Summary of all pairing tests and the IO capabilities required on each side.

Test SSP Mode ESP32 IO Cap Linux IO Cap User Interaction
1. Just Works Just Works no_io no_io None (auto-accept)
2. Numeric Comparison Numeric Comparison display_yesno display_yesno Confirm passkey match on both sides
3a. Passkey Entry (ESP32 displays) Passkey Entry display_only keyboard_only Linux enters passkey shown on ESP32
3b. Passkey Entry (Linux displays) Passkey Entry keyboard_only display_only ESP32 enters passkey shown on Linux
4. Legacy PIN Legacy PIN n/a n/a Both sides provide pre-shared PIN