docs: decisions HFP + plan Phase 4 bluetooth

This commit is contained in:
clement
2026-06-19 12:56:03 +02:00
parent 55b54da086
commit 8ca352d173
2 changed files with 681 additions and 0 deletions
@@ -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 <stdbool.h>
#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 <string.h>
#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).
@@ -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