Implement HFP client (Hands-Free Unit role) for the ESP32 test harness:
Firmware:
- bt_hfp.c/h: Full HFP client with call control, audio, volume, DTMF,
voice recognition
- Enable HFP in sdkconfig.defaults with Wide Band Speech support
- Add HFP commands/events to protocol.h and cmd_dispatcher.c
Python MCP tools:
- 15 new tools: enable, connect, audio_connect, answer, reject, dial,
send_dtmf, volume, voice_recognition_start/stop, query_calls, status
- Full protocol constants in protocol.py
Tested: HFP enable returns role='hands_free_unit', ready for AG pairing
Firmware:
- Add spp_connect, spp_disconnect, spp_data events
- Add spp_send, spp_disconnect, spp_status commands
- Track remote address for connected SPP peer
- Report received data as hex + optional text decode
Python MCP:
- esp32_spp_send(data/data_hex) - send text or binary
- esp32_spp_disconnect() - close SPP connection
- esp32_spp_status() - query connection state
Tested: Linux rfcomm connect → ESP32, bidirectional data transfer works
Two fixes for the E2E test failures:
1. event_reporter_init() was never called in app_main(), so the
FreeRTOS queue and reporter task were never created. Every BT
event (pair_request, gatt_read, gatt_write, gatt_subscribe)
was silently dropped at the NULL-queue guard.
2. SSP Numeric Comparison requires both sides to confirm, but
bt_pair blocks until completion — creating a deadlock since
the LLM can't send classic_pair_respond while waiting. Added
auto_accept flag to set_ssp_mode that auto-confirms numeric
comparison requests in the GAP callback.
The boot event fires early in app_main before the UART command
handler task is fully initialised. This means the first command
after connect can get lost, causing transient ping timeouts.
Now esp32_connect retries a ping (up to 5 attempts, 1s timeout
each) after the boot-event wait, so it only returns "connected"
when the firmware is actually responsive.
The previous commit only fixed Response.from_json(), but the serial
client's read loop uses parse_message() which constructs Response
directly. Apply the same string-to-dict normalisation there.
- Make get_client() sync (was async but did no async work). Callers
that omitted await silently got a coroutine object instead of the
SerialClient, causing "'coroutine' object has no attribute 'connect'"
errors on every tool call.
- Fix esp32_connect: use get_client_or_none() for init check and
client.event_queue.wait_for() for boot event (wait_event() didn't
exist on SerialClient).
- Normalise Response.data to dict at parse time — firmware returns
bare strings on some error paths, which broke .get() calls in tool
error handlers.
- Remove stale await from ble.py (9 calls) and classic.py (4 calls).
Tested with dual-MCP headless claude session: 26/27 PASS.