diff --git a/docs/superpowers/plans/2026-06-19-phase4-bluetooth-hfp.md b/docs/superpowers/plans/2026-06-19-phase4-bluetooth-hfp.md new file mode 100644 index 0000000..1721d9f --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-phase4-bluetooth-hfp.md @@ -0,0 +1,654 @@ +# Phase 4 — Bluetooth HFP-HF (kit mains-libres) : Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Le combiné devient un kit mains-libres Bluetooth pour un mobile : appairage, appel entrant qui fait sonner la cloche, décroché qui répond, numérotation qui passe l'appel sur le mobile, audio bidirectionnel via le pont SCO↔I2S, raccroché qui termine. + +**Architecture :** On ajoute la pile Bluetooth Classic HFP-HF (Bluedroid `esp_hf_client`, adaptée de l'exemple officiel ESP-IDF `bluetooth/bluedroid/classic_bt/hfp_hf`). Chemin audio **HCI + WBS (mSBC 16 kHz)** : les callbacks data échangent du PCM brut 16 bit/16 kHz mono, format identique à l'I2S/ES8388 → pont direct. Le PCM SCO entrant passe par `audio_router` (writer TX unique, nouveau mode SCO) ; le PCM micro sortant vient d'une tâche de capture I2S RX. `call_manager` est étendu avec les événements BT et pilote `esp_hf_client` (dial/answer/hangup). + +**Tech Stack :** ESP-IDF v5.4, Bluedroid (`esp_hf_client`, `esp_bt_gap`), FreeRTOS ring buffers, I2S `i2s_std`, codec ES8388 16 kHz. + +## Global Constraints + +- ESP-IDF v5.4 — `. /Users/electron/esp/esp-idf/export.sh` (utiliser `python3` si `idf.py` échoue sur l'interpréteur). Cible `esp32`. Port `/dev/cu.usbserial-0001`. Matériel branché. Monitor non bloquant (pyserial, pas de `timeout`). +- Référence d'adaptation : `/Users/electron/esp/esp-idf/examples/bluetooth/bluedroid/classic_bt/hfp_hf/main/{main.c,bt_app_hf.c,bt_app_hf.h,bt_app_core.c}`. Réutiliser les séquences/handlers, retirer le REPL console et le data path PCM matériel. +- **Audio SCO = data path HCI + WBS (mSBC)** : PCM 16 bit / 16 kHz mono dans les callbacks. Aucun encodage/décodage mSBC côté app (Bluedroid le fait). Repli CVSD (`AUDIO_STATE_CONNECTED` sans MSBC) = PCM 8 kHz → rééchantillonner 8↔16 kHz. +- **Invariant TX unique** : seul `audio_router` (tâche `tone_task`) écrit l'I2S TX. Le PCM SCO entrant est injecté via l'API `audio_router_sco_*`, jamais par un writer direct. +- Init BT (ordre exact) : `esp_bt_controller_mem_release(ESP_BT_MODE_BLE)` → `esp_bt_controller_init` → `esp_bt_controller_enable(ESP_BT_MODE_CLASSIC_BT)` → `esp_bluedroid_init` → `esp_bluedroid_enable` → `esp_bt_gap_set_device_name` → `esp_bt_gap_register_callback` → `esp_hf_client_register_callback` → `esp_hf_client_init` → SSP/PIN → `esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE)`. +- NVS déjà initialisé (Phase 1) — requis avant le contrôleur BT. Logs `ESP_LOGx`. Hook git : sujet ≤ 50 car., sans attribution IA, corps ≤ 72 car./ligne. +- Mémoire : Bluedroid + buffers ; PSRAM 8 MB dispo (Phase 1) — utiliser PSRAM pour les ring buffers SCO si besoin. + +--- + +### Task 1 : sdkconfig HFP HCI + WBS (mSBC) + +**Files:** +- Modify: `sdkconfig.defaults` + +**Interfaces:** +- Consumes : la config BT de Phase 1 (`CONFIG_BT_ENABLED`, `BLUEDROID`, `BT_CLASSIC_ENABLED`, `BTDM_CTRL_MODE_BR_EDR_ONLY`, `BT_HFP_ENABLE`, `BT_HFP_CLIENT_ENABLE`, `BT_BLE_ENABLED` off). +- Produces : `sdkconfig` avec `CONFIG_BT_HFP_AUDIO_DATA_PATH_HCI=y` et `CONFIG_BT_HFP_WBS_ENABLE=y`. + +- [ ] **Step 1 : Ajouter les clés HFP audio à `sdkconfig.defaults`** + +Dans la section Bluetooth de `sdkconfig.defaults`, après `CONFIG_BT_HFP_AUDIO_DATA_PATH_HCI=y` (déjà présent depuis Phase 1 — sinon l'ajouter), ajouter : +``` +# HFP audio : Wide Band Speech (mSBC 16 kHz) via HCI +CONFIG_BT_HFP_WBS_ENABLE=y +# Un seul lien SCO/eSCO simultané +CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN=1 +``` + +- [ ] **Step 2 : Régénérer et builder** + +Run : `rm -f sdkconfig && . /Users/electron/esp/esp-idf/export.sh && idf.py build` +Expected : `Project build complete.` + +- [ ] **Step 3 : Vérifier les clés** + +Run : +```bash +grep -E "CONFIG_BT_HFP_AUDIO_DATA_PATH_HCI=|CONFIG_BT_HFP_WBS_ENABLE=|CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN=" sdkconfig +``` +Expected : +``` +CONFIG_BT_HFP_AUDIO_DATA_PATH_HCI=y +CONFIG_BT_HFP_WBS_ENABLE=y +CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN=1 +``` +Si `WBS_ENABLE` absent : il dépend de `BT_HFP_ENABLE && BT_HFP_AUDIO_DATA_PATH_HCI` — vérifier que ces deux-là sont `y` d'abord. + +- [ ] **Step 4 : Commit** + +```bash +git add sdkconfig.defaults +git commit -m "feat(bt): active HFP audio HCI + WBS mSBC" +``` + +--- + +### Task 2 : `bt_hfp` — pile Bluetooth + événements (sans pont audio) + +**Files:** +- Create: `components/bt_hfp/include/bt_hfp.h` +- Create: `components/bt_hfp/bt_hfp.c` +- Create: `components/bt_hfp/CMakeLists.txt` +- Modify: `main/CMakeLists.txt` (REQUIRES += `bt_hfp`) +- Modify: `main/app_main.c` (appeler `bt_hfp_init()` avec un handler de log temporaire) + +**Interfaces:** +- Consumes : NVS (Phase 1). +- Produces : + - Type d'événements de haut niveau remontés à l'app : + ```c + typedef enum { + BT_HFP_EV_SLC_CONNECTED, BT_HFP_EV_SLC_DISCONNECTED, + BT_HFP_EV_INCOMING, /* appel entrant (number dans data) */ + BT_HFP_EV_CALL_ACTIVE, /* appel en cours */ + BT_HFP_EV_CALL_ENDED, /* plus d'appel */ + BT_HFP_EV_AUDIO_CONNECTED, /* SCO établi (data.msbc = true si mSBC) */ + BT_HFP_EV_AUDIO_DISCONNECTED, + } bt_hfp_event_t; + typedef struct { const char *number; bool msbc; } bt_hfp_event_data_t; + typedef void (*bt_hfp_event_cb_t)(bt_hfp_event_t ev, const bt_hfp_event_data_t *data); + ``` + - `esp_err_t bt_hfp_init(bt_hfp_event_cb_t cb);` — init contrôleur+bluedroid+hf_client, GAP, scan mode. + - `esp_err_t bt_hfp_dial(const char *number);` + - `esp_err_t bt_hfp_answer(void);` + - `esp_err_t bt_hfp_hangup(void);` + - `esp_err_t bt_hfp_send_dtmf(char code);` + - `bool bt_hfp_slc_connected(void);` + +- [ ] **Step 1 : Créer `components/bt_hfp/include/bt_hfp.h`** + +```c +#pragma once +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + BT_HFP_EV_SLC_CONNECTED, + BT_HFP_EV_SLC_DISCONNECTED, + BT_HFP_EV_INCOMING, + BT_HFP_EV_CALL_ACTIVE, + BT_HFP_EV_CALL_ENDED, + BT_HFP_EV_AUDIO_CONNECTED, + BT_HFP_EV_AUDIO_DISCONNECTED, +} bt_hfp_event_t; + +typedef struct { const char *number; bool msbc; } bt_hfp_event_data_t; +typedef void (*bt_hfp_event_cb_t)(bt_hfp_event_t ev, const bt_hfp_event_data_t *data); + +/* Init Bluetooth Classic + Bluedroid + HFP-HF. cb receives high-level events. */ +esp_err_t bt_hfp_init(bt_hfp_event_cb_t cb); + +esp_err_t bt_hfp_dial(const char *number); /* ATD */ +esp_err_t bt_hfp_answer(void); /* ATA */ +esp_err_t bt_hfp_hangup(void); /* AT+CHUP */ +esp_err_t bt_hfp_send_dtmf(char code); +bool bt_hfp_slc_connected(void); + +#ifdef __cplusplus +} +#endif +``` + +- [ ] **Step 2 : Créer `components/bt_hfp/bt_hfp.c`** (adapté de l'exemple `main.c` + `bt_app_hf.c`, sans REPL ni data path PCM) + +Structure à écrire (le pont audio data-callback est ajouté en Task 4 ; ici on ne fait que SLC + événements) : + +```c +#include "bt_hfp.h" +#include +#include "esp_log.h" +#include "esp_bt.h" +#include "esp_bt_main.h" +#include "esp_bt_device.h" +#include "esp_gap_bt_api.h" +#include "esp_hf_client_api.h" + +#define TAG "bt_hfp" + +static bt_hfp_event_cb_t s_cb = NULL; +static esp_bd_addr_t s_peer = {0}; +static volatile bool s_slc = false; + +static void emit(bt_hfp_event_t ev, const char *number, bool msbc) +{ + if (s_cb) { bt_hfp_event_data_t d = { .number = number, .msbc = msbc }; s_cb(ev, &d); } +} + +/* GAP callback : pairing/auth. Reprendre esp_bt_gap_cb de l'exemple (AUTH_CMPL, + * PIN_REQ, CFM_REQ). Pour SSP simple, log + accepter. */ +static void gap_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param) +{ + switch (event) { + case ESP_BT_GAP_AUTH_CMPL_EVT: + if (param->auth_cmpl.stat == ESP_BT_STATUS_SUCCESS) { + ESP_LOGI(TAG, "appairage OK: %s", param->auth_cmpl.device_name); + } else { + ESP_LOGE(TAG, "appairage échoué: %d", param->auth_cmpl.stat); + } + break; +#if (CONFIG_EXAMPLE_SSP_ENABLED == true) + case ESP_BT_GAP_CFM_REQ_EVT: + esp_bt_gap_ssp_confirm_reply(param->cfm_req.bda, true); + break; +#endif + default: break; + } +} + +/* HFP-HF callback : traduit les événements bas-niveau en événements de haut niveau. */ +static void hf_cb(esp_hf_client_cb_event_t event, esp_hf_client_cb_param_t *param) +{ + switch (event) { + case ESP_HF_CLIENT_CONNECTION_STATE_EVT: + if (param->conn_stat.state == ESP_HF_CLIENT_CONNECTION_STATE_SLC_CONNECTED) { + memcpy(s_peer, param->conn_stat.remote_bda, ESP_BD_ADDR_LEN); + s_slc = true; + ESP_LOGI(TAG, "SLC connecté"); + emit(BT_HFP_EV_SLC_CONNECTED, NULL, false); + } else if (param->conn_stat.state == ESP_HF_CLIENT_CONNECTION_STATE_DISCONNECTED) { + s_slc = false; + ESP_LOGI(TAG, "SLC déconnecté"); + emit(BT_HFP_EV_SLC_DISCONNECTED, NULL, false); + } + break; + case ESP_HF_CLIENT_CIND_CALL_SETUP_EVT: + if (param->call_setup.status == ESP_HF_CALL_SETUP_STATUS_INCOMING) { + ESP_LOGI(TAG, "appel entrant"); + emit(BT_HFP_EV_INCOMING, NULL, false); + } + break; + case ESP_HF_CLIENT_CLIP_EVT: + ESP_LOGI(TAG, "numéro appelant: %s", param->clip.number ? param->clip.number : "?"); + emit(BT_HFP_EV_INCOMING, param->clip.number, false); + break; + case ESP_HF_CLIENT_CIND_CALL_EVT: + if (param->call.status == ESP_HF_CALL_STATUS_CALL_IN_PROGRESS) { + emit(BT_HFP_EV_CALL_ACTIVE, NULL, false); + } else { + emit(BT_HFP_EV_CALL_ENDED, NULL, false); + } + break; + case ESP_HF_CLIENT_AUDIO_STATE_EVT: + if (param->audio_stat.state == ESP_HF_CLIENT_AUDIO_STATE_CONNECTED || + param->audio_stat.state == ESP_HF_CLIENT_AUDIO_STATE_CONNECTED_MSBC) { + bool msbc = (param->audio_stat.state == ESP_HF_CLIENT_AUDIO_STATE_CONNECTED_MSBC); + ESP_LOGI(TAG, "audio SCO connecté (%s)", msbc ? "mSBC 16k" : "CVSD 8k"); + emit(BT_HFP_EV_AUDIO_CONNECTED, NULL, msbc); + } else if (param->audio_stat.state == ESP_HF_CLIENT_AUDIO_STATE_DISCONNECTED) { + ESP_LOGI(TAG, "audio SCO déconnecté"); + emit(BT_HFP_EV_AUDIO_DISCONNECTED, NULL, false); + } + break; + default: break; + } +} + +esp_err_t bt_hfp_init(bt_hfp_event_cb_t cb) +{ + s_cb = cb; + ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_BLE)); + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_bt_controller_init(&bt_cfg); + if (ret != ESP_OK) { ESP_LOGE(TAG, "controller_init: %s", esp_err_to_name(ret)); return ret; } + ret = esp_bt_controller_enable(ESP_BT_MODE_CLASSIC_BT); + if (ret != ESP_OK) { ESP_LOGE(TAG, "controller_enable: %s", esp_err_to_name(ret)); return ret; } + ret = esp_bluedroid_init(); + if (ret != ESP_OK) { ESP_LOGE(TAG, "bluedroid_init: %s", esp_err_to_name(ret)); return ret; } + ret = esp_bluedroid_enable(); + if (ret != ESP_OK) { ESP_LOGE(TAG, "bluedroid_enable: %s", esp_err_to_name(ret)); return ret; } + + esp_bt_gap_set_device_name("RTC_BL_PHONE"); + esp_bt_gap_register_callback(gap_cb); + esp_hf_client_register_callback(hf_cb); + esp_hf_client_init(); + +#if (CONFIG_EXAMPLE_SSP_ENABLED == true) + esp_bt_sp_param_t pt = ESP_BT_SP_IOCAP_MODE; + esp_bt_io_cap_t iocap = ESP_BT_IO_CAP_IO; + esp_bt_gap_set_security_param(pt, &iocap, sizeof(uint8_t)); +#endif + esp_bt_pin_type_t pin_type = ESP_BT_PIN_TYPE_FIXED; + esp_bt_pin_code_t pin_code = { '0', '0', '0', '0' }; + esp_bt_gap_set_pin(pin_type, 4, pin_code); + + esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE); + ESP_LOGI(TAG, "bt_hfp prêt — appairer le mobile sur 'RTC_BL_PHONE'"); + return ESP_OK; +} + +esp_err_t bt_hfp_dial(const char *number) { return esp_hf_client_dial(number); } +esp_err_t bt_hfp_answer(void) { return esp_hf_client_answer_call(); } +esp_err_t bt_hfp_hangup(void) { return esp_hf_client_reject_call(); } +esp_err_t bt_hfp_send_dtmf(char code) { return esp_hf_client_send_dtmf(code); } +bool bt_hfp_slc_connected(void) { return s_slc; } +``` +Note : si `CONFIG_EXAMPLE_SSP_ENABLED` n'existe pas dans ce projet (pas de Kconfig.projbuild d'exemple), retirer les gardes `#if CONFIG_EXAMPLE_SSP_ENABLED` et garder le bloc SSP inconditionnel (SSP activé par défaut en IDF) — vérifier au build et adapter. + +- [ ] **Step 3 : Créer `components/bt_hfp/CMakeLists.txt`** + +```cmake +idf_component_register( + SRCS "bt_hfp.c" + INCLUDE_DIRS "include" + REQUIRES bt +) +``` + +- [ ] **Step 4 : Câbler dans `main/CMakeLists.txt` + `main/app_main.c`** + +Ajouter `bt_hfp` aux REQUIRES de `main`. Dans `app_main.c`, ajouter `#include "bt_hfp.h"` et, APRÈS `call_manager_start()`, un handler de log temporaire : +```c +static void bt_log_cb(bt_hfp_event_t ev, const bt_hfp_event_data_t *d) +{ + ESP_LOGI(TAG, "BT event %d (number=%s msbc=%d)", (int)ev, + d->number ? d->number : "-", (int)d->msbc); +} +``` +et l'appel : +```c + ESP_ERROR_CHECK(bt_hfp_init(bt_log_cb)); +``` + +- [ ] **Step 5 : Builder** + +Run : `. /Users/electron/esp/esp-idf/export.sh && idf.py build` +Expected : `Project build complete.` (corriger toute API BT divergente en consultant `esp_hf_client_api.h` / `esp_gap_bt_api.h` réels). + +- [ ] **Step 6 : Flasher et appairer (matériel + mobile)** + +Run : flash + monitor. Sur le mobile, Bluetooth → appairer **`RTC_BL_PHONE`**. +Expected : logs `bt_hfp prêt`, puis à l'appairage `appairage OK` + `SLC connecté` (`BT event 0`). Passer un appel ENTRANT depuis un autre téléphone vers le mobile : logs `appel entrant` + `numéro appelant` (`BT event 2`). (Le pont audio n'existe pas encore — pas de son. C'est l'objet des Tasks 3-5.) +Validation matérielle nécessitant l'utilisateur (mobile + appairage). Reporter DONE_WITH_CONCERNS avec les logs si l'appairage/SLC ne s'établit pas. + +- [ ] **Step 7 : Commit** + +```bash +git add components/bt_hfp main/CMakeLists.txt main/app_main.c +git commit -m "feat(bt): pile HFP-HF + evenements" +``` + +--- + +### Task 3 : `audio_router` — mode SCO (pont PCM ↔ I2S) + +**Files:** +- Modify: `components/audio_router/include/audio_router.h` +- Modify: `components/audio_router/audio_router.c` + +**Interfaces:** +- Consumes : `hal_i2s_spk_handle()`, `hal_i2s_capture_read_frame()`, `hal_audio_pa_set()`. +- Produces : + - `void audio_router_sco_begin(bool msbc);` — passe le writer TX en mode SCO (la tâche tone joue le PCM entrant au lieu des tonalités), démarre la tâche de capture micro→ring sortant, active la PA. `msbc` : true=16 kHz direct, false=CVSD 8 kHz (rééchantillonné). + - `void audio_router_sco_end(void);` — repasse en idle, coupe la PA, stoppe la capture. + - `void audio_router_sco_feed_playback(const uint8_t *pcm, uint32_t len);` — pousse le PCM SCO entrant (depuis `incoming_cb`) vers le ring de lecture. + - `uint32_t audio_router_sco_take_capture(uint8_t *pcm, uint32_t len);` — fournit le PCM micro sortant (pour `outgoing_cb`) ; retourne le nb d'octets fournis (0 si pas assez). + +- [ ] **Step 1 : Ajouter l'API SCO à `audio_router.h`** + +Avant la fermeture `#ifdef __cplusplus`, ajouter : +```c +/* SCO bridge (Phase 4 HFP). audio_router stays the single I2S TX writer. */ +void audio_router_sco_begin(bool msbc); +void audio_router_sco_end(void); +void audio_router_sco_feed_playback(const uint8_t *pcm, uint32_t len); +uint32_t audio_router_sco_take_capture(uint8_t *pcm, uint32_t len); +``` + +- [ ] **Step 2 : Implémenter le mode SCO dans `audio_router.c`** + +Ajouter les includes et états : +```c +#include "freertos/ringbuf.h" +#include "hal_i2s.h" +#include "esp_heap_caps.h" + +#define SCO_RB_SIZE 4096 /* ~128 ms @ 16 kHz mono 16-bit */ +static RingbufHandle_t s_sco_play_rb = NULL; /* PCM entrant -> écouteur (TX) */ +static RingbufHandle_t s_sco_cap_rb = NULL; /* micro -> PCM sortant */ +static volatile bool s_sco_active = false; +static volatile bool s_sco_msbc = true; +static TaskHandle_t s_sco_mic_task = NULL; +``` +Ajouter un mode `TONE_SCO` à l'enum `tone_mode_t`. Dans `tone_task`, traiter le cas SCO : au lieu de générer un sinus, lire une frame depuis `s_sco_play_rb` et l'écrire dans l'I2S TX (mono→stéréo en dupliquant L=R ; si CVSD 8 kHz, dupliquer chaque sample pour passer à 16 kHz). Si le ring est vide, écrire du silence (underrun). + +```c +/* Dans tone_task, ajouter avant le calcul de tonalité : */ +if (m == TONE_SCO) { + /* Lire ~20 ms de PCM mono entrant ; le convertir en stéréo pour l'I2S. */ + size_t want = s_sco_msbc ? (FRAME * 2) : (FRAME); /* mono samples voulus */ + size_t got_bytes = 0; + int16_t *in = (int16_t *)xRingbufferReceiveUpTo(s_sco_play_rb, &got_bytes, 0, + want * sizeof(int16_t)); + int16_t st[FRAME * 2]; + int frames = 0; + if (in) { + int n = (int)(got_bytes / sizeof(int16_t)); + for (int i = 0; i < n && frames < FRAME; i++) { + int16_t v = in[i]; + st[frames*2] = v; st[frames*2+1] = v; frames++; + if (!s_sco_msbc && frames < FRAME) { /* CVSD 8k -> 16k : doubler */ + st[frames*2] = v; st[frames*2+1] = v; frames++; + } + } + vRingbufferReturnItem(s_sco_play_rb, in); + } + while (frames < FRAME) { st[frames*2] = 0; st[frames*2+1] = 0; frames++; } /* underrun */ + size_t written = 0; + s_tone_idle = false; + i2s_channel_write(hal_i2s_spk_handle(), st, sizeof(st), &written, pdMS_TO_TICKS(50)); + continue; +} +``` + +Ajouter la tâche de capture micro qui alimente `s_sco_cap_rb` : +```c +static void sco_mic_task(void *a) +{ + (void)a; + int16_t mono[FRAME]; + while (s_sco_active) { + int64_t e = 0; + int n = hal_i2s_capture_read_frame(mono, FRAME, &e); + if (n <= 0) { vTaskDelay(pdMS_TO_TICKS(5)); continue; } + if (s_sco_msbc) { + xRingbufferSend(s_sco_cap_rb, mono, n * sizeof(int16_t), 0); + } else { /* 16k -> 8k : décimer 1 sur 2 */ + int16_t dec[FRAME/2]; int m = 0; + for (int i = 0; i < n; i += 2) dec[m++] = mono[i]; + xRingbufferSend(s_sco_cap_rb, dec, m * sizeof(int16_t), 0); + } + } + s_sco_mic_task = NULL; + vTaskDelete(NULL); +} +``` + +Implémenter les 4 fonctions publiques : +```c +void audio_router_sco_begin(bool msbc) +{ + s_sco_msbc = msbc; + if (!s_sco_play_rb) s_sco_play_rb = xRingbufferCreate(SCO_RB_SIZE, RINGBUF_TYPE_BYTEBUF); + if (!s_sco_cap_rb) s_sco_cap_rb = xRingbufferCreate(SCO_RB_SIZE, RINGBUF_TYPE_BYTEBUF); + hal_i2s_capture_begin(); + hal_audio_pa_set(true); + audio_router_init(); /* s'assure que tone_task existe */ + s_sco_active = true; + s_mode = TONE_SCO; + if (!s_sco_mic_task) xTaskCreatePinnedToCore(sco_mic_task, "sco_mic", 4096, NULL, 6, &s_sco_mic_task, 1); + ESP_LOGI(TAG, "SCO bridge ON (%s)", msbc ? "mSBC 16k" : "CVSD 8k"); +} + +void audio_router_sco_end(void) +{ + s_sco_active = false; + s_mode = TONE_NONE; + hal_audio_pa_set(false); + ESP_LOGI(TAG, "SCO bridge OFF"); +} + +void audio_router_sco_feed_playback(const uint8_t *pcm, uint32_t len) +{ + if (s_sco_play_rb && s_sco_active) xRingbufferSend(s_sco_play_rb, pcm, len, 0); +} + +uint32_t audio_router_sco_take_capture(uint8_t *pcm, uint32_t len) +{ + if (!s_sco_cap_rb) return 0; + size_t got = 0; + uint8_t *d = xRingbufferReceiveUpTo(s_sco_cap_rb, &got, 0, len); + if (d) { memcpy(pcm, d, got); vRingbufferReturnItem(s_sco_cap_rb, d); return (uint32_t)got; } + return 0; +} +``` +Le composant requiert déjà `hal_i2s` ; ajouter `esp_ringbuf` si nécessaire dans le REQUIRES de `components/audio_router/CMakeLists.txt` (le ring buffer fait partie de `freertos`/`esp_ringbuf`). + +- [ ] **Step 3 : Builder** + +Run : `idf.py build` → `Project build complete.` (ajuster les REQUIRES CMake si `xRingbuffer*` non résolu : ajouter `esp_ringbuf`). + +- [ ] **Step 4 : Commit** + +```bash +git add components/audio_router +git commit -m "feat(bt): audio_router mode SCO (pont PCM I2S)" +``` + +--- + +### Task 4 : Pont data SCO dans `bt_hfp` (callbacks ↔ audio_router) + +**Files:** +- Modify: `components/bt_hfp/bt_hfp.c` (enregistrer les callbacks data sur audio connecté, brancher sur audio_router) +- Modify: `components/bt_hfp/CMakeLists.txt` (REQUIRES += `audio_router`) + +**Interfaces:** +- Consumes : `audio_router_sco_feed_playback()`, `audio_router_sco_take_capture()` (Task 3). +- Produces : sur `AUDIO_STATE_CONNECTED(_MSBC)`, enregistre `esp_hf_client_register_data_callback(incoming, outgoing)` ; sur déconnexion, les retire. + +- [ ] **Step 1 : Ajouter les callbacks data dans `bt_hfp.c`** + +Ajouter `#include "audio_router.h"` et : +```c +static void sco_incoming_cb(const uint8_t *buf, uint32_t len) +{ + audio_router_sco_feed_playback(buf, len); + esp_hf_client_outgoing_data_ready(); +} + +static uint32_t sco_outgoing_cb(uint8_t *buf, uint32_t len) +{ + return audio_router_sco_take_capture(buf, len); +} +``` + +- [ ] **Step 2 : Brancher sur l'événement AUDIO_STATE dans `hf_cb`** + +Dans le `case ESP_HF_CLIENT_AUDIO_STATE_EVT`, après le `emit(BT_HFP_EV_AUDIO_CONNECTED, ...)` du cas connecté, ajouter : +```c + esp_hf_client_register_data_callback(sco_incoming_cb, sco_outgoing_cb); +``` +(l'`emit` déclenchera `audio_router_sco_begin(msbc)` côté call_manager en Task 5, donc les rings existent avant l'arrivée des data). + +- [ ] **Step 3 : REQUIRES audio_router** + +`components/bt_hfp/CMakeLists.txt` : `REQUIRES bt audio_router`. + +- [ ] **Step 4 : Builder** + +Run : `idf.py build` → `Project build complete.` + +- [ ] **Step 5 : Commit** + +```bash +git add components/bt_hfp +git commit -m "feat(bt): pont data SCO HCI vers audio_router" +``` + +--- + +### Task 5 : `call_manager` HFP — intégration complète (critère de phase) + +**Files:** +- Modify: `components/call_manager/call_manager.c` (états + événements BT) +- Modify: `components/call_manager/CMakeLists.txt` (REQUIRES += `bt_hfp` `slic_ks0835`) +- Modify: `main/app_main.c` (retirer le handler BT temporaire ; câbler bt_hfp → call_manager) + +**Interfaces:** +- Consumes : `bt_hfp_*` (Tasks 2-4), `slic_ring_start/stop` (Phase 3), `audio_router_sco_begin/end` (Task 3), `hook_monitor` + `dialer` + `dtmf` (Phase 3). +- Produces : machine d'état d'appel complète reliant gestes du combiné ↔ HFP. + +- [ ] **Step 1 : Étendre la FSM dans `call_manager.c`** + +Ajouter les états et le pont d'événements BT : +```c +typedef enum { ST_IDLE, ST_DIALTONE, ST_DIALING, ST_OUTGOING, ST_INCOMING, ST_ACTIVE } call_state_t; + +static volatile bool s_bt_dirty = false; +static volatile bt_hfp_event_t s_bt_ev; +static char s_incoming_num[32]; +static volatile bool s_sco_up = false; +static volatile bool s_sco_msbc = false; + +void call_manager_bt_event(bt_hfp_event_t ev, const bt_hfp_event_data_t *d) +{ + s_bt_ev = ev; + if (ev == BT_HFP_EV_INCOMING && d && d->number) { + strncpy(s_incoming_num, d->number, sizeof(s_incoming_num) - 1); + } + if (ev == BT_HFP_EV_AUDIO_CONNECTED && d) s_sco_msbc = d->msbc; + s_bt_dirty = true; +} +``` +Déclarer `void call_manager_bt_event(bt_hfp_event_t, const bt_hfp_event_data_t *);` dans `call_manager.h` (include `bt_hfp.h`). + +- [ ] **Step 2 : Traiter les événements BT dans `call_task`** + +Dans la boucle `call_task`, après le bloc hook, ajouter : +```c + if (s_bt_dirty) { + s_bt_dirty = false; + switch (s_bt_ev) { + case BT_HFP_EV_INCOMING: + if (st == ST_IDLE || st == ST_DIALTONE) { + audio_router_tones_stop(); + slic_ring_start(); /* fait sonner la cloche */ + st = ST_INCOMING; + ESP_LOGI(TAG, "-> INCOMING %s", s_incoming_num); + } + break; + case BT_HFP_EV_AUDIO_CONNECTED: + slic_ring_stop(); + audio_router_sco_begin(s_sco_msbc); + s_sco_up = true; + st = ST_ACTIVE; + ESP_LOGI(TAG, "-> ACTIVE (audio SCO)"); + break; + case BT_HFP_EV_AUDIO_DISCONNECTED: + if (s_sco_up) { audio_router_sco_end(); s_sco_up = false; } + break; + case BT_HFP_EV_CALL_ENDED: + slic_ring_stop(); + if (s_sco_up) { audio_router_sco_end(); s_sco_up = false; } + /* si toujours décroché, tonalité d'occupation puis attente raccroché */ + if (hook_monitor_offhook()) { audio_router_busy_start(); } + st = (hook_monitor_offhook()) ? ST_DIALTONE : ST_IDLE; + ESP_LOGI(TAG, "-> appel terminé"); + break; + default: break; + } + } +``` + +- [ ] **Step 3 : Adapter les transitions hook pour le HFP** + +Modifier le bloc hook de `call_task` : +- raccroché (`!s_offhook`) : si `s_sco_up` ou appel en cours → `bt_hfp_hangup()` ; puis `go_idle()` (qui doit aussi appeler `audio_router_sco_end()` si actif). +- décroché (`s_offhook`) depuis `ST_INCOMING` → `bt_hfp_answer()` (répondre). +- décroché depuis `ST_IDLE` → dialtone + dtmf (comme Phase 3). + +Et la complétion de numérotation (`ST_DIALING`, 3 s silence) : au lieu de figer, appeler `bt_hfp_dial(dialer_current())` et passer à `ST_OUTGOING` : +```c + if (!dialer_idle() && dialer_ms_since_last() > DIAL_COMPLETE_SILENCE_MS) { + ESP_LOGI(TAG, "appel sortant: %s", dialer_current()); + bt_hfp_dial(dialer_current()); + dtmf_stop(); + st = ST_OUTGOING; /* attend AUDIO_CONNECTED -> ACTIVE */ + } +``` +Mettre à jour `go_idle()` pour couper le SCO : +```c +static void go_idle(void) +{ + audio_router_tones_stop(); + if (s_sco_up) { audio_router_sco_end(); s_sco_up = false; } + dtmf_stop(); + slic_ring_stop(); + hal_audio_pa_set(false); + dialer_reset(); + ESP_LOGI(TAG, "-> IDLE"); +} +``` + +- [ ] **Step 4 : CMake + app_main** + +`components/call_manager/CMakeLists.txt` : `REQUIRES hook_monitor dialer dtmf audio_router hal_i2s bt_hfp slic_ks0835`. +Dans `main/app_main.c` : retirer `bt_log_cb` (handler temporaire) ; remplacer `bt_hfp_init(bt_log_cb)` par `bt_hfp_init(call_manager_bt_event)`. Ordre : `call_manager_start()` AVANT `bt_hfp_init(...)` (le callback doit être prêt à recevoir des événements). + +- [ ] **Step 5 : Builder** + +Run : `idf.py build` → `Project build complete.` + +- [ ] **Step 6 : VALIDER LE CRITÈRE DE PHASE (matériel + mobile)** + +Run : flash + monitor. Appairer le mobile sur `RTC_BL_PHONE`. +Tester les deux sens : +1. **Appel sortant** : décrocher → tonalité → composer un numéro (cadran ou DTMF) → attendre 3 s → log `appel sortant: ...` ; le mobile compose ; à la prise d'appel par le correspondant → `-> ACTIVE` + **audio bidirectionnel** (parler/écouter dans le combiné). Raccrocher → l'appel se termine. +2. **Appel entrant** : appeler le mobile depuis un autre téléphone → la **cloche du combiné sonne** (`-> INCOMING`) → décrocher → `bt_hfp_answer` → `-> ACTIVE` + audio bidirectionnel. Raccrocher → fin. +Expected : les deux parcours fonctionnent, audio clair dans les deux sens. C'est le critère de sortie de la Phase 4 (et la finalité du produit). Validation nécessitant l'utilisateur + un mobile ; reporter DONE_WITH_CONCERNS avec les logs et observations audio si un sens ne marche pas (underrun, latence, sens manquant). + +- [ ] **Step 7 : Commit** + +```bash +git add components/call_manager main/app_main.c +git commit -m "feat(bt): call_manager HFP appels entrant/sortant" +``` + +--- + +## Critère de sortie de Phase 4 + +Le combiné rétro fonctionne comme kit mains-libres Bluetooth : appairage au mobile, appel entrant qui fait sonner la cloche puis décroché pour répondre, numérotation (impulsion/DTMF) qui passe l'appel via le mobile, **audio bidirectionnel** combiné↔correspondant via le pont SCO↔I2S (mSBC 16 kHz), raccroché qui termine. C'est la finalité du produit. + +## Notes sur les tests + +- La pile BT et le pont SCO ne sont pas testables en host ; validation par **build + flash + appairage + appel réel** avec un mobile (intervention utilisateur). +- Points de vigilance au test : latence/underrun du pont SCO (dimensionner `SCO_RB_SIZE`), sens audio manquant (vérifier l'ordre register_data_callback vs sco_begin), repli CVSD si le mobile ne fait pas de WBS (le rééchantillonnage 8↔16 kHz simple peut nécessiter un filtrage anti-repliement ultérieur). diff --git a/docs/superpowers/specs/2026-06-19-esp-idf-hfp-migration-design.md b/docs/superpowers/specs/2026-06-19-esp-idf-hfp-migration-design.md index 59a867b..742bcde 100644 --- a/docs/superpowers/specs/2026-06-19-esp-idf-hfp-migration-design.md +++ b/docs/superpowers/specs/2026-06-19-esp-idf-hfp-migration-design.md @@ -78,6 +78,33 @@ pour l'I2S/codec. Le choix du codec SCO en Phase 4 (CVSD 8 kHz avec rééchantil **ou** mSBC 16 kHz natif aligné sur l'I2S) est reporté à la Phase 4 — la décision « CVSD 8 kHz au départ » de §1 est rouverte à la lumière de cette base 16 kHz. +## 2ter. Décisions Phase 4 (HFP) résolues (2026-06-19) + +Analyse de l'exemple officiel ESP-IDF `bluetooth/bluedroid/classic_bt/hfp_hf` (v5.4) : + +- **Codec SCO : mSBC 16 kHz via HCI + WBS** (et non CVSD 8 kHz). Avec + `CONFIG_BT_HFP_AUDIO_DATA_PATH_HCI=y` + `CONFIG_BT_HFP_WBS_ENABLE=y`, les callbacks data + reçoivent/fournissent du **PCM brut 16 bit / 16 kHz mono** ; Bluedroid encode/décode le mSBC + en interne. C'est exactement le format de l'I2S/ES8388 (16 kHz) → **pont direct, sans + rééchantillonnage ni codec applicatif**. Repli CVSD 8 kHz (état `AUDIO_STATE_CONNECTED` vs + `CONNECTED_MSBC`) géré par un rééchantillonnage simple 8↔16 kHz si un mobile ne supporte pas le WBS. +- **Init BT** (depuis l'exemple) : `esp_bt_controller_mem_release(BLE)` → `controller_init` → + `controller_enable(ESP_BT_MODE_CLASSIC_BT)` → `bluedroid_init` → `bluedroid_enable` → + `gap_set_device_name` → `gap_register_callback` → `esp_hf_client_register_callback` → + `esp_hf_client_init` → SSP/PIN → `gap_set_scan_mode(CONNECTABLE, DISCOVERABLE)`. +- **Pont SCO** (data path HCI) : sur `AUDIO_STATE_EVT` connecté → `esp_hf_client_register_data_callback`. + `incoming_cb(buf,len)` = PCM reçu à jouer ; `outgoing_cb(buf,len)` = PCM micro à fournir ; + `esp_hf_client_outgoing_data_ready()` signale la dispo. Bridge via ring buffers. +- **Invariant TX unique** (cf. §résultat revue Phase 3) : le PCM SCO entrant passe **par + `audio_router`** (mode SCO du writer TX unique), jamais par un second writer direct de l'I2S. + Le PCM micro sortant vient d'une tâche de capture lisant l'I2S RX (`hal_i2s_capture`). +- **Commandes** : `esp_hf_client_dial(num)` (ATD), `esp_hf_client_answer_call()` (ATA), + `esp_hf_client_reject_call()` (AT+CHUP), `esp_hf_client_send_dtmf(c)`. +- **Mapping gestes ↔ HFP** : décroché pendant `CALL_SETUP=INCOMING` → answer ; décroché au repos + → dialtone+numérotation → `dial` ; raccroché en appel → `reject_call` ; appel entrant (CALL_SETUP + INCOMING / RING_IND) → `slic_ring_start` + mémoriser le numéro (CLIP) ; `AUDIO_STATE` connecté → + démarrer le pont SCO + PA on ; `CALL=NO_CALLS` → stop SCO + `go_idle`. + ## 3. Approche retenue **Projet ESP-IDF neuf en composants `idf.py`, en greffant la couche BT depuis l'exemple officiel