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
357 lines
13 KiB
Python
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)}
|