"""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)}