Compare commits
10 Commits
6398a5223a
...
61da375a0c
| Author | SHA1 | Date | |
|---|---|---|---|
| 61da375a0c | |||
| 5dcacc23ab | |||
| 88d006e9c4 | |||
| 5a853c15fc | |||
| 397b164eee | |||
| 82cd0e5c9d | |||
| ea22f2f9db | |||
| 0e7b8c2ef5 | |||
| dc6078b296 | |||
| 73d3d438a2 |
229
README.md
Normal file
229
README.md
Normal 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
|
||||
287
docs/automated-e2e-testing.md
Normal file
287
docs/automated-e2e-testing.md
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
/* 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;
|
||||
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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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[] = {
|
||||
{ CMD_PING, handle_ping },
|
||||
{ CMD_RESET, handle_reset },
|
||||
{ CMD_GET_INFO, handle_get_info },
|
||||
{ CMD_GET_STATUS, handle_get_status },
|
||||
/* 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)
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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
29
tests/prompts/README.md
Normal 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
|
||||
153
tests/prompts/test-prompt-v4.md
Normal file
153
tests/prompts/test-prompt-v4.md
Normal 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
|
||||
170
tests/prompts/test-prompt-v5.md
Normal file
170
tests/prompts/test-prompt-v5.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user