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

284 lines
7.8 KiB
Python

"""Python protocol layer mirroring the ESP32 firmware's protocol.h — NDJSON over UART."""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class MsgType(StrEnum):
CMD = "cmd"
RESP = "resp"
EVENT = "event"
class Status(StrEnum):
OK = "ok"
ERROR = "error"
class IOCapability(StrEnum):
DISPLAY_ONLY = "display_only"
DISPLAY_YESNO = "display_yesno"
KEYBOARD_ONLY = "keyboard_only"
NO_IO = "no_io"
KEYBOARD_DISPLAY = "keyboard_display"
class Transport(StrEnum):
CLASSIC = "classic"
BLE = "ble"
BOTH = "both"
# ---------------------------------------------------------------------------
# Command strings (mirror firmware #defines)
# ---------------------------------------------------------------------------
# System
CMD_PING = "ping"
CMD_RESET = "reset"
CMD_GET_INFO = "get_info"
CMD_GET_STATUS = "get_status"
# Configuration
CMD_CONFIGURE = "configure"
CMD_LOAD_PERSONA = "load_persona"
CMD_LIST_PERSONAS = "list_personas"
CMD_CLASSIC_SET_SSP_MODE = "classic_set_ssp_mode"
# Classic BT
CMD_CLASSIC_ENABLE = "classic_enable"
CMD_CLASSIC_DISABLE = "classic_disable"
CMD_CLASSIC_SET_DISCOVERABLE = "classic_set_discoverable"
CMD_CLASSIC_PAIR_RESPOND = "classic_pair_respond"
# SPP (Serial Port Profile)
CMD_SPP_SEND = "spp_send"
CMD_SPP_DISCONNECT = "spp_disconnect"
CMD_SPP_STATUS = "spp_status"
# HID (Human Interface Device)
CMD_HID_ENABLE = "hid_enable"
CMD_HID_DISABLE = "hid_disable"
CMD_HID_CONNECT = "hid_connect"
CMD_HID_DISCONNECT = "hid_disconnect"
CMD_HID_SEND_KEYBOARD = "hid_send_keyboard"
CMD_HID_SEND_MOUSE = "hid_send_mouse"
CMD_HID_STATUS = "hid_status"
# HFP (Hands-Free Profile)
CMD_HFP_ENABLE = "hfp_enable"
CMD_HFP_DISABLE = "hfp_disable"
CMD_HFP_CONNECT = "hfp_connect"
CMD_HFP_DISCONNECT = "hfp_disconnect"
CMD_HFP_AUDIO_CONNECT = "hfp_audio_connect"
CMD_HFP_AUDIO_DISCONNECT = "hfp_audio_disconnect"
CMD_HFP_ANSWER = "hfp_answer"
CMD_HFP_REJECT = "hfp_reject"
CMD_HFP_DIAL = "hfp_dial"
CMD_HFP_SEND_DTMF = "hfp_send_dtmf"
CMD_HFP_VOLUME = "hfp_volume"
CMD_HFP_VOICE_RECOGNITION_START = "hfp_voice_recognition_start"
CMD_HFP_VOICE_RECOGNITION_STOP = "hfp_voice_recognition_stop"
CMD_HFP_QUERY_CALLS = "hfp_query_calls"
CMD_HFP_STATUS = "hfp_status"
# BLE
CMD_BLE_ENABLE = "ble_enable"
CMD_BLE_DISABLE = "ble_disable"
CMD_BLE_ADVERTISE = "ble_advertise"
CMD_BLE_SET_ADV_DATA = "ble_set_adv_data"
# GATT
CMD_GATT_ADD_SERVICE = "gatt_add_service"
CMD_GATT_ADD_CHARACTERISTIC = "gatt_add_characteristic"
CMD_GATT_SET_VALUE = "gatt_set_value"
CMD_GATT_NOTIFY = "gatt_notify"
CMD_GATT_CLEAR = "gatt_clear"
# Events
EVT_BOOT = "boot"
EVT_PAIR_REQUEST = "pair_request"
EVT_PAIR_COMPLETE = "pair_complete"
EVT_CONNECT = "connect"
EVT_DISCONNECT = "disconnect"
EVT_GATT_READ = "gatt_read"
EVT_GATT_WRITE = "gatt_write"
EVT_GATT_SUBSCRIBE = "gatt_subscribe"
# SPP Events
EVT_SPP_DATA = "spp_data"
EVT_SPP_CONNECT = "spp_connect"
EVT_SPP_DISCONNECT = "spp_disconnect"
# HID Events
EVT_HID_CONNECT = "hid_connect"
EVT_HID_DISCONNECT = "hid_disconnect"
EVT_HID_DATA = "hid_data"
# HFP Events
EVT_HFP_CONNECT = "hfp_connect"
EVT_HFP_DISCONNECT = "hfp_disconnect"
EVT_HFP_AUDIO_CONNECT = "hfp_audio_connect"
EVT_HFP_AUDIO_DISCONNECT = "hfp_audio_disconnect"
EVT_HFP_RING = "hfp_ring"
EVT_HFP_CALL_STATUS = "hfp_call_status"
EVT_HFP_CALL_SETUP = "hfp_call_setup"
EVT_HFP_CLIP = "hfp_clip"
EVT_HFP_VOLUME = "hfp_volume"
EVT_HFP_VOLUME_CHANGE = "hfp_volume_change"
EVT_HFP_CALL_LIST = "hfp_call_list"
EVT_HFP_VOICE_RECOGNITION = "hfp_voice_recognition"
# ---------------------------------------------------------------------------
# Protocol constants
# ---------------------------------------------------------------------------
BAUD_RATE: int = 115200
MAX_LINE_LENGTH: int = 2048
# ---------------------------------------------------------------------------
# Monotonic ID generator (thread-safe)
# ---------------------------------------------------------------------------
_id_counter: int = 0
_id_lock = threading.Lock()
def _next_id() -> str:
"""Return a monotonically increasing string ID like '1', '2', ..."""
global _id_counter
with _id_lock:
_id_counter += 1
return str(_id_counter)
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
@dataclass(slots=True)
class Command:
type: MsgType
id: str
cmd: str
params: dict[str, Any] = field(default_factory=dict)
def to_json(self) -> str:
"""Serialize to a single NDJSON line (no trailing newline)."""
obj: dict[str, Any] = {"type": self.type, "id": self.id, "cmd": self.cmd}
if self.params:
obj["params"] = self.params
return json.dumps(obj, separators=(",", ":"))
@dataclass(slots=True)
class Response:
type: MsgType
id: str
status: Status
data: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_json(cls, line: str) -> Response:
"""Parse a JSON line known to be a response."""
obj = json.loads(line)
raw_data = obj.get("data", {})
# Firmware may return a bare string on some error paths — normalise to dict
if isinstance(raw_data, str):
raw_data = {"error": raw_data}
return cls(
type=MsgType(obj["type"]),
id=obj["id"],
status=Status(obj["status"]),
data=raw_data if isinstance(raw_data, dict) else {},
)
@dataclass(slots=True)
class Event:
type: MsgType
event: str
data: dict[str, Any] = field(default_factory=dict)
ts: int | None = None
@classmethod
def from_json(cls, line: str) -> Event:
"""Parse a JSON line known to be an event."""
obj = json.loads(line)
return cls(
type=MsgType(obj["type"]),
event=obj["event"],
data=obj.get("data", {}),
ts=obj.get("ts"),
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def build_command(
cmd: str, params: dict[str, Any] | None = None, cmd_id: str | None = None
) -> Command:
"""Build a Command with an auto-generated monotonic ID if none is provided."""
return Command(
type=MsgType.CMD,
id=cmd_id or _next_id(),
cmd=cmd,
params=params or {},
)
def parse_message(line: str) -> Command | Response | Event:
"""Parse any NDJSON line into the appropriate message type.
Raises ValueError if the line is not valid JSON or has an unknown message type.
"""
try:
obj = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"invalid JSON: {exc}") from exc
msg_type = obj.get("type")
if msg_type == MsgType.CMD:
return Command(
type=MsgType.CMD,
id=obj["id"],
cmd=obj["cmd"],
params=obj.get("params", {}),
)
if msg_type == MsgType.RESP:
raw_data = obj.get("data", {})
if isinstance(raw_data, str):
raw_data = {"error": raw_data}
return Response(
type=MsgType.RESP,
id=obj["id"],
status=Status(obj["status"]),
data=raw_data if isinstance(raw_data, dict) else {},
)
if msg_type == MsgType.EVENT:
return Event(
type=MsgType.EVENT,
event=obj["event"],
data=obj.get("data", {}),
ts=obj.get("ts"),
)
raise ValueError(f"unknown message type: {msg_type!r}")