Ryan Malloy ab699bbca3 Add HFP (Hands-Free Profile) support
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
2026-02-03 14:34:13 -07:00

357 lines
13 KiB
Python

"""HFP (Hands-Free Profile) tools for ESP32 MCP server.
Provides headset/hands-free functionality for call control and audio over Classic Bluetooth.
The ESP32 acts as a Hands-Free Unit (HF) that connects to phones/computers (Audio Gateways).
"""
from __future__ import annotations
from typing import Any, Literal
from fastmcp import FastMCP
from ..protocol import (
CMD_HFP_ENABLE,
CMD_HFP_DISABLE,
CMD_HFP_CONNECT,
CMD_HFP_DISCONNECT,
CMD_HFP_AUDIO_CONNECT,
CMD_HFP_AUDIO_DISCONNECT,
CMD_HFP_ANSWER,
CMD_HFP_REJECT,
CMD_HFP_DIAL,
CMD_HFP_SEND_DTMF,
CMD_HFP_VOLUME,
CMD_HFP_VOICE_RECOGNITION_START,
CMD_HFP_VOICE_RECOGNITION_STOP,
CMD_HFP_QUERY_CALLS,
CMD_HFP_STATUS,
Status,
)
from ..serial_client import get_client
def register_tools(mcp: FastMCP) -> None:
"""Register HFP tools with the MCP server."""
@mcp.tool()
async def esp32_hfp_enable() -> dict[str, Any]:
"""Enable HFP (Hands-Free Profile) on the ESP32.
Initializes the ESP32 as a Hands-Free Unit (headset role) that can
connect to phones/computers (Audio Gateways) for call control and audio.
Classic Bluetooth must be enabled first via esp32_classic_enable.
Supports:
- Wide Band Speech (mSBC codec) for HD voice
- Call control (answer, reject, dial, DTMF)
- Volume control (speaker and microphone)
- Voice recognition activation (Siri, Google Assistant)
Returns:
Response with enabled status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_ENABLE)
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_hfp_disable() -> dict[str, Any]:
"""Disable HFP on the ESP32.
Disconnects any active HFP connection and deinitializes the HFP stack.
Returns:
Response confirming HFP is disabled.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_DISABLE)
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_hfp_connect(address: str) -> dict[str, Any]:
"""Connect to an Audio Gateway (phone/computer).
Establishes an HFP Service Level Connection (SLC) to the specified
device. The device must support the Audio Gateway role (typically
phones and computers).
Args:
address: Bluetooth address of the AG (e.g., "AA:BB:CC:DD:EE:FF").
Returns:
Response with connection status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_CONNECT, {"address": address})
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_hfp_disconnect() -> dict[str, Any]:
"""Disconnect from the current Audio Gateway.
Terminates the HFP connection including any active audio link.
Returns:
Response confirming disconnection.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_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_hfp_audio_connect() -> dict[str, Any]:
"""Connect SCO audio channel.
Opens the synchronous audio link for voice transmission. Requires an
active SLC connection (established via esp32_hfp_connect).
Audio routing:
- With CONFIG_BT_HFP_AUDIO_DATA_PATH_HCI: Audio data flows through
the host controller interface (can be processed by ESP32).
- Without: Audio is routed directly through the Bluetooth chip.
Returns:
Response with audio connection status and codec info.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_AUDIO_CONNECT)
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_hfp_audio_disconnect() -> dict[str, Any]:
"""Disconnect SCO audio channel.
Closes the audio link but keeps the control connection (SLC) open.
Useful for temporarily muting the audio path without fully disconnecting.
Returns:
Response confirming audio disconnection.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_AUDIO_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_hfp_answer() -> dict[str, Any]:
"""Answer an incoming call.
Sends the ATA command to the Audio Gateway to accept an incoming call.
The call must be in the ringing state.
Returns:
Response with call answer status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_ANSWER)
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_hfp_reject() -> dict[str, Any]:
"""Reject an incoming call or hang up an active call.
Sends the AT+CHUP command to the Audio Gateway. Works for:
- Rejecting an incoming/waiting call
- Ending an active call
- Terminating an outgoing call (before it's answered)
Returns:
Response with call rejection/hangup status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_REJECT)
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_hfp_dial(number: str) -> dict[str, Any]:
"""Dial a phone number.
Initiates an outgoing call through the connected Audio Gateway.
Args:
number: Phone number to dial.
Returns:
Response with dial status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_DIAL, {"number": number})
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_hfp_send_dtmf(code: str) -> dict[str, Any]:
"""Send a DTMF tone during an active call.
Used for navigating phone menus, entering PIN codes, etc.
Args:
code: Single DTMF character. Valid values: 0-9, *, #, A-D.
Returns:
Response with DTMF send status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_SEND_DTMF, {"code": code})
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_hfp_volume(
type: Literal["speaker", "microphone"],
volume: int,
) -> dict[str, Any]:
"""Adjust speaker or microphone volume.
Sets the volume level and notifies the Audio Gateway of the change.
Args:
type: Volume type - "speaker" for output, "microphone" for input.
volume: Volume level 0-15 (0 = muted, 15 = maximum).
Returns:
Response with applied volume settings.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_VOLUME, {"type": type, "volume": volume})
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_hfp_voice_recognition_start() -> dict[str, Any]:
"""Start voice recognition (Siri/Google Assistant).
Requests the Audio Gateway to activate its voice recognition feature.
The AG must support this feature (most modern phones do).
Returns:
Response with voice recognition activation status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_VOICE_RECOGNITION_START)
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_hfp_voice_recognition_stop() -> dict[str, Any]:
"""Stop voice recognition.
Requests the Audio Gateway to deactivate voice recognition.
Returns:
Response with voice recognition deactivation status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_VOICE_RECOGNITION_STOP)
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_hfp_query_calls() -> dict[str, Any]:
"""Query current call list from the Audio Gateway.
Retrieves information about all current calls including:
- Call index and direction (incoming/outgoing)
- Call status (active, held, dialing, alerting, incoming, waiting)
- Phone number and type
Returns:
Response with list of current calls.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_QUERY_CALLS)
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_hfp_status() -> dict[str, Any]:
"""Get the current HFP status.
Returns comprehensive status including:
- enabled: Whether HFP stack is initialized
- connected: Whether SLC is established
- audio_connected: Whether SCO audio link is active
- remote_address: Connected AG address
- call_status: Current call state
- signal_strength: AG signal strength (0-5)
- battery_level: AG battery level (0-5)
- speaker_volume: Current speaker volume (0-15)
- microphone_volume: Current mic volume (0-15)
Returns:
Response with HFP status details.
"""
try:
client = get_client()
response = await client.send_command(CMD_HFP_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)}