Compare commits

..

10 Commits

Author SHA1 Message Date
61da375a0c Add SPP (Serial Port Profile) support for bidirectional data transfer
Firmware:
- Add spp_connect, spp_disconnect, spp_data events
- Add spp_send, spp_disconnect, spp_status commands
- Track remote address for connected SPP peer
- Report received data as hex + optional text decode

Python MCP:
- esp32_spp_send(data/data_hex) - send text or binary
- esp32_spp_disconnect() - close SPP connection
- esp32_spp_status() - query connection state

Tested: Linux rfcomm connect → ESP32, bidirectional data transfer works
2026-02-03 13:39:17 -07:00
5dcacc23ab Add comprehensive README with ESP32 capabilities and E2E testing guide
- Project overview and architecture diagram
- ESP32 hardware requirements and capabilities
- Full MCP tool reference (connection, classic BT, BLE/GATT, events)
- Quick start guide and Claude Code integration
- E2E testing instructions with latest results (v5: 76/76 PASS)
- Links to detailed documentation
2026-02-03 12:11:11 -07:00
88d006e9c4 Add automated E2E testing documentation and test prompts
- docs/automated-e2e-testing.md: Guide for running headless Claude CLI
  tests with both mcbluetooth and mcbluetooth-esp32 MCP servers
- tests/prompts/test-prompt-v4.md: 71-test suite covering Classic BT,
  BLE GATT, HCI capture, device management
- tests/prompts/test-prompt-v5.md: 76-test suite adding Battery Service
  (0x180F) and bt_ble_battery verification

Test results from v4: 71/71 PASS with 143 HCI packets captured
2026-02-03 11:18:37 -07:00
5a853c15fc Fix event system init and add SSP auto-accept for E2E testing
Two fixes for the E2E test failures:

1. event_reporter_init() was never called in app_main(), so the
   FreeRTOS queue and reporter task were never created. Every BT
   event (pair_request, gatt_read, gatt_write, gatt_subscribe)
   was silently dropped at the NULL-queue guard.

2. SSP Numeric Comparison requires both sides to confirm, but
   bt_pair blocks until completion — creating a deadlock since
   the LLM can't send classic_pair_respond while waiting. Added
   auto_accept flag to set_ssp_mode that auto-confirms numeric
   comparison requests in the GAP callback.
2026-02-02 21:05:28 -07:00
397b164eee Add ready probe to esp32_connect for reliable startup
The boot event fires early in app_main before the UART command
handler task is fully initialised. This means the first command
after connect can get lost, causing transient ping timeouts.

Now esp32_connect retries a ping (up to 5 attempts, 1s timeout
each) after the boot-event wait, so it only returns "connected"
when the firmware is actually responsive.
2026-02-02 19:45:01 -07:00
82cd0e5c9d Fix Response.data normalisation in parse_message() too
The previous commit only fixed Response.from_json(), but the serial
client's read loop uses parse_message() which constructs Response
directly. Apply the same string-to-dict normalisation there.
2026-02-02 15:58:20 -07:00
ea22f2f9db Fix async/await bugs found by headless E2E test
- Make get_client() sync (was async but did no async work). Callers
  that omitted await silently got a coroutine object instead of the
  SerialClient, causing "'coroutine' object has no attribute 'connect'"
  errors on every tool call.

- Fix esp32_connect: use get_client_or_none() for init check and
  client.event_queue.wait_for() for boot event (wait_event() didn't
  exist on SerialClient).

- Normalise Response.data to dict at parse time — firmware returns
  bare strings on some error paths, which broke .get() calls in tool
  error handlers.

- Remove stale await from ble.py (9 calls) and classic.py (4 calls).

Tested with dual-MCP headless claude session: 26/27 PASS.
2026-02-02 15:54:36 -07:00
0e7b8c2ef5 Fix docs to match UART0 firmware implementation
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
2026-02-02 15:38:20 -07:00
dc6078b296 Fix firmware for ESP-IDF v5.3 and hardware-verified operation
- Switch UART0 for protocol I/O (USB bridge), disable ESP-IDF console
- Wire all 21 command handlers into dispatch table (was only 4)
- Add configure command handler (name, io_cap, device_class)
- Add bt_classic_is_enabled()/bt_ble_is_enabled() for live status
- Fix cJSON_False misuse in get_status (type constant, not boolean)
- Fix esp_bt_gap_set_cod() to use esp_bt_cod_t bitfield struct
- Fix auth_cmpl.auth_mode → lk_type for ESP-IDF v5.3
- Replace deprecated esp_bt_dev_set_device_name with stack-specific API
- Remove unused bytes_to_hex, obsolete kconfig symbols
- Use large partition table (1.5MB) for dual-mode BT stack

Verified on ESP32-D0WD-V3 rev 3.1, /dev/ttyUSB4, all commands tested.
2026-02-02 15:30:54 -07:00
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
24 changed files with 1938 additions and 295 deletions

229
README.md Normal file
View File

@ -0,0 +1,229 @@
# mcbluetooth-esp32
ESP32 Bluetooth test harness MCP server — UART-controlled peripheral for automated E2E Bluetooth testing.
## Overview
This project turns an ESP32 into a programmable Bluetooth peripheral that can be controlled via MCP (Model Context Protocol). Combined with [mcbluetooth](https://github.com/supported-systems/mcbluetooth) (Linux BlueZ MCP server), it enables fully automated end-to-end Bluetooth testing orchestrated by an LLM.
```
┌─────────────────────────────────────────────────────────────────┐
│ LLM (Claude, etc.) │
│ Orchestrates both MCP servers │
└─────────────────────────┬───────────────────────────────────────┘
┌───────────────┴───────────────┐
│ │
┌───────┴───────┐ ┌───────┴────────┐
│ mcbluetooth │ │mcbluetooth-esp32│
│ (bt_* tools)│ │ (esp32_* tools) │
└───────┬───────┘ └───────┬────────┘
│ │
D-Bus/BlueZ Serial/UART
│ │
┌───────┴───────┐ ┌───────┴────────┐
│ Linux Host │◄── Bluetooth ──►│ ESP32 │
│ (hci0/1) │ (over air) │ (peripheral) │
└───────────────┘ └────────────────┘
```
## ESP32 Capabilities
The ESP32 can emulate various Bluetooth devices for testing:
| Capability | Description |
|------------|-------------|
| **Classic BT Pairing** | All 4 SSP modes: Just Works, Numeric Comparison, Passkey Entry, Legacy PIN |
| **SPP (Serial Port)** | Bidirectional data transfer over Classic Bluetooth virtual serial port |
| **BLE GATT Server** | Dynamic service/characteristic creation at runtime |
| **Device Personas** | Presets for headset, speaker, keyboard, sensor, phone |
| **IO Capabilities** | Configurable: `no_io`, `display_only`, `display_yesno`, `keyboard_only`, `keyboard_display` |
| **Event Reporting** | Real-time events: pair_request, pair_complete, spp_data, gatt_read, gatt_write, gatt_subscribe |
### Hardware Requirements
- **Original ESP32** (ESP32-D0WD or similar) — must have Classic Bluetooth support
- ESP32-S3, C3, H2, S2 will NOT work (no BR/EDR radio)
- USB cable for serial communication
### Verified Hardware
| Property | Value |
|----------|-------|
| Chip | ESP32-D0WD-V3 (rev 3.1) |
| Features | Wi-Fi, BT Classic + BLE, Dual Core 240MHz |
| Flash | 4MB |
| Protocol | NDJSON over UART @ 115200 baud |
## Quick Start
### 1. Flash the firmware
```bash
# Install ESP-IDF v5.x first (see docs/hardware-setup.md)
cd firmware
idf.py set-target esp32
idf.py -p /dev/ttyUSB0 flash
```
### 2. Run the MCP server
```bash
# Install and run
uvx mcbluetooth-esp32
# Or with explicit port
ESP32_SERIAL_PORT=/dev/ttyUSB0 uvx mcbluetooth-esp32
```
### 3. Add to Claude Code
```bash
claude mcp add mcbluetooth-esp32 -- uvx mcbluetooth-esp32
```
### 4. Verify connection
```python
esp32_connect() # → connected=true, ready=true
esp32_ping() # → {pong: true}
esp32_get_info() # → chip, fw_version, bt_mac
```
## MCP Tools
### Connection & System
| Tool | Description |
|------|-------------|
| `esp32_connect` | Open serial connection to ESP32 |
| `esp32_disconnect` | Close serial connection |
| `esp32_ping` | Verify UART link is alive |
| `esp32_status` | Get current BT/BLE state |
| `esp32_get_info` | Get chip model, firmware version, MAC |
| `esp32_reset` | Reboot the ESP32 |
### Configuration
| Tool | Description |
|------|-------------|
| `esp32_configure` | Set device name, IO capabilities, PIN |
| `esp32_set_ssp_mode` | Configure SSP pairing mode with optional auto-accept |
| `esp32_load_persona` | Load preset device profile (headset, speaker, etc.) |
### Classic Bluetooth
| Tool | Description |
|------|-------------|
| `esp32_classic_enable` | Enable BR/EDR radio |
| `esp32_classic_disable` | Disable BR/EDR radio |
| `esp32_classic_set_discoverable` | Make device visible for pairing |
| `esp32_classic_pair_respond` | Accept/reject incoming pairing |
### SPP (Serial Port Profile)
| Tool | Description |
|------|-------------|
| `esp32_spp_send` | Send data (text or hex) over SPP connection |
| `esp32_spp_disconnect` | Close the active SPP connection |
| `esp32_spp_status` | Get SPP connection status and remote address |
### BLE / GATT
| Tool | Description |
|------|-------------|
| `esp32_ble_enable` | Enable BLE radio |
| `esp32_ble_disable` | Disable BLE radio |
| `esp32_ble_advertise` | Start/stop BLE advertising |
| `esp32_ble_set_adv_data` | Configure advertisement data |
| `esp32_gatt_add_service` | Create GATT service |
| `esp32_gatt_add_characteristic` | Add characteristic to service |
| `esp32_gatt_set_value` | Set characteristic value |
| `esp32_gatt_notify` | Send notification to subscribers |
| `esp32_gatt_clear` | Remove all GATT services |
### Events
| Tool | Description |
|------|-------------|
| `esp32_get_events` | Retrieve event history |
| `esp32_wait_event` | Block until specific event occurs |
| `esp32_clear_events` | Clear event history |
## E2E Testing
Run automated tests using Claude CLI in headless mode:
```bash
# Setup test environment
mkdir -p /tmp/bt-e2e-test && cd /tmp/bt-e2e-test
git init
# Create MCP config with both servers
cat > .mcp.json << 'EOF'
{
"mcpServers": {
"esp32": {
"type": "stdio",
"command": "uvx",
"args": ["mcbluetooth-esp32"],
"env": {"ESP32_SERIAL_PORT": "/dev/ttyUSB0"}
},
"bluez": {
"type": "stdio",
"command": "uvx",
"args": ["mcbluetooth"]
}
}
}
EOF
# Run test suite
PROMPT=$(cat tests/prompts/test-prompt-v5.md)
claude -p "$PROMPT" \
--mcp-config .mcp.json \
--allowedTools "mcp__esp32__*,mcp__bluez__*" \
--output-format json
```
### Test Coverage (v5 - 76 tests)
| Phase | Tests | Coverage |
|-------|-------|----------|
| ESP32 Connection | 1-4 | connect, ping, info, status |
| BlueZ Adapter | 5-8 | list, info, pairable, discoverable |
| Classic BT + SSP | 9-24 | Full pairing workflow with auto-accept |
| BLE GATT Setup | 30-42 | Battery Service, Environmental Sensing |
| HCI Capture | 43-55 | btsnoop capture and analysis |
| GATT Operations | 56-63 | read, write, notify, subscribe |
| Cleanup | 64-76 | Adapter management, reset |
### Recent Test Results
| Version | Tests | Result | Duration |
|---------|-------|--------|----------|
| v4 | 71 | 66 PASS, 5 PARTIAL | 8.6 min |
| v5 | 76 | **76/76 PASS (100%)** | 7.6 min |
## Documentation
| Document | Description |
|----------|-------------|
| [hardware-setup.md](docs/hardware-setup.md) | ESP-IDF installation, flashing, wiring |
| [protocol-spec.md](docs/protocol-spec.md) | NDJSON UART protocol specification |
| [test-scenarios.md](docs/test-scenarios.md) | Manual E2E test procedures |
| [automated-e2e-testing.md](docs/automated-e2e-testing.md) | Claude CLI headless testing guide |
## Development
```bash
# Install dev dependencies
uv sync --all-extras
# Run unit tests
uv run pytest tests/ -v --ignore=tests/integration
# Run integration tests (requires ESP32)
ESP32_SERIAL_PORT=/dev/ttyUSB0 uv run pytest tests/integration/ -v
# Lint
uv run ruff check src/
```
## License
MIT

View File

@ -0,0 +1,287 @@
# Automated E2E Testing with Claude CLI
This document describes how to run fully automated end-to-end Bluetooth tests using the Claude CLI in headless mode. The tests exercise the complete Bluetooth stack across two devices: a Linux host running `mcbluetooth` (BlueZ) and an ESP32 running the `mcbluetooth-esp32` firmware.
## Architecture
```
┌───────────────────────────────────────────────────────────────────┐
│ Claude CLI (headless mode) │
│ Orchestrates both MCP servers │
└───────────────────────────┬───────────────────────────────────────┘
┌───────────────┴───────────────┐
│ │
┌───────┴───────┐ ┌───────┴───────┐
│ mcbluetooth │ │mcbluetooth-esp32│
│ MCP Server │ │ MCP Server │
│ (bt_* tools)│ │ (esp32_* tools)│
└───────┬───────┘ └───────┬────────┘
│ │
D-Bus/BlueZ Serial/UART
│ │
┌───────┴───────┐ ┌───────┴────────┐
│ Linux Host │◄── Bluetooth ──►│ ESP32 │
│ (hci1) │ (over air) │ (peripheral) │
└───────────────┘ └────────────────┘
```
## Prerequisites
### Hardware
- ESP32 dev board connected via USB (typically `/dev/ttyUSB0` or `/dev/ttyUSB4`)
- Linux host with Bluetooth adapter (typically `hci0` or `hci1`)
### Software
- ESP32 flashed with mcbluetooth-esp32 firmware
- Both MCP servers installed and accessible via `uvx`
- Claude CLI installed
### Permissions
For HCI packet capture tests, grant btmon the required capability:
```bash
sudo setcap cap_net_raw+ep /usr/bin/btmon
```
## Test Environment Setup
### 1. Create a test directory
```bash
mkdir -p /tmp/bt-e2e-test
cd /tmp/bt-e2e-test
```
### 2. Create MCP configuration
Create `.mcp.json` with both MCP servers:
```json
{
"mcpServers": {
"esp32": {
"type": "stdio",
"command": "uvx",
"args": ["mcbluetooth-esp32"],
"env": {
"ESP32_SERIAL_PORT": "/dev/ttyUSB4"
}
},
"bluez": {
"type": "stdio",
"command": "uvx",
"args": ["mcbluetooth"]
}
}
}
```
### 3. Initialize git (required for Claude CLI)
```bash
git init
```
## Running Tests
### Basic Command Structure
```bash
claude -p "$(cat test-prompt.md)" \
--mcp-config .mcp.json \
--allowedTools "mcp__esp32__*,mcp__bluez__*" \
--output-format json \
2>/dev/null | tee results.json | jq -r '.result'
```
**Key flags:**
- `-p`: Print/headless mode (non-interactive)
- `--mcp-config`: Path to MCP server configuration
- `--allowedTools`: Glob patterns for permitted tools (required in headless mode)
- `--output-format json`: Machine-parseable output
### Full Test Suite (76 tests)
The comprehensive test suite covers:
- ESP32 connection and system commands
- BlueZ adapter management
- Classic Bluetooth SSP pairing with auto-accept
- BLE GATT service creation (Environmental Sensing + Battery Service)
- HCI packet capture and analysis
- GATT read/write/notify operations
- Device management (trust, block, alias)
```bash
claude -p "$(cat test-prompt-v5.md)" \
--mcp-config .mcp.json \
--allowedTools "mcp__esp32__*,mcp__bluez__*" \
--output-format json 2>/dev/null | tee results-v5.json
```
### Analyzing Results
Extract the summary:
```bash
jq -r '.result' results-v5.json
```
Check pass/fail statistics:
```bash
jq -r '.result' results-v5.json | grep -E "(PASS|FAIL|Total)"
```
View full metrics:
```bash
jq '{
duration_ms: .duration_ms,
num_turns: .num_turns,
total_cost_usd: .total_cost_usd,
success: .is_error == false
}' results-v5.json
```
## Test Phases
The test suite is organized into phases that must run sequentially:
| Phase | Tests | Coverage |
|-------|-------|----------|
| 1. ESP32 Connection | 1-4 | connect, ping, get_info, status |
| 2. BlueZ Adapter | 5-8 | list_adapters, adapter_info, pairable, discoverable |
| 3. Classic BT + SSP | 9-24 | enable, configure, SSP mode, scan, pair, device management |
| 4. Classic Cleanup | 25-29 | disable, events, clear_events |
| 5. BLE GATT Setup | 30-42 | Battery Service, Environmental Sensing, advertising |
| 6. HCI Capture + Discovery | 43-51 | capture_start, BLE scan, connect, services, characteristics |
| 7. Analyze Capture | 52-55 | capture_stop, parse, analyze, read_raw |
| 8. GATT Write + Notify | 56-63 | write, subscribe, notify, unsubscribe |
| 9. BLE Cleanup | 64-68 | stop advertising, clear GATT, disable BLE |
| 10. Adapter Management | 69-73 | set_alias, restore, disable discoverable |
| 11. Final Cleanup | 74-76 | ESP32 reset, disconnect, final check |
## SSP Pairing: The auto_accept Flag
Numeric Comparison SSP requires **both sides** to confirm the passkey. In headless mode, this creates a deadlock:
1. Linux calls `bt_pair()` which blocks waiting for ESP32 confirmation
2. ESP32 can't receive the confirmation command because the LLM is blocked
**Solution:** The ESP32 firmware supports `auto_accept` mode:
```
esp32_set_ssp_mode(mode="numeric_comparison", auto_accept=true)
```
This makes the ESP32 automatically confirm SSP pairings, breaking the deadlock.
## Battery Service Test
The test suite creates a standard Battery Service (UUID 0x180F) on the ESP32:
1. Add Battery Service as primary GATT service
2. Add Battery Level characteristic (UUID 0x2A19) with read property
3. Set value to "4b" (75% in hex)
4. After BLE connection, call `bt_ble_battery` on Linux
5. Verify it returns 75
This tests the dedicated `bt_ble_battery` tool in mcbluetooth which reads from the standard Battery Level characteristic.
## HCI Packet Capture
Tests 43-55 exercise the btsnoop capture functionality:
```
bt_capture_start(adapter="hci1", output_file="/tmp/ble-gatt-capture.btsnoop")
# ... BLE operations ...
bt_capture_stop(capture_id="...")
bt_capture_parse(filepath="...", max_packets=50)
bt_capture_analyze(filepath="...")
bt_capture_read_raw(filepath="...", count=20)
```
Typical captures include 100-150 packets covering:
- HCI commands (LE scanning, connection)
- ACL data (GATT operations)
- HCI events (connection complete, encryption)
## Test Prompt Format
Test prompts follow a structured format:
```markdown
# Test Suite Title
## Phase N: Phase Name (Tests X-Y)
N. **Test Name**: Call `tool_name` with params — expected result
## Summary
After all tests, print a DETAILED summary table:
| # | Test | Result | Notes |
|---|------|--------|-------|
| 1 | Connect | PASS/FAIL | ... |
```
## Troubleshooting
### Serial port busy
```
Error: could not open port /dev/ttyUSB4
```
Check for other processes using the port:
```bash
lsof /dev/ttyUSB4
```
### btmon permission denied
```
Error: Failed to open HCI raw socket
```
Grant capability:
```bash
sudo setcap cap_net_raw+ep /usr/bin/btmon
```
### ESP32 not responding
Power cycle the ESP32 and check the firmware is flashed:
```bash
# Monitor serial output
screen /dev/ttyUSB4 115200
```
Press reset button — should see boot event JSON.
### Pairing timeout
Ensure `auto_accept=true` is set for SSP numeric comparison mode before initiating pairing from Linux.
## Example Results
A successful v5 run produces:
```json
{
"type": "result",
"subtype": "success",
"is_error": false,
"duration_ms": 320000,
"num_turns": 88,
"result": "All 76 tests passed..."
}
```
Key metrics from successful runs:
- Duration: ~5-6 minutes
- API turns: 80-90
- HCI packets captured: 100-150
- Cost: ~$1.50-1.70 USD

View File

@ -22,41 +22,13 @@ Any ESP32 board based on the original ESP32 chip should work. Commonly available
## Wiring
### Default: USB only
### USB only (default)
For most development, a single USB cable handles both flashing and protocol communication. The ESP32's built-in USB-to-UART bridge (typically CP2102 or CH340) provides the serial link.
A single USB cable handles both flashing and NDJSON protocol communication. The ESP32 dev board's built-in USB-to-UART bridge (typically CP2102 or CH340) connects to UART0 (TX=GPIO1, RX=GPIO3).
The firmware uses **UART1** (GPIO4/GPIO5) for the NDJSON protocol and keeps **UART0** for ESP-IDF console logging. When using USB only, the USB bridge connects to UART0 by default -- so you will need either:
The firmware uses **UART0** for the NDJSON protocol. The ESP-IDF console is disabled (`CONFIG_ESP_CONSOLE_NONE=y`) so there is no conflict -- the firmware owns UART0 exclusively. No additional wiring or USB-UART adapters are needed.
1. A board that routes UART1 through the USB bridge (uncommon), or
2. A separate USB-UART adapter connected to GPIO4/GPIO5 (described below)
For quick testing with the firmware's default pin assignment, connect a USB-UART adapter.
### Dedicated UART (GPIO4/GPIO5)
Connect a USB-UART adapter (e.g., FTDI FT232R, CP2102, CH340) to the ESP32:
```
ESP32 GPIO4 (TX) ----> USB-UART adapter RX
ESP32 GPIO5 (RX) <---- USB-UART adapter TX
ESP32 GND ----> USB-UART adapter GND
```
The adapter appears as a second `/dev/ttyUSB*` device on the host. Use this device path for `ESP32_SERIAL_PORT`.
Do not connect voltage lines (VCC/3V3) between the adapter and the ESP32 if the board is already powered via its own USB port.
### Pin reassignment
If GPIO4/GPIO5 conflict with other peripherals on your board, change the pin definitions in `firmware/main/uart_handler.c`:
```c
#define UART_TX_PIN GPIO_NUM_4
#define UART_RX_PIN GPIO_NUM_5
```
Rebuild and reflash after changing pins.
The dev board appears as `/dev/ttyUSB*` on the host. Use this device path for `ESP32_SERIAL_PORT`.
## ESP-IDF Setup
@ -115,13 +87,13 @@ make flash SERIAL_PORT=/dev/ttyUSB4
### 5. Monitor (optional)
Open the ESP-IDF serial monitor to see console logs from UART0:
Open the ESP-IDF serial monitor to watch raw UART traffic. Since the firmware owns UART0 (console is disabled), you will see NDJSON protocol messages rather than ESP-IDF log output:
```bash
idf.py -p /dev/ttyUSB4 monitor
```
Press `Ctrl+]` to exit the monitor.
Press `Ctrl+]` to exit the monitor. Note: while the monitor is open, the MCP server cannot use the same serial port.
### 6. Flash and monitor in one step
@ -184,13 +156,13 @@ The project ships `firmware/sdkconfig.defaults` with the required Bluetooth conf
| `CONFIG_BT_BLUEDROID_ENABLED` | y | Use Bluedroid host stack |
| `CONFIG_BT_CLASSIC_ENABLED` | y | Enable BR/EDR (Classic BT) |
| `CONFIG_BT_BLE_ENABLED` | y | Enable BLE |
| `CONFIG_BT_SSP_ENABLED` | y | Enable Secure Simple Pairing |
| `CONFIG_BT_SPP_ENABLED` | y | Enable Serial Port Profile |
| `CONFIG_BT_GATTS_ENABLE` | y | Enable GATT Server |
| `CONFIG_BTDM_CTRL_MODE_BTDM` | y | Dual-mode controller (Classic + BLE simultaneously) |
| `CONFIG_NVS_ENABLED` | y | Non-volatile storage for bonding data |
| `CONFIG_ESP_CONSOLE_NONE` | y | Disable ESP-IDF console so firmware owns UART0 |
| `CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE` | y | 1.5MB app partition (dual-mode BT stack needs >1MB) |
Do not modify these unless you understand the implications. Disabling `CONFIG_BT_CLASSIC_ENABLED` breaks all Classic BT pairing tests. Disabling `CONFIG_BT_SSP_ENABLED` forces legacy PIN-only pairing.
Do not modify these unless you understand the implications. Disabling `CONFIG_BT_CLASSIC_ENABLED` breaks all Classic BT pairing tests.
## Troubleshooting
@ -232,13 +204,13 @@ If the board has auto-download circuitry (most DevKitC boards do), this should n
### No response over UART
1. **Verify TX/RX pin assignment.** The firmware uses UART1 on GPIO4 (TX) and GPIO5 (RX). If your adapter is connected to different pins, update `uart_handler.c`.
1. **Check baud rate.** Both sides must use 115200. Verify in `screen` or your terminal emulator.
2. **Check baud rate.** Both sides must use 115200. Verify in `screen` or your terminal emulator.
2. **Make sure nothing else is using the port.** The ESP-IDF monitor, `screen`, another MCP server instance, or any other serial tool will lock the device. Only one process can open `/dev/ttyUSB*` at a time.
3. **Check the correct serial device.** If the board has two USB-UART interfaces (one for UART0 console, one for UART1 protocol), make sure you are talking to the right one.
3. **Send valid JSON.** The firmware expects complete JSON objects terminated by `\n`. A bare `ping` won't work -- send `{"type":"cmd","id":"1","cmd":"ping"}\n`.
4. **Look at UART0 console output.** Connect the ESP-IDF monitor to the console port. Boot messages and error logs appear there. If you see `UART1 ready (TX=4 RX=5 @ 115200 baud)` in the log, the firmware started correctly.
4. **Verify the firmware booted.** After flashing, the firmware should emit a `boot` event within ~2 seconds. If you see nothing at all, try pressing the EN (reset) button on the board.
### Build errors about missing Bluetooth headers

View File

@ -8,7 +8,7 @@ Authoritative reference for the JSON-over-UART protocol used between the Python
- **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
- **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:** `\n` only. Carriage returns (`\r`) are stripped by the firmware reader.

View File

@ -1,263 +1,655 @@
# E2E Test Scenarios
# 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
Tests require both MCP servers running simultaneously:
- **mcbluetooth** — controls the Linux BlueZ stack (host side)
- **mcbluetooth-esp32** — controls the ESP32 peripheral (device side)
Each test requires **two MCP servers** running simultaneously:
An LLM orchestrates both servers, issuing tool calls to each side to execute the test flow.
| Server | Controls | Tool prefix |
|--------|----------|-------------|
| `mcbluetooth` | Linux BlueZ stack (hci0) | `bt_*` |
| `mcbluetooth-esp32` | ESP32 peripheral via UART | `esp32_*` |
## Prerequisites
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.
```bash
# Terminal 1: Start ESP32 MCP server
ESP32_SERIAL_PORT=/dev/ttyUSB4 uvx mcbluetooth-esp32
### Prerequisites
# Terminal 2: mcbluetooth is already running (or add to Claude Code config)
```
- 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 auto-completes without user interaction.
Both devices have `no_io` capability. Pairing should auto-complete with no user interaction.
### Flow
### Setup
```python
# ESP32 side: Configure as headset (no_io)
esp32_load_persona("headset") # io_cap=no_io → Just Works
**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(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
esp32_classic_set_discoverable(discoverable=true)
```
### Expected Result
- Pairing completes without any passkey exchange
- Both sides report success
**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` — both display a 6-digit passkey that must match.
Both devices have `display_yesno` capability. Both display a 6-digit passkey that the user (or LLM) must confirm matches.
### Flow
### Setup
```python
# ESP32 side: Configure as phone (keyboard_display → numeric comparison)
esp32_load_persona("phone")
**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(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}
esp32_classic_set_discoverable(discoverable=true)
```
### Expected Result
- Both sides display the same 6-digit passkey
- After both confirm, pairing succeeds
**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.
One side displays a passkey, the other must enter it. Test both directions.
### Flow (ESP32 displays, Linux enters)
### Direction A: ESP32 displays, Linux enters
```python
# ESP32: display_only → shows passkey
esp32_configure(io_cap="display_only")
**Setup:**
```
esp32_configure(name="PasskeyDisp-Test", 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}
esp32_classic_set_discoverable(discoverable=true)
```
### Flow (Linux displays, ESP32 enters)
**Execute:**
```python
# 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")
```
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 legacy PIN code.
ESP32 configured with a PIN code. Linux initiates pairing with that PIN. This tests pre-SSP pairing mode.
```python
esp32_configure(pin_code="1234")
### 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(True)
esp32_classic_set_discoverable(discoverable=true)
```
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")
### Execute
# Linux enters PIN
bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0",
pin="1234", accept=True)
**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.
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.
```python
# ESP32: Set up as sensor
esp32_load_persona("sensor")
esp32_gatt_add_service(uuid="0000181a-0000-1000-8000-00805f9b34fb", primary=True)
# → {"service_handle": 40}
### 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="00002a6e-0000-1000-8000-00805f9b34fb", # Temperature
uuid="2A6E",
properties=["read", "notify"],
value="e803" # 25.0°C (0x03e8 = 1000 in little-endian → 10.00°C? or raw)
value="c409"
)
# → {"char_handle": 42}
```
esp32_ble_advertise(enable=True)
Returns: `{"handle": 42}` (example handle)
# 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", ...}
The value `"c409"` encodes 25.00 C as a little-endian `int16` in hundredths of a degree (2500 = 0x09C4, LE = `c4 09`).
# ESP32: Update value
esp32_gatt_set_value(char_handle=42, value="f003") # 25.5°C
**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 Notifications
## Test 6: BLE GATT Subscribe
```python
# 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)
Test notification subscription and delivery. The ESP32 pushes a value update to a subscribed Linux client.
# ESP32: Verify subscription
esp32_wait_event("gatt_subscribe")
# → {"char_handle": 42, "subscribed": true}
### Setup
# ESP32: Update and notify
esp32_gatt_set_value(char_handle=42, value="0004")
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)
```
# 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"}
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
Verify device identity changes are visible from the Linux side.
Load different device personas on the ESP32 and verify that the device name and Bluetooth class are visible from a Linux scan.
```python
# 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)
### Execute
bt_scan(adapter="hci0", mode="both", timeout=5)
# Verify device name and class match persona definition
**Step 1 -- List available personas:**
esp32_classic_disable()
```
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:
```json
{
"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.
```bash
# 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
```
Or directly:
```bash
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.
```bash
ESP32_SERIAL_PORT=/dev/ttyUSB4 make test-integration
```
Or directly:
```bash
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:
```bash
# 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:
```bash
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
| 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) |
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 |

View File

@ -114,14 +114,6 @@ static int hex_to_bytes(const char *hex, uint8_t *out, int max_len)
return len;
}
static void bytes_to_hex(const uint8_t *data, int len, char *out)
{
for (int i = 0; i < len; i++) {
sprintf(out + i * 2, "%02x", data[i]);
}
out[len * 2] = '\0';
}
static uint8_t properties_from_json(cJSON *arr)
{
uint8_t props = 0;
@ -424,6 +416,15 @@ static void gatts_event_handler(esp_gatts_cb_event_t event,
}
}
/* ------------------------------------------------------------------ */
/* State query */
/* ------------------------------------------------------------------ */
bool bt_ble_is_enabled(void)
{
return s_ble.enabled;
}
/* ------------------------------------------------------------------ */
/* Command handlers */
/* ------------------------------------------------------------------ */

View File

@ -1,10 +1,14 @@
#pragma once
#include <stdbool.h>
#include "cJSON.h"
/* Initialize BLE subsystem (Bluedroid GATTS) */
void bt_ble_init(void);
/* State query */
bool bt_ble_is_enabled(void);
/* Command handlers (called from cmd_dispatcher) */
void cmd_ble_enable(const char *id, cJSON *params);
void cmd_ble_disable(const char *id, cJSON *params);

View File

@ -46,6 +46,7 @@ static struct {
esp_bt_io_cap_t io_cap;
char pin_code[17];
bool ssp_enabled;
bool auto_accept; /* Auto-confirm SSP pairing (for testing) */
/* Pending pairing state */
char pending_pair_address[18];
char pending_pair_cmd_id[32];
@ -53,6 +54,7 @@ static struct {
pair_type_t pair_type;
/* SPP handle for the listening server */
uint32_t spp_handle;
char spp_remote_addr[18]; /* Connected peer address */
} classic_state = {
.io_cap = ESP_BT_IO_CAP_IO, /* display+yesno (numeric comparison) */
.ssp_enabled = true,
@ -105,9 +107,9 @@ static void gap_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param)
bd_addr_to_str(param->auth_cmpl.bda, addr_str);
bool ok = (param->auth_cmpl.stat == ESP_BT_STATUS_SUCCESS);
ESP_LOGI(TAG, "auth_cmpl: %s %s (mode=%d)",
ESP_LOGI(TAG, "auth_cmpl: %s %s (lk_type=%d)",
addr_str, ok ? "success" : "FAIL",
param->auth_cmpl.auth_mode);
param->auth_cmpl.lk_type);
event_report_pair_complete(addr_str, ok);
@ -132,10 +134,16 @@ static void gap_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param)
event_report_pair_request(addr_str, "numeric_comparison", (int)passkey);
if (classic_state.auto_accept) {
/* Auto-confirm for automated E2E testing */
esp_bt_gap_ssp_confirm_reply(param->cfm_req.bda, true);
ESP_LOGI(TAG, "cfm_req: auto-accepted (auto_accept=true)");
} else {
/* Stash address so pair_respond can reply */
strncpy(classic_state.pending_pair_address, addr_str,
sizeof(classic_state.pending_pair_address) - 1);
classic_state.pair_type = PAIR_TYPE_NUMERIC_COMPARISON;
}
break;
}
@ -236,24 +244,37 @@ static void spp_cb(esp_spp_cb_event_t event, esp_spp_cb_param_t *param)
case ESP_SPP_SRV_OPEN_EVT:
bd_addr_to_str(param->srv_open.rem_bda, addr_str);
classic_state.spp_handle = param->srv_open.handle;
strncpy(classic_state.spp_remote_addr, addr_str,
sizeof(classic_state.spp_remote_addr) - 1);
classic_state.spp_remote_addr[sizeof(classic_state.spp_remote_addr) - 1] = '\0';
ESP_LOGI(SPP_TAG, "SPP client connected: %s (handle=%" PRIu32 ")",
addr_str, param->srv_open.handle);
event_report_connect(addr_str, "classic");
/* Report specific SPP connect event with more detail */
{
cJSON *d = cJSON_CreateObject();
cJSON_AddStringToObject(d, "address", addr_str);
cJSON_AddNumberToObject(d, "handle", (double)param->srv_open.handle);
cJSON_AddStringToObject(d, "transport", "spp");
event_report(EVT_SPP_CONNECT, d);
}
break;
case ESP_SPP_CLOSE_EVT:
ESP_LOGI(SPP_TAG, "SPP disconnected (handle=%" PRIu32 ")",
param->close.handle);
/* We don't have the remote address in CLOSE_EVT on all IDF versions,
* so report with handle info. */
/* Report SPP disconnect with saved remote address */
{
cJSON *d = cJSON_CreateObject();
cJSON_AddNumberToObject(d, "handle", (double)param->close.handle);
cJSON_AddStringToObject(d, "transport", "classic");
event_report(EVT_DISCONNECT, d);
cJSON_AddStringToObject(d, "transport", "spp");
if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(d, "address", classic_state.spp_remote_addr);
}
event_report(EVT_SPP_DISCONNECT, d);
}
if (classic_state.spp_handle == param->close.handle) {
classic_state.spp_handle = 0;
classic_state.spp_remote_addr[0] = '\0';
}
break;
@ -267,9 +288,46 @@ static void spp_cb(esp_spp_cb_event_t event, esp_spp_cb_param_t *param)
break;
case ESP_SPP_DATA_IND_EVT:
/* Data received over SPP -- log but don't process for now. */
/* Data received over SPP -- report as event */
ESP_LOGI(SPP_TAG, "SPP data: %d bytes on handle %" PRIu32,
param->data_ind.len, param->data_ind.handle);
{
cJSON *d = cJSON_CreateObject();
cJSON_AddNumberToObject(d, "handle", (double)param->data_ind.handle);
cJSON_AddNumberToObject(d, "length", param->data_ind.len);
if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(d, "address", classic_state.spp_remote_addr);
}
/* Encode data as hex string */
size_t hex_len = param->data_ind.len * 2 + 1;
char *hex_str = malloc(hex_len);
if (hex_str) {
for (int i = 0; i < param->data_ind.len; i++) {
sprintf(hex_str + i * 2, "%02x", param->data_ind.data[i]);
}
hex_str[param->data_ind.len * 2] = '\0';
cJSON_AddStringToObject(d, "data_hex", hex_str);
free(hex_str);
}
/* Also try UTF-8 if printable */
bool printable = true;
for (int i = 0; i < param->data_ind.len && printable; i++) {
uint8_t c = param->data_ind.data[i];
if (c < 0x20 && c != '\n' && c != '\r' && c != '\t') {
printable = false;
}
}
if (printable && param->data_ind.len < 256) {
char *text = malloc(param->data_ind.len + 1);
if (text) {
memcpy(text, param->data_ind.data, param->data_ind.len);
text[param->data_ind.len] = '\0';
cJSON_AddStringToObject(d, "data_text", text);
free(text);
}
}
event_report(EVT_SPP_DATA, d);
}
break;
default:
@ -575,14 +633,151 @@ void cmd_classic_set_ssp_mode(const char *id, cJSON *params)
esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap,
sizeof(iocap));
ESP_LOGI(TAG, "SSP mode set to '%s' (io_cap=%d)", mode, new_cap);
/* Optional auto_accept flag for automated testing */
const cJSON *j_auto = cJSON_GetObjectItem(params, "auto_accept");
if (cJSON_IsBool(j_auto)) {
classic_state.auto_accept = cJSON_IsTrue(j_auto);
}
ESP_LOGI(TAG, "SSP mode set to '%s' (io_cap=%d, auto_accept=%d)",
mode, new_cap, classic_state.auto_accept);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "mode", mode);
cJSON_AddNumberToObject(data, "io_cap", new_cap);
cJSON_AddBoolToObject(data, "auto_accept", classic_state.auto_accept);
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */
/* SPP command handlers */
/* ------------------------------------------------------------------ */
static bool hex_to_bytes(const char *hex, uint8_t *out, size_t *out_len, size_t max_len)
{
size_t hex_len = strlen(hex);
if (hex_len % 2 != 0) return false;
size_t bytes = hex_len / 2;
if (bytes > max_len) return false;
for (size_t i = 0; i < bytes; i++) {
unsigned int b;
if (sscanf(hex + i * 2, "%2x", &b) != 1) return false;
out[i] = (uint8_t)b;
}
*out_len = bytes;
return true;
}
void cmd_spp_send(const char *id, cJSON *params)
{
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
if (classic_state.spp_handle == 0) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("no SPP connection"));
return;
}
const cJSON *j_data = cJSON_GetObjectItem(params, "data");
const cJSON *j_hex = cJSON_GetObjectItem(params, "data_hex");
uint8_t buf[512];
size_t len = 0;
if (cJSON_IsString(j_hex)) {
/* Hex-encoded data */
if (!hex_to_bytes(j_hex->valuestring, buf, &len, sizeof(buf))) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("invalid hex data"));
return;
}
} else if (cJSON_IsString(j_data)) {
/* Plain text data */
len = strlen(j_data->valuestring);
if (len > sizeof(buf)) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("data too long (max 512 bytes)"));
return;
}
memcpy(buf, j_data->valuestring, len);
} else {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("missing 'data' or 'data_hex'"));
return;
}
esp_err_t err = esp_spp_write(classic_state.spp_handle, len, buf);
if (err != ESP_OK) {
ESP_LOGE(SPP_TAG, "spp_write failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
ESP_LOGI(SPP_TAG, "SPP sent %zu bytes", len);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "bytes_sent", (double)len);
uart_send_response(id, STATUS_OK, data);
}
void cmd_spp_disconnect(const char *id, cJSON *params)
{
(void)params;
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
if (classic_state.spp_handle == 0) {
uart_send_response(id, STATUS_OK,
cJSON_CreateString("no active connection"));
return;
}
uint32_t handle = classic_state.spp_handle;
esp_err_t err = esp_spp_disconnect(handle);
if (err != ESP_OK) {
ESP_LOGE(SPP_TAG, "spp_disconnect failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
ESP_LOGI(SPP_TAG, "SPP disconnect initiated (handle=%" PRIu32 ")", handle);
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_spp_status(const char *id, cJSON *params)
{
(void)params;
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "connected", classic_state.spp_handle != 0);
if (classic_state.spp_handle != 0) {
cJSON_AddNumberToObject(data, "handle", (double)classic_state.spp_handle);
if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(data, "remote_address", classic_state.spp_remote_addr);
}
}
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */
/* State query */
/* ------------------------------------------------------------------ */
bool bt_classic_is_enabled(void)
{
return classic_state.enabled;
}
/* ------------------------------------------------------------------ */
/* Configure helpers (called from the configure command handler) */
/* ------------------------------------------------------------------ */
@ -605,11 +800,16 @@ void bt_classic_set_io_cap(const char *io_cap_str)
}
}
void bt_classic_set_device_class(uint32_t cod)
void bt_classic_set_device_class(uint32_t cod_raw)
{
esp_err_t err = esp_bt_gap_set_cod(cod, ESP_BT_SET_COD_ALL);
esp_bt_cod_t cod;
memset(&cod, 0, sizeof(cod));
cod.minor = (cod_raw >> 2) & 0x3F;
cod.major = (cod_raw >> 8) & 0x1F;
cod.service = (cod_raw >> 13) & 0x7FF;
esp_err_t err = esp_bt_gap_set_cod(cod, ESP_BT_SET_COD_MAJOR_MINOR | ESP_BT_SET_COD_SERVICE_CLASS);
if (err == ESP_OK) {
ESP_LOGI(TAG, "CoD set to 0x%06" PRIx32, cod);
ESP_LOGI(TAG, "CoD set to 0x%06" PRIx32, cod_raw);
} else {
ESP_LOGE(TAG, "set_cod failed: %s", esp_err_to_name(err));
}

View File

@ -3,6 +3,9 @@
*/
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "cJSON.h"
void bt_classic_init(void);
@ -14,6 +17,14 @@ void cmd_classic_set_discoverable(const char *id, cJSON *params);
void cmd_classic_pair_respond(const char *id, cJSON *params);
void cmd_classic_set_ssp_mode(const char *id, cJSON *params);
/* SPP command handlers */
void cmd_spp_send(const char *id, cJSON *params);
void cmd_spp_disconnect(const char *id, cJSON *params);
void cmd_spp_status(const char *id, cJSON *params);
/* State query */
bool bt_classic_is_enabled(void);
/* Called by configure command to set IO capabilities */
void bt_classic_set_io_cap(const char *io_cap_str);
void bt_classic_set_device_class(uint32_t cod);
void bt_classic_set_device_class(uint32_t cod_raw);

View File

@ -1,13 +1,16 @@
#include "cmd_dispatcher.h"
#include "protocol.h"
#include "uart_handler.h"
#include "bt_classic.h"
#include "bt_ble.h"
#include "personas.h"
#include "esp_system.h"
#include "esp_chip_info.h"
#include "esp_timer.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "esp_bt.h"
#include "esp_gap_bt_api.h"
#include "cJSON.h"
#include <string.h>
@ -23,11 +26,6 @@ typedef struct {
cmd_handler_t handler;
} cmd_entry_t;
/* Forward declarations for handlers in other modules */
extern void bt_classic_register_commands(void);
extern void bt_ble_register_commands(void);
extern void personas_register_commands(void);
/* --- System command handlers --- */
static void handle_ping(const char *id, cJSON *params)
@ -97,30 +95,92 @@ static void handle_get_status(const char *id, cJSON *params)
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "uptime_ms", (double)uptime_ms);
cJSON_AddNumberToObject(data, "free_heap", (double)esp_get_free_heap_size());
cJSON_AddBoolToObject(data, "bt_enabled", cJSON_False);
cJSON_AddBoolToObject(data, "ble_enabled", cJSON_False);
cJSON_AddBoolToObject(data, "bt_enabled", bt_classic_is_enabled());
cJSON_AddBoolToObject(data, "ble_enabled", bt_ble_is_enabled());
uart_send_response(id, STATUS_OK, data);
}
/* --- Configure command: sets device name, IO capabilities, CoD --- */
static void handle_configure(const char *id, cJSON *params)
{
if (!params) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "params required");
uart_send_response(id, STATUS_ERROR, err);
return;
}
cJSON *applied = cJSON_CreateObject();
cJSON *name = cJSON_GetObjectItem(params, "name");
if (name && cJSON_IsString(name)) {
esp_bt_gap_set_device_name(name->valuestring);
cJSON_AddStringToObject(applied, "name", name->valuestring);
}
cJSON *io_cap = cJSON_GetObjectItem(params, "io_cap");
if (io_cap && cJSON_IsString(io_cap)) {
bt_classic_set_io_cap(io_cap->valuestring);
cJSON_AddStringToObject(applied, "io_cap", io_cap->valuestring);
}
cJSON *device_class = cJSON_GetObjectItem(params, "device_class");
if (device_class && cJSON_IsNumber(device_class)) {
bt_classic_set_device_class((uint32_t)device_class->valuedouble);
cJSON_AddNumberToObject(applied, "device_class", device_class->valuedouble);
}
uart_send_response(id, STATUS_OK, applied);
}
/* --- Command table --- */
static const cmd_entry_t cmd_table[] = {
/* System */
{ CMD_PING, handle_ping },
{ CMD_RESET, handle_reset },
{ CMD_GET_INFO, handle_get_info },
{ CMD_GET_STATUS, handle_get_status },
/* Configuration */
{ CMD_CONFIGURE, handle_configure },
{ CMD_LOAD_PERSONA, cmd_load_persona },
{ CMD_LIST_PERSONAS, cmd_list_personas },
/* Classic BT */
{ CMD_CLASSIC_ENABLE, cmd_classic_enable },
{ CMD_CLASSIC_DISABLE, cmd_classic_disable },
{ CMD_CLASSIC_SET_DISCOVERABLE,cmd_classic_set_discoverable },
{ CMD_CLASSIC_PAIR_RESPOND, cmd_classic_pair_respond },
{ CMD_CLASSIC_SET_SSP_MODE, cmd_classic_set_ssp_mode },
/* SPP */
{ CMD_SPP_SEND, cmd_spp_send },
{ CMD_SPP_DISCONNECT, cmd_spp_disconnect },
{ CMD_SPP_STATUS, cmd_spp_status },
/* BLE */
{ CMD_BLE_ENABLE, cmd_ble_enable },
{ CMD_BLE_DISABLE, cmd_ble_disable },
{ CMD_BLE_ADVERTISE, cmd_ble_advertise },
{ CMD_BLE_SET_ADV_DATA, cmd_ble_set_adv_data },
/* GATT */
{ CMD_GATT_ADD_SERVICE, cmd_gatt_add_service },
{ CMD_GATT_ADD_CHARACTERISTIC, cmd_gatt_add_characteristic },
{ CMD_GATT_SET_VALUE, cmd_gatt_set_value },
{ CMD_GATT_NOTIFY, cmd_gatt_notify },
{ CMD_GATT_CLEAR, cmd_gatt_clear },
{ NULL, NULL } /* sentinel */
};
void cmd_dispatcher_init(void)
{
ESP_LOGI(TAG, "command dispatcher ready");
/* Future: these will register their own handlers into the table */
/* bt_classic_register_commands(); */
/* bt_ble_register_commands(); */
/* personas_register_commands(); */
ESP_LOGI(TAG, "command dispatcher ready (%d commands)",
(int)(sizeof(cmd_table) / sizeof(cmd_table[0])) - 1);
}
void cmd_dispatch(const char *id, const char *cmd, cJSON *params)

View File

@ -1,5 +1,6 @@
#include "protocol.h"
#include "uart_handler.h"
#include "event_reporter.h"
#include "cmd_dispatcher.h"
#include "nvs_flash.h"
@ -57,6 +58,7 @@ void app_main(void)
}
uart_handler_init();
event_reporter_init();
send_boot_event();
cmd_dispatcher_init();

View File

@ -14,6 +14,7 @@
#include "bt_ble.h"
#include "esp_bt_device.h"
#include "esp_gap_bt_api.h"
#include "esp_gap_ble_api.h"
#include "esp_log.h"
#include "cJSON.h"
@ -174,7 +175,7 @@ void cmd_load_persona(const char *id, cJSON *params)
/* --- Set device name on both stacks --- */
if (p->classic_enabled) {
esp_bt_dev_set_device_name(p->device_name);
esp_bt_gap_set_device_name(p->device_name);
}
if (p->ble_enabled) {
esp_ble_gap_set_device_name(p->device_name);

View File

@ -3,7 +3,7 @@
#include "driver/uart.h"
/* UART configuration */
#define PROTO_UART_NUM UART_NUM_1
#define PROTO_UART_NUM UART_NUM_0
#define PROTO_BAUD_RATE 115200
#define PROTO_TX_BUF_SIZE 4096
#define PROTO_RX_BUF_SIZE 4096
@ -36,6 +36,11 @@
#define CMD_CLASSIC_SET_DISCOVERABLE "classic_set_discoverable"
#define CMD_CLASSIC_PAIR_RESPOND "classic_pair_respond"
/* SPP commands */
#define CMD_SPP_SEND "spp_send"
#define CMD_SPP_DISCONNECT "spp_disconnect"
#define CMD_SPP_STATUS "spp_status"
/* BLE commands */
#define CMD_BLE_ENABLE "ble_enable"
#define CMD_BLE_DISABLE "ble_disable"
@ -59,6 +64,11 @@
#define EVT_GATT_WRITE "gatt_write"
#define EVT_GATT_SUBSCRIBE "gatt_subscribe"
/* SPP events */
#define EVT_SPP_DATA "spp_data"
#define EVT_SPP_CONNECT "spp_connect"
#define EVT_SPP_DISCONNECT "spp_disconnect"
/* SSP IO capabilities */
#define IO_CAP_DISPLAY_ONLY "display_only"
#define IO_CAP_DISPLAY_YESNO "display_yesno"

View File

@ -14,9 +14,11 @@
static const char *TAG = "uart";
static SemaphoreHandle_t tx_mutex;
/* Pin assignments -- keep UART0 free for ESP-IDF console/logging */
#define UART_TX_PIN GPIO_NUM_4
#define UART_RX_PIN GPIO_NUM_5
/*
* Use UART0 the dev board's USB bridge connects here.
* ESP-IDF console is disabled (CONFIG_ESP_CONSOLE_NONE=y) so we own UART0.
* Default UART0 pins (TX=GPIO1, RX=GPIO3) route through the USB bridge.
*/
void uart_handler_init(void)
{
@ -30,8 +32,7 @@ void uart_handler_init(void)
};
ESP_ERROR_CHECK(uart_param_config(PROTO_UART_NUM, &cfg));
ESP_ERROR_CHECK(uart_set_pin(PROTO_UART_NUM, UART_TX_PIN, UART_RX_PIN,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
/* UART0 default pins (TX=1, RX=3) — no set_pin needed */
ESP_ERROR_CHECK(uart_driver_install(PROTO_UART_NUM,
PROTO_RX_BUF_SIZE, PROTO_TX_BUF_SIZE,
0, NULL, 0));
@ -39,8 +40,8 @@ void uart_handler_init(void)
tx_mutex = xSemaphoreCreateMutex();
assert(tx_mutex);
ESP_LOGI(TAG, "UART%d ready (TX=%d RX=%d @ %d baud)",
PROTO_UART_NUM, UART_TX_PIN, UART_RX_PIN, PROTO_BAUD_RATE);
ESP_LOGI(TAG, "UART%d ready (USB bridge @ %d baud)",
PROTO_UART_NUM, PROTO_BAUD_RATE);
}
char *uart_read_line(void)

View File

@ -5,7 +5,6 @@ CONFIG_BT_CLASSIC_ENABLED=y
CONFIG_BT_BLE_ENABLED=y
CONFIG_BT_A2DP_ENABLE=n
CONFIG_BT_SPP_ENABLED=y
CONFIG_BT_SSP_ENABLED=y
# GAP & GATTS
CONFIG_BT_GATTS_ENABLE=y
@ -14,22 +13,19 @@ CONFIG_BT_GATTC_ENABLE=n
# Bluetooth controller
CONFIG_BTDM_CTRL_MODE_BTDM=y
# UART
CONFIG_ESP_CONSOLE_UART_NUM=0
# UART — disable ESP-IDF console so our protocol handler owns UART0
CONFIG_ESP_CONSOLE_NONE=y
# Stack sizes (Bluetooth needs generous stacks)
CONFIG_BT_BTU_TASK_STACK_SIZE=8192
CONFIG_BTDM_CTRL_HCI_MODE_VHCI=y
# NVS (for bonding storage)
CONFIG_NVS_ENABLED=y
# Logging — keep it reasonable
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y
# Partition table
CONFIG_PARTITION_TABLE_SINGLE_APP=y
# Partition table — large (1.5MB app) needed for dual-mode BT stack
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
# FreeRTOS
CONFIG_FREERTOS_HZ=1000

View File

@ -60,6 +60,11 @@ CMD_CLASSIC_DISABLE = "classic_disable"
CMD_CLASSIC_SET_DISCOVERABLE = "classic_set_discoverable"
CMD_CLASSIC_PAIR_RESPOND = "classic_pair_respond"
# SPP (Serial Port Profile)
CMD_SPP_SEND = "spp_send"
CMD_SPP_DISCONNECT = "spp_disconnect"
CMD_SPP_STATUS = "spp_status"
# BLE
CMD_BLE_ENABLE = "ble_enable"
CMD_BLE_DISABLE = "ble_disable"
@ -83,6 +88,11 @@ EVT_GATT_READ = "gatt_read"
EVT_GATT_WRITE = "gatt_write"
EVT_GATT_SUBSCRIBE = "gatt_subscribe"
# SPP Events
EVT_SPP_DATA = "spp_data"
EVT_SPP_CONNECT = "spp_connect"
EVT_SPP_DISCONNECT = "spp_disconnect"
# ---------------------------------------------------------------------------
# Protocol constants
# ---------------------------------------------------------------------------
@ -138,11 +148,15 @@ class Response:
def from_json(cls, line: str) -> Response:
"""Parse a JSON line known to be a response."""
obj = json.loads(line)
raw_data = obj.get("data", {})
# Firmware may return a bare string on some error paths — normalise to dict
if isinstance(raw_data, str):
raw_data = {"error": raw_data}
return cls(
type=MsgType(obj["type"]),
id=obj["id"],
status=Status(obj["status"]),
data=obj.get("data", {}),
data=raw_data if isinstance(raw_data, dict) else {},
)
@ -203,11 +217,14 @@ def parse_message(line: str) -> Command | Response | Event:
)
if msg_type == MsgType.RESP:
raw_data = obj.get("data", {})
if isinstance(raw_data, str):
raw_data = {"error": raw_data}
return Response(
type=MsgType.RESP,
id=obj["id"],
status=Status(obj["status"]),
data=obj.get("data", {}),
data=raw_data if isinstance(raw_data, dict) else {},
)
if msg_type == MsgType.EVENT:

View File

@ -263,7 +263,7 @@ class SerialClient:
_client: SerialClient | None = None
async def get_client() -> SerialClient:
def get_client() -> SerialClient:
"""Get the singleton serial client.
Raises:

View File

@ -35,7 +35,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_BLE_ENABLE)
return response.data
except Exception as exc:
@ -52,7 +52,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_BLE_DISABLE)
return response.data
except Exception as exc:
@ -76,7 +76,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {
"enable": enable,
"interval_ms": interval_ms,
@ -108,7 +108,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {}
if name is not None:
params["name"] = name
@ -141,7 +141,7 @@ def register_tools(mcp: FastMCP) -> None:
Dict containing the assigned service handle.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {
"uuid": uuid,
"primary": primary,
@ -175,7 +175,7 @@ def register_tools(mcp: FastMCP) -> None:
Dict containing the assigned characteristic handle.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {
"service_handle": service_handle,
"uuid": uuid,
@ -207,7 +207,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {
"char_handle": char_handle,
"value": value,
@ -231,7 +231,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {"char_handle": char_handle}
response = await client.send_command(CMD_GATT_NOTIFY, params)
return response.data
@ -250,7 +250,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_GATT_CLEAR)
return response.data
except Exception as exc:

View File

@ -11,6 +11,9 @@ from ..protocol import (
CMD_CLASSIC_ENABLE,
CMD_CLASSIC_PAIR_RESPOND,
CMD_CLASSIC_SET_DISCOVERABLE,
CMD_SPP_DISCONNECT,
CMD_SPP_SEND,
CMD_SPP_STATUS,
Status,
)
from ..serial_client import get_client
@ -30,7 +33,7 @@ def register_tools(mcp: FastMCP) -> None:
Response data from the ESP32 including current BT state.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_CLASSIC_ENABLE)
if response.status == Status.OK:
return {"status": "ok", **response.data}
@ -49,7 +52,7 @@ def register_tools(mcp: FastMCP) -> None:
Response data from the ESP32 confirming BT is disabled.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_CLASSIC_DISABLE)
if response.status == Status.OK:
return {"status": "ok", **response.data}
@ -75,7 +78,7 @@ def register_tools(mcp: FastMCP) -> None:
Response data confirming the new discoverable state.
"""
try:
client = await get_client()
client = get_client()
params = {"discoverable": discoverable, "timeout": timeout}
response = await client.send_command(CMD_CLASSIC_SET_DISCOVERABLE, params)
if response.status == Status.OK:
@ -110,7 +113,7 @@ def register_tools(mcp: FastMCP) -> None:
Response data with the pairing result from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {"address": address, "accept": accept}
if passkey is not None:
params["passkey"] = passkey
@ -122,3 +125,81 @@ def register_tools(mcp: FastMCP) -> None:
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
# -------------------------------------------------------------------------
# SPP (Serial Port Profile) tools
# -------------------------------------------------------------------------
@mcp.tool()
async def esp32_spp_send(
data: str | None = None,
data_hex: str | None = None,
) -> dict[str, Any]:
"""Send data over the SPP (Serial Port Profile) connection.
SPP provides a virtual serial port over Bluetooth. After a Linux host
connects via rfcomm or similar, this tool sends data to that connection.
Args:
data: Plain text data to send (UTF-8 encoded).
data_hex: Hex-encoded binary data to send (e.g., "48656c6c6f" for "Hello").
Use this for binary protocols. Takes precedence over 'data'.
Returns:
Response with bytes_sent count on success.
"""
if data is None and data_hex is None:
return {"error": "Either 'data' or 'data_hex' must be provided"}
try:
client = get_client()
params: dict[str, Any] = {}
if data_hex is not None:
params["data_hex"] = data_hex
elif data is not None:
params["data"] = data
response = await client.send_command(CMD_SPP_SEND, params)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_spp_disconnect() -> dict[str, Any]:
"""Disconnect the current SPP connection.
Terminates the active SPP (Serial Port Profile) connection if one exists.
The SPP server will continue listening for new connections.
Returns:
Response confirming the disconnection.
"""
try:
client = get_client()
response = await client.send_command(CMD_SPP_DISCONNECT)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_spp_status() -> dict[str, Any]:
"""Get the current SPP connection status.
Returns information about whether an SPP connection is active,
the connection handle, and the remote device address.
Returns:
Response with connected (bool), handle, and remote_address if connected.
"""
try:
client = get_client()
response = await client.send_command(CMD_SPP_STATUS)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}

View File

@ -74,7 +74,10 @@ def register_tools(mcp: FastMCP) -> None:
return {"error": str(e)}
@mcp.tool()
async def esp32_set_ssp_mode(mode: str) -> dict[str, Any]:
async def esp32_set_ssp_mode(
mode: str,
auto_accept: bool = False,
) -> dict[str, Any]:
"""Set the Secure Simple Pairing (SSP) mode on the ESP32.
SSP mode determines which pairing association model the ESP32
@ -90,6 +93,11 @@ def register_tools(mcp: FastMCP) -> None:
io_cap).
"passkey_display" ESP32 displays a passkey for the
remote device to enter.
auto_accept: When true, the ESP32 automatically confirms
numeric comparison pairing requests without waiting for
a classic_pair_respond command. Useful for automated
E2E testing where both sides need to confirm but the
MCP tool calls are sequential.
Returns:
Response data confirming the new SSP mode, or an error dict
@ -99,9 +107,13 @@ def register_tools(mcp: FastMCP) -> None:
if mode not in valid_modes:
return {"error": f"Invalid SSP mode '{mode}'. Must be one of: {sorted(valid_modes)}"}
params: dict[str, Any] = {"mode": mode}
if auto_accept:
params["auto_accept"] = True
try:
client = get_client()
resp = await client.send_command(CMD_CLASSIC_SET_SSP_MODE, {"mode": mode})
resp = await client.send_command(CMD_CLASSIC_SET_SSP_MODE, params)
if resp.status == Status.OK:
return resp.data
return {"error": resp.data.get("error", "Failed to set SSP mode")}

View File

@ -8,7 +8,7 @@ from typing import Any
from fastmcp import FastMCP
from ..protocol import CMD_GET_INFO, CMD_GET_STATUS, CMD_PING, CMD_RESET, Status
from ..serial_client import NotConnected, get_client, init_client
from ..serial_client import CommandTimeout, NotConnected, get_client, init_client
def register_tools(mcp: FastMCP) -> None:
@ -32,9 +32,10 @@ def register_tools(mcp: FastMCP) -> None:
Connection status including port, baudrate, and whether the
device responded with a boot event.
"""
try:
client = get_client()
except NotConnected:
from ..serial_client import get_client_or_none
client = get_client_or_none()
if client is None:
client = init_client(port=port, baudrate=baudrate)
try:
@ -45,16 +46,30 @@ def register_tools(mcp: FastMCP) -> None:
# Give the ESP32 a moment to send its boot event
boot_received = False
try:
event = await asyncio.wait_for(client.wait_event("boot"), timeout=2.0)
event = await client.event_queue.wait_for(event_name="boot", timeout=2.0)
boot_received = event is not None
except TimeoutError:
pass
# Ready probe: retry ping until the firmware's command handler is up.
# The boot event fires early in app_main, before the UART command
# task is fully initialised, so the first command can get lost.
ready = False
for attempt in range(5):
try:
resp = await client.send_command(CMD_PING, timeout=1.0)
if resp.status == Status.OK:
ready = True
break
except (CommandTimeout, NotConnected):
await asyncio.sleep(0.3)
return {
"connected": True,
"port": port,
"baudrate": baudrate,
"boot_event": boot_received,
"ready": ready,
}
@mcp.tool()

29
tests/prompts/README.md Normal file
View File

@ -0,0 +1,29 @@
# E2E Test Prompts
These markdown files are test prompts for running automated E2E Bluetooth tests using Claude CLI in headless mode.
## Usage
```bash
cd /tmp/bt-e2e-test
claude -p "$(cat test-prompt-v5.md)" \
--mcp-config .mcp.json \
--allowedTools "mcp__esp32__*,mcp__bluez__*" \
--output-format json
```
See [automated-e2e-testing.md](../../docs/automated-e2e-testing.md) for full setup instructions.
## Test Prompts
| File | Tests | Coverage |
|------|-------|----------|
| `test-prompt-v4.md` | 71 | Classic BT, BLE GATT, HCI capture |
| `test-prompt-v5.md` | 76 | v4 + Battery Service, bt_ble_battery |
## Requirements
- ESP32 with mcbluetooth-esp32 firmware
- Linux host with Bluetooth adapter
- Both MCP servers configured in `.mcp.json`
- btmon with CAP_NET_RAW for HCI capture tests

View File

@ -0,0 +1,153 @@
# ESP32 + BlueZ Full E2E Bluetooth Test Suite (v4 - Extended Coverage)
You have two MCP servers available:
- **esp32** (prefix: `mcp__esp32__`) — controls an ESP32 dev board via serial/UART
- **bluez** (prefix: `mcp__bluez__`) — controls the Linux BlueZ Bluetooth stack
Run ALL tests IN ORDER. For each test, report PASS or FAIL with actual response data.
If a test fails, note the error and continue with remaining tests.
**Important**: The Linux adapter is `hci1` (not hci0). Use `hci1` for all BlueZ calls.
---
## Phase 1: ESP32 Connection & System (Tests 1-4)
1. **Connect**: Call `esp32_connect` — should return connected=true AND ready=true
2. **Ping**: Call `esp32_ping` — expect `{"pong": true}`
3. **Get Info**: Call `esp32_get_info` — note the chip model, FW version, and BT MAC address (you'll need the MAC later)
4. **Get Status**: Call `esp32_status` — confirm bt_enabled=false, ble_enabled=false
## Phase 2: BlueZ Adapter Deep Dive (Tests 5-8)
5. **List Adapters**: Call `bt_list_adapters` — find the powered adapter, note its name (probably hci1)
6. **Adapter Info**: Call `bt_adapter_info` with the adapter name — report full details including the adapter's MAC address
7. **Set Pairable**: Call `bt_adapter_pairable` with adapter, on=true — enable pairing acceptance
8. **Set Discoverable**: Call `bt_adapter_discoverable` with adapter, on=true, timeout=300 — make Linux visible
## Phase 3: Classic BT + SSP Pairing (Tests 9-24)
IMPORTANT: Enable Classic BT FIRST, then configure IO capabilities.
9. **Enable Classic**: Call `esp32_classic_enable`
10. **Configure ESP32**: Call `esp32_configure` with name="SSP-Test-Device", io_cap="display_yesno"
11. **Set SSP Mode**: Call `esp32_set_ssp_mode` with mode="numeric_comparison", auto_accept=true
12. **Check Status**: Call `esp32_status` — confirm bt_enabled=true
13. **Set ESP32 Discoverable**: Call `esp32_classic_set_discoverable` with discoverable=true, timeout=120
14. **Scan from Linux**: Call `bt_scan` with adapter="hci1", mode="classic", timeout=10 — find the ESP32 by its MAC
Now initiate pairing:
15. **Check Pairing Status Before**: Call `bt_pairing_status` — should show no pending requests
16. **Start Pairing**: Call `bt_pair` with adapter="hci1", address=<ESP32 MAC>, pairing_mode="auto"
17. **Check ESP32 Pair Events**: Call `esp32_get_events` — look for pair_request and pair_complete events
18. **Verify Paired on Linux**: Call `bt_device_info` with adapter="hci1", address=<ESP32 MAC> — check paired=true
Post-pairing device management:
19. **List Known Devices**: Call `bt_list_devices` with adapter="hci1", filter="paired" — ESP32 should be in the list
20. **Trust Device**: Call `bt_trust` with adapter="hci1", address=<ESP32 MAC>, trusted=true
21. **Set Alias**: Call `bt_device_set_alias` with adapter="hci1", address=<ESP32 MAC>, alias="My ESP32 Test Device"
22. **Block Device**: Call `bt_block` with adapter="hci1", address=<ESP32 MAC>, blocked=true — block the device
23. **Unblock Device**: Call `bt_block` with adapter="hci1", address=<ESP32 MAC>, blocked=false — unblock it
24. **Unpair**: Call `bt_unpair` with adapter="hci1", address=<ESP32 MAC> — clean up pairing
## Phase 4: Classic Cleanup + Event System (Tests 25-29)
25. **Disable Classic**: Call `esp32_classic_disable`
26. **Get All Events**: Call `esp32_get_events` — report ALL events captured so far
27. **Clear Events**: Call `esp32_clear_events`
28. **Verify Cleared**: Call `esp32_get_events` — should return empty list
29. **Check Status**: Call `esp32_status` — confirm bt_enabled=false
## Phase 5: BLE GATT Setup (Tests 30-38)
Set up ESP32 as a BLE peripheral with multiple characteristics:
30. **Enable BLE**: Call `esp32_ble_enable`
31. **Add Service**: Call `esp32_gatt_add_service` with uuid="0000181a-0000-1000-8000-00805f9b34fb" (Environmental Sensing), primary=true — save the service_handle
32. **Add Read+Notify Char**: Call `esp32_gatt_add_characteristic` with the service_handle, uuid="00002a6e-0000-1000-8000-00805f9b34fb" (Temperature), properties=["read","notify"] — save as temp_handle
33. **Add Read+Write Char**: Call `esp32_gatt_add_characteristic` with the service_handle, uuid="00002a6f-0000-1000-8000-00805f9b34fb" (Humidity), properties=["read","write"] — save as humidity_handle
34. **Set Temperature**: Call `esp32_gatt_set_value` with char_handle=temp_handle, value="e803" (25.0°C)
35. **Set Humidity**: Call `esp32_gatt_set_value` with char_handle=humidity_handle, value="3200" (50%)
36. **Set Adv Data**: Call `esp32_ble_set_adv_data` with name="BLE-Sensor-Test", service_uuids=["0000181a-0000-1000-8000-00805f9b34fb"]
37. **Start Advertising**: Call `esp32_ble_advertise` with enable=true
38. **Clear Events**: Call `esp32_clear_events` — clear event history before BLE tests
## Phase 6: HCI Capture + BLE Discovery (Tests 39-47)
Start HCI packet capture BEFORE connecting, then analyze it afterwards:
39. **Start HCI Capture**: Call `bt_capture_start` with adapter="hci1", output_file="/tmp/ble-gatt-capture.btsnoop"
40. **List Active Captures**: Call `bt_capture_list_active` — verify capture is running
41. **BLE Scan**: Call `bt_ble_scan` with adapter="hci1", timeout=10 — find the ESP32, verify name="BLE-Sensor-Test"
42. **Connect BLE**: Call `bt_connect` with adapter="hci1", address=<ESP32 MAC>
Wait 3 seconds for service discovery to complete, then:
43. **List Services**: Call `bt_ble_services` with adapter="hci1", address=<ESP32 MAC> — should see Environmental Sensing (181a)
44. **List Characteristics**: Call `bt_ble_characteristics` with adapter="hci1", address=<ESP32 MAC> — should see Temperature (2a6e) and Humidity (2a6f)
45. **Read Temperature**: Call `bt_ble_read` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6e-0000-1000-8000-00805f9b34fb" — should return hex "e803"
46. **Check Read Event on ESP32**: Call `esp32_get_events` — look for a gatt_read event
47. **Stop HCI Capture**: Call `bt_capture_stop` with capture_id from test 39
## Phase 7: Analyze HCI Capture (Tests 48-50)
48. **Parse Capture**: Call `bt_capture_parse` with filepath="/tmp/ble-gatt-capture.btsnoop", max_packets=50 — report packet count and types
49. **Analyze Capture**: Call `bt_capture_analyze` with filepath="/tmp/ble-gatt-capture.btsnoop" — report statistics
50. **Read Raw Packets**: Call `bt_capture_read_raw` with filepath="/tmp/ble-gatt-capture.btsnoop", count=20 — show decoded packets
## Phase 8: BLE GATT Write + Notifications (Tests 51-58)
51. **Write Humidity**: Call `bt_ble_write` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6f-0000-1000-8000-00805f9b34fb", value="4b00", value_type="hex"
52. **Check Write Event**: Call `esp32_get_events` — look for a gatt_write event with the written value
53. **Subscribe Notifications**: Call `bt_ble_notify` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6e-0000-1000-8000-00805f9b34fb", enable=true
54. **Check Subscribe Event**: Call `esp32_get_events` — look for a gatt_subscribe event
55. **Push Notification**: First call `esp32_gatt_set_value` with char_handle=temp_handle, value="f401" (50.0°C), then call `esp32_gatt_notify` with char_handle=temp_handle
56. **Read Updated Value**: Call `bt_ble_read` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6e-0000-1000-8000-00805f9b34fb" — should now return "f401"
57. **Unsubscribe**: Call `bt_ble_notify` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6e-0000-1000-8000-00805f9b34fb", enable=false
58. **Disconnect BLE**: Call `bt_disconnect` with adapter="hci1", address=<ESP32 MAC>
## Phase 9: BLE Cleanup (Tests 59-63)
59. **Stop Advertising**: Call `esp32_ble_advertise` with enable=false
60. **Clear GATT**: Call `esp32_gatt_clear`
61. **Disable BLE**: Call `esp32_ble_disable`
62. **Final Status**: Call `esp32_status` — bt_enabled=false, ble_enabled=false
63. **Clear ESP32 Events**: Call `esp32_clear_events`
## Phase 10: Adapter Self-Management (Tests 64-68)
These tests verify adapter configuration tools. Done at the end to not interfere with earlier tests.
64. **Get Original Alias**: Call `bt_adapter_info` with adapter="hci1" — note the current alias
65. **Set New Alias**: Call `bt_adapter_set_alias` with adapter="hci1", alias="E2E-Test-Adapter" — change the name
66. **Verify Alias Changed**: Call `bt_adapter_info` with adapter="hci1" — confirm alias is "E2E-Test-Adapter"
67. **Restore Original Alias**: Call `bt_adapter_set_alias` with adapter="hci1", alias=<original alias from test 64>
68. **Disable Discoverable**: Call `bt_adapter_discoverable` with adapter="hci1", on=false — clean up
## Phase 11: Final Cleanup (Tests 69-71)
69. **Reset ESP32**: Call `esp32_reset`
70. **Disconnect Serial**: Call `esp32_disconnect`
71. **Final Adapter Check**: Call `bt_adapter_info` with adapter="hci1" — verify adapter is still powered and healthy
---
## Summary
After all tests, print a DETAILED summary table:
| # | Test | Result | Notes |
|---|------|--------|-------|
| 1 | Connect | PASS/FAIL | ... |
| ... | ... | ... | ... |
Report:
- Total PASS/FAIL counts
- Whether pairing succeeded (phase 3)
- What ESP32 events were captured (phases 4, 6, 8)
- Whether GATT read returned expected value (test 45)
- Whether GATT write succeeded (test 51)
- Whether the updated value was readable after notification (test 56)
- HCI capture statistics (tests 48-50): packet count, types captured, any errors

View File

@ -0,0 +1,170 @@
# ESP32 + BlueZ Full E2E Bluetooth Test Suite (v5 - Complete Coverage)
You have two MCP servers available:
- **esp32** (prefix: `mcp__esp32__`) — controls an ESP32 dev board via serial/UART
- **bluez** (prefix: `mcp__bluez__`) — controls the Linux BlueZ Bluetooth stack
Run ALL tests IN ORDER. For each test, report PASS or FAIL with actual response data.
If a test fails, note the error and continue with remaining tests.
**Important**: The Linux adapter is `hci1` (not hci0). Use `hci1` for all BlueZ calls.
---
## Phase 1: ESP32 Connection & System (Tests 1-4)
1. **Connect**: Call `esp32_connect` — should return connected=true AND ready=true
2. **Ping**: Call `esp32_ping` — expect `{"pong": true}`
3. **Get Info**: Call `esp32_get_info` — note the chip model, FW version, and BT MAC address (you'll need the MAC later)
4. **Get Status**: Call `esp32_status` — confirm bt_enabled=false, ble_enabled=false
## Phase 2: BlueZ Adapter Deep Dive (Tests 5-8)
5. **List Adapters**: Call `bt_list_adapters` — find the powered adapter, note its name (probably hci1)
6. **Adapter Info**: Call `bt_adapter_info` with the adapter name — report full details including the adapter's MAC address
7. **Set Pairable**: Call `bt_adapter_pairable` with adapter, on=true — enable pairing acceptance
8. **Set Discoverable**: Call `bt_adapter_discoverable` with adapter, on=true, timeout=300 — make Linux visible
## Phase 3: Classic BT + SSP Pairing (Tests 9-24)
IMPORTANT: Enable Classic BT FIRST, then configure IO capabilities.
9. **Enable Classic**: Call `esp32_classic_enable`
10. **Configure ESP32**: Call `esp32_configure` with name="SSP-Test-Device", io_cap="display_yesno"
11. **Set SSP Mode**: Call `esp32_set_ssp_mode` with mode="numeric_comparison", auto_accept=true
12. **Check Status**: Call `esp32_status` — confirm bt_enabled=true
13. **Set ESP32 Discoverable**: Call `esp32_classic_set_discoverable` with discoverable=true, timeout=120
14. **Scan from Linux**: Call `bt_scan` with adapter="hci1", mode="classic", timeout=10 — find the ESP32 by its MAC
Now initiate pairing:
15. **Check Pairing Status Before**: Call `bt_pairing_status` — should show no pending requests
16. **Start Pairing**: Call `bt_pair` with adapter="hci1", address=<ESP32 MAC>, pairing_mode="auto"
17. **Check ESP32 Pair Events**: Call `esp32_get_events` — look for pair_request and pair_complete events
18. **Verify Paired on Linux**: Call `bt_device_info` with adapter="hci1", address=<ESP32 MAC> — check paired=true
Post-pairing device management:
19. **List Known Devices**: Call `bt_list_devices` with adapter="hci1", filter="paired" — ESP32 should be in the list
20. **Trust Device**: Call `bt_trust` with adapter="hci1", address=<ESP32 MAC>, trusted=true
21. **Set Alias**: Call `bt_device_set_alias` with adapter="hci1", address=<ESP32 MAC>, alias="My ESP32 Test Device"
22. **Block Device**: Call `bt_block` with adapter="hci1", address=<ESP32 MAC>, blocked=true — block the device
23. **Unblock Device**: Call `bt_block` with adapter="hci1", address=<ESP32 MAC>, blocked=false — unblock it
24. **Unpair**: Call `bt_unpair` with adapter="hci1", address=<ESP32 MAC> — clean up pairing
## Phase 4: Classic Cleanup + Event System (Tests 25-29)
25. **Disable Classic**: Call `esp32_classic_disable`
26. **Get All Events**: Call `esp32_get_events` — report ALL events captured so far
27. **Clear Events**: Call `esp32_clear_events`
28. **Verify Cleared**: Call `esp32_get_events` — should return empty list
29. **Check Status**: Call `esp32_status` — confirm bt_enabled=false
## Phase 5: BLE GATT Setup with Battery Service (Tests 30-42)
Set up ESP32 as a BLE peripheral with Environmental Sensing AND Battery Service:
30. **Enable BLE**: Call `esp32_ble_enable`
First, add Battery Service (0x180F) for bt_ble_battery test:
31. **Add Battery Service**: Call `esp32_gatt_add_service` with uuid="0000180f-0000-1000-8000-00805f9b34fb" (Battery Service), primary=true — save as battery_svc_handle
32. **Add Battery Level Char**: Call `esp32_gatt_add_characteristic` with service_handle=battery_svc_handle, uuid="00002a19-0000-1000-8000-00805f9b34fb" (Battery Level), properties=["read"] — save as battery_char_handle
33. **Set Battery Level**: Call `esp32_gatt_set_value` with char_handle=battery_char_handle, value="4b" (75%)
Now add Environmental Sensing Service:
34. **Add Env Service**: Call `esp32_gatt_add_service` with uuid="0000181a-0000-1000-8000-00805f9b34fb" (Environmental Sensing), primary=true — save as env_svc_handle
35. **Add Temperature Char**: Call `esp32_gatt_add_characteristic` with service_handle=env_svc_handle, uuid="00002a6e-0000-1000-8000-00805f9b34fb" (Temperature), properties=["read","notify"] — save as temp_handle
36. **Add Humidity Char**: Call `esp32_gatt_add_characteristic` with service_handle=env_svc_handle, uuid="00002a6f-0000-1000-8000-00805f9b34fb" (Humidity), properties=["read","write"] — save as humidity_handle
37. **Set Temperature**: Call `esp32_gatt_set_value` with char_handle=temp_handle, value="e803" (25.0°C)
38. **Set Humidity**: Call `esp32_gatt_set_value` with char_handle=humidity_handle, value="3200" (50%)
Configure advertising:
39. **Set Adv Data**: Call `esp32_ble_set_adv_data` with name="BLE-Sensor-Test", service_uuids=["0000180f-0000-1000-8000-00805f9b34fb", "0000181a-0000-1000-8000-00805f9b34fb"]
40. **Start Advertising**: Call `esp32_ble_advertise` with enable=true
41. **Clear Events**: Call `esp32_clear_events` — clear event history before BLE tests
42. **Check BLE Status**: Call `esp32_status` — confirm ble_enabled=true
## Phase 6: HCI Capture + BLE Discovery (Tests 43-51)
Start HCI packet capture BEFORE connecting:
43. **Start HCI Capture**: Call `bt_capture_start` with adapter="hci1", output_file="/tmp/ble-gatt-capture.btsnoop"
44. **List Active Captures**: Call `bt_capture_list_active` — verify capture is running
45. **BLE Scan**: Call `bt_ble_scan` with adapter="hci1", timeout=10 — find the ESP32, verify name="BLE-Sensor-Test"
46. **Connect BLE**: Call `bt_connect` with adapter="hci1", address=<ESP32 MAC>
Wait 3 seconds for service discovery to complete, then:
47. **List Services**: Call `bt_ble_services` with adapter="hci1", address=<ESP32 MAC> — should see Battery (180f) and Environmental Sensing (181a)
48. **List Characteristics**: Call `bt_ble_characteristics` with adapter="hci1", address=<ESP32 MAC> — should see Battery Level (2a19), Temperature (2a6e), Humidity (2a6f)
49. **Read Battery Level**: Call `bt_ble_battery` with adapter="hci1", address=<ESP32 MAC> — should return 75 (the value we set as "4b")
50. **Read Temperature**: Call `bt_ble_read` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6e-0000-1000-8000-00805f9b34fb" — should return hex "e803"
51. **Check Read Events**: Call `esp32_get_events` — look for gatt_read events
Stop capture for analysis:
## Phase 7: Analyze HCI Capture (Tests 52-55)
52. **Stop HCI Capture**: Call `bt_capture_stop` with capture_id from test 43
53. **Parse Capture**: Call `bt_capture_parse` with filepath="/tmp/ble-gatt-capture.btsnoop", max_packets=50 — report packet count and types
54. **Analyze Capture**: Call `bt_capture_analyze` with filepath="/tmp/ble-gatt-capture.btsnoop" — report statistics
55. **Read Raw Packets**: Call `bt_capture_read_raw` with filepath="/tmp/ble-gatt-capture.btsnoop", count=20 — show decoded packets
## Phase 8: BLE GATT Write + Notifications (Tests 56-63)
56. **Write Humidity**: Call `bt_ble_write` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6f-0000-1000-8000-00805f9b34fb", value="4b00", value_type="hex"
57. **Check Write Event**: Call `esp32_get_events` — look for a gatt_write event with the written value
58. **Subscribe Notifications**: Call `bt_ble_notify` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6e-0000-1000-8000-00805f9b34fb", enable=true
59. **Check Subscribe Event**: Call `esp32_get_events` — look for a gatt_subscribe event
60. **Push Notification**: First call `esp32_gatt_set_value` with char_handle=temp_handle, value="f401" (50.0°C), then call `esp32_gatt_notify` with char_handle=temp_handle
61. **Read Updated Value**: Call `bt_ble_read` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6e-0000-1000-8000-00805f9b34fb" — should now return "f401"
62. **Unsubscribe**: Call `bt_ble_notify` with adapter="hci1", address=<ESP32 MAC>, char_uuid="00002a6e-0000-1000-8000-00805f9b34fb", enable=false
63. **Disconnect BLE**: Call `bt_disconnect` with adapter="hci1", address=<ESP32 MAC>
## Phase 9: BLE Cleanup (Tests 64-68)
64. **Stop Advertising**: Call `esp32_ble_advertise` with enable=false
65. **Clear GATT**: Call `esp32_gatt_clear`
66. **Disable BLE**: Call `esp32_ble_disable`
67. **Final Status**: Call `esp32_status` — bt_enabled=false, ble_enabled=false
68. **Clear ESP32 Events**: Call `esp32_clear_events`
## Phase 10: Adapter Self-Management (Tests 69-73)
These tests verify adapter configuration tools. Done at the end to not interfere with earlier tests.
69. **Get Original Alias**: Call `bt_adapter_info` with adapter="hci1" — note the current alias
70. **Set New Alias**: Call `bt_adapter_set_alias` with adapter="hci1", alias="E2E-Test-Adapter" — change the name
71. **Verify Alias Changed**: Call `bt_adapter_info` with adapter="hci1" — confirm alias is "E2E-Test-Adapter"
72. **Restore Original Alias**: Call `bt_adapter_set_alias` with adapter="hci1", alias=<original alias from test 69>
73. **Disable Discoverable**: Call `bt_adapter_discoverable` with adapter="hci1", on=false — clean up
## Phase 11: Final Cleanup (Tests 74-76)
74. **Reset ESP32**: Call `esp32_reset`
75. **Disconnect Serial**: Call `esp32_disconnect`
76. **Final Adapter Check**: Call `bt_adapter_info` with adapter="hci1" — verify adapter is still powered and healthy
---
## Summary
After all tests, print a DETAILED summary table:
| # | Test | Result | Notes |
|---|------|--------|-------|
| 1 | Connect | PASS/FAIL | ... |
| ... | ... | ... | ... |
Report:
- Total PASS/FAIL counts
- Whether pairing succeeded (phase 3)
- What ESP32 events were captured (phases 4, 6, 8)
- Whether bt_ble_battery returned the expected value 75 (test 49)
- Whether GATT read returned expected value (test 50)
- Whether GATT write succeeded (test 56)
- Whether the updated value was readable after notification (test 61)
- HCI capture statistics (tests 53-55): packet count, types captured, any errors