Expand test scenarios with step-by-step procedures

Rewrite test-scenarios.md with detailed per-step instructions
including exact tool calls, expected responses, negative test
cases, teardown procedures, and an environment variable reference.
This commit is contained in:
Ryan Malloy 2026-02-02 15:13:36 -07:00
parent 6398a5223a
commit 73d3d438a2

View File

@ -1,263 +1,655 @@
# E2E Test Scenarios
# End-to-End Test Scenarios
Tests that exercise the full Bluetooth stack across two devices: a Linux host running `mcbluetooth` (BlueZ) and an ESP32 running the `mcbluetooth-esp32` firmware.
## Overview
Tests require both MCP servers running simultaneously:
- **mcbluetooth** — controls the Linux BlueZ stack (host side)
- **mcbluetooth-esp32** — controls the ESP32 peripheral (device side)
Each test requires **two MCP servers** running simultaneously:
An LLM orchestrates both servers, issuing tool calls to each side to execute the test flow.
| Server | Controls | Tool prefix |
|--------|----------|-------------|
| `mcbluetooth` | Linux BlueZ stack (hci0) | `bt_*` |
| `mcbluetooth-esp32` | ESP32 peripheral via UART | `esp32_*` |
## Prerequisites
The LLM orchestrates both sides, acting as the test conductor. It issues commands to one side, observes events on the other, and verifies that the protocol exchange matches expectations.
```bash
# Terminal 1: Start ESP32 MCP server
ESP32_SERIAL_PORT=/dev/ttyUSB4 uvx mcbluetooth-esp32
### Prerequisites
# Terminal 2: mcbluetooth is already running (or add to Claude Code config)
```
- ESP32 connected and reachable at `ESP32_SERIAL_PORT` (default `/dev/ttyUSB4`)
- BlueZ adapter powered on (`bt_adapter_power(adapter="hci0", on=true)`)
- Both MCP servers registered in the Claude Code session
---
## Test 1: SSP Just Works
Both devices have `no_io` capability — pairing auto-completes without user interaction.
Both devices have `no_io` capability. Pairing should auto-complete with no user interaction.
### Flow
### Setup
```python
# ESP32 side: Configure as headset (no_io)
esp32_load_persona("headset") # io_cap=no_io → Just Works
**Step 1 -- Connect to the ESP32 and configure it as a no-IO device:**
```
esp32_connect(port="/dev/ttyUSB4")
esp32_configure(name="JustWorks-Test", io_cap="no_io")
esp32_classic_enable()
esp32_classic_set_discoverable(True)
# Linux side: Discover and pair
bt_scan(adapter="hci0", mode="classic", timeout=10)
# → Find "BT Headset" in scan results
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="auto")
# Just Works: auto-accepts on both sides
# Verify
esp32_wait_event("pair_complete", timeout=15)
# → {"address": "XX:XX:XX:XX:XX:XX", "success": true}
bt_device_info(adapter="hci0", address="D8:13:2A:7F:47:C0")
# → paired: true
esp32_classic_set_discoverable(discoverable=true)
```
### Expected Result
- Pairing completes without any passkey exchange
- Both sides report success
**Step 2 -- Power on the Linux adapter:**
```
bt_adapter_power(adapter="hci0", on=true)
```
### Execute
**Step 3 -- Scan from Linux to discover the ESP32:**
```
bt_scan(adapter="hci0", timeout=10, mode="classic")
```
Verify that a device named `"JustWorks-Test"` appears in the scan results. Note its Bluetooth address (e.g., `"AA:BB:CC:DD:EE:FF"`).
**Step 4 -- Initiate pairing from Linux:**
```
bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="auto")
```
Since both sides have `no_io`, SSP Just Works is negotiated. No passkey exchange occurs.
**Step 5 -- Wait for the pair_request event on the ESP32 side:**
```
esp32_wait_event(event_name="pair_request", timeout=10)
```
Expected: `{"event":"pair_request","data":{"address":"...","type":"just_works","passkey":0},...}`
**Step 6 -- Accept the pairing on the ESP32:**
```
esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true)
```
**Step 7 -- Verify pairing completed on both sides:**
```
esp32_wait_event(event_name="pair_complete", timeout=10)
```
Expected: `{"event":"pair_complete","data":{"address":"AA:BB:CC:DD:EE:FF","success":true},...}`
```
bt_list_devices(adapter="hci0", filter="paired")
```
Verify the ESP32 address appears in the paired devices list.
### Teardown
```
bt_unpair(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
esp32_classic_set_discoverable(discoverable=false)
esp32_classic_disable()
```
---
## Test 2: SSP Numeric Comparison
Both devices have `display_yesno` — both display a 6-digit passkey that must match.
Both devices have `display_yesno` capability. Both display a 6-digit passkey that the user (or LLM) must confirm matches.
### Flow
### Setup
```python
# ESP32 side: Configure as phone (keyboard_display → numeric comparison)
esp32_load_persona("phone")
**Step 1 -- Configure the ESP32 with display+yesno:**
```
esp32_connect(port="/dev/ttyUSB4")
esp32_configure(name="NumComp-Test", io_cap="display_yesno")
esp32_classic_enable()
esp32_classic_set_discoverable(True)
# Linux side: Scan and initiate pairing
bt_scan(adapter="hci0", mode="classic")
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")
# Both sides receive the passkey
esp32_wait_event("pair_request", timeout=15)
# → {"type": "numeric_comparison", "passkey": 123456, "address": "..."}
bt_pairing_status()
# → passkey: 123456 (should match ESP32's passkey!)
# Both sides confirm
esp32_classic_pair_respond(address="XX:XX:XX:XX:XX:XX", accept=True)
bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0", accept=True)
# Verify
esp32_wait_event("pair_complete")
# → {"success": true}
esp32_classic_set_discoverable(discoverable=true)
```
### Expected Result
- Both sides display the same 6-digit passkey
- After both confirm, pairing succeeds
**Step 2 -- Ensure Linux adapter is powered:**
```
bt_adapter_power(adapter="hci0", on=true)
```
### Execute
**Step 3 -- Scan and discover:**
```
bt_scan(adapter="hci0", timeout=10, mode="classic")
```
Locate `"NumComp-Test"` in results.
**Step 4 -- Initiate pairing from Linux in interactive mode:**
```
bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="interactive")
```
This returns a pairing status indicating it is awaiting confirmation with a passkey.
**Step 5 -- Capture the passkey from the ESP32:**
```
esp32_wait_event(event_name="pair_request", timeout=10)
```
Expected: `{"event":"pair_request","data":{"address":"...","type":"numeric_comparison","passkey":482901},...}`
Note the `passkey` value (e.g., `482901`).
**Step 6 -- Verify the passkeys match and confirm on both sides:**
Check that the passkey from the Linux pairing status matches the ESP32 event.
Accept on ESP32:
```
esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true, passkey=482901)
```
Confirm on Linux:
```
bt_pair_confirm(adapter="hci0", address="AA:BB:CC:DD:EE:FF", accept=true)
```
**Step 7 -- Verify completion:**
```
esp32_wait_event(event_name="pair_complete", timeout=10)
```
Expected: `{"data":{"address":"AA:BB:CC:DD:EE:FF","success":true}}`
### Negative case
Repeat the flow but provide mismatched confirmation: accept on one side, reject on the other. Verify `pair_complete` reports `"success":false`.
### Teardown
```
bt_unpair(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
esp32_classic_set_discoverable(discoverable=false)
```
---
## Test 3: SSP Passkey Entry
One side displays a passkey, the other must enter it.
One side displays a passkey, the other must enter it. Test both directions.
### Flow (ESP32 displays, Linux enters)
### Direction A: ESP32 displays, Linux enters
```python
# ESP32: display_only → shows passkey
esp32_configure(io_cap="display_only")
**Setup:**
```
esp32_configure(name="PasskeyDisp-Test", io_cap="display_only")
esp32_classic_enable()
esp32_classic_set_discoverable(True)
# Linux: Initiate pairing
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")
# ESP32 displays passkey
esp32_wait_event("pair_request")
# → {"type": "passkey_display", "passkey": 654321}
# Linux enters the passkey
bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0",
passkey=654321, accept=True)
# Verify
esp32_wait_event("pair_complete")
# → {"success": true}
esp32_classic_set_discoverable(discoverable=true)
```
### Flow (Linux displays, ESP32 enters)
**Execute:**
```python
# ESP32: keyboard_only → must enter passkey
esp32_configure(io_cap="keyboard_only")
esp32_classic_enable()
esp32_classic_set_discoverable(True)
# Linux initiates pairing — Linux displays passkey
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")
bt_pairing_status()
# → passkey: 789012 (displayed on Linux)
# ESP32 receives passkey entry request
esp32_wait_event("pair_request")
# → {"type": "passkey_entry"}
# ESP32 enters the passkey shown on Linux
esp32_classic_pair_respond(address="XX:XX:XX:XX:XX:XX", accept=True, passkey=789012)
# Verify
esp32_wait_event("pair_complete")
```
bt_scan(adapter="hci0", timeout=10, mode="classic")
bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="interactive")
```
The ESP32 receives a `pair_request` event with `"type":"passkey_entry"` and a `passkey` value.
```
esp32_wait_event(event_name="pair_request", timeout=10)
```
Extract the passkey (e.g., `731205`). Then send it from the Linux side:
```
bt_pair_confirm(adapter="hci0", address="AA:BB:CC:DD:EE:FF", passkey=731205, accept=true)
```
Accept on ESP32:
```
esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true)
```
Verify `pair_complete` succeeds.
### Direction B: Linux displays, ESP32 enters
**Setup:**
```
esp32_configure(name="PasskeyEntry-Test", io_cap="keyboard_only")
esp32_classic_enable()
esp32_classic_set_discoverable(discoverable=true)
```
**Execute:**
```
bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="interactive")
```
The Linux side generates a passkey for the ESP32 to enter. The ESP32 receives a `pair_request` event. Forward the passkey from the Linux pairing status to the ESP32 response:
```
esp32_wait_event(event_name="pair_request", timeout=10)
esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true, passkey=<passkey_from_linux>)
```
Verify `pair_complete` succeeds.
---
## Test 4: Legacy PIN
ESP32 configured with a legacy PIN code.
ESP32 configured with a PIN code. Linux initiates pairing with that PIN. This tests pre-SSP pairing mode.
```python
esp32_configure(pin_code="1234")
### Setup
```
esp32_connect(port="/dev/ttyUSB4")
esp32_configure(name="LegacyPIN-Test", io_cap="no_io", pin_code="1234")
esp32_classic_enable()
esp32_classic_set_discoverable(True)
esp32_classic_set_discoverable(discoverable=true)
```
bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive")
### Execute
# Linux enters PIN
bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0",
pin="1234", accept=True)
**Step 1 -- Scan and pair from Linux with the PIN:**
```
bt_scan(adapter="hci0", timeout=10, mode="classic")
bt_pair(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pairing_mode="interactive")
```
**Step 2 -- Handle the pair request on ESP32:**
```
esp32_wait_event(event_name="pair_request", timeout=10)
```
Expected: `"type":"legacy_pin"`
**Step 3 -- Respond with the PIN on the ESP32:**
```
esp32_classic_pair_respond(address="AA:BB:CC:DD:EE:FF", accept=true, pin="1234")
```
**Step 4 -- Provide the PIN from Linux:**
```
bt_pair_confirm(adapter="hci0", address="AA:BB:CC:DD:EE:FF", pin="1234", accept=true)
```
**Step 5 -- Verify:**
```
esp32_wait_event(event_name="pair_complete", timeout=10)
```
### Negative case
Provide a wrong PIN from the Linux side (e.g., `"0000"`). Verify pairing fails with `"success":false`.
### Teardown
```
bt_unpair(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
```
---
## Test 5: BLE GATT Read/Write
ESP32 creates an Environmental Sensing service with a Temperature characteristic.
ESP32 creates an Environmental Sensing service with a Temperature characteristic. Linux reads the value and verifies correctness. ESP32 updates the value and Linux reads again.
```python
# ESP32: Set up as sensor
esp32_load_persona("sensor")
esp32_gatt_add_service(uuid="0000181a-0000-1000-8000-00805f9b34fb", primary=True)
# → {"service_handle": 40}
### Setup
**Step 1 -- Prepare the ESP32 as a BLE sensor:**
```
esp32_connect(port="/dev/ttyUSB4")
esp32_configure(name="Temp-Sensor", io_cap="no_io")
esp32_ble_enable()
```
**Step 2 -- Create the GATT service and characteristic:**
```
esp32_gatt_add_service(uuid="181A", primary=true)
```
Returns: `{"handle": 40}` (example handle)
```
esp32_gatt_add_characteristic(
service_handle=40,
uuid="00002a6e-0000-1000-8000-00805f9b34fb", # Temperature
uuid="2A6E",
properties=["read", "notify"],
value="e803" # 25.0°C (0x03e8 = 1000 in little-endian → 10.00°C? or raw)
value="c409"
)
# → {"char_handle": 42}
```
esp32_ble_advertise(enable=True)
Returns: `{"handle": 42}` (example handle)
# Linux: Scan, connect, read
bt_ble_scan(adapter="hci0", timeout=5)
bt_connect(adapter="hci0", address="D8:13:2A:7F:47:C0")
bt_ble_read(adapter="hci0", address="D8:13:2A:7F:47:C0",
char_uuid="00002a6e-0000-1000-8000-00805f9b34fb")
# → {"hex": "e803", ...}
The value `"c409"` encodes 25.00 C as a little-endian `int16` in hundredths of a degree (2500 = 0x09C4, LE = `c4 09`).
# ESP32: Update value
esp32_gatt_set_value(char_handle=42, value="f003") # 25.5°C
**Step 3 -- Start advertising:**
```
esp32_ble_set_adv_data(name="Temp-Sensor", service_uuids=["181A"])
esp32_ble_advertise(enable=true, interval_ms=100)
```
### Read
**Step 4 -- Scan from Linux:**
```
bt_ble_scan(adapter="hci0", timeout=10, name_filter="Temp-Sensor")
```
Locate the device and note its address.
**Step 5 -- Connect and read the characteristic:**
```
bt_connect(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
bt_ble_read(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E")
```
Expected: hex value `"c409"`, decoded as temperature 25.00 C.
**Step 6 -- Verify the ESP32 received a GATT read event:**
```
esp32_get_events(event_name="gatt_read")
```
Expected: an event with `{"handle":42,"address":"..."}`.
### Write (update from ESP32, re-read from Linux)
**Step 7 -- Update the value on the ESP32:**
```
esp32_gatt_set_value(char_handle=42, value="d007")
```
The value `"d007"` encodes 20.00 C (2000 = 0x07D0, LE = `d0 07`).
**Step 8 -- Re-read from Linux:**
```
bt_ble_read(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E")
```
Expected: `"d007"` (20.00 C).
### Teardown
```
bt_disconnect(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
esp32_ble_advertise(enable=false)
esp32_gatt_clear()
```
---
## Test 6: BLE GATT Notifications
## Test 6: BLE GATT Subscribe
```python
# Linux: Subscribe to temperature notifications
bt_ble_notify(adapter="hci0", address="D8:13:2A:7F:47:C0",
char_uuid="00002a6e-0000-1000-8000-00805f9b34fb",
enable=True)
Test notification subscription and delivery. The ESP32 pushes a value update to a subscribed Linux client.
# ESP32: Verify subscription
esp32_wait_event("gatt_subscribe")
# → {"char_handle": 42, "subscribed": true}
### Setup
# ESP32: Update and notify
esp32_gatt_set_value(char_handle=42, value="0004")
Use the same GATT service structure from Test 5 (Environmental Sensing, Temperature characteristic with `notify` property).
```
esp32_connect(port="/dev/ttyUSB4")
esp32_ble_enable()
esp32_gatt_add_service(uuid="181A", primary=true)
# -> handle: 40
esp32_gatt_add_characteristic(
service_handle=40,
uuid="2A6E",
properties=["read", "notify"],
value="c409"
)
# -> handle: 42
esp32_ble_set_adv_data(name="Notify-Sensor", service_uuids=["181A"])
esp32_ble_advertise(enable=true)
```
### Execute
**Step 1 -- Connect from Linux and subscribe to notifications:**
```
bt_ble_scan(adapter="hci0", timeout=10, name_filter="Notify-Sensor")
bt_connect(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
bt_ble_notify(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E", enable=true)
```
**Step 2 -- Verify the ESP32 received a subscribe event:**
```
esp32_wait_event(event_name="gatt_subscribe", timeout=5)
```
Expected: `{"handle":42,"subscribed":true}`
**Step 3 -- Update the value on the ESP32 and send a notification:**
```
esp32_gatt_set_value(char_handle=42, value="0c0a")
esp32_gatt_notify(char_handle=42)
```
# Linux should receive the updated value
bt_ble_read(adapter="hci0", address="D8:13:2A:7F:47:C0",
char_uuid="00002a6e-0000-1000-8000-00805f9b34fb")
# → {"hex": "0004"}
The value `"0c0a"` encodes 25.72 C (2572 = 0x0A0C, LE = `0c 0a`).
**Step 4 -- Verify the Linux client received the notification:**
The BLE notification should arrive as an updated characteristic value on the Linux side. Read the cached value:
```
bt_ble_read(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E")
```
Expected: `"0c0a"`.
**Step 5 -- Unsubscribe:**
```
bt_ble_notify(adapter="hci0", address="AA:BB:CC:DD:EE:FF", char_uuid="2A6E", enable=false)
esp32_wait_event(event_name="gatt_subscribe", timeout=5)
```
Expected: `{"handle":42,"subscribed":false}`
### Teardown
```
bt_disconnect(adapter="hci0", address="AA:BB:CC:DD:EE:FF")
esp32_ble_advertise(enable=false)
esp32_gatt_clear()
```
---
## Test 7: Persona Switching
Verify device identity changes are visible from the Linux side.
Load different device personas on the ESP32 and verify that the device name and Bluetooth class are visible from a Linux scan.
```python
# Load different personas and scan each time
for persona in ["headset", "speaker", "keyboard", "sensor", "phone", "bare"]:
esp32_load_persona(persona)
esp32_classic_enable()
esp32_classic_set_discoverable(True)
### Execute
bt_scan(adapter="hci0", mode="both", timeout=5)
# Verify device name and class match persona definition
**Step 1 -- List available personas:**
esp32_classic_disable()
```
esp32_connect(port="/dev/ttyUSB4")
esp32_list_personas()
```
Verify the response contains all six personas: `headset`, `speaker`, `keyboard`, `sensor`, `phone`, `bare`.
**Step 2 -- Load the headset persona:**
```
esp32_load_persona(persona="headset")
```
Expected response:
```json
{
"persona": "headset",
"device_name": "BT Headset",
"io_cap": "no_io",
"classic": true,
"ble": true,
"device_class": "0x200404",
"services": ["0000180f-...", "0000180a-..."]
}
```
**Step 3 -- Enable Classic BT and make discoverable:**
```
esp32_classic_enable()
esp32_classic_set_discoverable(discoverable=true)
```
**Step 4 -- Scan from Linux and verify the device appears as "BT Headset":**
```
bt_scan(adapter="hci0", timeout=10, mode="classic")
```
Check that the scan results include a device named `"BT Headset"`.
**Step 5 -- Switch to the keyboard persona:**
```
esp32_load_persona(persona="keyboard")
esp32_classic_set_discoverable(discoverable=true)
```
**Step 6 -- Scan again and verify the name changed:**
```
bt_scan(adapter="hci0", timeout=10, mode="classic")
```
Check that the device now appears as `"BT Keyboard"`.
**Step 7 -- Load the sensor persona (BLE only):**
```
esp32_classic_disable()
esp32_load_persona(persona="sensor")
esp32_ble_enable()
esp32_ble_set_adv_data(name="Environment Sensor", service_uuids=["181A"])
esp32_ble_advertise(enable=true)
```
**Step 8 -- Scan via BLE from Linux:**
```
bt_ble_scan(adapter="hci0", timeout=10, name_filter="Environment Sensor")
```
Verify the device appears in the BLE scan results.
### Teardown
```
esp32_ble_advertise(enable=false)
esp32_ble_disable()
```
---
## Running Tests
### Unit tests (no hardware required)
Unit tests exercise the Python protocol layer, event queue, and MCP tool registration using mock serial connections.
```bash
# Unit tests only (no hardware needed)
make test-unit
# Integration tests (requires ESP32 on /dev/ttyUSB4)
ESP32_SERIAL_PORT=/dev/ttyUSB4 make test-integration
# Full suite
make test
```
Or directly:
```bash
uv run pytest tests/ -v --ignore=tests/integration
```
### Integration tests (requires ESP32 on serial port)
Integration tests require a physical ESP32 connected and flashed with the firmware.
```bash
ESP32_SERIAL_PORT=/dev/ttyUSB4 make test-integration
```
Or directly:
```bash
ESP32_SERIAL_PORT=/dev/ttyUSB4 uv run pytest tests/integration/ -v
```
### Full E2E tests (requires both MCP servers)
The test scenarios in this document are designed to be executed by an LLM with both MCP servers available. Configure your Claude Code session with:
```bash
# Add the ESP32 MCP server
claude mcp add mcbluetooth-esp32 -- uvx mcbluetooth-esp32
# The mcbluetooth server (Linux BlueZ) should already be available
```
Set the serial port via environment variable:
```bash
export ESP32_SERIAL_PORT=/dev/ttyUSB4
```
Then instruct the LLM to run through the test scenarios, e.g.:
> "Run Test 1 (SSP Just Works) from the test scenarios document. Both mcbluetooth and mcbluetooth-esp32 MCP servers are available."
### Test environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ESP32_SERIAL_PORT` | `/dev/ttyUSB0` | Serial device path for the ESP32 |
| `ESP32_SERIAL_BAUD` | `115200` | Baud rate (should not need changing) |
| `BT_ADAPTER` | `hci0` | Linux Bluetooth adapter for mcbluetooth |
---
## Test Matrix
| Test | SSP Mode | ESP32 IO Cap | Linux IO Cap | Auto? |
|------|----------|--------------|--------------|-------|
| Just Works | NoInputNoOutput | no_io | no_io | Yes |
| Numeric Comparison | NumericComparison | keyboard_display | display_yesno | No (confirm) |
| Passkey Entry (ESP32 displays) | PasskeyEntry | display_only | keyboard_only | No (enter) |
| Passkey Entry (Linux displays) | PasskeyEntry | keyboard_only | display_only | No (enter) |
| Legacy PIN | LegacyPIN | n/a | n/a | No (PIN) |
Summary of all pairing tests and the IO capabilities required on each side.
| Test | SSP Mode | ESP32 IO Cap | Linux IO Cap | User Interaction |
|------|----------|--------------|--------------|------------------|
| 1. Just Works | Just Works | `no_io` | `no_io` | None (auto-accept) |
| 2. Numeric Comparison | Numeric Comparison | `display_yesno` | `display_yesno` | Confirm passkey match on both sides |
| 3a. Passkey Entry (ESP32 displays) | Passkey Entry | `display_only` | `keyboard_only` | Linux enters passkey shown on ESP32 |
| 3b. Passkey Entry (Linux displays) | Passkey Entry | `keyboard_only` | `display_only` | ESP32 enters passkey shown on Linux |
| 4. Legacy PIN | Legacy PIN | n/a | n/a | Both sides provide pre-shared PIN |