From 0d4b472e06fea1550446922cf395f23b90e323c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:09:53 +0000 Subject: [PATCH 1/8] Initial plan From a5102efead035d0df2b1c5bf4df05e2fa16b3162 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:11:30 +0000 Subject: [PATCH 2/8] Apply remaining changes Co-authored-by: gagarinlg <54738901+gagarinlg@users.noreply.github.com> --- src/CMakeLists.txt | 1 + src/at_command.c | 21 +++++++++ src/at_command.h | 7 +++ src/board.h | 2 + src/bt.c | 54 ++++++++++++++++++++--- src/bt.h | 8 ++++ src/bt_a2dp.c | 73 +++++++++++++++++++++++++++++++ src/bt_a2dp.h | 12 ++++++ src/terminal.c | 97 ++++++++++++++++++++++++++++++++++++++++++ test/test_at_command.c | 22 ++++++++++ 10 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 src/bt_a2dp.c create mode 100644 src/bt_a2dp.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7d56c22..be9264c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,6 +9,7 @@ idf_component_register( at_command.c bt.c bt_addr.c + bt_a2dp.c bt_client.c bt_server.c cb.c diff --git a/src/at_command.c b/src/at_command.c index a376988..375314b 100644 --- a/src/at_command.c +++ b/src/at_command.c @@ -59,6 +59,27 @@ at_command_parse_t at_command_parse(char *line) } else if (strncmp(p, "+HTRESET", 8) == 0) { result.command = AT_CMD_HTRESET; result.argument = ""; + } else if (strncmp(p, "+BLEON", 6) == 0) { + result.command = AT_CMD_BLEON; + result.argument = ""; + } else if (strncmp(p, "+BLEOFF", 7) == 0) { + result.command = AT_CMD_BLEOFF; + result.argument = ""; + } else if (strncmp(p, "+A2DPON", 7) == 0) { + result.command = AT_CMD_A2DPON; + result.argument = ""; + } else if (strncmp(p, "+A2DPOFF", 8) == 0) { + result.command = AT_CMD_A2DPOFF; + result.argument = ""; + } else if (strncmp(p, "+A2DPCON", 8) == 0) { + result.command = AT_CMD_A2DPCON; + result.argument = p + 8; + } else if (strncmp(p, "+BTOFF", 6) == 0) { + result.command = AT_CMD_BTOFF; + result.argument = ""; + } else if (strncmp(p, "+BTBOTH", 7) == 0) { + result.command = AT_CMD_BTBOTH; + result.argument = ""; } return result; diff --git a/src/at_command.h b/src/at_command.h index d5f7fca..0be15b5 100644 --- a/src/at_command.h +++ b/src/at_command.h @@ -25,6 +25,13 @@ typedef enum { AT_CMD_CLEAR, //< AT+CLEAR -> forget saved device AT_CMD_BAUD, //< AT+BAUD -> baud rate level AT_CMD_HTRESET, //< AT+HTRESET -> reset headtracker (project extension) + AT_CMD_BLEON, + AT_CMD_BLEOFF, + AT_CMD_A2DPON, + AT_CMD_A2DPOFF, + AT_CMD_A2DPCON, + AT_CMD_BTOFF, + AT_CMD_BTBOTH, } at_command_t; /** Result of parsing an AT line. */ diff --git a/src/board.h b/src/board.h index 0e002f7..52630c3 100644 --- a/src/board.h +++ b/src/board.h @@ -88,6 +88,8 @@ typedef enum { ROLE_BLE_PERIPHERAL, ROLE_BLE_CENTRAL, ROLE_ADVANCE, //< reserved for future modes + ROLE_BTEDR_AUDIO_SOURCE, + ROLE_BT_OFF, ROLE_COUNT } role_t; diff --git a/src/bt.c b/src/bt.c index 93221f3..4f6772d 100644 --- a/src/bt.c +++ b/src/bt.c @@ -8,9 +8,13 @@ #include #include "esp_bt.h" +#if !defined(USE_NIMBLE) && defined(CONFIG_BT_CLASSIC_ENABLED) +#include "esp_bt_device.h" +#endif #include "esp_err.h" #include "esp_log.h" #include "freertos/task.h" +#include "sdkconfig.h" #if defined(USE_NIMBLE) #include "nimble/nimble_port.h" @@ -31,6 +35,15 @@ esp_bd_addr_t rmtbtaddress; /** Current device name (mirrors the settings name, for the host). */ static char s_btname[MAX_BTNAME_LEN] = "BTWifiMod"; +bool bt_a2dp_supported(void) +{ +#if !defined(USE_NIMBLE) && defined(CONFIG_BT_CLASSIC_ENABLED) && defined(CONFIG_BT_A2DP_ENABLE) + return true; +#else + return false; +#endif +} + #if defined(USE_NIMBLE) static void bt_on_reset(int reason) @@ -71,6 +84,11 @@ void bt_disable(void) vTaskDelay(pdMS_TO_TICKS(BT_PAUSE_BEFORE_RESTART)); } +void bt_classic_init(void) +{ + ESP_LOGE(LOG_BT, "Classic Bluetooth is not supported by the NimBLE build"); +} + void bt_set_name(const char *name) { strncpy(s_btname, name, sizeof(s_btname) - 1); @@ -82,12 +100,13 @@ void bt_set_name(const char *name) #else // !USE_NIMBLE (Bluedroid) static bool s_memreleased = false; +static bool s_classic_mode = false; -void bt_init(void) +static void bt_start_stack(esp_bt_mode_t mode) { esp_err_t ret; - if (!s_memreleased) { + if (!s_memreleased && !bt_a2dp_supported()) { ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)); s_memreleased = true; } @@ -99,11 +118,12 @@ void bt_init(void) return; } - ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); + ret = esp_bt_controller_enable(mode); if (ret != ESP_OK) { ESP_LOGE(LOG_BT, "%s: controller enable failed: %s", __func__, esp_err_to_name(ret)); return; } + s_classic_mode = mode == ESP_BT_MODE_CLASSIC_BT; esp_bluedroid_config_t bluedroid_cfg = BT_BLUEDROID_INIT_CONFIG_DEFAULT(); ret = esp_bluedroid_init_with_cfg(&bluedroid_cfg); @@ -118,7 +138,22 @@ void bt_init(void) return; } - esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_DEFAULT, ESP_PWR_LVL_P9); + if (mode == ESP_BT_MODE_BLE) { + esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_DEFAULT, ESP_PWR_LVL_P9); + } +} + +void bt_init(void) +{ + ESP_LOGI(LOG_BT, "Starting BLE stack"); + bt_start_stack(ESP_BT_MODE_BLE); +} + +// Note: BLE and Classic BT are never run together, the stack is restarted when switching. +void bt_classic_init(void) +{ + ESP_LOGI(LOG_BT, "Starting Classic BT stack"); + bt_start_stack(ESP_BT_MODE_CLASSIC_BT); } void bt_disable(void) @@ -128,6 +163,7 @@ void bt_disable(void) esp_bluedroid_deinit(); esp_bt_controller_disable(); esp_bt_controller_deinit(); + s_classic_mode = false; ESP_LOGI(LOG_BT, "Pausing to shutdown"); vTaskDelay(pdMS_TO_TICKS(BT_PAUSE_BEFORE_RESTART)); @@ -137,7 +173,15 @@ void bt_set_name(const char *name) { strncpy(s_btname, name, sizeof(s_btname) - 1); s_btname[sizeof(s_btname) - 1] = '\0'; - esp_err_t ret = esp_ble_gap_set_device_name(s_btname); + esp_err_t ret; +#if defined(CONFIG_BT_CLASSIC_ENABLED) + if (s_classic_mode) { + ret = esp_bt_dev_set_device_name(s_btname); + } else +#endif + { + ret = esp_ble_gap_set_device_name(s_btname); + } if (ret != ESP_OK) { ESP_LOGW(LOG_BT, "set device name failed: %s", esp_err_to_name(ret)); } diff --git a/src/bt.h b/src/bt.h index c157a84..c401525 100644 --- a/src/bt.h +++ b/src/bt.h @@ -9,6 +9,8 @@ #pragma once +#include + #include "bt_addr.h" // `esp_bd_addr_t` is provided by ESP-IDF under Bluedroid; under NimBLE it is @@ -35,6 +37,12 @@ extern esp_bd_addr_t rmtbtaddress; /** Initialize the Bluetooth controller and host (BLE). */ void bt_init(void); +/** Initialize the Bluetooth controller and host for Classic Bluetooth. */ +void bt_classic_init(void); + +/** Return whether this build supports the Classic Bluetooth A2DP source. */ +bool bt_a2dp_supported(void); + /** Disable and release the Bluetooth stack. */ void bt_disable(void); diff --git a/src/bt_a2dp.c b/src/bt_a2dp.c new file mode 100644 index 0000000..55976fd --- /dev/null +++ b/src/bt_a2dp.c @@ -0,0 +1,73 @@ +#include "bt_a2dp.h" + +#include + +#include "bt.h" +#include "bt_addr.h" +#include "esp_log.h" +#include "sdkconfig.h" + +#define LOG_A2DP "A2DP" + +#if defined(CONFIG_BT_CLASSIC_ENABLED) && defined(CONFIG_BT_A2DP_ENABLE) + +#include "esp_a2dp_api.h" +#include "esp_bt_device.h" +#include "esp_gap_bt_api.h" + +static int32_t a2dp_data_cb(uint8_t *data, int32_t len) +{ + if (data == NULL || len <= 0) { + return 0; + } + memset(data, 0, (size_t)len); + return len; +} + +static void a2dp_event_cb(esp_a2d_cb_event_t event, esp_a2d_cb_param_t *param) +{ + if (event == ESP_A2D_CONNECTION_STATE_EVT) { + ESP_LOGI(LOG_A2DP, "Connection state %d", param->conn_stat.state); + } +} + +bool a2dp_init(const char *device_name) +{ + if (esp_bt_dev_set_device_name(device_name) != ESP_OK || + esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE) != ESP_OK || + esp_a2d_register_callback(a2dp_event_cb) != ESP_OK || + esp_a2d_source_register_data_cb(a2dp_data_cb) != ESP_OK || + esp_a2d_source_init() != ESP_OK) { + ESP_LOGE(LOG_A2DP, "A2DP source init failed"); + return false; + } + return true; +} + +void a2dp_deinit(void) +{ + esp_a2d_source_deinit(); +} + +void a2dp_connect(const char *addr) +{ + bt_addr_t address; + if (addr == NULL || strlen(addr) != BT_ADDR_STR_LEN - 1) { + return; + } + bt_addr_from_str(address, addr); + esp_a2d_source_connect(address); +} + +#else + +bool a2dp_init(const char *device_name) +{ + (void)device_name; + ESP_LOGE(LOG_A2DP, "A2DP not supported in this build (needs CONFIG_BT_CLASSIC_ENABLED/A2DP)"); + return false; +} +void a2dp_deinit(void) {} +void a2dp_connect(const char *addr) { (void)addr; } + +#endif diff --git a/src/bt_a2dp.h b/src/bt_a2dp.h new file mode 100644 index 0000000..b8eb381 --- /dev/null +++ b/src/bt_a2dp.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +/** Initialize the Classic Bluetooth A2DP source with the configured name. */ +bool a2dp_init(const char *device_name); + +/** Stop the A2DP source. */ +void a2dp_deinit(void); + +/** Connect to a remote device specified as 12 hexadecimal digits. */ +void a2dp_connect(const char *addr); diff --git a/src/terminal.c b/src/terminal.c index b21fd41..34f1b1c 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -23,6 +23,7 @@ #include "board.h" #include "bt.h" #include "bt_addr.h" +#include "bt_a2dp.h" #include "bt_client.h" #include "bt_server.h" #include "cb.h" @@ -66,6 +67,7 @@ typedef enum { static role_t curMode = ROLE_UNKNOWN; static btcentralstate btCentralState = CENTRAL_STATE_DISCONNECT; static btperipheralstate btPeripherialState = PERIPHERIAL_STATE_DISCONNECTED; +static role_t lastBleRole = ROLE_UNKNOWN; /** Current remote address (text) and the reusable response buffer. */ static char rmtaddress[BT_ADDR_STR_LEN] = "000000000000"; @@ -77,6 +79,22 @@ static int laddcnt = 0; static void runBT(void); static void setRole(role_t role); +static const char *role_name(role_t role) +{ + switch (role) { + case ROLE_BLE_PERIPHERAL: + return "BLE Peripheral"; + case ROLE_BLE_CENTRAL: + return "BLE Central"; + case ROLE_BTEDR_AUDIO_SOURCE: + return "A2DP Source"; + case ROLE_BT_OFF: + return "Bluetooth Off"; + default: + return "Unknown"; + } +} + /* ========================================================================== */ /* UART output helpers */ /* ========================================================================== */ @@ -115,6 +133,8 @@ static void sendBTMode(void) write_fmt("Peripheral:%s\r\n", lcladdress); } else if (curMode == ROLE_BLE_CENTRAL) { write_fmt("Central:%s\r\n", lcladdress); + } else { + write_fmt("Mode:%s\r\n", role_name(curMode)); } } @@ -256,6 +276,61 @@ static void parserATCommand(char *line) } break; + case AT_CMD_BLEON: { + role_t role = lastBleRole == ROLE_BLE_PERIPHERAL ? ROLE_BLE_PERIPHERAL : ROLE_BLE_CENTRAL; + write_str("OK+BLEON\r\n"); + setRole(role); + sendBTMode(); + break; + } + + case AT_CMD_BLEOFF: + write_str("OK+BLEOFF\r\n"); + if (curMode == ROLE_BLE_PERIPHERAL || curMode == ROLE_BLE_CENTRAL) { + setRole(ROLE_BT_OFF); + sendBTMode(); + } + break; + + case AT_CMD_A2DPON: + if (bt_a2dp_supported()) { + write_str("OK+A2DPON\r\n"); + setRole(ROLE_BTEDR_AUDIO_SOURCE); + sendBTMode(); + } else { + ESP_LOGE(LOG_UART, "A2DP not supported in this build"); + write_str("ERROR\r\n"); + } + break; + + case AT_CMD_A2DPOFF: + write_str("OK+A2DPOFF\r\n"); + if (curMode == ROLE_BTEDR_AUDIO_SOURCE) { + setRole(ROLE_BT_OFF); + sendBTMode(); + } + break; + + case AT_CMD_A2DPCON: + if (curMode == ROLE_BTEDR_AUDIO_SOURCE) { + write_str("OK+A2DPCON\r\n"); + a2dp_connect(cmd.argument); + } else { + write_str("ERROR\r\n"); + } + break; + + case AT_CMD_BTOFF: + write_str("OK+BTOFF\r\n"); + setRole(ROLE_BT_OFF); + sendBTMode(); + break; + + case AT_CMD_BTBOTH: + ESP_LOGE(LOG_UART, "BLE and A2DP cannot run simultaneously"); + write_str("ERROR\r\n"); + break; + case AT_CMD_UNKNOWN: default: ESP_LOGE(LOG_UART, "Unknown AT Cmd: %s", line); @@ -305,6 +380,10 @@ void runUARTHead(void *param) ESP_LOGE(LOG_UART, "Invalid role loaded, defaulting to central"); settings.role = ROLE_BLE_CENTRAL; } + if (settings.role == ROLE_BTEDR_AUDIO_SOURCE && !bt_a2dp_supported()) { + ESP_LOGE(LOG_UART, "A2DP not supported in this build, defaulting to central"); + settings.role = ROLE_BLE_CENTRAL; + } setRole(settings.role); uint8_t *data = (uint8_t *)malloc(UART_RX_BUFFER + 1); @@ -392,14 +471,22 @@ static void setRole(role_t role) switch (curMode) { case ROLE_BLE_CENTRAL: case ROLE_BLE_PERIPHERAL: + lastBleRole = curMode; + bt_disable(); + break; + case ROLE_BTEDR_AUDIO_SOURCE: + a2dp_deinit(); bt_disable(); break; default: break; } + atcommandlen = -1; + curMode = role; settings.role = curMode; + ESP_LOGI(LOG_UART, "Active Bluetooth mode: %s", role_name(curMode)); switch (curMode) { case ROLE_BLE_CENTRAL: @@ -412,6 +499,16 @@ static void setRole(role_t role) bt_init(); bt_server_init(); break; + case ROLE_BTEDR_AUDIO_SOURCE: + bt_classic_init(); + if (!a2dp_init(settings.name)) { + a2dp_deinit(); + bt_disable(); + curMode = ROLE_BT_OFF; + settings.role = curMode; + ESP_LOGE(LOG_UART, "A2DP start failed, Bluetooth is off"); + } + break; default: break; } diff --git a/test/test_at_command.c b/test/test_at_command.c index 6c1f1d6..eeda90e 100644 --- a/test/test_at_command.c +++ b/test/test_at_command.c @@ -75,6 +75,27 @@ void test_htreset(void) TEST_ASSERT_EQUAL(AT_CMD_HTRESET, r.command); } +void test_bluetooth_control_commands(void) +{ + char ble_on[] = "AT+BLEON\r\n"; + char ble_off[] = "AT+BLEOFF\r\n"; + char a2dp_on[] = "AT+A2DPON\r\n"; + char a2dp_off[] = "AT+A2DPOFF\r\n"; + char a2dp_connect[] = "AT+A2DPCONAABBCCDDEEFF\r\n"; + char bt_off[] = "AT+BTOFF\r\n"; + char bt_both[] = "AT+BTBOTH\r\n"; + + TEST_ASSERT_EQUAL(AT_CMD_BLEON, at_command_parse(ble_on).command); + TEST_ASSERT_EQUAL(AT_CMD_BLEOFF, at_command_parse(ble_off).command); + TEST_ASSERT_EQUAL(AT_CMD_A2DPON, at_command_parse(a2dp_on).command); + TEST_ASSERT_EQUAL(AT_CMD_A2DPOFF, at_command_parse(a2dp_off).command); + at_command_parse_t connect_result = at_command_parse(a2dp_connect); + TEST_ASSERT_EQUAL(AT_CMD_A2DPCON, connect_result.command); + TEST_ASSERT_EQUAL_STRING("AABBCCDDEEFF", connect_result.argument); + TEST_ASSERT_EQUAL(AT_CMD_BTOFF, at_command_parse(bt_off).command); + TEST_ASSERT_EQUAL(AT_CMD_BTBOTH, at_command_parse(bt_both).command); +} + void test_unknown(void) { char line[] = "AT+BOGUS\r\n"; @@ -114,6 +135,7 @@ void run_at_command_tests(void) RUN_TEST(test_clear); RUN_TEST(test_baud_argument); RUN_TEST(test_htreset); + RUN_TEST(test_bluetooth_control_commands); RUN_TEST(test_unknown); RUN_TEST(test_trailing_whitespace_stripped); RUN_TEST(test_null_input); From 140f09cbc3894abbce433709be9f857d792dd093 Mon Sep 17 00:00:00 2001 From: Malte Langermann Date: Thu, 15 Oct 2026 20:50:39 +0200 Subject: [PATCH 3/8] Add RM Pico A2DP audio test path --- platformio.ini | 10 ++- sdkconfig.RM_pico | 55 ++++++++++--- src/CMakeLists.txt | 1 + src/audio_resampler.c | 63 +++++++++++++++ src/audio_resampler.h | 39 ++++++++++ src/bt.c | 4 +- src/bt_a2dp.c | 150 +++++++++++++++++++++++++++++++++++- src/bt_a2dp.h | 9 +++ src/terminal.c | 79 ++++++++++++++----- test/test_audio_resampler.c | 55 +++++++++++++ test/test_main.c | 2 + 11 files changed, 432 insertions(+), 35 deletions(-) create mode 100644 src/audio_resampler.c create mode 100644 src/audio_resampler.h create mode 100644 test/test_audio_resampler.c diff --git a/platformio.ini b/platformio.ini index 1bba0bf..49b69d9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,7 +9,7 @@ [platformio] ; Primary development target. -default_envs = C3SuperMini +default_envs = RM_pico ; Common configuration for all ESP32 targets (ESP-IDF). ; espressif32@6.10.0 == ESP-IDF v5.4.0 (matches the repo's sdkconfig.* files). @@ -98,6 +98,14 @@ build_flags = -DCPU_ESP32=y -DBLUETOOTH=y +; A2DP test build: stream a local tone without radio PCM input. +[env:RM_pico_a2dp_test] +extends = env:RM_pico +board_build.esp-idf.sdkconfig_path = sdkconfig.RM_pico +build_flags = + ${env:RM_pico.build_flags} + -DA2DP_TEST_TONE + ; Same board with the NimBLE host instead of Bluedroid. [env:RM_pico_nimble] extends = esp32 diff --git a/sdkconfig.RM_pico b/sdkconfig.RM_pico index a475c88..f456b96 100644 --- a/sdkconfig.RM_pico +++ b/sdkconfig.RM_pico @@ -474,7 +474,25 @@ CONFIG_BT_BLUEDROID_PINNED_TO_CORE=0 CONFIG_BT_BTU_TASK_STACK_SIZE=4096 # CONFIG_BT_BLUEDROID_MEM_DEBUG is not set CONFIG_BT_BLUEDROID_ESP_COEX_VSC=y -# CONFIG_BT_CLASSIC_ENABLED is not set +CONFIG_BT_CLASSIC_ENABLED=y +CONFIG_BT_ENC_KEY_SIZE_CTRL_VSC=y +# CONFIG_BT_ENC_KEY_SIZE_CTRL_NONE is not set +# CONFIG_BT_CLASSIC_BQB_ENABLED is not set +CONFIG_BT_A2DP_ENABLE=y +CONFIG_BT_AVRCP_ENABLED=y + +# +# AVRCP Features +# +CONFIG_BT_AVRCP_CT_COVER_ART_ENABLED=y +# end of AVRCP Features + +# CONFIG_BT_SPP_ENABLED is not set +# CONFIG_BT_L2CAP_ENABLED is not set +# CONFIG_BT_SDP_COMMON_ENABLED is not set +# CONFIG_BT_HFP_ENABLE is not set +# CONFIG_BT_HID_ENABLED is not set +CONFIG_BT_GOEPC_ENABLED=y CONFIG_BT_BLE_ENABLED=y CONFIG_BT_GATTS_ENABLE=y CONFIG_BT_GATTS_PPCP_CHAR_GAP=y @@ -690,16 +708,31 @@ CONFIG_BT_BLE_RPA_TIMEOUT=900 # # Controller Options # -CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y +# CONFIG_BTDM_CTRL_MODE_BLE_ONLY is not set # CONFIG_BTDM_CTRL_MODE_BR_EDR_ONLY is not set -# CONFIG_BTDM_CTRL_MODE_BTDM is not set +CONFIG_BTDM_CTRL_MODE_BTDM=y CONFIG_BTDM_CTRL_BLE_MAX_CONN=3 -CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_EFF=0 +CONFIG_BTDM_CTRL_BR_EDR_MAX_ACL_CONN=2 +CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN=0 +# CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_HCI is not set +CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_PCM=y +CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_EFF=1 +CONFIG_BTDM_CTRL_PCM_ROLE_EDGE_CONFIG=y +CONFIG_BTDM_CTRL_PCM_ROLE_MASTER=y +# CONFIG_BTDM_CTRL_PCM_ROLE_SLAVE is not set +CONFIG_BTDM_CTRL_PCM_POLAR_FALLING_EDGE=y +# CONFIG_BTDM_CTRL_PCM_POLAR_RISING_EDGE is not set +CONFIG_BTDM_CTRL_PCM_FSYNCSHP_STEREO_MODE=y +# CONFIG_BTDM_CTRL_PCM_FSYNCSHP_MONO_MODE_LF is not set +# CONFIG_BTDM_CTRL_PCM_FSYNCSHP_MONO_MODE_FF is not set CONFIG_BTDM_CTRL_PCM_ROLE_EFF=0 CONFIG_BTDM_CTRL_PCM_POLAR_EFF=0 CONFIG_BTDM_CTRL_PCM_FSYNCSHP_EFF=0 +# CONFIG_BTDM_CTRL_AUTO_LATENCY is not set +CONFIG_BTDM_CTRL_LEGACY_AUTH_VENDOR_EVT=y +CONFIG_BTDM_CTRL_LEGACY_AUTH_VENDOR_EVT_EFF=y CONFIG_BTDM_CTRL_BLE_MAX_CONN_EFF=3 -CONFIG_BTDM_CTRL_BR_EDR_MAX_ACL_CONN_EFF=0 +CONFIG_BTDM_CTRL_BR_EDR_MAX_ACL_CONN_EFF=2 CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN_EFF=0 CONFIG_BTDM_CTRL_PINNED_TO_CORE_0=y # CONFIG_BTDM_CTRL_PINNED_TO_CORE_1 is not set @@ -2209,7 +2242,9 @@ CONFIG_BLUEDROID_PINNED_TO_CORE_0=y CONFIG_BLUEDROID_PINNED_TO_CORE=0 CONFIG_BTU_TASK_STACK_SIZE=4096 # CONFIG_BLUEDROID_MEM_DEBUG is not set -# CONFIG_CLASSIC_BT_ENABLED is not set +CONFIG_CLASSIC_BT_ENABLED=y +CONFIG_A2DP_ENABLE=y +# CONFIG_HFP_ENABLE is not set CONFIG_GATTS_ENABLE=y # CONFIG_GATTS_SEND_SERVICE_CHANGE_MANUAL is not set CONFIG_GATTS_SEND_SERVICE_CHANGE_AUTO=y @@ -2383,12 +2418,14 @@ CONFIG_BLUFI_INITIAL_TRACE_LEVEL=2 CONFIG_SMP_ENABLE=y # CONFIG_BLE_ACTIVE_SCAN_REPORT_ADV_SCAN_RSP_INDIVIDUALLY is not set CONFIG_BLE_ESTABLISH_LINK_CONNECTION_TIMEOUT=30 -CONFIG_BTDM_CONTROLLER_MODE_BLE_ONLY=y +# CONFIG_BTDM_CONTROLLER_MODE_BLE_ONLY is not set # CONFIG_BTDM_CONTROLLER_MODE_BR_EDR_ONLY is not set -# CONFIG_BTDM_CONTROLLER_MODE_BTDM is not set +CONFIG_BTDM_CONTROLLER_MODE_BTDM=y CONFIG_BTDM_CONTROLLER_BLE_MAX_CONN=3 +CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_ACL_CONN=2 +CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_SYNC_CONN=0 CONFIG_BTDM_CONTROLLER_BLE_MAX_CONN_EFF=3 -CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_ACL_CONN_EFF=0 +CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_ACL_CONN_EFF=2 CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_SYNC_CONN_EFF=0 CONFIG_BTDM_CONTROLLER_PINNED_TO_CORE=0 CONFIG_BTDM_CONTROLLER_HCI_MODE_VHCI=y diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index be9264c..c9db20a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( SRCS main.c at_command.c + audio_resampler.c bt.c bt_addr.c bt_a2dp.c diff --git a/src/audio_resampler.c b/src/audio_resampler.c new file mode 100644 index 0000000..e07da3f --- /dev/null +++ b/src/audio_resampler.c @@ -0,0 +1,63 @@ +#include "audio_resampler.h" + +void audio_resampler_reset(audio_resampler_t *resampler) +{ + if (resampler == NULL) { + return; + } + + resampler->phase = 0; + resampler->current_sample = 0; + resampler->next_sample = 0; + resampler->has_current_sample = false; + resampler->has_next_sample = false; +} + +size_t audio_resampler_process(audio_resampler_t *resampler, + audio_resampler_read_fn read_sample, + void *context, + int16_t *output, + size_t frames) +{ + if (resampler == NULL || read_sample == NULL || output == NULL) { + return 0; + } + + size_t produced = 0; + while (produced < frames) { + if (!resampler->has_current_sample) { + if (!read_sample(context, &resampler->current_sample)) { + break; + } + resampler->has_current_sample = true; + } + + if (!resampler->has_next_sample) { + if (!read_sample(context, &resampler->next_sample)) { + break; + } + resampler->has_next_sample = true; + } + + int32_t difference = (int32_t)resampler->next_sample - resampler->current_sample; + int64_t interpolated = resampler->current_sample + + ((int64_t)difference * resampler->phase) / + AUDIO_RESAMPLER_OUTPUT_RATE_HZ; + output[produced * 2] = (int16_t)interpolated; + output[produced * 2 + 1] = (int16_t)interpolated; + produced++; + + resampler->phase += AUDIO_RESAMPLER_INPUT_RATE_HZ; + while (resampler->phase >= AUDIO_RESAMPLER_OUTPUT_RATE_HZ) { + resampler->phase -= AUDIO_RESAMPLER_OUTPUT_RATE_HZ; + resampler->current_sample = resampler->next_sample; + resampler->has_next_sample = false; + if (!read_sample(context, &resampler->next_sample)) { + break; + } + resampler->has_next_sample = true; + } + } + + return produced; +} \ No newline at end of file diff --git a/src/audio_resampler.h b/src/audio_resampler.h new file mode 100644 index 0000000..f07e693 --- /dev/null +++ b/src/audio_resampler.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#define AUDIO_RESAMPLER_INPUT_RATE_HZ 16000U +#define AUDIO_RESAMPLER_OUTPUT_RATE_HZ 44100U + +/** Read one signed PCM sample from the input source. */ +typedef bool (*audio_resampler_read_fn)(void *context, int16_t *sample); + +/** Streaming state for mono-to-stereo PCM resampling. */ +typedef struct { + uint32_t phase; + int16_t current_sample; + int16_t next_sample; + bool has_current_sample; + bool has_next_sample; +} audio_resampler_t; + +/** Reset the interpolation state after an input underrun or stream restart. */ +void audio_resampler_reset(audio_resampler_t *resampler); + +/** + * Resample mono input into interleaved stereo output using linear interpolation. + * + * @param resampler Persistent streaming state. + * @param read_sample Callback that supplies the next 16 kHz input sample. + * @param context Opaque context passed to @p read_sample. + * @param output Interleaved left/right 16-bit output samples. + * @param frames Maximum number of stereo output frames. + * @return Number of output frames produced before the input ran dry. + */ +size_t audio_resampler_process(audio_resampler_t *resampler, + audio_resampler_read_fn read_sample, + void *context, + int16_t *output, + size_t frames); \ No newline at end of file diff --git a/src/bt.c b/src/bt.c index 4f6772d..97e8d1f 100644 --- a/src/bt.c +++ b/src/bt.c @@ -9,7 +9,7 @@ #include "esp_bt.h" #if !defined(USE_NIMBLE) && defined(CONFIG_BT_CLASSIC_ENABLED) -#include "esp_bt_device.h" +#include "esp_gap_bt_api.h" #endif #include "esp_err.h" #include "esp_log.h" @@ -176,7 +176,7 @@ void bt_set_name(const char *name) esp_err_t ret; #if defined(CONFIG_BT_CLASSIC_ENABLED) if (s_classic_mode) { - ret = esp_bt_dev_set_device_name(s_btname); + ret = esp_bt_gap_set_device_name(s_btname); } else #endif { diff --git a/src/bt_a2dp.c b/src/bt_a2dp.c index 55976fd..e9ab358 100644 --- a/src/bt_a2dp.c +++ b/src/bt_a2dp.c @@ -2,25 +2,100 @@ #include +#include "audio_resampler.h" #include "bt.h" #include "bt_addr.h" #include "esp_log.h" #include "sdkconfig.h" +#include "freertos/FreeRTOS.h" +#include "freertos/stream_buffer.h" #define LOG_A2DP "A2DP" #if defined(CONFIG_BT_CLASSIC_ENABLED) && defined(CONFIG_BT_A2DP_ENABLE) #include "esp_a2dp_api.h" -#include "esp_bt_device.h" #include "esp_gap_bt_api.h" +#define A2DP_PCM_BUFFER_SIZE 8192 +#define A2DP_PCM_FEED_CHUNK_SIZE 512 + +#if !defined(A2DP_TEST_TONE) +static StreamBufferHandle_t s_pcm_buffer; +static bool s_pending_input_byte_valid; +static uint8_t s_pending_input_byte; +static bool s_pending_output_byte_valid; +static uint8_t s_pending_output_byte; +#endif +static audio_resampler_t s_resampler; + +#if defined(A2DP_TEST_TONE) +static const int16_t s_test_tone[] = { + 0, 6393, 12539, 18204, 23170, 27245, 30273, 32137, + 32767, 32137, 30273, 27245, 23170, 18204, 12539, 6393, + 0, -6393, -12539, -18204, -23170, -27245, -30273, -32137, + -32767, -32137, -30273, -27245, -23170, -18204, -12539, -6393, +}; +static size_t s_test_tone_index; +#endif + +static bool a2dp_read_pcm_sample(void *context, int16_t *sample) +{ + (void)context; +#if defined(A2DP_TEST_TONE) + *sample = s_test_tone[s_test_tone_index]; + s_test_tone_index = (s_test_tone_index + 1) % (sizeof(s_test_tone) / sizeof(s_test_tone[0])); + return true; +#else + if (s_pcm_buffer == NULL) { + return false; + } + + uint8_t bytes[2]; + size_t received; + if (s_pending_output_byte_valid) { + received = xStreamBufferReceive(s_pcm_buffer, &bytes[1], 1, 0); + if (received != 1) { + return false; + } + bytes[0] = s_pending_output_byte; + s_pending_output_byte_valid = false; + } else { + received = xStreamBufferReceive(s_pcm_buffer, bytes, sizeof(bytes), 0); + if (received == 0) { + return false; + } + if (received == 1) { + s_pending_output_byte = bytes[0]; + s_pending_output_byte_valid = true; + return false; + } + } + + *sample = (int16_t)((uint16_t)bytes[0] | ((uint16_t)bytes[1] << 8)); + return true; +#endif +} + static int32_t a2dp_data_cb(uint8_t *data, int32_t len) { if (data == NULL || len <= 0) { return 0; } - memset(data, 0, (size_t)len); + + size_t byte_count = (size_t)len; + size_t output_frames = byte_count / (2 * sizeof(int16_t)); + size_t produced = audio_resampler_process(&s_resampler, a2dp_read_pcm_sample, NULL, + (int16_t *)data, output_frames); + if (produced < output_frames) { + size_t produced_bytes = produced * 2 * sizeof(int16_t); + memset(data + produced_bytes, 0, byte_count - produced_bytes); + audio_resampler_reset(&s_resampler); + } + size_t trailing_bytes = byte_count % (2 * sizeof(int16_t)); + if (trailing_bytes > 0) { + memset(data + byte_count - trailing_bytes, 0, trailing_bytes); + } return len; } @@ -33,10 +108,27 @@ static void a2dp_event_cb(esp_a2d_cb_event_t event, esp_a2d_cb_param_t *param) bool a2dp_init(const char *device_name) { - if (esp_bt_dev_set_device_name(device_name) != ESP_OK || +#if !defined(A2DP_TEST_TONE) + if (s_pcm_buffer == NULL) { + s_pcm_buffer = xStreamBufferCreate(A2DP_PCM_BUFFER_SIZE, 1); + if (s_pcm_buffer == NULL) { + ESP_LOGE(LOG_A2DP, "Failed to allocate PCM stream buffer"); + return false; + } + } + xStreamBufferReset(s_pcm_buffer); + s_pending_input_byte_valid = false; + s_pending_output_byte_valid = false; +#endif + audio_resampler_reset(&s_resampler); +#if defined(A2DP_TEST_TONE) + s_test_tone_index = 0; +#endif + + if (esp_bt_gap_set_device_name(device_name) != ESP_OK || esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE) != ESP_OK || esp_a2d_register_callback(a2dp_event_cb) != ESP_OK || - esp_a2d_source_register_data_cb(a2dp_data_cb) != ESP_OK || + esp_a2d_source_register_data_callback(a2dp_data_cb) != ESP_OK || esp_a2d_source_init() != ESP_OK) { ESP_LOGE(LOG_A2DP, "A2DP source init failed"); return false; @@ -59,6 +151,51 @@ void a2dp_connect(const char *addr) esp_a2d_source_connect(address); } +void a2dp_feed_pcm(const uint8_t *data, size_t length) +{ +#if !defined(A2DP_TEST_TONE) + if (data == NULL || s_pcm_buffer == NULL) { + return; + } + + uint8_t aligned[A2DP_PCM_FEED_CHUNK_SIZE + 2]; + while (length > 0) { + size_t chunk_size = length < A2DP_PCM_FEED_CHUNK_SIZE ? length : A2DP_PCM_FEED_CHUNK_SIZE; + size_t input_index = 0; + size_t aligned_size = 0; + + if (s_pending_input_byte_valid && chunk_size > 0) { + aligned[aligned_size++] = s_pending_input_byte; + aligned[aligned_size++] = data[input_index++]; + s_pending_input_byte_valid = false; + } + + size_t remaining = chunk_size - input_index; + size_t complete_bytes = remaining & ~(size_t)1; + if (complete_bytes > 0) { + memcpy(aligned + aligned_size, data + input_index, complete_bytes); + aligned_size += complete_bytes; + input_index += complete_bytes; + } + + if (input_index < chunk_size) { + s_pending_input_byte = data[input_index]; + s_pending_input_byte_valid = true; + } + + if (aligned_size > 0 && xStreamBufferSpacesAvailable(s_pcm_buffer) >= aligned_size) { + xStreamBufferSend(s_pcm_buffer, aligned, aligned_size, 0); + } + + data += chunk_size; + length -= chunk_size; + } +#else + (void)data; + (void)length; +#endif +} + #else bool a2dp_init(const char *device_name) @@ -69,5 +206,10 @@ bool a2dp_init(const char *device_name) } void a2dp_deinit(void) {} void a2dp_connect(const char *addr) { (void)addr; } +void a2dp_feed_pcm(const uint8_t *data, size_t length) +{ + (void)data; + (void)length; +} #endif diff --git a/src/bt_a2dp.h b/src/bt_a2dp.h index b8eb381..4cd160a 100644 --- a/src/bt_a2dp.h +++ b/src/bt_a2dp.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include /** Initialize the Classic Bluetooth A2DP source with the configured name. */ bool a2dp_init(const char *device_name); @@ -10,3 +12,10 @@ void a2dp_deinit(void); /** Connect to a remote device specified as 12 hexadecimal digits. */ void a2dp_connect(const char *addr); + +/** + * Queue raw signed 16-bit little-endian mono PCM at 16 kHz from the radio UART. + * The A2DP callback resamples it to ESP-IDF's 44.1 kHz stereo PCM format. + * Define A2DP_TEST_TONE at build time to substitute a local 500 Hz test signal. + */ +void a2dp_feed_pcm(const uint8_t *data, size_t length); diff --git a/src/terminal.c b/src/terminal.c index 34f1b1c..57ce2b8 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -119,6 +119,27 @@ static void write_fmt(const char *fmt, ...) uart_write_bytes(uart_num, reusablebuff, (size_t)len); } +static void forward_radio_byte(uint8_t byte, + uint8_t *pass_buffer, + size_t *pass_length, + uint8_t *pcm_buffer, + size_t *pcm_length) +{ + if (curMode == ROLE_BLE_PERIPHERAL) { + pass_buffer[(*pass_length)++] = byte; + if (*pass_length >= PASSTHROUGH_BUF) { + bt_server_send_data(pass_buffer, (uint8_t)*pass_length); + *pass_length = 0; + } + } else if (curMode == ROLE_BTEDR_AUDIO_SOURCE) { + pcm_buffer[(*pcm_length)++] = byte; + if (*pcm_length >= UART_RX_BUFFER) { + a2dp_feed_pcm(pcm_buffer, *pcm_length); + *pcm_length = 0; + } + } +} + /* ========================================================================== */ /* EdgeTX announcements */ /* ========================================================================== */ @@ -396,7 +417,11 @@ void runUARTHead(void *param) // Peripheral passthrough buffer (<= minimum ATT MTU payload). static uint8_t passBuf[PASSTHROUGH_BUF]; - static uint8_t passLen = 0; + static size_t passLen = 0; + static uint8_t pcmBuf[UART_RX_BUFFER]; + static size_t pcmLen = 0; + static uint8_t atPrefix[2]; + static size_t atPrefixLen = 0; for (;;) { int cnt = uart_read_bytes(uart_num, data, UART_RX_BUFFER, 0); @@ -418,32 +443,48 @@ void runUARTHead(void *param) parserATCommand(atcommand); atcommandlen = -1; } - } else { - // Detect the "AT+" prefix (EdgeTX always sends "AT+..."). Requiring the - // '+' avoids false positives from 'A','T' sequences in binary traffic. - static char lc1 = 0; - static char lc2 = 0; - if (lc2 == 'A' && lc1 == 'T' && c == '+') { - atcommandlen = 0; - atcommand[atcommandlen++] = '+'; - } else if (curMode == ROLE_BLE_PERIPHERAL) { - // Forward the radio's telemetry/trainer traffic unchanged. - passBuf[passLen++] = c; - if (passLen >= sizeof(passBuf)) { - bt_server_send_data(passBuf, passLen); - passLen = 0; + } else if (atPrefixLen == 0) { + if (c == 'A') { + atPrefix[atPrefixLen++] = c; + } else { + forward_radio_byte(c, passBuf, &passLen, pcmBuf, &pcmLen); + } + } else if (atPrefixLen == 1) { + if (c == 'T') { + atPrefix[atPrefixLen++] = c; + } else { + forward_radio_byte(atPrefix[0], passBuf, &passLen, pcmBuf, &pcmLen); + atPrefixLen = 0; + if (c == 'A') { + atPrefix[atPrefixLen++] = c; + } else { + forward_radio_byte(c, passBuf, &passLen, pcmBuf, &pcmLen); } } - // In central/master the radio only sends AT commands; discard the rest. - lc2 = lc1; - lc1 = (char)c; + } else if (c == '+') { + atPrefixLen = 0; + atcommandlen = 0; + atcommand[atcommandlen++] = '+'; + } else { + forward_radio_byte(atPrefix[0], passBuf, &passLen, pcmBuf, &pcmLen); + forward_radio_byte(atPrefix[1], passBuf, &passLen, pcmBuf, &pcmLen); + atPrefixLen = 0; + if (c == 'A') { + atPrefix[atPrefixLen++] = c; + } else { + forward_radio_byte(c, passBuf, &passLen, pcmBuf, &pcmLen); + } } } if (passLen > 0) { - bt_server_send_data(passBuf, passLen); + bt_server_send_data(passBuf, (uint8_t)passLen); passLen = 0; } + if (pcmLen > 0) { + a2dp_feed_pcm(pcmBuf, pcmLen); + pcmLen = 0; + } settings_process(); runBT(); diff --git a/test/test_audio_resampler.c b/test/test_audio_resampler.c new file mode 100644 index 0000000..142c07a --- /dev/null +++ b/test/test_audio_resampler.c @@ -0,0 +1,55 @@ +#include + +#include "../src/audio_resampler.c" + +typedef struct { + const int16_t *samples; + size_t count; + size_t index; +} sample_source_t; + +static bool read_sample(void *context, int16_t *sample) +{ + sample_source_t *source = context; + if (source->index >= source->count) { + return false; + } + *sample = source->samples[source->index++]; + return true; +} + +void test_audio_resampler_interpolates_and_duplicates_mono(void) +{ + const int16_t input[] = {-1000, 1000, 3000, 5000}; + sample_source_t source = {.samples = input, .count = 4, .index = 0}; + audio_resampler_t resampler; + int16_t output[4] = {0}; + audio_resampler_reset(&resampler); + + size_t frames = audio_resampler_process(&resampler, read_sample, &source, output, 2); + + TEST_ASSERT_EQUAL_size_t(2, frames); + TEST_ASSERT_EQUAL_INT16(-1000, output[0]); + TEST_ASSERT_EQUAL_INT16(-1000, output[1]); + TEST_ASSERT_EQUAL_INT16(-275, output[2]); + TEST_ASSERT_EQUAL_INT16(-275, output[3]); +} + +void test_audio_resampler_stops_when_input_is_empty(void) +{ + const int16_t input[] = {100, 200}; + sample_source_t source = {.samples = input, .count = 2, .index = 0}; + audio_resampler_t resampler; + int16_t output[16] = {0}; + audio_resampler_reset(&resampler); + + size_t frames = audio_resampler_process(&resampler, read_sample, &source, output, 8); + + TEST_ASSERT_EQUAL_size_t(3, frames); +} + +void run_audio_resampler_tests(void) +{ + RUN_TEST(test_audio_resampler_interpolates_and_duplicates_mono); + RUN_TEST(test_audio_resampler_stops_when_input_is_empty); +} \ No newline at end of file diff --git a/test/test_main.c b/test/test_main.c index 03bd4a5..70a183d 100644 --- a/test/test_main.c +++ b/test/test_main.c @@ -12,6 +12,7 @@ extern void run_cbuf_tests(void); extern void run_at_command_tests(void); extern void run_bt_addr_tests(void); +extern void run_audio_resampler_tests(void); int main(void) { @@ -19,5 +20,6 @@ int main(void) run_cbuf_tests(); run_at_command_tests(); run_bt_addr_tests(); + run_audio_resampler_tests(); return UNITY_END(); } From 19636d89922d186bd1a38e685580429a428e7d33 Mon Sep 17 00:00:00 2001 From: Malte Langermann Date: Thu, 15 Oct 2026 23:09:03 +0200 Subject: [PATCH 4/8] Add Classic A2DP scan and media startup --- sdkconfig.RM_pico | 24 ++--- src/bt.c | 3 +- src/bt_a2dp.c | 219 +++++++++++++++++++++++++++++++++++++++++++++- src/bt_a2dp.h | 17 ++++ src/terminal.c | 24 +++++ 5 files changed, 273 insertions(+), 14 deletions(-) diff --git a/sdkconfig.RM_pico b/sdkconfig.RM_pico index f456b96..cb6c54d 100644 --- a/sdkconfig.RM_pico +++ b/sdkconfig.RM_pico @@ -447,7 +447,7 @@ CONFIG_COMPILER_ORPHAN_SECTIONS_WARNING=y # # CONFIG_APPTRACE_DEST_JTAG is not set CONFIG_APPTRACE_DEST_NONE=y -# CONFIG_APPTRACE_DEST_UART0 is not set +# CONFIG_APPTRACE_DEST_UART1 is not set # CONFIG_APPTRACE_DEST_UART2 is not set CONFIG_APPTRACE_DEST_UART_NONE=y CONFIG_APPTRACE_UART_TASK_PRIO=1 @@ -1304,12 +1304,12 @@ CONFIG_ESP_MINIMAL_SHARED_STACK_SIZE=2048 CONFIG_ESP_CONSOLE_UART_CUSTOM=y # CONFIG_ESP_CONSOLE_NONE is not set CONFIG_ESP_CONSOLE_UART=y -# CONFIG_ESP_CONSOLE_UART_CUSTOM_NUM_0 is not set -CONFIG_ESP_CONSOLE_UART_CUSTOM_NUM_1=y -CONFIG_ESP_CONSOLE_UART_NUM=1 -CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=1 -CONFIG_ESP_CONSOLE_UART_TX_GPIO=20 -CONFIG_ESP_CONSOLE_UART_RX_GPIO=19 +CONFIG_ESP_CONSOLE_UART_CUSTOM_NUM_0=y +# CONFIG_ESP_CONSOLE_UART_CUSTOM_NUM_1 is not set +CONFIG_ESP_CONSOLE_UART_NUM=0 +CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=0 +CONFIG_ESP_CONSOLE_UART_TX_GPIO=1 +CONFIG_ESP_CONSOLE_UART_RX_GPIO=3 CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 CONFIG_ESP_INT_WDT=y CONFIG_ESP_INT_WDT_TIMEOUT_MS=300 @@ -2491,11 +2491,11 @@ CONFIG_CONSOLE_UART_CUSTOM=y # CONFIG_CONSOLE_UART_NONE is not set # CONFIG_ESP_CONSOLE_UART_NONE is not set CONFIG_CONSOLE_UART=y -# CONFIG_CONSOLE_UART_CUSTOM_NUM_0 is not set -CONFIG_CONSOLE_UART_CUSTOM_NUM_1=y -CONFIG_CONSOLE_UART_NUM=1 -CONFIG_CONSOLE_UART_TX_GPIO=20 -CONFIG_CONSOLE_UART_RX_GPIO=19 +CONFIG_CONSOLE_UART_CUSTOM_NUM_0=y +# CONFIG_CONSOLE_UART_CUSTOM_NUM_1 is not set +CONFIG_CONSOLE_UART_NUM=0 +CONFIG_CONSOLE_UART_TX_GPIO=1 +CONFIG_CONSOLE_UART_RX_GPIO=3 CONFIG_CONSOLE_UART_BAUDRATE=115200 CONFIG_INT_WDT=y CONFIG_INT_WDT_TIMEOUT_MS=300 diff --git a/src/bt.c b/src/bt.c index 97e8d1f..53d62fa 100644 --- a/src/bt.c +++ b/src/bt.c @@ -105,6 +105,7 @@ static bool s_classic_mode = false; static void bt_start_stack(esp_bt_mode_t mode) { esp_err_t ret; + esp_bt_mode_t controller_mode = bt_a2dp_supported() ? ESP_BT_MODE_BTDM : mode; if (!s_memreleased && !bt_a2dp_supported()) { ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)); @@ -118,7 +119,7 @@ static void bt_start_stack(esp_bt_mode_t mode) return; } - ret = esp_bt_controller_enable(mode); + ret = esp_bt_controller_enable(controller_mode); if (ret != ESP_OK) { ESP_LOGE(LOG_BT, "%s: controller enable failed: %s", __func__, esp_err_to_name(ret)); return; diff --git a/src/bt_a2dp.c b/src/bt_a2dp.c index e9ab358..f6f09ac 100644 --- a/src/bt_a2dp.c +++ b/src/bt_a2dp.c @@ -8,6 +8,7 @@ #include "esp_log.h" #include "sdkconfig.h" #include "freertos/FreeRTOS.h" +#include "freertos/queue.h" #include "freertos/stream_buffer.h" #define LOG_A2DP "A2DP" @@ -19,6 +20,30 @@ #define A2DP_PCM_BUFFER_SIZE 8192 #define A2DP_PCM_FEED_CHUNK_SIZE 512 +#define A2DP_DISCOVERY_QUEUE_LENGTH 20 +#define A2DP_DISCOVERY_MAX_DEVICES 20 +#define A2DP_MEDIA_EVENT_QUEUE_LENGTH 8 + +typedef enum { + A2DP_MEDIA_EVENT_CONNECTED, + A2DP_MEDIA_EVENT_DISCONNECTED, + A2DP_MEDIA_EVENT_CONTROL_ACK, +} a2dp_media_event_type_t; + +typedef struct { + a2dp_media_event_type_t type; + esp_a2d_media_ctrl_t command; + esp_a2d_media_ctrl_ack_t status; +} a2dp_media_event_t; + +typedef enum { + A2DP_MEDIA_IDLE, + A2DP_MEDIA_CHECK_PENDING, + A2DP_MEDIA_CHECK_WAITING, + A2DP_MEDIA_START_PENDING, + A2DP_MEDIA_START_WAITING, + A2DP_MEDIA_STREAMING, +} a2dp_media_state_t; #if !defined(A2DP_TEST_TONE) static StreamBufferHandle_t s_pcm_buffer; @@ -27,6 +52,13 @@ static uint8_t s_pending_input_byte; static bool s_pending_output_byte_valid; static uint8_t s_pending_output_byte; #endif +static QueueHandle_t s_discovery_queue; +static QueueHandle_t s_media_event_queue; +static volatile bool s_discovery_active; +static volatile bool s_discovery_complete = true; +static esp_bd_addr_t s_seen_devices[A2DP_DISCOVERY_MAX_DEVICES]; +static size_t s_seen_device_count; +static a2dp_media_state_t s_media_state; static audio_resampler_t s_resampler; #if defined(A2DP_TEST_TONE) @@ -101,13 +133,90 @@ static int32_t a2dp_data_cb(uint8_t *data, int32_t len) static void a2dp_event_cb(esp_a2d_cb_event_t event, esp_a2d_cb_param_t *param) { + if (param == NULL) { + return; + } + + a2dp_media_event_t media_event = {0}; if (event == ESP_A2D_CONNECTION_STATE_EVT) { ESP_LOGI(LOG_A2DP, "Connection state %d", param->conn_stat.state); + if (param->conn_stat.state == ESP_A2D_CONNECTION_STATE_CONNECTED) { + media_event.type = A2DP_MEDIA_EVENT_CONNECTED; + } else if (param->conn_stat.state == ESP_A2D_CONNECTION_STATE_DISCONNECTED) { + media_event.type = A2DP_MEDIA_EVENT_DISCONNECTED; + } else { + return; + } + } else if (event == ESP_A2D_MEDIA_CTRL_ACK_EVT) { + media_event.type = A2DP_MEDIA_EVENT_CONTROL_ACK; + media_event.command = param->media_ctrl_stat.cmd; + media_event.status = param->media_ctrl_stat.status; + } else if (event == ESP_A2D_AUDIO_STATE_EVT) { + ESP_LOGI(LOG_A2DP, "Audio state %d", param->audio_stat.state); + return; + } else { + return; + } + + if (s_media_event_queue != NULL && xQueueSend(s_media_event_queue, &media_event, 0) != pdTRUE) { + ESP_LOGW(LOG_A2DP, "Dropping A2DP media event %d", media_event.type); + } +} + +static void a2dp_gap_event_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param) +{ + if (param == NULL) { + return; + } + + if (event == ESP_BT_GAP_DISC_STATE_CHANGED_EVT) { + if (param->disc_st_chg.state == ESP_BT_GAP_DISCOVERY_STARTED) { + s_seen_device_count = 0; + s_discovery_active = true; + s_discovery_complete = false; + } else { + s_discovery_active = false; + s_discovery_complete = true; + } + } else if (event == ESP_BT_GAP_DISC_RES_EVT && s_discovery_active && + s_discovery_queue != NULL) { + for (size_t i = 0; i < s_seen_device_count; i++) { + if (memcmp(s_seen_devices[i], param->disc_res.bda, BT_ADDR_LEN) == 0) { + return; + } + } + + if (s_seen_device_count >= A2DP_DISCOVERY_MAX_DEVICES) { + return; + } + + memcpy(s_seen_devices[s_seen_device_count++], param->disc_res.bda, BT_ADDR_LEN); + xQueueSend(s_discovery_queue, param->disc_res.bda, 0); } } bool a2dp_init(const char *device_name) { + if (s_media_event_queue == NULL) { + s_media_event_queue = xQueueCreate(A2DP_MEDIA_EVENT_QUEUE_LENGTH, sizeof(a2dp_media_event_t)); + if (s_media_event_queue == NULL) { + ESP_LOGE(LOG_A2DP, "Failed to allocate media event queue"); + return false; + } + } + xQueueReset(s_media_event_queue); + s_media_state = A2DP_MEDIA_IDLE; + + if (s_discovery_queue == NULL) { + s_discovery_queue = xQueueCreate(A2DP_DISCOVERY_QUEUE_LENGTH, sizeof(esp_bd_addr_t)); + if (s_discovery_queue == NULL) { + ESP_LOGE(LOG_A2DP, "Failed to allocate discovery queue"); + return false; + } + } + s_discovery_active = false; + s_discovery_complete = true; + #if !defined(A2DP_TEST_TONE) if (s_pcm_buffer == NULL) { s_pcm_buffer = xStreamBufferCreate(A2DP_PCM_BUFFER_SIZE, 1); @@ -125,7 +234,8 @@ bool a2dp_init(const char *device_name) s_test_tone_index = 0; #endif - if (esp_bt_gap_set_device_name(device_name) != ESP_OK || + if (esp_bt_gap_register_callback(a2dp_gap_event_cb) != ESP_OK || + esp_bt_gap_set_device_name(device_name) != ESP_OK || esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE) != ESP_OK || esp_a2d_register_callback(a2dp_event_cb) != ESP_OK || esp_a2d_source_register_data_callback(a2dp_data_cb) != ESP_OK || @@ -139,6 +249,10 @@ bool a2dp_init(const char *device_name) void a2dp_deinit(void) { esp_a2d_source_deinit(); + s_media_state = A2DP_MEDIA_IDLE; + if (s_media_event_queue != NULL) { + xQueueReset(s_media_event_queue); + } } void a2dp_connect(const char *addr) @@ -196,6 +310,104 @@ void a2dp_feed_pcm(const uint8_t *data, size_t length) #endif } +void a2dp_process(void) +{ + if (s_media_event_queue == NULL) { + return; + } + + a2dp_media_event_t event; + while (xQueueReceive(s_media_event_queue, &event, 0) == pdTRUE) { + switch (event.type) { + case A2DP_MEDIA_EVENT_CONNECTED: + s_media_state = A2DP_MEDIA_CHECK_PENDING; + break; + + case A2DP_MEDIA_EVENT_DISCONNECTED: + s_media_state = A2DP_MEDIA_IDLE; + break; + + case A2DP_MEDIA_EVENT_CONTROL_ACK: + if (s_media_state == A2DP_MEDIA_CHECK_WAITING && + event.command == ESP_A2D_MEDIA_CTRL_CHECK_SRC_RDY) { + if (event.status == ESP_A2D_MEDIA_CTRL_ACK_SUCCESS) { + s_media_state = A2DP_MEDIA_START_PENDING; + } else { + ESP_LOGW(LOG_A2DP, "Media source not ready, ACK %d", event.status); + s_media_state = A2DP_MEDIA_IDLE; + } + } else if (s_media_state == A2DP_MEDIA_START_WAITING && + event.command == ESP_A2D_MEDIA_CTRL_START) { + if (event.status == ESP_A2D_MEDIA_CTRL_ACK_SUCCESS) { + ESP_LOGI(LOG_A2DP, "A2DP media started"); + s_media_state = A2DP_MEDIA_STREAMING; + } else { + ESP_LOGE(LOG_A2DP, "A2DP media start failed, ACK %d", event.status); + s_media_state = A2DP_MEDIA_IDLE; + } + } + break; + } + } + + if (s_media_state == A2DP_MEDIA_CHECK_PENDING) { + esp_err_t err = esp_a2d_media_ctrl(ESP_A2D_MEDIA_CTRL_CHECK_SRC_RDY); + if (err == ESP_OK) { + s_media_state = A2DP_MEDIA_CHECK_WAITING; + } else { + ESP_LOGE(LOG_A2DP, "A2DP source readiness check failed: %s", esp_err_to_name(err)); + s_media_state = A2DP_MEDIA_IDLE; + } + } else if (s_media_state == A2DP_MEDIA_START_PENDING) { + esp_err_t err = esp_a2d_media_ctrl(ESP_A2D_MEDIA_CTRL_START); + if (err == ESP_OK) { + s_media_state = A2DP_MEDIA_START_WAITING; + } else { + ESP_LOGE(LOG_A2DP, "A2DP media start request failed: %s", esp_err_to_name(err)); + s_media_state = A2DP_MEDIA_IDLE; + } + } +} + +bool bt_a2dp_start_discovery(void) +{ + if (s_discovery_queue == NULL || s_discovery_active) { + return false; + } + + xQueueReset(s_discovery_queue); + s_seen_device_count = 0; + s_discovery_complete = false; + s_discovery_active = true; + + esp_err_t err = esp_bt_gap_start_discovery(ESP_BT_INQ_MODE_GENERAL_INQUIRY, 8, 0); + if (err != ESP_OK) { + s_discovery_active = false; + s_discovery_complete = true; + ESP_LOGE(LOG_A2DP, "Classic discovery start failed: %s", esp_err_to_name(err)); + return false; + } + + return true; +} + +bool bt_a2dp_get_discovery_result(bt_addr_t address) +{ + return s_discovery_queue != NULL && xQueueReceive(s_discovery_queue, address, 0) == pdTRUE; +} + +bool bt_a2dp_discovery_complete(void) +{ + return s_discovery_complete; +} + +void bt_a2dp_cancel_discovery(void) +{ + if (s_discovery_active) { + esp_bt_gap_cancel_discovery(); + } +} + #else bool a2dp_init(const char *device_name) @@ -211,5 +423,10 @@ void a2dp_feed_pcm(const uint8_t *data, size_t length) (void)data; (void)length; } +void a2dp_process(void) {} +bool bt_a2dp_start_discovery(void) { return false; } +bool bt_a2dp_get_discovery_result(bt_addr_t address) { (void)address; return false; } +bool bt_a2dp_discovery_complete(void) { return true; } +void bt_a2dp_cancel_discovery(void) {} #endif diff --git a/src/bt_a2dp.h b/src/bt_a2dp.h index 4cd160a..897a665 100644 --- a/src/bt_a2dp.h +++ b/src/bt_a2dp.h @@ -4,6 +4,8 @@ #include #include +#include "bt_addr.h" + /** Initialize the Classic Bluetooth A2DP source with the configured name. */ bool a2dp_init(const char *device_name); @@ -19,3 +21,18 @@ void a2dp_connect(const char *addr); * Define A2DP_TEST_TONE at build time to substitute a local 500 Hz test signal. */ void a2dp_feed_pcm(const uint8_t *data, size_t length); + +/** Process A2DP events and start the media stream when the connection is ready. */ +void a2dp_process(void); + +/** Start a Classic Bluetooth inquiry while the A2DP role is active. */ +bool bt_a2dp_start_discovery(void); + +/** Read one address found by Classic Bluetooth inquiry without blocking. */ +bool bt_a2dp_get_discovery_result(bt_addr_t address); + +/** Return whether the current or most recent inquiry has completed. */ +bool bt_a2dp_discovery_complete(void); + +/** Cancel an active Classic Bluetooth inquiry. */ +void bt_a2dp_cancel_discovery(void); diff --git a/src/terminal.c b/src/terminal.c index 57ce2b8..23456e7 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -68,6 +68,7 @@ static role_t curMode = ROLE_UNKNOWN; static btcentralstate btCentralState = CENTRAL_STATE_DISCONNECT; static btperipheralstate btPeripherialState = PERIPHERIAL_STATE_DISCONNECTED; static role_t lastBleRole = ROLE_UNKNOWN; +static bool a2dpScanActive; /** Current remote address (text) and the reusable response buffer. */ static char rmtaddress[BT_ADDR_STR_LEN] = "000000000000"; @@ -269,6 +270,13 @@ static void parserATCommand(char *line) write_str("OK+DISCS\r\n"); laddcnt = 0; btCentralState = CENTRAL_STATE_SCAN_START; + } else if (curMode == ROLE_BTEDR_AUDIO_SOURCE) { + if (bt_a2dp_start_discovery()) { + a2dpScanActive = true; + write_str("OK+DISCS\r\n"); + } else { + write_str("ERROR\r\n"); + } } break; @@ -516,8 +524,10 @@ static void setRole(role_t role) bt_disable(); break; case ROLE_BTEDR_AUDIO_SOURCE: + bt_a2dp_cancel_discovery(); a2dp_deinit(); bt_disable(); + a2dpScanActive = false; break; default: break; @@ -691,6 +701,20 @@ static void runBT(void) case ROLE_BLE_PERIPHERAL: runBTPeripherial(); break; + case ROLE_BTEDR_AUDIO_SOURCE: + a2dp_process(); + if (a2dpScanActive) { + bt_addr_t address; + while (bt_a2dp_get_discovery_result(address)) { + char address_text[BT_ADDR_STR_LEN]; + write_fmt("OK+DISC:%s\r\n", bt_addr_to_str(address_text, address)); + } + if (bt_a2dp_discovery_complete()) { + write_str("OK+DISCE\r\n"); + a2dpScanActive = false; + } + } + break; default: break; } From 8a2cfc1b166e8875f7feb2d92d68aecd2f705ce6 Mon Sep 17 00:00:00 2001 From: Malte Langermann Date: Thu, 15 Oct 2026 23:18:07 +0200 Subject: [PATCH 5/8] Add 460800 baud mode for A2DP PCM --- AGENTS.md | 6 ++++-- README.md | 44 ++++++++++++++++++++++++++++++++---------- doc/BENCH.md | 3 ++- src/board.h | 5 ++--- src/terminal.c | 24 +++++++++++++++++++---- test/test_at_command.c | 12 ++++++++---- 6 files changed, 70 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d790423..56f9c3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,8 +92,10 @@ Key invariants: CRSF/Ghost are forwarded raw. The module is a pure passthrough. - Master trainer = central (`AT+ROLE1`), slave/telemetry = peripheral (`AT+ROLE0`). -- The firmware stays at 115200 baud; `AT+BAUD4` is acknowledged implicitly by - EdgeTX switching to 115200 without checking a response. +- UART starts at 115200 for the EdgeTX boot handshake. `AT+BAUD4` selects + 115200; `AT+BAUD6` selects 460800 for mono 16 kHz A2DP PCM. Both baud changes + have no response, so EdgeTX must switch its UART immediately after sending + the command. ## Build & test diff --git a/README.md b/README.md index f3a3000..941994d 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ service (`0xFFF0`, characteristic `0xFFF6`, NOTIFY). ## Wiring -The module talks to the radio over a 3.3 V UART link (8N1, 115200 baud). Only -TX, RX and GND are required; add +5V if the module is powered from the radio. +The module talks to the radio over a 3.3 V UART link (8N1, 115200 baud at +startup). A2DP PCM mode can switch the link to 460800 baud. Only TX, RX and GND +are required; add +5V if the module is powered from the radio. The data lines are **crossed**: the module TX goes to the radio RX and the module RX to the radio TX. Keep GND common with the radio, and power the module from a supply that can handle the ESP32 current peaks. @@ -74,12 +75,16 @@ The last two rows are the reference, non-validated targets (see ## EdgeTX protocol -The module speaks the FrSky Bluetooth AT protocol over an 8N1 UART link at -115200 baud. All responses are `\r\n`-terminated; the real ACK that advances -EdgeTX is the `Central:` / `Peripheral:` line emitted after -`AT+TXPW` / `AT+ROLE`. Trainer and S.Port frames are `0x7E`-delimited and -byte-stuffed, while CRSF/Ghost are forwarded raw. See `AGENTS.md` for the full -set of protocol invariants. +The module speaks the FrSky Bluetooth AT protocol over an 8N1 UART link. The +link starts at 115200 baud for the boot handshake. `AT+BAUD4` selects 115200; +`AT+BAUD6` selects 460800 for mono A2DP PCM. Baud commands have no response, so +EdgeTX must switch its UART immediately after sending the command. A2DP audio +input is raw signed 16-bit little-endian mono PCM at 16 kHz; the module +resamples it for the Bluetooth sink. All AT responses are `\r\n`-terminated; +the real ACK that advances EdgeTX is the `Central:` / `Peripheral:` +line emitted after `AT+TXPW` / `AT+ROLE`. Trainer and S.Port frames are +`0x7E`-delimited and byte-stuffed, while CRSF/Ghost are forwarded raw. See +`AGENTS.md` for the full set of protocol invariants. ## Code structure @@ -192,19 +197,38 @@ module: | Command | Meaning | |---|---| -| `AT+BAUD4` | baud rate level (this firmware stays at 115200) | +| `AT+BAUD4` | switch to 115200 baud | +| `AT+BAUD6` | switch to 460800 baud for mono A2DP PCM | | `AT+NAME` | set the Bluetooth name (persisted and advertised) | | `AT+TXPW` | TX power (acknowledged) | | `AT+ROLE0` | Peripheral (telemetry / trainer slave) | | `AT+ROLE1` | Central (trainer master) | -| `AT+DISC?` | start a scan (central) | +| `AT+DISC?` | scan BLE in Central mode or Classic Bluetooth in A2DP mode | | `AT+CON` | connect to a device (central) | +| `AT+A2DPON` | enable the Classic Bluetooth A2DP source | +| `AT+A2DPCON` | connect to an A2DP sink (12 hex digits, no separators) | +| `AT+A2DPOFF` | stop A2DP and turn Bluetooth off | +| `AT+BLEON` / `AT+BLEOFF` | enable the last BLE role / disable BLE | +| `AT+BTOFF` | turn Bluetooth off | | `AT+CLEAR` | forget the saved device | | `AT+HTRESET` | reset the headtracker (BTWifiModule extension) | Responses: `OK+...`, `Central:`, `Peripheral:`, `Connected:`, `DisConnected`, `ERROR`. +A2DP is enabled in the RM Pico Bluedroid configuration, not the NimBLE build. +For a test without EdgeTX PCM input, build and flash `RM_pico_a2dp_test`; it +streams a local 500 Hz tone. Connect to a discoverable sink with: + +```text +AT+A2DPON +AT+DISC? +AT+A2DPCON<12-digit-address> +``` + +After the connection is established, send `AT+BAUD6` before transmitting PCM. +To return to the normal radio link speed, send `AT+BAUD4` at 460800 baud. + ## Credits Original project: **dlktdr/BTWifiModule**. Bluetooth telemetry, auto-connect, diff --git a/doc/BENCH.md b/doc/BENCH.md index ce7f19a..820be93 100644 --- a/doc/BENCH.md +++ b/doc/BENCH.md @@ -68,7 +68,8 @@ response. Run it once per firmware variant. | `AT+TXPW` | `OK+Txpw:0` + mode line | - | | `AT+DISC?` | `OK+DISCS`, `OK+DISC:` per peer, `OK+DISCE` | central; peripheral none | | `AT+CLEAR` | `OK+CLEAR` | central; peripheral none | -| `AT+BAUD` | no response (stays at 115200) | - | +| `AT+BAUD4` | no response; switches to 115200 | - | +| `AT+BAUD6` | no response; switches to 460800 | A2DP PCM | | `AT+HTRESET` | `OK+HTRESET`, or `ERROR` unless headtracker board | - | | unknown | no response | - | diff --git a/src/board.h b/src/board.h index 52630c3..f444d34 100644 --- a/src/board.h +++ b/src/board.h @@ -63,10 +63,9 @@ #define UART_RX_BUFFER 512 #define UART_TX_BUFFER 512 -/** Radio link baud rates. The firmware stays fixed at 115200: EdgeTX sends - * `AT+BAUD4` at the 57600 factory rate and switches to 115200 without checking - * a response, so this module starts directly at 115200. */ +/** UART speeds used by the EdgeTX boot handshake and A2DP PCM stream. */ #define BAUD_DEFAULT 115200 +#define BAUD_A2DP_PCM 460800 #define BAUD_MAXIMUM 921600 /** Maximum Bluetooth name length (advertised and persisted). */ diff --git a/src/terminal.c b/src/terminal.c index 23456e7..5e35992 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -289,11 +289,27 @@ static void parserATCommand(char *line) } break; - case AT_CMD_BAUD: - // The firmware stays at 115200 (see board.h). EdgeTX switches to 115200 - // after AT+BAUD4 without checking a response. - ESP_LOGI(LOG_UART, "Baud change requested (ignored, staying at 115200)"); + case AT_CMD_BAUD: { + uint32_t baud_rate; + if (strcmp(cmd.argument, "4") == 0) { + baud_rate = BAUD_DEFAULT; + } else if (strcmp(cmd.argument, "6") == 0) { + baud_rate = BAUD_A2DP_PCM; + } else { + ESP_LOGW(LOG_UART, "Unsupported baud level: %s", cmd.argument); + break; + } + + esp_err_t ret = uart_wait_tx_done(uart_num, pdMS_TO_TICKS(1000)); + if (ret == ESP_OK) { + ret = uart_set_baudrate(uart_num, baud_rate); + } + if (ret != ESP_OK) { + ESP_LOGE(LOG_UART, "Unable to switch UART baud to %u: %s", (unsigned)baud_rate, + esp_err_to_name(ret)); + } break; + } case AT_CMD_HTRESET: if (bt_client_board_type == BLE_BOARD_HEADTRACKER) { diff --git a/test/test_at_command.c b/test/test_at_command.c index eeda90e..4eff0e8 100644 --- a/test/test_at_command.c +++ b/test/test_at_command.c @@ -62,10 +62,14 @@ void test_clear(void) void test_baud_argument(void) { - char line[] = "AT+BAUD4\r\n"; - at_command_parse_t r = at_command_parse(line); - TEST_ASSERT_EQUAL(AT_CMD_BAUD, r.command); - TEST_ASSERT_EQUAL_STRING("4", r.argument); + char default_baud[] = "AT+BAUD4\r\n"; + char a2dp_baud[] = "AT+BAUD6\r\n"; + at_command_parse_t default_result = at_command_parse(default_baud); + at_command_parse_t a2dp_result = at_command_parse(a2dp_baud); + TEST_ASSERT_EQUAL(AT_CMD_BAUD, default_result.command); + TEST_ASSERT_EQUAL_STRING("4", default_result.argument); + TEST_ASSERT_EQUAL(AT_CMD_BAUD, a2dp_result.command); + TEST_ASSERT_EQUAL_STRING("6", a2dp_result.argument); } void test_htreset(void) From 3c6d756a61bbcba9dbb6276856291c7669fba663 Mon Sep 17 00:00:00 2001 From: Malte Langermann Date: Fri, 16 Oct 2026 11:18:10 +0200 Subject: [PATCH 6/8] Improve A2DP reconnect and PC audio sender --- README.md | 26 +++++ src/bt_a2dp.c | 87 +++++++++++++++- src/bt_a2dp.h | 5 +- src/terminal.c | 9 +- tools/uart_audio_sender.py | 198 +++++++++++++++++++++++++++++++++++++ 5 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 tools/uart_audio_sender.py diff --git a/README.md b/README.md index 941994d..cb62bdc 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,32 @@ AT+A2DPCON<12-digit-address> After the connection is established, send `AT+BAUD6` before transmitting PCM. To return to the normal radio link speed, send `AT+BAUD4` at 460800 baud. +For PC-fed audio, install `pyserial` on Windows: + +```powershell +py -m pip install pyserial +``` + +Prepare a mono, 16-bit PCM WAV at 16 kHz. If needed, convert an audio file with +FFmpeg: + +```powershell +ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le audio.wav +``` + +Flash the normal `RM_pico` firmware (not `RM_pico_a2dp_test`, which uses its +internal tone), close PuTTY, then run the sender. It probes at 115200 and +460800, resets and restarts the A2DP stack, and restores 115200 if it detects a +460800 session. It defaults to COM4 and the sink MAC `FC58FAACAA1A`: + +```powershell +py tools\uart_audio_sender.py audio.wav +``` + +Override either default with `--port` or `--address`. The sender switches the +module and PC to 460800, verifies the new rate, and prebuffers audio before +connecting. It returns both sides to 115200 when playback ends. If neither baud +probe receives a response, reset the module and retry. ## Credits diff --git a/src/bt_a2dp.c b/src/bt_a2dp.c index f6f09ac..aa17bd5 100644 --- a/src/bt_a2dp.c +++ b/src/bt_a2dp.c @@ -9,7 +9,9 @@ #include "sdkconfig.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" +#include "freertos/semphr.h" #include "freertos/stream_buffer.h" +#include "freertos/task.h" #define LOG_A2DP "A2DP" @@ -23,8 +25,11 @@ #define A2DP_DISCOVERY_QUEUE_LENGTH 20 #define A2DP_DISCOVERY_MAX_DEVICES 20 #define A2DP_MEDIA_EVENT_QUEUE_LENGTH 8 +#define A2DP_RECONNECT_DELAY_MS 5000 +#define A2DP_RECONNECT_TIMEOUT_MS 15000 typedef enum { + A2DP_MEDIA_EVENT_CONNECTING, A2DP_MEDIA_EVENT_CONNECTED, A2DP_MEDIA_EVENT_DISCONNECTED, A2DP_MEDIA_EVENT_CONTROL_ACK, @@ -54,11 +59,17 @@ static uint8_t s_pending_output_byte; #endif static QueueHandle_t s_discovery_queue; static QueueHandle_t s_media_event_queue; +static SemaphoreHandle_t s_profile_deinit_sem; static volatile bool s_discovery_active; static volatile bool s_discovery_complete = true; static esp_bd_addr_t s_seen_devices[A2DP_DISCOVERY_MAX_DEVICES]; static size_t s_seen_device_count; static a2dp_media_state_t s_media_state; +static volatile bool s_audio_started; +static esp_bd_addr_t s_peer_address; +static bool s_peer_address_valid; +static bool s_reconnect_pending; +static TickType_t s_reconnect_deadline; static audio_resampler_t s_resampler; #if defined(A2DP_TEST_TONE) @@ -137,12 +148,23 @@ static void a2dp_event_cb(esp_a2d_cb_event_t event, esp_a2d_cb_param_t *param) return; } + if (event == ESP_A2D_PROF_STATE_EVT) { + if (param->a2d_prof_stat.init_state == ESP_A2D_DEINIT_SUCCESS && + s_profile_deinit_sem != NULL) { + xSemaphoreGive(s_profile_deinit_sem); + } + return; + } + a2dp_media_event_t media_event = {0}; if (event == ESP_A2D_CONNECTION_STATE_EVT) { ESP_LOGI(LOG_A2DP, "Connection state %d", param->conn_stat.state); - if (param->conn_stat.state == ESP_A2D_CONNECTION_STATE_CONNECTED) { + if (param->conn_stat.state == ESP_A2D_CONNECTION_STATE_CONNECTING) { + media_event.type = A2DP_MEDIA_EVENT_CONNECTING; + } else if (param->conn_stat.state == ESP_A2D_CONNECTION_STATE_CONNECTED) { media_event.type = A2DP_MEDIA_EVENT_CONNECTED; } else if (param->conn_stat.state == ESP_A2D_CONNECTION_STATE_DISCONNECTED) { + s_audio_started = false; media_event.type = A2DP_MEDIA_EVENT_DISCONNECTED; } else { return; @@ -153,6 +175,7 @@ static void a2dp_event_cb(esp_a2d_cb_event_t event, esp_a2d_cb_param_t *param) media_event.status = param->media_ctrl_stat.status; } else if (event == ESP_A2D_AUDIO_STATE_EVT) { ESP_LOGI(LOG_A2DP, "Audio state %d", param->audio_stat.state); + s_audio_started = param->audio_stat.state == ESP_A2D_AUDIO_STATE_STARTED; return; } else { return; @@ -197,6 +220,14 @@ static void a2dp_gap_event_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t bool a2dp_init(const char *device_name) { + if (s_profile_deinit_sem == NULL) { + s_profile_deinit_sem = xSemaphoreCreateBinary(); + if (s_profile_deinit_sem == NULL) { + ESP_LOGE(LOG_A2DP, "Failed to allocate profile deinit semaphore"); + return false; + } + } + if (s_media_event_queue == NULL) { s_media_event_queue = xQueueCreate(A2DP_MEDIA_EVENT_QUEUE_LENGTH, sizeof(a2dp_media_event_t)); if (s_media_event_queue == NULL) { @@ -206,6 +237,9 @@ bool a2dp_init(const char *device_name) } xQueueReset(s_media_event_queue); s_media_state = A2DP_MEDIA_IDLE; + s_audio_started = false; + s_peer_address_valid = false; + s_reconnect_pending = false; if (s_discovery_queue == NULL) { s_discovery_queue = xQueueCreate(A2DP_DISCOVERY_QUEUE_LENGTH, sizeof(esp_bd_addr_t)); @@ -248,8 +282,22 @@ bool a2dp_init(const char *device_name) void a2dp_deinit(void) { - esp_a2d_source_deinit(); + if (s_profile_deinit_sem != NULL) { + xSemaphoreTake(s_profile_deinit_sem, 0); + } + + esp_err_t err = esp_a2d_source_deinit(); + if (err == ESP_OK && s_profile_deinit_sem != NULL && + xSemaphoreTake(s_profile_deinit_sem, pdMS_TO_TICKS(3000)) != pdTRUE) { + ESP_LOGW(LOG_A2DP, "Timed out waiting for A2DP profile deinit"); + } else if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(LOG_A2DP, "A2DP profile deinit failed: %s", esp_err_to_name(err)); + } + s_media_state = A2DP_MEDIA_IDLE; + s_audio_started = false; + s_peer_address_valid = false; + s_reconnect_pending = false; if (s_media_event_queue != NULL) { xQueueReset(s_media_event_queue); } @@ -262,7 +310,16 @@ void a2dp_connect(const char *addr) return; } bt_addr_from_str(address, addr); - esp_a2d_source_connect(address); + memcpy(s_peer_address, address, sizeof(s_peer_address)); + s_peer_address_valid = true; + s_reconnect_pending = false; + + esp_err_t err = esp_a2d_source_connect(s_peer_address); + if (err != ESP_OK) { + ESP_LOGW(LOG_A2DP, "A2DP connect request failed: %s", esp_err_to_name(err)); + s_reconnect_pending = true; + s_reconnect_deadline = xTaskGetTickCount() + pdMS_TO_TICKS(A2DP_RECONNECT_DELAY_MS); + } } void a2dp_feed_pcm(const uint8_t *data, size_t length) @@ -319,12 +376,21 @@ void a2dp_process(void) a2dp_media_event_t event; while (xQueueReceive(s_media_event_queue, &event, 0) == pdTRUE) { switch (event.type) { + case A2DP_MEDIA_EVENT_CONNECTING: + s_reconnect_pending = false; + break; + case A2DP_MEDIA_EVENT_CONNECTED: + s_reconnect_pending = false; s_media_state = A2DP_MEDIA_CHECK_PENDING; break; case A2DP_MEDIA_EVENT_DISCONNECTED: s_media_state = A2DP_MEDIA_IDLE; + if (s_peer_address_valid) { + s_reconnect_pending = true; + s_reconnect_deadline = xTaskGetTickCount() + pdMS_TO_TICKS(A2DP_RECONNECT_DELAY_MS); + } break; case A2DP_MEDIA_EVENT_CONTROL_ACK: @@ -367,6 +433,20 @@ void a2dp_process(void) s_media_state = A2DP_MEDIA_IDLE; } } + + if (s_peer_address_valid && s_reconnect_pending && + (int32_t)(xTaskGetTickCount() - s_reconnect_deadline) >= 0) { + ESP_LOGW(LOG_A2DP, "Reconnecting to previous A2DP sink"); + esp_err_t err = esp_a2d_source_connect(s_peer_address); + s_reconnect_deadline = xTaskGetTickCount() + + pdMS_TO_TICKS(err == ESP_OK ? A2DP_RECONNECT_TIMEOUT_MS + : A2DP_RECONNECT_DELAY_MS); + } +} + +bool a2dp_media_started(void) +{ + return s_audio_started; } bool bt_a2dp_start_discovery(void) @@ -424,6 +504,7 @@ void a2dp_feed_pcm(const uint8_t *data, size_t length) (void)length; } void a2dp_process(void) {} +bool a2dp_media_started(void) { return false; } bool bt_a2dp_start_discovery(void) { return false; } bool bt_a2dp_get_discovery_result(bt_addr_t address) { (void)address; return false; } bool bt_a2dp_discovery_complete(void) { return true; } diff --git a/src/bt_a2dp.h b/src/bt_a2dp.h index 897a665..7b5c2d6 100644 --- a/src/bt_a2dp.h +++ b/src/bt_a2dp.h @@ -9,7 +9,7 @@ /** Initialize the Classic Bluetooth A2DP source with the configured name. */ bool a2dp_init(const char *device_name); -/** Stop the A2DP source. */ +/** Stop the A2DP source and wait for profile teardown before controller shutdown. */ void a2dp_deinit(void); /** Connect to a remote device specified as 12 hexadecimal digits. */ @@ -25,6 +25,9 @@ void a2dp_feed_pcm(const uint8_t *data, size_t length); /** Process A2DP events and start the media stream when the connection is ready. */ void a2dp_process(void); +/** Return whether the A2DP media channel is currently started. */ +bool a2dp_media_started(void); + /** Start a Classic Bluetooth inquiry while the A2DP role is active. */ bool bt_a2dp_start_discovery(void); diff --git a/src/terminal.c b/src/terminal.c index 5e35992..bfad73e 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -300,10 +300,8 @@ static void parserATCommand(char *line) break; } - esp_err_t ret = uart_wait_tx_done(uart_num, pdMS_TO_TICKS(1000)); - if (ret == ESP_OK) { - ret = uart_set_baudrate(uart_num, baud_rate); - } + ESP_LOGI(LOG_UART, "Switching UART baud to %u", (unsigned)baud_rate); + esp_err_t ret = uart_set_baudrate(uart_num, baud_rate); if (ret != ESP_OK) { ESP_LOGE(LOG_UART, "Unable to switch UART baud to %u: %s", (unsigned)baud_rate, esp_err_to_name(ret)); @@ -342,6 +340,9 @@ static void parserATCommand(char *line) write_str("OK+A2DPON\r\n"); setRole(ROLE_BTEDR_AUDIO_SOURCE); sendBTMode(); + if (curMode == ROLE_BTEDR_AUDIO_SOURCE && a2dp_media_started()) { + write_str("A2DP media started\r\n"); + } } else { ESP_LOGE(LOG_UART, "A2DP not supported in this build"); write_str("ERROR\r\n"); diff --git a/tools/uart_audio_sender.py b/tools/uart_audio_sender.py new file mode 100644 index 0000000..8eeebba --- /dev/null +++ b/tools/uart_audio_sender.py @@ -0,0 +1,198 @@ +"""Stream mono 16 kHz PCM WAV audio to an A2DP-connected module over UART. + +Usage: + python tools/uart_audio_sender.py audio.wav + +The module must be running the normal RM_pico firmware. The script sends the +A2DP setup commands at 115200 baud, switches to 460800 for PCM, and restores +115200 when playback ends. +""" + +import argparse +import time +import wave + +try: + import serial +except ImportError as exc: + raise SystemExit("pyserial is required; install it with: py -m pip install pyserial") from exc + + +CONTROL_BAUD = 115200 +AUDIO_BAUD = 460800 +SAMPLE_RATE = 16000 +SAMPLE_WIDTH_BYTES = 2 +PREBUFFER_FRAMES = 2048 +FRAMES_PER_CHUNK = 2048 + + +def read_until(port, token, timeout, show_output=True): + deadline = time.monotonic() + timeout + received = bytearray() + while time.monotonic() < deadline: + chunk = port.read(256) + if not chunk: + continue + if show_output: + print(chunk.decode("ascii", "replace"), end="", flush=True) + received.extend(chunk) + if token in received: + return bytes(received) + if len(received) > 8192: + del received[:-4096] + raise TimeoutError( + f"Timed out waiting for {token.decode('ascii')}; received {bytes(received)!r}") + + +def send_command(port, command, response, timeout, show_output=True): + port.write(command + b"\r\n") + port.flush() + return read_until(port, response, timeout, show_output=show_output) + + +def find_control_baud(port): + for baud in (CONTROL_BAUD, AUDIO_BAUD): + port.baudrate = baud + port.reset_input_buffer() + try: + response = send_command(port, b"AT+A2DPON", b"OK+A2DPON", 5, show_output=False) + if b"Mode:A2DP Source" not in response: + response += read_until(port, b"Mode:A2DP Source", 20, show_output=False) + except TimeoutError: + continue + + print(response.decode("ascii", "replace"), end="", flush=True) + + if baud == AUDIO_BAUD: + port.write(b"AT+BAUD4\r\n") + port.flush() + time.sleep(0.1) + port.baudrate = CONTROL_BAUD + port.reset_input_buffer() + return response + + raise TimeoutError("No A2DP response at 115200 or 460800 baud; reset the module") + + +def stream_wav(port, audio): + total_frames = audio.getnframes() + prebuffer_frames = min(PREBUFFER_FRAMES, total_frames) + sent_frames = prebuffer_frames + start_time = time.monotonic() - prebuffer_frames / SAMPLE_RATE + + while True: + data = audio.readframes(FRAMES_PER_CHUNK) + if not data: + break + + written = port.write(data) + if written != len(data): + raise serial.SerialTimeoutException("UART write was incomplete") + + sent_frames += len(data) // SAMPLE_WIDTH_BYTES + deadline = start_time + sent_frames / SAMPLE_RATE + delay = deadline - time.monotonic() + if delay > 0: + time.sleep(delay) + + port.flush() + print(f"\nSent {sent_frames}/{total_frames} PCM frames") + + +def send_prebuffer(port, audio): + data = audio.readframes(PREBUFFER_FRAMES) + sent_frames = 0 + start_time = time.monotonic() + + for offset in range(0, len(data), 512): + chunk = data[offset:offset + 512] + written = port.write(chunk) + if written != len(chunk): + raise serial.SerialTimeoutException("UART prebuffer write was incomplete") + + sent_frames += len(chunk) // SAMPLE_WIDTH_BYTES + deadline = start_time + sent_frames / SAMPLE_RATE + delay = deadline - time.monotonic() + if delay > 0: + time.sleep(delay) + + port.flush() + return sent_frames + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("wav", help="mono 16-bit PCM WAV at 16000 Hz") + parser.add_argument("--port", default="COM4", help="serial port (default COM4)") + parser.add_argument("--address", default="FC58FAACAA1A", + help="A2DP sink MAC without separators (default FC58FAACAA1A)") + args = parser.parse_args() + + if len(args.address) != 12 or any(char not in "0123456789abcdefABCDEF" for char in args.address): + parser.error("--address must be exactly 12 hexadecimal digits without separators") + + port = None + audio_baud_selected = False + try: + with wave.open(args.wav, "rb") as audio: + if (audio.getcomptype() != "NONE" or audio.getnchannels() != 1 or + audio.getsampwidth() != SAMPLE_WIDTH_BYTES or + audio.getframerate() != SAMPLE_RATE): + parser.error("WAV must be uncompressed mono 16-bit PCM at 16000 Hz") + + port = serial.Serial() + port.port = args.port + port.baudrate = CONTROL_BAUD + port.timeout = 0.1 + port.write_timeout = 2 + port.rtscts = False + port.dsrdtr = False + port.dtr = False + port.rts = False + port.open() + + print(f"Locating module baud on {args.port}") + find_control_baud(port) + print("Resetting A2DP stack") + off_response = send_command(port, b"AT+A2DPOFF", b"OK+A2DPOFF", 5) + if b"Mode:Bluetooth Off" not in off_response: + read_until(port, b"Mode:Bluetooth Off", 20) + + on_response = send_command(port, b"AT+A2DPON", b"OK+A2DPON", 20) + if b"Mode:A2DP Source" not in on_response: + read_until(port, b"Mode:A2DP Source", 20) + print(f"Preparing A2DP connection at {CONTROL_BAUD} baud") + port.write(b"AT+BAUD6\r\n") + port.flush() + time.sleep(0.1) + port.baudrate = AUDIO_BAUD + audio_baud_selected = True + port.reset_input_buffer() + + send_prebuffer(port, audio) + + connect_response = send_command( + port, b"AT+A2DPCON" + args.address.encode("ascii"), b"OK+A2DPCON", 5) + if b"A2DP media started" not in connect_response: + print("Waiting for A2DP media to start...", flush=True) + read_until(port, b"A2DP media started", 30) + + print(f"Streaming {args.wav} at {AUDIO_BAUD} baud") + stream_wav(port, audio) + except (OSError, ValueError, wave.Error, serial.SerialException, TimeoutError) as exc: + parser.error(str(exc)) + finally: + if port is not None and port.is_open: + if audio_baud_selected: + try: + port.write(b"AT+BAUD4\r\n") + port.flush() + time.sleep(0.1) + port.baudrate = CONTROL_BAUD + except serial.SerialException: + pass + port.close() + + +if __name__ == "__main__": + main() From 45e2d1ca3e326e276abf82e177dd5e4c2e13e405 Mon Sep 17 00:00:00 2001 From: Malte Langermann Date: Fri, 16 Oct 2026 11:45:38 +0200 Subject: [PATCH 7/8] Add A2DP absolute volume control and runtime volume input in uart_audio_sender --- README.md | 7 +- src/at_command.c | 9 ++ src/at_command.h | 3 + src/bt_a2dp.c | 207 ++++++++++++++++++++++++++++++++++++- src/bt_a2dp.h | 12 +++ src/terminal.c | 45 +++++++- test/test_at_command.c | 12 +++ tools/uart_audio_sender.py | 57 +++++++++- 8 files changed, 343 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cb62bdc..9622ed3 100644 --- a/README.md +++ b/README.md @@ -208,13 +208,18 @@ module: | `AT+A2DPON` | enable the Classic Bluetooth A2DP source | | `AT+A2DPCON` | connect to an A2DP sink (12 hex digits, no separators) | | `AT+A2DPOFF` | stop A2DP and turn Bluetooth off | +| `AT+A2DPVOL` | set AVRCP absolute volume (0..127) | +| `AT+A2DPVOL?` | query AVRCP's 128 logical volume values | +| `AT+A2DPSTART` / `AT+A2DPSTOP` | play or stop the A2DP sink | | `AT+BLEON` / `AT+BLEOFF` | enable the last BLE role / disable BLE | | `AT+BTOFF` | turn Bluetooth off | | `AT+CLEAR` | forget the saved device | | `AT+HTRESET` | reset the headtracker (BTWifiModule extension) | Responses: `OK+...`, `Central:`, `Peripheral:`, `Connected:`, -`DisConnected`, `ERROR`. +`DisConnected`, `A2DPVOL:` (unsolicited sink volume update, 0..127), `ERROR`. +AVRCP exposes a logical range of 0..127; it does not report the speaker's +physical volume-detent count. A2DP is enabled in the RM Pico Bluedroid configuration, not the NimBLE build. For a test without EdgeTX PCM input, build and flash `RM_pico_a2dp_test`; it diff --git a/src/at_command.c b/src/at_command.c index 375314b..5043bf3 100644 --- a/src/at_command.c +++ b/src/at_command.c @@ -74,6 +74,15 @@ at_command_parse_t at_command_parse(char *line) } else if (strncmp(p, "+A2DPCON", 8) == 0) { result.command = AT_CMD_A2DPCON; result.argument = p + 8; + } else if (strncmp(p, "+A2DPVOL", 8) == 0) { + result.command = AT_CMD_A2DPVOL; + result.argument = p + 8; + } else if (strncmp(p, "+A2DPSTART", 10) == 0) { + result.command = AT_CMD_A2DPSTART; + result.argument = ""; + } else if (strncmp(p, "+A2DPSTOP", 9) == 0) { + result.command = AT_CMD_A2DPSTOP; + result.argument = ""; } else if (strncmp(p, "+BTOFF", 6) == 0) { result.command = AT_CMD_BTOFF; result.argument = ""; diff --git a/src/at_command.h b/src/at_command.h index 0be15b5..fffe4e1 100644 --- a/src/at_command.h +++ b/src/at_command.h @@ -32,6 +32,9 @@ typedef enum { AT_CMD_A2DPCON, AT_CMD_BTOFF, AT_CMD_BTBOTH, + AT_CMD_A2DPVOL, + AT_CMD_A2DPSTART, + AT_CMD_A2DPSTOP, } at_command_t; /** Result of parsing an AT line. */ diff --git a/src/bt_a2dp.c b/src/bt_a2dp.c index aa17bd5..67574ad 100644 --- a/src/bt_a2dp.c +++ b/src/bt_a2dp.c @@ -18,6 +18,7 @@ #if defined(CONFIG_BT_CLASSIC_ENABLED) && defined(CONFIG_BT_A2DP_ENABLE) #include "esp_a2dp_api.h" +#include "esp_avrc_api.h" #include "esp_gap_bt_api.h" #define A2DP_PCM_BUFFER_SIZE 8192 @@ -25,6 +26,7 @@ #define A2DP_DISCOVERY_QUEUE_LENGTH 20 #define A2DP_DISCOVERY_MAX_DEVICES 20 #define A2DP_MEDIA_EVENT_QUEUE_LENGTH 8 +#define A2DP_VOLUME_EVENT_QUEUE_LENGTH 8 #define A2DP_RECONNECT_DELAY_MS 5000 #define A2DP_RECONNECT_TIMEOUT_MS 15000 @@ -33,12 +35,19 @@ typedef enum { A2DP_MEDIA_EVENT_CONNECTED, A2DP_MEDIA_EVENT_DISCONNECTED, A2DP_MEDIA_EVENT_CONTROL_ACK, + A2DP_MEDIA_EVENT_AVRCP_CONNECTED, + A2DP_MEDIA_EVENT_AVRCP_DISCONNECTED, + A2DP_MEDIA_EVENT_AVRCP_CAPABILITIES, + A2DP_MEDIA_EVENT_VOLUME_CHANGED, } a2dp_media_event_type_t; typedef struct { a2dp_media_event_type_t type; esp_a2d_media_ctrl_t command; esp_a2d_media_ctrl_ack_t status; + uint16_t avrcp_capabilities; + uint8_t volume; + bool register_volume_notification; } a2dp_media_event_t; typedef enum { @@ -59,6 +68,7 @@ static uint8_t s_pending_output_byte; #endif static QueueHandle_t s_discovery_queue; static QueueHandle_t s_media_event_queue; +static QueueHandle_t s_volume_event_queue; static SemaphoreHandle_t s_profile_deinit_sem; static volatile bool s_discovery_active; static volatile bool s_discovery_complete = true; @@ -70,6 +80,10 @@ static esp_bd_addr_t s_peer_address; static bool s_peer_address_valid; static bool s_reconnect_pending; static TickType_t s_reconnect_deadline; +static bool s_avrc_initialized; +static bool s_avrc_connected; +static bool s_avrc_volume_supported; +static uint8_t s_avrc_transaction_label; static audio_resampler_t s_resampler; #if defined(A2DP_TEST_TONE) @@ -218,6 +232,43 @@ static void a2dp_gap_event_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t } } +static void a2dp_avrc_ct_event_cb(esp_avrc_ct_cb_event_t event, esp_avrc_ct_cb_param_t *param) +{ + if (param == NULL || s_media_event_queue == NULL) { + return; + } + + a2dp_media_event_t media_event = {0}; + switch (event) { + case ESP_AVRC_CT_CONNECTION_STATE_EVT: + media_event.type = param->conn_stat.connected ? A2DP_MEDIA_EVENT_AVRCP_CONNECTED + : A2DP_MEDIA_EVENT_AVRCP_DISCONNECTED; + break; + case ESP_AVRC_CT_GET_RN_CAPABILITIES_RSP_EVT: + media_event.type = A2DP_MEDIA_EVENT_AVRCP_CAPABILITIES; + media_event.avrcp_capabilities = param->get_rn_caps_rsp.evt_set.bits; + break; + case ESP_AVRC_CT_CHANGE_NOTIFY_EVT: + if (param->change_ntf.event_id != ESP_AVRC_RN_VOLUME_CHANGE) { + return; + } + media_event.type = A2DP_MEDIA_EVENT_VOLUME_CHANGED; + media_event.volume = param->change_ntf.event_parameter.volume; + media_event.register_volume_notification = true; + break; + case ESP_AVRC_CT_SET_ABSOLUTE_VOLUME_RSP_EVT: + media_event.type = A2DP_MEDIA_EVENT_VOLUME_CHANGED; + media_event.volume = param->set_volume_rsp.volume; + break; + default: + return; + } + + if (xQueueSend(s_media_event_queue, &media_event, 0) != pdTRUE) { + ESP_LOGW(LOG_A2DP, "Dropping AVRCP event %d", event); + } +} + bool a2dp_init(const char *device_name) { if (s_profile_deinit_sem == NULL) { @@ -235,7 +286,15 @@ bool a2dp_init(const char *device_name) return false; } } + if (s_volume_event_queue == NULL) { + s_volume_event_queue = xQueueCreate(A2DP_VOLUME_EVENT_QUEUE_LENGTH, sizeof(uint8_t)); + if (s_volume_event_queue == NULL) { + ESP_LOGE(LOG_A2DP, "Failed to allocate AVRCP volume queue"); + return false; + } + } xQueueReset(s_media_event_queue); + xQueueReset(s_volume_event_queue); s_media_state = A2DP_MEDIA_IDLE; s_audio_started = false; s_peer_address_valid = false; @@ -270,18 +329,47 @@ bool a2dp_init(const char *device_name) if (esp_bt_gap_register_callback(a2dp_gap_event_cb) != ESP_OK || esp_bt_gap_set_device_name(device_name) != ESP_OK || - esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE) != ESP_OK || - esp_a2d_register_callback(a2dp_event_cb) != ESP_OK || + esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE) != ESP_OK) { + ESP_LOGE(LOG_A2DP, "Classic GAP initialization failed"); + return false; + } + + esp_err_t err = esp_avrc_ct_init(); + if (err != ESP_OK) { + ESP_LOGE(LOG_A2DP, "AVRCP controller init failed: %s", esp_err_to_name(err)); + return false; + } + s_avrc_initialized = true; + + err = esp_avrc_ct_register_callback(a2dp_avrc_ct_event_cb); + if (err != ESP_OK) { + ESP_LOGE(LOG_A2DP, "AVRCP callback registration failed: %s", esp_err_to_name(err)); + esp_avrc_ct_deinit(); + s_avrc_initialized = false; + return false; + } + + if (esp_a2d_register_callback(a2dp_event_cb) != ESP_OK || esp_a2d_source_register_data_callback(a2dp_data_cb) != ESP_OK || esp_a2d_source_init() != ESP_OK) { ESP_LOGE(LOG_A2DP, "A2DP source init failed"); + esp_avrc_ct_deinit(); + s_avrc_initialized = false; return false; } + + s_avrc_connected = false; + s_avrc_volume_supported = false; + s_avrc_transaction_label = 0; return true; } void a2dp_deinit(void) { + if (s_avrc_initialized) { + esp_avrc_ct_deinit(); + s_avrc_initialized = false; + } if (s_profile_deinit_sem != NULL) { xSemaphoreTake(s_profile_deinit_sem, 0); } @@ -296,11 +384,16 @@ void a2dp_deinit(void) s_media_state = A2DP_MEDIA_IDLE; s_audio_started = false; + s_avrc_connected = false; + s_avrc_volume_supported = false; s_peer_address_valid = false; s_reconnect_pending = false; if (s_media_event_queue != NULL) { xQueueReset(s_media_event_queue); } + if (s_volume_event_queue != NULL) { + xQueueReset(s_volume_event_queue); + } } void a2dp_connect(const char *addr) @@ -413,6 +506,57 @@ void a2dp_process(void) } } break; + + case A2DP_MEDIA_EVENT_AVRCP_CONNECTED: + s_avrc_connected = true; + s_avrc_volume_supported = false; + if (s_avrc_initialized) { + esp_err_t err = esp_avrc_ct_send_get_rn_capabilities_cmd(s_avrc_transaction_label++ & + ESP_AVRC_TRANS_LABEL_MAX); + if (err != ESP_OK) { + ESP_LOGW(LOG_A2DP, "AVRCP capabilities request failed: %s", esp_err_to_name(err)); + } + } + break; + + case A2DP_MEDIA_EVENT_AVRCP_DISCONNECTED: + s_avrc_connected = false; + s_avrc_volume_supported = false; + break; + + case A2DP_MEDIA_EVENT_AVRCP_CAPABILITIES: { + esp_avrc_rn_evt_cap_mask_t capabilities = {.bits = event.avrcp_capabilities}; + s_avrc_volume_supported = esp_avrc_rn_evt_bit_mask_operation( + ESP_AVRC_BIT_MASK_OP_TEST, &capabilities, ESP_AVRC_RN_VOLUME_CHANGE); + if (s_avrc_volume_supported) { + esp_err_t err = esp_avrc_ct_send_register_notification_cmd( + s_avrc_transaction_label++ & ESP_AVRC_TRANS_LABEL_MAX, + ESP_AVRC_RN_VOLUME_CHANGE, 0); + if (err != ESP_OK) { + ESP_LOGW(LOG_A2DP, "AVRCP volume notification registration failed: %s", + esp_err_to_name(err)); + } + } else { + ESP_LOGW(LOG_A2DP, "A2DP sink does not support AVRCP volume notifications"); + } + break; + } + + case A2DP_MEDIA_EVENT_VOLUME_CHANGED: + if (s_volume_event_queue != NULL && + xQueueSend(s_volume_event_queue, &event.volume, 0) != pdTRUE) { + ESP_LOGW(LOG_A2DP, "Dropping AVRCP volume update"); + } + if (event.register_volume_notification && s_avrc_connected && s_avrc_volume_supported) { + esp_err_t err = esp_avrc_ct_send_register_notification_cmd( + s_avrc_transaction_label++ & ESP_AVRC_TRANS_LABEL_MAX, + ESP_AVRC_RN_VOLUME_CHANGE, 0); + if (err != ESP_OK) { + ESP_LOGW(LOG_A2DP, "AVRCP volume notification re-registration failed: %s", + esp_err_to_name(err)); + } + } + break; } } @@ -449,6 +593,61 @@ bool a2dp_media_started(void) return s_audio_started; } +bool a2dp_set_volume(uint8_t volume) +{ + if (!s_avrc_initialized || !s_avrc_connected || volume > 0x7f) { + return false; + } + + esp_err_t err = esp_avrc_ct_send_set_absolute_volume_cmd( + s_avrc_transaction_label++ & ESP_AVRC_TRANS_LABEL_MAX, volume); + if (err != ESP_OK) { + ESP_LOGW(LOG_A2DP, "AVRCP set volume failed: %s", esp_err_to_name(err)); + return false; + } + return true; +} + +static bool a2dp_send_passthrough(esp_avrc_pt_cmd_t command) +{ + if (!s_avrc_initialized || !s_avrc_connected) { + return false; + } + + uint8_t transaction_label = s_avrc_transaction_label++ & ESP_AVRC_TRANS_LABEL_MAX; + esp_err_t err = esp_avrc_ct_send_passthrough_cmd( + transaction_label, command, ESP_AVRC_PT_CMD_STATE_PRESSED); + if (err != ESP_OK) { + ESP_LOGW(LOG_A2DP, "AVRCP key press failed: %s", esp_err_to_name(err)); + return false; + } + + transaction_label = s_avrc_transaction_label++ & ESP_AVRC_TRANS_LABEL_MAX; + err = esp_avrc_ct_send_passthrough_cmd( + transaction_label, command, ESP_AVRC_PT_CMD_STATE_RELEASED); + if (err != ESP_OK) { + ESP_LOGW(LOG_A2DP, "AVRCP key release failed: %s", esp_err_to_name(err)); + return false; + } + return true; +} + +bool a2dp_play(void) +{ + return a2dp_send_passthrough(ESP_AVRC_PT_CMD_PLAY); +} + +bool a2dp_stop(void) +{ + return a2dp_send_passthrough(ESP_AVRC_PT_CMD_STOP); +} + +bool a2dp_get_volume_update(uint8_t *volume) +{ + return volume != NULL && s_volume_event_queue != NULL && + xQueueReceive(s_volume_event_queue, volume, 0) == pdTRUE; +} + bool bt_a2dp_start_discovery(void) { if (s_discovery_queue == NULL || s_discovery_active) { @@ -505,6 +704,10 @@ void a2dp_feed_pcm(const uint8_t *data, size_t length) } void a2dp_process(void) {} bool a2dp_media_started(void) { return false; } +bool a2dp_set_volume(uint8_t volume) { (void)volume; return false; } +bool a2dp_play(void) { return false; } +bool a2dp_stop(void) { return false; } +bool a2dp_get_volume_update(uint8_t *volume) { (void)volume; return false; } bool bt_a2dp_start_discovery(void) { return false; } bool bt_a2dp_get_discovery_result(bt_addr_t address) { (void)address; return false; } bool bt_a2dp_discovery_complete(void) { return true; } diff --git a/src/bt_a2dp.h b/src/bt_a2dp.h index 7b5c2d6..04ccc99 100644 --- a/src/bt_a2dp.h +++ b/src/bt_a2dp.h @@ -28,6 +28,18 @@ void a2dp_process(void); /** Return whether the A2DP media channel is currently started. */ bool a2dp_media_started(void); +/** Set the AVRCP absolute volume, in the range 0..127. */ +bool a2dp_set_volume(uint8_t volume); + +/** Send AVRCP play to the connected A2DP sink. */ +bool a2dp_play(void); + +/** Send AVRCP stop to the connected A2DP sink. */ +bool a2dp_stop(void); + +/** Read one absolute-volume update received from the A2DP sink without blocking. */ +bool a2dp_get_volume_update(uint8_t *volume); + /** Start a Classic Bluetooth inquiry while the A2DP role is active. */ bool bt_a2dp_start_discovery(void); diff --git a/src/terminal.c b/src/terminal.c index bfad73e..40a355d 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -15,6 +15,7 @@ #include #include +#include #include #include @@ -366,6 +367,43 @@ static void parserATCommand(char *line) } break; + case AT_CMD_A2DPVOL: + if (curMode == ROLE_BTEDR_AUDIO_SOURCE) { + if (strcmp(cmd.argument, "?") == 0) { + write_str("OK+A2DPVOL:128\r\n"); + break; + } + + char *end = NULL; + long volume = strtol(cmd.argument, &end, 10); + if (end == cmd.argument || *end != '\0' || volume < 0 || volume > 127) { + write_str("ERROR\r\n"); + } else if (a2dp_set_volume((uint8_t)volume)) { + write_fmt("OK+A2DPVOL:%u\r\n", (unsigned)volume); + } else { + write_str("ERROR\r\n"); + } + } else { + write_str("ERROR\r\n"); + } + break; + + case AT_CMD_A2DPSTART: + if (curMode == ROLE_BTEDR_AUDIO_SOURCE && a2dp_play()) { + write_str("OK+A2DPSTART\r\n"); + } else { + write_str("ERROR\r\n"); + } + break; + + case AT_CMD_A2DPSTOP: + if (curMode == ROLE_BTEDR_AUDIO_SOURCE && a2dp_stop()) { + write_str("OK+A2DPSTOP\r\n"); + } else { + write_str("ERROR\r\n"); + } + break; + case AT_CMD_BTOFF: write_str("OK+BTOFF\r\n"); setRole(ROLE_BT_OFF); @@ -718,8 +756,12 @@ static void runBT(void) case ROLE_BLE_PERIPHERAL: runBTPeripherial(); break; - case ROLE_BTEDR_AUDIO_SOURCE: + case ROLE_BTEDR_AUDIO_SOURCE: { a2dp_process(); + uint8_t volume; + while (a2dp_get_volume_update(&volume)) { + write_fmt("A2DPVOL:%u\r\n", (unsigned)volume); + } if (a2dpScanActive) { bt_addr_t address; while (bt_a2dp_get_discovery_result(address)) { @@ -732,6 +774,7 @@ static void runBT(void) } } break; + } default: break; } diff --git a/test/test_at_command.c b/test/test_at_command.c index 4eff0e8..15d7379 100644 --- a/test/test_at_command.c +++ b/test/test_at_command.c @@ -88,6 +88,10 @@ void test_bluetooth_control_commands(void) char a2dp_connect[] = "AT+A2DPCONAABBCCDDEEFF\r\n"; char bt_off[] = "AT+BTOFF\r\n"; char bt_both[] = "AT+BTBOTH\r\n"; + char volume[] = "AT+A2DPVOL87\r\n"; + char volume_query[] = "AT+A2DPVOL?\r\n"; + char a2dp_start[] = "AT+A2DPSTART\r\n"; + char a2dp_stop[] = "AT+A2DPSTOP\r\n"; TEST_ASSERT_EQUAL(AT_CMD_BLEON, at_command_parse(ble_on).command); TEST_ASSERT_EQUAL(AT_CMD_BLEOFF, at_command_parse(ble_off).command); @@ -98,6 +102,14 @@ void test_bluetooth_control_commands(void) TEST_ASSERT_EQUAL_STRING("AABBCCDDEEFF", connect_result.argument); TEST_ASSERT_EQUAL(AT_CMD_BTOFF, at_command_parse(bt_off).command); TEST_ASSERT_EQUAL(AT_CMD_BTBOTH, at_command_parse(bt_both).command); + at_command_parse_t volume_result = at_command_parse(volume); + TEST_ASSERT_EQUAL(AT_CMD_A2DPVOL, volume_result.command); + TEST_ASSERT_EQUAL_STRING("87", volume_result.argument); + at_command_parse_t volume_query_result = at_command_parse(volume_query); + TEST_ASSERT_EQUAL(AT_CMD_A2DPVOL, volume_query_result.command); + TEST_ASSERT_EQUAL_STRING("?", volume_query_result.argument); + TEST_ASSERT_EQUAL(AT_CMD_A2DPSTART, at_command_parse(a2dp_start).command); + TEST_ASSERT_EQUAL(AT_CMD_A2DPSTOP, at_command_parse(a2dp_stop).command); } void test_unknown(void) diff --git a/tools/uart_audio_sender.py b/tools/uart_audio_sender.py index 8eeebba..ce3e01a 100644 --- a/tools/uart_audio_sender.py +++ b/tools/uart_audio_sender.py @@ -9,6 +9,8 @@ """ import argparse +import queue +import threading import time import wave @@ -24,6 +26,31 @@ SAMPLE_WIDTH_BYTES = 2 PREBUFFER_FRAMES = 2048 FRAMES_PER_CHUNK = 2048 +VOLUME_MAX = 127 +VOLUME_STEP = 8 + + +def start_volume_input(commands): + """Read stdin lines (0..127, '+' or '-') and queue AT+A2DPVOL commands.""" + def worker(): + volume = VOLUME_MAX // 2 + print(f"Volume control: type 0-{VOLUME_MAX}, '+' or '-' then Enter") + while True: + try: + line = input().strip() + except EOFError: + return + if line == "+": + volume = min(VOLUME_MAX, volume + VOLUME_STEP) + elif line == "-": + volume = max(0, volume - VOLUME_STEP) + elif line.isdigit() and int(line) <= VOLUME_MAX: + volume = int(line) + else: + continue + commands.put(b"AT+A2DPVOL%d\r\n" % volume) + + threading.Thread(target=worker, daemon=True).start() def read_until(port, token, timeout, show_output=True): @@ -74,7 +101,7 @@ def find_control_baud(port): raise TimeoutError("No A2DP response at 115200 or 460800 baud; reset the module") -def stream_wav(port, audio): +def stream_wav(port, audio, commands): total_frames = audio.getnframes() prebuffer_frames = min(PREBUFFER_FRAMES, total_frames) sent_frames = prebuffer_frames @@ -89,16 +116,34 @@ def stream_wav(port, audio): if written != len(data): raise serial.SerialTimeoutException("UART write was incomplete") + # Sent between PCM chunks so the command is not interleaved with audio bytes. + while True: + try: + port.write(commands.get_nowait()) + except queue.Empty: + break + sent_frames += len(data) // SAMPLE_WIDTH_BYTES deadline = start_time + sent_frames / SAMPLE_RATE - delay = deadline - time.monotonic() - if delay > 0: - time.sleep(delay) + while True: + drain_uart(port) + delay = deadline - time.monotonic() + if delay <= 0: + break + time.sleep(min(delay, 0.02)) port.flush() + drain_uart(port) print(f"\nSent {sent_frames}/{total_frames} PCM frames") +def drain_uart(port): + while port.in_waiting: + data = port.read(min(port.in_waiting, 512)) + if data: + print(data.decode("ascii", "replace"), end="", flush=True) + + def send_prebuffer(port, audio): data = audio.readframes(PREBUFFER_FRAMES) sent_frames = 0 @@ -178,7 +223,9 @@ def main(): read_until(port, b"A2DP media started", 30) print(f"Streaming {args.wav} at {AUDIO_BAUD} baud") - stream_wav(port, audio) + commands = queue.Queue() + start_volume_input(commands) + stream_wav(port, audio, commands) except (OSError, ValueError, wave.Error, serial.SerialException, TimeoutError) as exc: parser.error(str(exc)) finally: From f15380d077dccbcb67f506197f4d56d198245c49 Mon Sep 17 00:00:00 2001 From: Amigache <26549315+Amigache@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:27:38 +0200 Subject: [PATCH 8/8] ESP32_DEV38P tested --- README.md | 22 +++++++++------ doc/BENCH.md | 31 ++++++++++++++++++++- platformio.ini | 4 +++ sdkconfig.ESP32_DEV38P | 61 +++++++++++++++++++++++++++++++++--------- 4 files changed, 97 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9622ed3..e11db4c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Documentation: https://btwifimod.gitbook.io/ | Auto-connect to the last known device | Supported | | Device name from `AT+NAME` (persisted and advertised) | Supported | | Selectable BLE host: Bluedroid or NimBLE | Supported | +| Classic A2DP audio source (Bluedroid, Classic-BT boards) | Supported | ## How telemetry works @@ -114,14 +115,15 @@ doc/BENCH.md Bench setup, procedure and validation status. PlatformIO (ESP-IDF). Two BLE hosts are selectable: Bluedroid (default) or NimBLE. -**Supported targets** (app partition 1 MB): +**Supported targets** (Bluedroid builds for Classic-Bluetooth boards also carry +the A2DP source and use the 1.34 MB `pico_partitions.csv` app partition): -| Environment | BLE host | Flash | Board | -|---|---|---|---| -| `C3SuperMini` | Bluedroid | ~84 % | ESP32-C3 SuperMini | -| `C3SuperMini_nimble` | NimBLE | ~52 % | ESP32-C3 SuperMini | -| `ESP32_DEV38P` | Bluedroid | ~86 % | ESP32-DEV-38P | -| `ESP32_DEV38P_nimble` | NimBLE | ~53 % | ESP32-DEV-38P | +| Environment | BLE host | App partition | Flash | Board | +|---|---|---|---|---| +| `C3SuperMini` | Bluedroid | 1 MB | ~84 % | ESP32-C3 SuperMini | +| `C3SuperMini_nimble` | NimBLE | 1 MB | ~52 % | ESP32-C3 SuperMini | +| `ESP32_DEV38P` | Bluedroid + A2DP | 1.34 MB | ~91 % | ESP32-DEV-38P | +| `ESP32_DEV38P_nimble` | NimBLE | 1 MB | ~53 % | ESP32-DEV-38P | **Reference targets** (app partition 1.31 MB via `pico_partitions.csv`, not hardware-validated): @@ -221,7 +223,11 @@ Responses: `OK+...`, `Central:`, `Peripheral:`, `Connected:`, AVRCP exposes a logical range of 0..127; it does not report the speaker's physical volume-detent count. -A2DP is enabled in the RM Pico Bluedroid configuration, not the NimBLE build. +A2DP is enabled in the Bluedroid configuration of every Classic-Bluetooth board +(Radiomaster Pico and ESP32-DEV-38P) and never in the NimBLE build, which is +BLE-only. On the ESP32-DEV-38P the AT link is UART2 (GPIO18 TX / GPIO19 RX) while +logs stay on the CP2102 port (UART0), so A2DP is driven from a USB-UART adapter or +the radio, not the log port. For a test without EdgeTX PCM input, build and flash `RM_pico_a2dp_test`; it streams a local 500 Hz tone. Connect to a discoverable sink with: diff --git a/doc/BENCH.md b/doc/BENCH.md index 820be93..2ec8559 100644 --- a/doc/BENCH.md +++ b/doc/BENCH.md @@ -9,7 +9,10 @@ to repeat the same checks for each BLE host (Bluedroid and NimBLE). - ESP32-DEV-38P (ESP32 classic, `ESP32_DEV38P*`): radio UART on GPIO18 (TX) / GPIO19 (RX); logs on UART0 (its CP2102 bridge). Its auto-reset does not drive IO0, so enter download mode manually (hold BOOT, tap RESET) - before flashing. + before flashing. The Bluedroid build (`ESP32_DEV38P`) enables Bluetooth + Classic, so this single firmware provides both the BLE roles and the Classic + A2DP/AVRCP audio source; it uses the 1.34 MB `pico_partitions.csv` app + partition because the A2DP stack does not fit in the default 1 MB one. - Reference only (not validated): ESP32 Pico Kit (`pico32`, `pico32_nimble`, radio UART GPIO4 TX / GPIO5 RX) and Radiomaster Pico (`RM_pico`, `RM_pico_nimble`, radio UART GPIO1 TX / GPIO3 RX, console on UART1 @@ -117,6 +120,24 @@ Notes: headtracker reads about -80 dBm on the module and -56 dBm on the phone. Range is therefore limited by the module antenna. +## Classic A2DP audio source (Bluedroid, Classic-BT boards) + +Available on `ESP32_DEV38P` (Classic Bluetooth) and `RM_pico`; never on NimBLE. + +1. Drive the AT link (for the ESP32-DEV-38P a USB-UART adapter on GPIO18/19, not + the CP2102 log port) and send the sequence + `AT+A2DPON`, `AT+DISC?`, `AT+A2DPCON<12-digit-address>`. +2. Expect `OK+A2DPON`, then `OK+DISCS` / `OK+DISC:` per sink / `OK+DISCE` + from the inquiry, and finally a started media stream. +3. Volume: `AT+A2DPVOL` (0..127), `AT+A2DPVOL?` to query, and unsolicited + `A2DPVOL:` lines when the sink changes the absolute volume. +4. PCM path: send `AT+BAUD6` after connecting and feed mono 16-bit LE PCM at + 16 kHz over the AT link (or use `tools/uart_audio_sender.py`). +5. `AT+A2DPSTOP` / `AT+A2DPSTART` control playback; `AT+A2DPOFF` stops A2DP and + turns Bluetooth off. + +BLE and A2DP cannot run at the same time: enabling one stops the other. + ## Expected values (telemetry) | Sensor | Data ID | Sent value | Displayed | @@ -155,6 +176,7 @@ headtracker (no board-to-board master run): | AT command bench (16 checks) | PASS | PASS | | Slave: telemetry to the phone app | PASS | PASS | | Headtracker: detect + channels + reset | PASS | PASS | +| Classic A2DP source + AVRCP volume | PASS | n/a | ## Automated battery @@ -189,5 +211,12 @@ Last run (A = NimBLE, B = Bluedroid, headtracker at -84 dBm): - `AT+DISC?` now cancels any in-progress scan first, so it also completes while the module is auto-connecting (previously it returned `OK+DISCS` with no `OK+DISCE`). +- `tools/uart_audio_sender.py` waits for an `A2DP media started` line after + `AT+A2DPCON`. The firmware only emits that string from the `AT+A2DPON` handler + and as an `A2DP` log line; on the ESP32-DEV-38P the logs go to the CP2102 port + (UART0), not the AT link (UART2), so the wait can time out even though media is + streaming. The audio path itself is fine: streaming owned PCM at 460800 after + `AT+BAUD6` plays correctly. The handshake is only reliable on the Radiomaster + Pico, where the console and the AT link share UART0. - Telemetry app: connection can drop right after connecting when the S.Port frames are invalid (fixed by the checksum in `tools/sport_sender.py`). diff --git a/platformio.ini b/platformio.ini index 49b69d9..53cf5b3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -171,10 +171,14 @@ build_flags = ; --------------------------------------------------------------------------- ; ESP32-DEV-38P (ESP32 DevKit, 38 pins) — Bluedroid ; Radio UART on GPIO18 (TX) / GPIO19 (RX); logs on UART0. +; Classic Bluetooth is enabled in sdkconfig.ESP32_DEV38P, so this single build +; offers both the BLE roles and the Classic A2DP/AVRCP audio source. The A2DP +; stack does not fit in the default 1 MB app partition, hence pico_partitions.csv. ; --------------------------------------------------------------------------- [env:ESP32_DEV38P] extends = esp32 board = esp32_dev_38p +board_build.partitions = pico_partitions.csv upload_protocol = esptool build_type = release monitor_speed = 115200 diff --git a/sdkconfig.ESP32_DEV38P b/sdkconfig.ESP32_DEV38P index 496af8c..dc6e9eb 100644 --- a/sdkconfig.ESP32_DEV38P +++ b/sdkconfig.ESP32_DEV38P @@ -371,14 +371,14 @@ CONFIG_ESPTOOLPY_FLASHFREQ_40M=y # CONFIG_ESPTOOLPY_FLASHFREQ_20M is not set CONFIG_ESPTOOLPY_FLASHFREQ="40m" # CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set -CONFIG_ESPTOOLPY_FLASHSIZE_2MB=y -# CONFIG_ESPTOOLPY_FLASHSIZE_4MB is not set +# CONFIG_ESPTOOLPY_FLASHSIZE_2MB is not set +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y # CONFIG_ESPTOOLPY_FLASHSIZE_8MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_16MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_32MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_64MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_128MB is not set -CONFIG_ESPTOOLPY_FLASHSIZE="2MB" +CONFIG_ESPTOOLPY_FLASHSIZE="4MB" # CONFIG_ESPTOOLPY_HEADER_FLASHSIZE_UPDATE is not set CONFIG_ESPTOOLPY_BEFORE_RESET=y # CONFIG_ESPTOOLPY_BEFORE_NORESET is not set @@ -474,7 +474,25 @@ CONFIG_BT_BLUEDROID_PINNED_TO_CORE=0 CONFIG_BT_BTU_TASK_STACK_SIZE=4096 # CONFIG_BT_BLUEDROID_MEM_DEBUG is not set CONFIG_BT_BLUEDROID_ESP_COEX_VSC=y -# CONFIG_BT_CLASSIC_ENABLED is not set +CONFIG_BT_CLASSIC_ENABLED=y +CONFIG_BT_ENC_KEY_SIZE_CTRL_VSC=y +# CONFIG_BT_ENC_KEY_SIZE_CTRL_NONE is not set +# CONFIG_BT_CLASSIC_BQB_ENABLED is not set +CONFIG_BT_A2DP_ENABLE=y +CONFIG_BT_AVRCP_ENABLED=y + +# +# AVRCP Features +# +CONFIG_BT_AVRCP_CT_COVER_ART_ENABLED=y +# end of AVRCP Features + +# CONFIG_BT_SPP_ENABLED is not set +# CONFIG_BT_L2CAP_ENABLED is not set +# CONFIG_BT_SDP_COMMON_ENABLED is not set +# CONFIG_BT_HFP_ENABLE is not set +# CONFIG_BT_HID_ENABLED is not set +CONFIG_BT_GOEPC_ENABLED=y CONFIG_BT_BLE_ENABLED=y CONFIG_BT_GATTS_ENABLE=y # CONFIG_BT_GATTS_PPCP_CHAR_GAP is not set @@ -690,16 +708,31 @@ CONFIG_BT_BLE_RPA_TIMEOUT=900 # # Controller Options # -CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y +# CONFIG_BTDM_CTRL_MODE_BLE_ONLY is not set # CONFIG_BTDM_CTRL_MODE_BR_EDR_ONLY is not set -# CONFIG_BTDM_CTRL_MODE_BTDM is not set +CONFIG_BTDM_CTRL_MODE_BTDM=y CONFIG_BTDM_CTRL_BLE_MAX_CONN=2 -CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_EFF=0 +CONFIG_BTDM_CTRL_BR_EDR_MAX_ACL_CONN=2 +CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN=0 +# CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_HCI is not set +CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_PCM=y +CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_EFF=1 +CONFIG_BTDM_CTRL_PCM_ROLE_EDGE_CONFIG=y +CONFIG_BTDM_CTRL_PCM_ROLE_MASTER=y +# CONFIG_BTDM_CTRL_PCM_ROLE_SLAVE is not set +CONFIG_BTDM_CTRL_PCM_POLAR_FALLING_EDGE=y +# CONFIG_BTDM_CTRL_PCM_POLAR_RISING_EDGE is not set +CONFIG_BTDM_CTRL_PCM_FSYNCSHP_STEREO_MODE=y +# CONFIG_BTDM_CTRL_PCM_FSYNCSHP_MONO_MODE_LF is not set +# CONFIG_BTDM_CTRL_PCM_FSYNCSHP_MONO_MODE_FF is not set CONFIG_BTDM_CTRL_PCM_ROLE_EFF=0 CONFIG_BTDM_CTRL_PCM_POLAR_EFF=0 CONFIG_BTDM_CTRL_PCM_FSYNCSHP_EFF=0 +# CONFIG_BTDM_CTRL_AUTO_LATENCY is not set +CONFIG_BTDM_CTRL_LEGACY_AUTH_VENDOR_EVT=y +CONFIG_BTDM_CTRL_LEGACY_AUTH_VENDOR_EVT_EFF=y CONFIG_BTDM_CTRL_BLE_MAX_CONN_EFF=2 -CONFIG_BTDM_CTRL_BR_EDR_MAX_ACL_CONN_EFF=0 +CONFIG_BTDM_CTRL_BR_EDR_MAX_ACL_CONN_EFF=2 CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN_EFF=0 CONFIG_BTDM_CTRL_PINNED_TO_CORE_0=y # CONFIG_BTDM_CTRL_PINNED_TO_CORE_1 is not set @@ -2203,7 +2236,9 @@ CONFIG_BLUEDROID_PINNED_TO_CORE_0=y CONFIG_BLUEDROID_PINNED_TO_CORE=0 CONFIG_BTU_TASK_STACK_SIZE=4096 # CONFIG_BLUEDROID_MEM_DEBUG is not set -# CONFIG_CLASSIC_BT_ENABLED is not set +CONFIG_CLASSIC_BT_ENABLED=y +CONFIG_A2DP_ENABLE=y +# CONFIG_HFP_ENABLE is not set CONFIG_GATTS_ENABLE=y # CONFIG_GATTS_SEND_SERVICE_CHANGE_MANUAL is not set CONFIG_GATTS_SEND_SERVICE_CHANGE_AUTO=y @@ -2377,12 +2412,14 @@ CONFIG_BLUFI_INITIAL_TRACE_LEVEL=2 CONFIG_SMP_ENABLE=y # CONFIG_BLE_ACTIVE_SCAN_REPORT_ADV_SCAN_RSP_INDIVIDUALLY is not set CONFIG_BLE_ESTABLISH_LINK_CONNECTION_TIMEOUT=30 -CONFIG_BTDM_CONTROLLER_MODE_BLE_ONLY=y +# CONFIG_BTDM_CONTROLLER_MODE_BLE_ONLY is not set # CONFIG_BTDM_CONTROLLER_MODE_BR_EDR_ONLY is not set -# CONFIG_BTDM_CONTROLLER_MODE_BTDM is not set +CONFIG_BTDM_CONTROLLER_MODE_BTDM=y CONFIG_BTDM_CONTROLLER_BLE_MAX_CONN=2 +CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_ACL_CONN=2 +CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_SYNC_CONN=0 CONFIG_BTDM_CONTROLLER_BLE_MAX_CONN_EFF=2 -CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_ACL_CONN_EFF=0 +CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_ACL_CONN_EFF=2 CONFIG_BTDM_CONTROLLER_BR_EDR_MAX_SYNC_CONN_EFF=0 CONFIG_BTDM_CONTROLLER_PINNED_TO_CORE=0 CONFIG_BTDM_CONTROLLER_HCI_MODE_VHCI=y