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.
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 |