diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 7181c13..d6462d6 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -6,6 +6,7 @@ idf_component_register( "bt_classic.c" "bt_ble.c" "bt_hid.c" + "bt_hfp.c" "personas.c" "event_reporter.c" INCLUDE_DIRS "." diff --git a/firmware/main/bt_hfp.c b/firmware/main/bt_hfp.c new file mode 100644 index 0000000..27d849f --- /dev/null +++ b/firmware/main/bt_hfp.c @@ -0,0 +1,739 @@ +/** + * @file bt_hfp.c + * @brief HFP (Hands-Free Profile) Client implementation. + * + * Implements the Hands-Free Unit role - the ESP32 acts as a Bluetooth headset + * that can connect to phones/computers for call control and audio. + */ + +#include "bt_hfp.h" +#include "protocol.h" +#include "uart_handler.h" +#include "event_reporter.h" + +#include "esp_bt.h" +#include "esp_bt_main.h" +#include "esp_bt_device.h" +#include "esp_gap_bt_api.h" +#include "esp_hf_client_api.h" +#include "esp_log.h" + +#include +#include + +static const char *TAG = "bt_hfp"; + +/* --------------------------------------------------------------------------- + * State + * --------------------------------------------------------------------------- */ + +static struct { + bool enabled; + bool connected; + bool audio_connected; + bool call_active; + uint8_t remote_addr[6]; + char remote_addr_str[18]; + char operator_name[32]; + int signal_strength; + int battery_level; + int speaker_volume; + int mic_volume; + esp_hf_client_connection_state_t conn_state; + esp_hf_client_audio_state_t audio_state; + esp_hf_call_status_t call_status; + esp_hf_call_setup_status_t call_setup_status; +} s_hfp = {0}; + +/* --------------------------------------------------------------------------- + * Helpers + * --------------------------------------------------------------------------- */ + +static void addr_to_str(const uint8_t *addr, char *str) +{ + snprintf(str, 18, "%02X:%02X:%02X:%02X:%02X:%02X", + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]); +} + +static bool str_to_addr(const char *str, uint8_t *addr) +{ + unsigned int tmp[6]; + if (sscanf(str, "%02X:%02X:%02X:%02X:%02X:%02X", + &tmp[0], &tmp[1], &tmp[2], &tmp[3], &tmp[4], &tmp[5]) != 6) { + return false; + } + for (int i = 0; i < 6; i++) { + addr[i] = (uint8_t)tmp[i]; + } + return true; +} + +/* --------------------------------------------------------------------------- + * HFP Client Callback + * --------------------------------------------------------------------------- */ + +static void hfp_client_cb(esp_hf_client_cb_event_t event, esp_hf_client_cb_param_t *param) +{ + cJSON *data; + + switch (event) { + case ESP_HF_CLIENT_CONNECTION_STATE_EVT: + s_hfp.conn_state = param->conn_stat.state; + memcpy(s_hfp.remote_addr, param->conn_stat.remote_bda, 6); + addr_to_str(s_hfp.remote_addr, s_hfp.remote_addr_str); + + switch (param->conn_stat.state) { + case ESP_HF_CLIENT_CONNECTION_STATE_CONNECTED: + ESP_LOGI(TAG, "HFP connected (RFCOMM) to %s", s_hfp.remote_addr_str); + break; + case ESP_HF_CLIENT_CONNECTION_STATE_SLC_CONNECTED: + ESP_LOGI(TAG, "HFP SLC connected to %s (peer_feat=0x%04lx)", + s_hfp.remote_addr_str, (unsigned long)param->conn_stat.peer_feat); + s_hfp.connected = true; + data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "address", s_hfp.remote_addr_str); + cJSON_AddNumberToObject(data, "peer_features", param->conn_stat.peer_feat); + event_report(EVT_HFP_CONNECT, data); + break; + case ESP_HF_CLIENT_CONNECTION_STATE_DISCONNECTED: + ESP_LOGI(TAG, "HFP disconnected from %s", s_hfp.remote_addr_str); + s_hfp.connected = false; + s_hfp.audio_connected = false; + data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "address", s_hfp.remote_addr_str); + event_report(EVT_HFP_DISCONNECT, data); + break; + default: + break; + } + break; + + case ESP_HF_CLIENT_AUDIO_STATE_EVT: + s_hfp.audio_state = param->audio_stat.state; + switch (param->audio_stat.state) { + case ESP_HF_CLIENT_AUDIO_STATE_CONNECTED: + ESP_LOGI(TAG, "HFP audio connected (CVSD)"); + s_hfp.audio_connected = true; + data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "codec", "cvsd"); + event_report(EVT_HFP_AUDIO_CONNECT, data); + break; + case ESP_HF_CLIENT_AUDIO_STATE_CONNECTED_MSBC: + ESP_LOGI(TAG, "HFP audio connected (mSBC)"); + s_hfp.audio_connected = true; + data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "codec", "msbc"); + event_report(EVT_HFP_AUDIO_CONNECT, data); + break; + case ESP_HF_CLIENT_AUDIO_STATE_DISCONNECTED: + ESP_LOGI(TAG, "HFP audio disconnected"); + s_hfp.audio_connected = false; + event_report(EVT_HFP_AUDIO_DISCONNECT, NULL); + break; + default: + break; + } + break; + + case ESP_HF_CLIENT_CIND_CALL_EVT: + s_hfp.call_status = param->call.status; + ESP_LOGI(TAG, "Call indicator: %d", param->call.status); + if (param->call.status == ESP_HF_CALL_STATUS_NO_CALLS) { + s_hfp.call_active = false; + } else { + s_hfp.call_active = true; + } + data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "call_status", param->call.status); + cJSON_AddBoolToObject(data, "active", s_hfp.call_active); + event_report(EVT_HFP_CALL_STATUS, data); + break; + + case ESP_HF_CLIENT_CIND_CALL_SETUP_EVT: + s_hfp.call_setup_status = param->call_setup.status; + ESP_LOGI(TAG, "Call setup: %d", param->call_setup.status); + data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "call_setup", param->call_setup.status); + event_report(EVT_HFP_CALL_SETUP, data); + break; + + case ESP_HF_CLIENT_RING_IND_EVT: + ESP_LOGI(TAG, "Incoming call - RING"); + event_report(EVT_HFP_RING, NULL); + break; + + case ESP_HF_CLIENT_CLIP_EVT: + ESP_LOGI(TAG, "Caller ID: %s", param->clip.number ? param->clip.number : "unknown"); + data = cJSON_CreateObject(); + if (param->clip.number) { + cJSON_AddStringToObject(data, "number", param->clip.number); + } + event_report(EVT_HFP_CLIP, data); + break; + + case ESP_HF_CLIENT_CIND_SIGNAL_STRENGTH_EVT: + s_hfp.signal_strength = param->signal_strength.value; + ESP_LOGD(TAG, "Signal strength: %d", param->signal_strength.value); + break; + + case ESP_HF_CLIENT_CIND_BATTERY_LEVEL_EVT: + s_hfp.battery_level = param->battery_level.value; + ESP_LOGD(TAG, "Battery level: %d", param->battery_level.value); + break; + + case ESP_HF_CLIENT_COPS_CURRENT_OPERATOR_EVT: + if (param->cops.name) { + strncpy(s_hfp.operator_name, param->cops.name, sizeof(s_hfp.operator_name) - 1); + ESP_LOGI(TAG, "Operator: %s", param->cops.name); + } + break; + + case ESP_HF_CLIENT_VOLUME_CONTROL_EVT: + if (param->volume_control.type == ESP_HF_VOLUME_CONTROL_TARGET_SPK) { + s_hfp.speaker_volume = param->volume_control.volume; + ESP_LOGI(TAG, "Speaker volume: %d", param->volume_control.volume); + } else { + s_hfp.mic_volume = param->volume_control.volume; + ESP_LOGI(TAG, "Mic volume: %d", param->volume_control.volume); + } + data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "type", + param->volume_control.type == ESP_HF_VOLUME_CONTROL_TARGET_SPK ? "speaker" : "microphone"); + cJSON_AddNumberToObject(data, "volume", param->volume_control.volume); + event_report(EVT_HFP_VOLUME, data); + break; + + case ESP_HF_CLIENT_BVRA_EVT: + ESP_LOGI(TAG, "Voice recognition: %s", + param->bvra.value == ESP_HF_VR_STATE_ENABLED ? "enabled" : "disabled"); + data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "active", param->bvra.value == ESP_HF_VR_STATE_ENABLED); + event_report(EVT_HFP_VOICE_RECOGNITION, data); + break; + + case ESP_HF_CLIENT_CLCC_EVT: + ESP_LOGI(TAG, "Call list entry: idx=%d dir=%d status=%d", + param->clcc.idx, param->clcc.dir, param->clcc.status); + data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "index", param->clcc.idx); + cJSON_AddNumberToObject(data, "direction", param->clcc.dir); + cJSON_AddNumberToObject(data, "status", param->clcc.status); + cJSON_AddNumberToObject(data, "mode", param->clcc.mpty); + if (param->clcc.number) { + cJSON_AddStringToObject(data, "number", param->clcc.number); + } + event_report(EVT_HFP_CALL_LIST, data); + break; + + case ESP_HF_CLIENT_AT_RESPONSE_EVT: + if (param->at_response.code != ESP_HF_AT_RESPONSE_CODE_OK) { + ESP_LOGW(TAG, "AT response: code=%d cme=%d", + param->at_response.code, param->at_response.cme); + } + break; + + default: + ESP_LOGD(TAG, "HFP event: %d", event); + break; + } +} + +/* --------------------------------------------------------------------------- + * Public API + * --------------------------------------------------------------------------- */ + +void bt_hfp_init(void) +{ + ESP_LOGI(TAG, "HFP module initialized"); +} + +bool bt_hfp_is_enabled(void) +{ + return s_hfp.enabled; +} + +bool bt_hfp_is_connected(void) +{ + return s_hfp.connected; +} + +bool bt_hfp_is_audio_connected(void) +{ + return s_hfp.audio_connected; +} + +/* --------------------------------------------------------------------------- + * Command Handlers + * --------------------------------------------------------------------------- */ + +void cmd_hfp_enable(const char *id, cJSON *params) +{ + (void)params; + + if (s_hfp.enabled) { + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "enabled", true); + cJSON_AddStringToObject(data, "note", "already enabled"); + uart_send_response(id, STATUS_OK, data); + return; + } + + esp_err_t err; + + /* Register callback */ + err = esp_hf_client_register_callback(hfp_client_cb); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + /* Initialize HFP client */ + err = esp_hf_client_init(); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + s_hfp.enabled = true; + s_hfp.speaker_volume = 10; + s_hfp.mic_volume = 10; + + ESP_LOGI(TAG, "HFP Client enabled"); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "enabled", true); + cJSON_AddStringToObject(data, "role", "hands_free_unit"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_disable(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.enabled) { + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "enabled", false); + cJSON_AddStringToObject(data, "note", "already disabled"); + uart_send_response(id, STATUS_OK, data); + return; + } + + /* Disconnect if connected */ + if (s_hfp.connected) { + esp_hf_client_disconnect(s_hfp.remote_addr); + } + + esp_err_t err = esp_hf_client_deinit(); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + s_hfp.enabled = false; + s_hfp.connected = false; + s_hfp.audio_connected = false; + + ESP_LOGI(TAG, "HFP Client disabled"); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "enabled", false); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_connect(const char *id, cJSON *params) +{ + if (!s_hfp.enabled) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "HFP not enabled"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *addr_json = cJSON_GetObjectItem(params, "address"); + if (!addr_json || !cJSON_IsString(addr_json)) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "address required"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + uint8_t addr[6]; + if (!str_to_addr(addr_json->valuestring, addr)) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "invalid address format"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + ESP_LOGI(TAG, "Connecting HFP to %s", addr_json->valuestring); + + esp_err_t err = esp_hf_client_connect(addr); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "status", "connecting"); + cJSON_AddStringToObject(data, "address", addr_json->valuestring); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_disconnect(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_err_t err = esp_hf_client_disconnect(s_hfp.remote_addr); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "status", "disconnecting"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_audio_connect(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + if (s_hfp.audio_connected) { + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "audio_connected", true); + cJSON_AddStringToObject(data, "note", "already connected"); + uart_send_response(id, STATUS_OK, data); + return; + } + + esp_err_t err = esp_hf_client_connect_audio(s_hfp.remote_addr); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "status", "connecting_audio"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_audio_disconnect(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.audio_connected) { + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "audio_connected", false); + cJSON_AddStringToObject(data, "note", "already disconnected"); + uart_send_response(id, STATUS_OK, data); + return; + } + + esp_err_t err = esp_hf_client_disconnect_audio(s_hfp.remote_addr); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "status", "disconnecting_audio"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_answer(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_err_t err = esp_hf_client_answer_call(); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "action", "answered"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_reject(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_err_t err = esp_hf_client_reject_call(); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "action", "rejected"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_dial(const char *id, cJSON *params) +{ + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *number_json = cJSON_GetObjectItem(params, "number"); + if (!number_json || !cJSON_IsString(number_json)) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "number required"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_err_t err = esp_hf_client_dial(number_json->valuestring); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "action", "dialing"); + cJSON_AddStringToObject(data, "number", number_json->valuestring); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_send_dtmf(const char *id, cJSON *params) +{ + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *code_json = cJSON_GetObjectItem(params, "code"); + if (!code_json || !cJSON_IsString(code_json) || strlen(code_json->valuestring) != 1) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "single character code required"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + char code = code_json->valuestring[0]; + esp_err_t err = esp_hf_client_send_dtmf(code); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + char code_str[2] = {code, '\0'}; + cJSON_AddStringToObject(data, "dtmf", code_str); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_volume(const char *id, cJSON *params) +{ + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *type_json = cJSON_GetObjectItem(params, "type"); + cJSON *volume_json = cJSON_GetObjectItem(params, "volume"); + + if (!type_json || !cJSON_IsString(type_json)) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "type required (speaker/microphone)"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + if (!volume_json || !cJSON_IsNumber(volume_json)) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "volume required (0-15)"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + int volume = (int)volume_json->valuedouble; + if (volume < 0 || volume > 15) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "volume must be 0-15"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_hf_volume_control_target_t target; + if (strcmp(type_json->valuestring, "speaker") == 0) { + target = ESP_HF_VOLUME_CONTROL_TARGET_SPK; + s_hfp.speaker_volume = volume; + } else if (strcmp(type_json->valuestring, "microphone") == 0) { + target = ESP_HF_VOLUME_CONTROL_TARGET_MIC; + s_hfp.mic_volume = volume; + } else { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "type must be speaker or microphone"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_err_t err = esp_hf_client_volume_update(target, volume); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "type", type_json->valuestring); + cJSON_AddNumberToObject(data, "volume", volume); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_voice_recognition_start(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_err_t err = esp_hf_client_start_voice_recognition(); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "action", "voice_recognition_started"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_voice_recognition_stop(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_err_t err = esp_hf_client_stop_voice_recognition(); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "action", "voice_recognition_stopped"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_query_calls(const char *id, cJSON *params) +{ + (void)params; + + if (!s_hfp.connected) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", "not connected"); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + esp_err_t err = esp_hf_client_query_current_calls(); + if (err != ESP_OK) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "error", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, e); + return; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "action", "querying_calls"); + cJSON_AddStringToObject(data, "note", "results via hfp_call_list events"); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_hfp_status(const char *id, cJSON *params) +{ + (void)params; + + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "enabled", s_hfp.enabled); + cJSON_AddBoolToObject(data, "connected", s_hfp.connected); + cJSON_AddBoolToObject(data, "audio_connected", s_hfp.audio_connected); + + if (s_hfp.connected) { + cJSON_AddStringToObject(data, "remote_address", s_hfp.remote_addr_str); + cJSON_AddBoolToObject(data, "call_active", s_hfp.call_active); + cJSON_AddNumberToObject(data, "signal_strength", s_hfp.signal_strength); + cJSON_AddNumberToObject(data, "battery_level", s_hfp.battery_level); + cJSON_AddNumberToObject(data, "speaker_volume", s_hfp.speaker_volume); + cJSON_AddNumberToObject(data, "mic_volume", s_hfp.mic_volume); + if (strlen(s_hfp.operator_name) > 0) { + cJSON_AddStringToObject(data, "operator", s_hfp.operator_name); + } + } + + uart_send_response(id, STATUS_OK, data); +} diff --git a/firmware/main/bt_hfp.h b/firmware/main/bt_hfp.h new file mode 100644 index 0000000..577b86e --- /dev/null +++ b/firmware/main/bt_hfp.h @@ -0,0 +1,122 @@ +/** + * @file bt_hfp.h + * @brief HFP (Hands-Free Profile) Client module for ESP32 test harness. + * + * Implements HFP Hands-Free Unit (HF) role - acts as a Bluetooth headset. + * Connects to phones/computers (Audio Gateways) for call control and audio. + */ + +#pragma once + +#include +#include +#include "cJSON.h" + +/** + * @brief Initialize HFP module (register callbacks, but don't init stack yet). + */ +void bt_hfp_init(void); + +/* Command handlers (dispatched from cmd_dispatcher) */ + +/** + * @brief Enable HFP Client (Hands-Free Unit). + * + * Initializes the HFP client stack and registers the device as a headset. + * Classic Bluetooth must be enabled first. + */ +void cmd_hfp_enable(const char *id, cJSON *params); + +/** + * @brief Disable HFP Client. + * + * Disconnects any active connection and deinitializes the HFP stack. + */ +void cmd_hfp_disable(const char *id, cJSON *params); + +/** + * @brief Connect to an Audio Gateway (phone/computer). + * + * Params: + * - address: Bluetooth address of the AG (required) + */ +void cmd_hfp_connect(const char *id, cJSON *params); + +/** + * @brief Disconnect from the current Audio Gateway. + */ +void cmd_hfp_disconnect(const char *id, cJSON *params); + +/** + * @brief Connect SCO audio channel. + * + * Opens the audio link for voice. Requires an active SLC connection. + */ +void cmd_hfp_audio_connect(const char *id, cJSON *params); + +/** + * @brief Disconnect SCO audio channel. + * + * Closes the audio link but keeps the control connection open. + */ +void cmd_hfp_audio_disconnect(const char *id, cJSON *params); + +/** + * @brief Answer an incoming call. + */ +void cmd_hfp_answer(const char *id, cJSON *params); + +/** + * @brief Reject an incoming call or hang up an active call. + */ +void cmd_hfp_reject(const char *id, cJSON *params); + +/** + * @brief Dial a phone number. + * + * Params: + * - number: Phone number to dial (required) + */ +void cmd_hfp_dial(const char *id, cJSON *params); + +/** + * @brief Send DTMF tone during an active call. + * + * Params: + * - code: Single DTMF character (0-9, *, #, A-D) + */ +void cmd_hfp_send_dtmf(const char *id, cJSON *params); + +/** + * @brief Adjust speaker or microphone volume. + * + * Params: + * - type: "speaker" or "microphone" + * - volume: 0-15 + */ +void cmd_hfp_volume(const char *id, cJSON *params); + +/** + * @brief Start voice recognition (Siri/Google Assistant). + */ +void cmd_hfp_voice_recognition_start(const char *id, cJSON *params); + +/** + * @brief Stop voice recognition. + */ +void cmd_hfp_voice_recognition_stop(const char *id, cJSON *params); + +/** + * @brief Query current call list from AG. + */ +void cmd_hfp_query_calls(const char *id, cJSON *params); + +/** + * @brief Get HFP status (enabled, connected, audio state, call info). + */ +void cmd_hfp_status(const char *id, cJSON *params); + +/* State query functions */ +bool bt_hfp_is_enabled(void); +bool bt_hfp_is_connected(void); +bool bt_hfp_is_audio_connected(void); diff --git a/firmware/main/cmd_dispatcher.c b/firmware/main/cmd_dispatcher.c index 4deb035..40f9e8e 100644 --- a/firmware/main/cmd_dispatcher.c +++ b/firmware/main/cmd_dispatcher.c @@ -4,6 +4,7 @@ #include "bt_classic.h" #include "bt_ble.h" #include "bt_hid.h" +#include "bt_hfp.h" #include "personas.h" #include "esp_system.h" @@ -171,6 +172,23 @@ static const cmd_entry_t cmd_table[] = { { CMD_HID_SEND_MOUSE, cmd_hid_send_mouse }, { CMD_HID_STATUS, cmd_hid_status }, + /* HFP */ + { CMD_HFP_ENABLE, cmd_hfp_enable }, + { CMD_HFP_DISABLE, cmd_hfp_disable }, + { CMD_HFP_CONNECT, cmd_hfp_connect }, + { CMD_HFP_DISCONNECT, cmd_hfp_disconnect }, + { CMD_HFP_AUDIO_CONNECT, cmd_hfp_audio_connect }, + { CMD_HFP_AUDIO_DISCONNECT, cmd_hfp_audio_disconnect }, + { CMD_HFP_ANSWER, cmd_hfp_answer }, + { CMD_HFP_REJECT, cmd_hfp_reject }, + { CMD_HFP_DIAL, cmd_hfp_dial }, + { CMD_HFP_SEND_DTMF, cmd_hfp_send_dtmf }, + { CMD_HFP_VOLUME, cmd_hfp_volume }, + { CMD_HFP_VOICE_RECOGNITION_START, cmd_hfp_voice_recognition_start }, + { CMD_HFP_VOICE_RECOGNITION_STOP, cmd_hfp_voice_recognition_stop }, + { CMD_HFP_QUERY_CALLS, cmd_hfp_query_calls }, + { CMD_HFP_STATUS, cmd_hfp_status }, + /* BLE */ { CMD_BLE_ENABLE, cmd_ble_enable }, { CMD_BLE_DISABLE, cmd_ble_disable }, diff --git a/firmware/main/protocol.h b/firmware/main/protocol.h index 86653de..caab386 100644 --- a/firmware/main/protocol.h +++ b/firmware/main/protocol.h @@ -50,6 +50,23 @@ #define CMD_HID_SEND_MOUSE "hid_send_mouse" #define CMD_HID_STATUS "hid_status" +/* HFP (Hands-Free Profile) commands */ +#define CMD_HFP_ENABLE "hfp_enable" +#define CMD_HFP_DISABLE "hfp_disable" +#define CMD_HFP_CONNECT "hfp_connect" +#define CMD_HFP_DISCONNECT "hfp_disconnect" +#define CMD_HFP_AUDIO_CONNECT "hfp_audio_connect" +#define CMD_HFP_AUDIO_DISCONNECT "hfp_audio_disconnect" +#define CMD_HFP_ANSWER "hfp_answer" +#define CMD_HFP_REJECT "hfp_reject" +#define CMD_HFP_DIAL "hfp_dial" +#define CMD_HFP_SEND_DTMF "hfp_send_dtmf" +#define CMD_HFP_VOLUME "hfp_volume" +#define CMD_HFP_VOICE_RECOGNITION_START "hfp_voice_recognition_start" +#define CMD_HFP_VOICE_RECOGNITION_STOP "hfp_voice_recognition_stop" +#define CMD_HFP_QUERY_CALLS "hfp_query_calls" +#define CMD_HFP_STATUS "hfp_status" + /* BLE commands */ #define CMD_BLE_ENABLE "ble_enable" #define CMD_BLE_DISABLE "ble_disable" @@ -83,6 +100,20 @@ #define EVT_HID_DISCONNECT "hid_disconnect" #define EVT_HID_DATA "hid_data" +/* HFP events */ +#define EVT_HFP_CONNECT "hfp_connect" +#define EVT_HFP_DISCONNECT "hfp_disconnect" +#define EVT_HFP_AUDIO_CONNECT "hfp_audio_connect" +#define EVT_HFP_AUDIO_DISCONNECT "hfp_audio_disconnect" +#define EVT_HFP_RING "hfp_ring" +#define EVT_HFP_CALL_STATUS "hfp_call_status" +#define EVT_HFP_CALL_SETUP "hfp_call_setup" +#define EVT_HFP_CLIP "hfp_clip" +#define EVT_HFP_VOLUME "hfp_volume" +#define EVT_HFP_VOLUME_CHANGE "hfp_volume_change" +#define EVT_HFP_CALL_LIST "hfp_call_list" +#define EVT_HFP_VOICE_RECOGNITION "hfp_voice_recognition" + /* SSP IO capabilities */ #define IO_CAP_DISPLAY_ONLY "display_only" #define IO_CAP_DISPLAY_YESNO "display_yesno" diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index b533614..82f0201 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -10,6 +10,13 @@ CONFIG_BT_HID_ENABLED=y CONFIG_BT_HID_DEVICE_ENABLED=y CONFIG_BT_A2DP_ENABLE=n +# HFP (Hands-Free Profile) - headset role +CONFIG_BT_HFP_ENABLE=y +CONFIG_BT_HFP_CLIENT_ENABLE=y +CONFIG_BT_HFP_AG_ENABLE=n +CONFIG_BT_HFP_AUDIO_DATA_PATH_HCI=y +CONFIG_BT_HFP_WBS_ENABLE=y + # GAP & GATTS CONFIG_BT_GATTS_ENABLE=y CONFIG_BT_GATTC_ENABLE=n diff --git a/src/mcbluetooth_esp32/protocol.py b/src/mcbluetooth_esp32/protocol.py index 2e784f2..e55ed8f 100644 --- a/src/mcbluetooth_esp32/protocol.py +++ b/src/mcbluetooth_esp32/protocol.py @@ -74,6 +74,23 @@ 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" @@ -107,6 +124,20 @@ 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 # --------------------------------------------------------------------------- diff --git a/src/mcbluetooth_esp32/server.py b/src/mcbluetooth_esp32/server.py index 187f783..2bb24c0 100644 --- a/src/mcbluetooth_esp32/server.py +++ b/src/mcbluetooth_esp32/server.py @@ -8,7 +8,7 @@ from fastmcp import FastMCP from .resources import register_resources from .serial_client import init_client -from .tools import ble, classic, configure, connection, events, hid, persona +from .tools import ble, classic, configure, connection, events, hfp, hid, persona mcp = FastMCP( name="mcbluetooth-esp32", @@ -28,6 +28,7 @@ Configure: esp32_configure, esp32_set_ssp_mode Classic BT: esp32_classic_enable, esp32_classic_set_discoverable, esp32_classic_pair_respond SPP: esp32_spp_send, esp32_spp_disconnect, esp32_spp_status HID: esp32_hid_enable, esp32_hid_send_keyboard, esp32_hid_send_mouse, esp32_hid_status +HFP: esp32_hfp_enable, esp32_hfp_connect, esp32_hfp_audio_connect, esp32_hfp_answer, esp32_hfp_dial, esp32_hfp_volume, esp32_hfp_status BLE: esp32_ble_advertise, esp32_gatt_add_service, esp32_gatt_add_characteristic, esp32_gatt_set_value, esp32_gatt_notify Personas: esp32_load_persona, esp32_list_personas Events: esp32_get_events, esp32_wait_event, esp32_clear_events @@ -46,6 +47,7 @@ connection.register_tools(mcp) configure.register_tools(mcp) classic.register_tools(mcp) hid.register_tools(mcp) +hfp.register_tools(mcp) ble.register_tools(mcp) persona.register_tools(mcp) events.register_tools(mcp) diff --git a/src/mcbluetooth_esp32/tools/hfp.py b/src/mcbluetooth_esp32/tools/hfp.py new file mode 100644 index 0000000..3c07704 --- /dev/null +++ b/src/mcbluetooth_esp32/tools/hfp.py @@ -0,0 +1,356 @@ +"""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)}