From 7844378e2ce3595140e7a9aeced6715efa2f5596 Mon Sep 17 00:00:00 2001 From: clement Date: Fri, 19 Jun 2026 09:46:56 +0200 Subject: [PATCH] docs(plan): plan Phase 3 telephonie locale --- .../2026-06-19-phase3-telephonie-locale.md | 849 ++++++++++++++++++ 1 file changed, 849 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-phase3-telephonie-locale.md diff --git a/docs/superpowers/plans/2026-06-19-phase3-telephonie-locale.md b/docs/superpowers/plans/2026-06-19-phase3-telephonie-locale.md new file mode 100644 index 0000000..fbd8f23 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-phase3-telephonie-locale.md @@ -0,0 +1,849 @@ +# Phase 3 — Téléphonie locale (SLIC + DTMF + numérotation) : 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:** Au décroché du combiné, jouer la tonalité de numérotation ; capturer le numéro composé au cadran à impulsions ET au clavier DTMF ; le journaliser ; au raccroché, couper le son et l'ampli. Sans Bluetooth (Phase 4). + +**Architecture :** On adapte les modules natifs éprouvés de `plip_voice` (`/Users/electron/code/zacus/ESP32_ZACUS/plip_voice/main`) en composants ESP-IDF : `slic_ks0835` (GPIO hook/sonnerie), capture micro ajoutée à `hal_i2s` (lecture RX), `dtmf` (Goertzel, testé host), `dialer` (accumulateur de chiffres, testé host), `hook_monitor` (surveillance décroché + impulsions cadran), `call_manager` (machine d'état IDLE→DIALTONE→DIALING). On intègre aussi deux nettoyages reportés de la Phase 2 : PA à propriétaire unique et tâche de capture micro. + +**Tech Stack :** ESP-IDF v5.4, `driver/gpio.h`, `driver/i2s_std.h`, `esp_timer.h`, FreeRTOS, codec ES8388 / SLIC K50835F, carte AI-Thinker ESP32-A1S. + +## Global Constraints + +- ESP-IDF v5.4 — `. /Users/electron/esp/esp-idf/export.sh` dans CHAQUE shell. Si `idf.py` échoue sur l'interpréteur, utiliser `python3` (venv py3.14 sur ce poste). +- Cible `esp32`. Port `/dev/cu.usbserial-0001`. Matériel branché. Monitor lu en non-bloquant (pyserial ~15 s ; pas de `timeout` sur ce macOS). +- API drivers neuves uniquement (`driver/gpio.h`, `driver/i2s_std.h`). Logs `ESP_LOGx`. +- Source d'adaptation : `/Users/electron/code/zacus/ESP32_ZACUS/plip_voice/main/{slic.c,slic.h,dtmf.c,dtmf.h,dialer.c,dialer.h,phone.c}`. Code éprouvé banc — préserver la logique/valeurs, n'adapter que ce que le plan indique (suppression du couplage WiFi/conversation/scene/BT). +- Pinout SLIC (depuis `components/bsp/include/board_config.h`, déjà en place) : RM=18, FR=5, SHK=23, PD=19. Cadence sonnerie France Télécom : burst 1500 ms / pause 3500 ms, FR togglé ~25 Hz (demi-période 20 ms). +- **Polarité SHK ambiguë** : la table fournie dit actif-BAS, le code plip_voice utilise actif-HAUT. On démarre avec la valeur plip_voice (`SLIC_SHK_OFFHOOK_LEVEL = 1`, HIGH=décroché) et on tranche EMPIRIQUEMENT au test « décroché » (Task 1) ; si inversé, basculer le seul `#define` à `0`. +- DTMF/audio : 16 kHz, frame 320 samples (20 ms), mono après downmix L+R. Goertzel sur basses {697,770,852,941} / hautes {1209,1336,1477,1633}. +- Hook git : sujet ≤ 50 car., sans attribution IA, corps ≤ 72 car./ligne. `sdkconfig`/`build/` non versionnés. +- Le firmware actuel joue la tonalité au boot (Phase 2) — `call_manager` (Task 6) remplacera ce comportement : tonalité au décroché, pas au boot. + +--- + +### Task 1 : `slic_ks0835` (GPIO hook + sonnerie) + +**Files:** +- Create: `components/slic_ks0835/include/slic.h` +- Create: `components/slic_ks0835/slic.c` +- Create: `components/slic_ks0835/CMakeLists.txt` +- Modify: `main/CMakeLists.txt` (REQUIRES += `slic_ks0835`) +- Modify: `main/app_main.c` (appeler `slic_init()` + log temporaire de l'état hook pour valider la polarité) + +**Interfaces:** +- Consumes : `board_config.h` (bsp) — pins SLIC. +- Produces : + - `esp_err_t slic_init(void);` — config GPIO RM/FR/SHK/PD + crée la tâche de sonnerie. + - `bool slic_is_offhook(void);` — true si décroché (selon `SLIC_SHK_OFFHOOK_LEVEL`). + - `void slic_ring_start(void);` / `void slic_ring_stop(void);` / `bool slic_is_ringing(void);` + +- [ ] **Step 1 : Copier `slic.h` et `slic.c` depuis plip_voice** + +Copier `/Users/electron/code/zacus/ESP32_ZACUS/plip_voice/main/slic.h` → `components/slic_ks0835/include/slic.h` et `/Users/electron/code/zacus/ESP32_ZACUS/plip_voice/main/slic.c` → `components/slic_ks0835/slic.c` (verbatim). Ils n'ont pas de dépendance WiFi/conversation — adaptation minimale. + +- [ ] **Step 2 : Vérifier l'include du pinout dans `slic.c`** + +`slic.c` inclut `"board_config.h"`. Comme ce header vit maintenant dans le composant `bsp`, aucun changement de chemin n'est nécessaire (les composants exposent leurs `include/`), mais le CMake devra requérir `bsp`. Vérifier que `slic.c` contient bien `#define SLIC_SHK_OFFHOOK_LEVEL 1` (sinon l'ajouter en tête, avec le commentaire : `/* 1=HIGH off-hook (plip_voice proven). Mettre 0 si le test decroche est inverse. */`). + +- [ ] **Step 3 : Créer `components/slic_ks0835/CMakeLists.txt`** + +```cmake +idf_component_register( + SRCS "slic.c" + INCLUDE_DIRS "include" + REQUIRES esp_driver_gpio bsp +) +``` + +- [ ] **Step 4 : Référencer dans `main/CMakeLists.txt`** + +Ajouter `slic_ks0835` à la liste `REQUIRES` de `main` (conserver les composants existants : `nvs_flash config_store hal_es8388 hal_i2s audio_router`). + +- [ ] **Step 5 : Câbler `slic_init` + sonde de polarité dans `main/app_main.c`** + +Ajouter `#include "slic.h"`. Après `audio_router_dialtone_start();` (existant), ajouter : +```c + ESP_ERROR_CHECK(slic_init()); + /* Sonde temporaire de polarite hook (a retirer en Task 6). */ + for (int i = 0; i < 20; i++) { + ESP_LOGI(TAG, "HOOK sonde: offhook=%d (niveau brut SHK)", (int)slic_is_offhook()); + vTaskDelay(pdMS_TO_TICKS(500)); + } +``` + +- [ ] **Step 6 : Builder, flasher, VALIDER LA POLARITÉ** + +Run : `. /Users/electron/esp/esp-idf/export.sh && idf.py build && idf.py -p /dev/cu.usbserial-0001 flash`, puis lire ~12 s de série en **manipulant le combiné** (raccroché puis décroché). +Expected : `slic init` OK, puis la sonde logue `offhook=0` quand le combiné est RACCROCHÉ et `offhook=1` quand il est DÉCROCHÉ. +**Si c'est inversé** (offhook=1 au raccroché) : éditer `slic.c`, passer `#define SLIC_SHK_OFFHOOK_LEVEL 0`, rebuild+reflash, re-vérifier. Documenter la valeur retenue dans le rapport. + +- [ ] **Step 7 : Commit** + +```bash +git add components/slic_ks0835 main/CMakeLists.txt main/app_main.c +git commit -m "feat(tel): SLIC KS0835 hook + sonnerie" +``` + +--- + +### Task 2 : Capture micro (API RX dans `hal_i2s`) + +**Files:** +- Modify: `components/hal_i2s/include/hal_i2s.h` (ajouter l'API capture) +- Modify: `components/hal_i2s/hal_i2s.c` (implémenter la capture) +- Modify: `main/app_main.c` (sonde temporaire RMS micro) + +**Interfaces:** +- Consumes : `hal_i2s_mic_handle()` (Phase 2). +- Produces : + - `esp_err_t hal_i2s_capture_begin(void);` — alloue le buffer scratch RX (PSRAM si dispo). Idempotent. + - `int hal_i2s_capture_read_frame(int16_t *mono_out, int n_samples, int64_t *rms_sq_out);` — lit une frame 20 ms du micro, downmixe L+R→mono dans `mono_out` (n_samples, attendu 320), renseigne `*rms_sq_out` (énergie moyenne). Retourne le nb de samples (0 = timeout, -1 = erreur). + - `void hal_i2s_capture_end(void);` — libère le scratch. + +- [ ] **Step 1 : Ajouter l'API capture à `components/hal_i2s/include/hal_i2s.h`** + +Avant le `#ifdef __cplusplus` de fermeture, ajouter : +```c +/* Microphone capture (RX). capture_begin allocates a scratch buffer; read_frame + * reads one 20 ms frame, downmixes L+R to mono, and reports mean energy. */ +esp_err_t hal_i2s_capture_begin(void); +int hal_i2s_capture_read_frame(int16_t *mono_out, int n_samples, int64_t *rms_sq_out); +void hal_i2s_capture_end(void); +``` + +- [ ] **Step 2 : Implémenter dans `components/hal_i2s/hal_i2s.c`** + +Ajouter les includes en tête (après les existants) : +```c +#include "esp_heap_caps.h" +#include +``` +Ajouter une variable statique près de `s_mic_handle` : +```c +#define HAL_I2S_CAP_FRAME_SAMPLES 320 +#define HAL_I2S_CAP_IN_BYTES (HAL_I2S_CAP_FRAME_SAMPLES * 2 * (int)sizeof(int16_t)) +static int16_t *s_rx_scratch = NULL; +``` +Ajouter les trois fonctions (adaptées de plip_voice audio.c:764-830) : +```c +esp_err_t hal_i2s_capture_begin(void) +{ + if (!s_mic_handle) { + ESP_LOGE(TAG, "capture_begin: pas de canal RX"); + return ESP_ERR_INVALID_STATE; + } + if (s_rx_scratch) return ESP_OK; /* idempotent */ + s_rx_scratch = heap_caps_malloc(HAL_I2S_CAP_IN_BYTES, MALLOC_CAP_SPIRAM); + if (!s_rx_scratch) s_rx_scratch = malloc(HAL_I2S_CAP_IN_BYTES); + if (!s_rx_scratch) return ESP_ERR_NO_MEM; + ESP_LOGI(TAG, "capture_begin: prêt (full-duplex, TX actif)"); + return ESP_OK; +} + +int hal_i2s_capture_read_frame(int16_t *mono_out, int n_samples, int64_t *rms_sq_out) +{ + if (!s_rx_scratch || !s_mic_handle) return -1; + size_t bytes_read = 0; + esp_err_t ret = i2s_channel_read(s_mic_handle, s_rx_scratch, + HAL_I2S_CAP_IN_BYTES, &bytes_read, + pdMS_TO_TICKS(100)); + if (ret != ESP_OK || bytes_read == 0) return 0; /* timeout */ + int n = (int)(bytes_read / (2 * sizeof(int16_t))); + if (n > n_samples) n = n_samples; + int64_t rms_sq = 0; + for (int i = 0; i < n; i++) { + int32_t l = s_rx_scratch[i * 2]; + int32_t r = s_rx_scratch[i * 2 + 1]; + int16_t mono = (int16_t)((l + r) / 2); + mono_out[i] = mono; + rms_sq += (int64_t)mono * mono; + } + if (rms_sq_out) *rms_sq_out = (n > 0) ? rms_sq / n : 0; + return n; +} + +void hal_i2s_capture_end(void) +{ + if (s_rx_scratch) { free(s_rx_scratch); s_rx_scratch = NULL; } +} +``` + +- [ ] **Step 3 : Sonde RMS micro temporaire dans `main/app_main.c`** + +Remplacer la sonde hook de Task 1 (la boucle `HOOK sonde`) par une sonde combinée hook + micro (pour valider le chemin micro LIN2/RIN2) : +```c + ESP_ERROR_CHECK(hal_i2s_capture_begin()); + int16_t cap[320]; + for (int i = 0; i < 20; i++) { + int64_t rms_sq = 0; + int got = hal_i2s_capture_read_frame(cap, 320, &rms_sq); + ESP_LOGI(TAG, "MIC sonde: offhook=%d frame=%d rms_sq=%lld", + (int)slic_is_offhook(), got, (long long)rms_sq); + vTaskDelay(pdMS_TO_TICKS(500)); + } +``` + +- [ ] **Step 4 : Builder, flasher, valider le micro** + +Run : build + flash, lire ~12 s en **parlant/soufflant dans le micro du combiné décroché**. +Expected : `capture_begin: prêt`, puis `rms_sq` proche de 0 au silence et nettement plus élevé (milliers+) quand on parle dans le micro. Confirme que le chemin micro LIN2/RIN2 capte du signal. +Si `rms_sq` reste figé à ~0 même en parlant : le DLL fix / la sélection LIN2 est en cause — reporter DONE_WITH_CONCERNS avec les valeurs. + +- [ ] **Step 5 : Commit** + +```bash +git add components/hal_i2s main/app_main.c +git commit -m "feat(audio): API capture micro RX hal_i2s" +``` + +--- + +### Task 3 : `dtmf` (détecteur Goertzel, testé host) + +**Files:** +- Create: `components/dtmf/include/dtmf.h` +- Create: `components/dtmf/dtmf.c` +- Create: `components/dtmf/CMakeLists.txt` +- Test: `components/dtmf/test_dtmf.c` (host, `cc`) +- Modify: `main/CMakeLists.txt` (REQUIRES += `dtmf`) + +**Interfaces:** +- Consumes : `hal_i2s_capture_read_frame()` (Task 2) ; `dialer_push_digit()` (Task 4 — déclaré mais la tâche dtmf n'est démarrée qu'en Task 6). +- Produces : + - `char dtmf_detect_frame(const int16_t *mono, int n);` — analyse une frame 20 ms mono ; retourne le caractère DTMF confirmé (`'0'`-`'9'`, `'*'`, `'#'`) ou `'\0'`. **Fonction pure, testable host.** + - `void dtmf_start(void);` / `void dtmf_stop(void);` — arme/désarme la tâche de capture→décodage (utilisée en Task 6). + +- [ ] **Step 1 : Copier `dtmf.h` et `dtmf.c` depuis plip_voice** + +Copier `/Users/electron/code/zacus/ESP32_ZACUS/plip_voice/main/dtmf.h` → `components/dtmf/include/dtmf.h` et `dtmf.c` → `components/dtmf/dtmf.c` (verbatim). `dtmf_detect_frame` et le Goertzel sont purs. + +- [ ] **Step 2 : Adapter la source de samples dans `dtmf.c`** + +Dans `dtmf.c`, la tâche `dtmf_task` appelle `audio_capture_read_frame(...)` (API plip_voice). Remplacer chaque appel `audio_capture_read_frame(` par `hal_i2s_capture_read_frame(`, et tout appel `audio_capture_begin()` par `hal_i2s_capture_begin()`. Remplacer l'include `#include "audio.h"` par `#include "hal_i2s.h"`. La tâche appelle `dialer_push_digit(sym - '0')` sur un chiffre 0-9 — garder ; ajouter `#include "dialer.h"`. NE PAS modifier `dtmf_detect_frame` ni le Goertzel. + +- [ ] **Step 3 : Écrire le test host qui échoue — `components/dtmf/test_dtmf.c`** + +```c +/* Test host de dtmf_detect_frame : synthétise des frames DTMF et vérifie le décodage. */ +#include "dtmf.h" +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +#define FS 16000 +#define N 320 + +/* Remplit une frame mono 20 ms avec la somme de deux sinus (low+high). */ +static void synth(int16_t *buf, int n, float f_low, float f_high) +{ + for (int i = 0; i < n; i++) { + float s = 9000.0f * sinf(2.0f*(float)M_PI*f_low *i/FS) + + 9000.0f * sinf(2.0f*(float)M_PI*f_high*i/FS); + buf[i] = (int16_t)s; + } +} + +int main(void) +{ + int16_t buf[N]; + char d = '\0'; + + /* '1' = 697 + 1209. Le détecteur confirme après DTMF_CONFIRM_FRAMES (2) + * frames soutenues ; on l'alimente plusieurs fois puis on lit le résultat. */ + for (int k = 0; k < 5; k++) { + synth(buf, N, 697.0f, 1209.0f); + char c = dtmf_detect_frame(buf, N); + if (c != '\0') d = c; + } + assert(d == '1'); + + /* Silence → aucun chiffre. */ + int16_t zero[N] = {0}; + char s = '\0'; + for (int k = 0; k < 3; k++) { char c = dtmf_detect_frame(zero, N); if (c) s = c; } + assert(s == '\0'); + + /* '9' = 852 + 1477 après ré-arm (silence intercalé ci-dessus). */ + char d9 = '\0'; + for (int k = 0; k < 5; k++) { + synth(buf, N, 852.0f, 1477.0f); + char c = dtmf_detect_frame(buf, N); + if (c != '\0') d9 = c; + } + assert(d9 == '9'); + + printf("dtmf: 3/3 assertions OK\n"); + return 0; +} +``` + +- [ ] **Step 4 : Compiler le test → RED** + +Run : +```bash +cc -o /tmp/test_dtmf -I /Users/electron/RTC_BL_PHONE/components/dtmf/include \ + /Users/electron/RTC_BL_PHONE/components/dtmf/test_dtmf.c \ + /Users/electron/RTC_BL_PHONE/components/dtmf/dtmf.c -lm 2>&1 | head -20 +``` +Expected : ÉCHEC de compilation — `dtmf.c` référence des symboles IDF (FreeRTOS, ESP_LOG, hal_i2s) absents en host. C'est attendu : on ne peut pas lier la tâche IDF en host. **Adapter la stratégie de test** : compiler UNIQUEMENT la fonction pure. Pour cela, le test doit être lié à un `dtmf.c` dont seule `dtmf_detect_frame` + le Goertzel sont host-compatibles. + + → Solution : isoler la partie pure. Si `dtmf.c` mélange `dtmf_detect_frame` (pur) et `dtmf_task` (IDF), encadrer la section IDF (`dtmf_task`, `dtmf_start`, `dtmf_stop`, includes FreeRTOS/esp_log/hal_i2s/dialer) par `#ifndef DTMF_HOST_TEST ... #endif`. Le test host compile avec `-DDTMF_HOST_TEST`. La commande RED devient : +```bash +cc -DDTMF_HOST_TEST -o /tmp/test_dtmf -I /Users/electron/RTC_BL_PHONE/components/dtmf/include \ + /Users/electron/RTC_BL_PHONE/components/dtmf/test_dtmf.c \ + /Users/electron/RTC_BL_PHONE/components/dtmf/dtmf.c -lm +``` +Avant d'ajouter les gardes, lancer cette commande pour observer l'échec initial (symboles IDF non définis), ce qui motive l'ajout des `#ifndef DTMF_HOST_TEST`. + +- [ ] **Step 5 : Ajouter les gardes `#ifndef DTMF_HOST_TEST` dans `dtmf.c`** + +Encadrer par `#ifndef DTMF_HOST_TEST` / `#endif` : les includes IDF (`freertos/*`, `esp_log.h`, `hal_i2s.h`, `dialer.h`), et les fonctions `dtmf_task`, `dtmf_start`, `dtmf_stop`. Laisser HORS garde : les includes ``/``, les constantes (fréquences, seuils), `goertzel_power`, et `dtmf_detect_frame`. Remplacer les `ESP_LOGx` éventuels DANS `dtmf_detect_frame` par rien (ou un `#define` no-op sous DTMF_HOST_TEST) si présents. + +- [ ] **Step 6 : Compiler le test → GREEN** + +Run : +```bash +cc -DDTMF_HOST_TEST -o /tmp/test_dtmf -I /Users/electron/RTC_BL_PHONE/components/dtmf/include \ + /Users/electron/RTC_BL_PHONE/components/dtmf/test_dtmf.c \ + /Users/electron/RTC_BL_PHONE/components/dtmf/dtmf.c -lm && /tmp/test_dtmf +``` +Expected : `dtmf: 3/3 assertions OK`. Si une assertion échoue (chiffre non détecté), NE pas affaiblir le test : vérifier l'amplitude de synthèse (9000+9000 peut saturer — réduire à 6000 chacun si besoin) et les seuils ; documenter tout ajustement de l'amplitude de test (pas des seuils du détecteur). + +- [ ] **Step 7 : Créer `components/dtmf/CMakeLists.txt`** + +```cmake +idf_component_register( + SRCS "dtmf.c" + INCLUDE_DIRS "include" + REQUIRES hal_i2s dialer +) +``` + +- [ ] **Step 8 : Référencer `dtmf` dans `main/CMakeLists.txt` et builder** + +Ajouter `dtmf` aux REQUIRES de `main`. Run `idf.py build` → `Project build complete.` (le composant `dtmf` compile la version IDF, sans `DTMF_HOST_TEST`). + +- [ ] **Step 9 : Commit** + +```bash +git add components/dtmf main/CMakeLists.txt +git commit -m "feat(tel): DTMF Goertzel + test host" +``` + +--- + +### Task 4 : `dialer` (accumulateur de chiffres, testé host) + +**Files:** +- Create: `components/dialer/include/dialer.h` +- Create: `components/dialer/dialer.c` +- Create: `components/dialer/CMakeLists.txt` +- Test: `components/dialer/test_dialer.c` (host, `cc`) +- Modify: `main/CMakeLists.txt` (REQUIRES += `dialer`) + +**Interfaces:** +- Consumes : `esp_timer_get_time()` (IDF) en cible ; en host, une implémentation de substitution sous garde. +- Produces : + - `void dialer_init(void);` `void dialer_reset(void);` + - `void dialer_push_digit(int d);` — ajoute un chiffre 0-9 (ignore hors plage / au-delà de 12 chiffres). + - `const char *dialer_current(void);` — numéro accumulé (C-string). + - `bool dialer_idle(void);` — true si aucun chiffre encore. + - `int dialer_ms_since_last(void);` — ms depuis le dernier chiffre. + +- [ ] **Step 1 : Copier `dialer.h`/`dialer.c` depuis plip_voice** + +Copier `/Users/electron/code/zacus/ESP32_ZACUS/plip_voice/main/dialer.h` → `components/dialer/include/dialer.h` et `dialer.c` → `components/dialer/dialer.c` (verbatim). + +- [ ] **Step 2 : Rendre `dialer.c` testable host (substitution du temps + log)** + +En tête de `dialer.c`, encadrer pour le host : remplacer l'include `esp_timer.h`/`esp_log.h` par une abstraction. Ajouter au début : +```c +#ifdef DIALER_HOST_TEST +#include +static int64_t g_fake_us = 0; +static int64_t esp_timer_get_time(void) { return g_fake_us; } +void dialer_test_set_time_us(int64_t us); /* déclaré pour le test */ +void dialer_test_set_time_us(int64_t us) { g_fake_us = us; } +#define ESP_LOGI(tag, ...) ((void)0) +#else +#include "esp_timer.h" +#include "esp_log.h" +#endif +``` +(Si `dialer.c` a un `#define TAG`/`static const char *TAG`, le laisser ; sous host le `ESP_LOGI` no-op l'ignore.) Le reste de `dialer.c` reste inchangé. + +- [ ] **Step 3 : Écrire le test host — `components/dialer/test_dialer.c`** + +```c +#include "dialer.h" +#include +#include +#include + +void dialer_test_set_time_us(int64_t us); /* fourni par dialer.c sous DIALER_HOST_TEST */ + +int main(void) +{ + dialer_init(); + assert(dialer_idle()); /* vide au départ */ + + dialer_test_set_time_us(1000000); /* t = 1 s */ + dialer_push_digit(0); + dialer_push_digit(1); + dialer_push_digit(4); + assert(!dialer_idle()); + assert(strcmp(dialer_current(), "014") == 0); + + /* ms depuis le dernier chiffre */ + dialer_test_set_time_us(1000000 + 1500000); /* +1.5 s */ + assert(dialer_ms_since_last() == 1500); + + /* chiffres hors plage ignorés */ + dialer_push_digit(-1); + dialer_push_digit(10); + assert(strcmp(dialer_current(), "014") == 0); + + dialer_reset(); + assert(dialer_idle()); + assert(strcmp(dialer_current(), "") == 0); + + printf("dialer: 6/6 assertions OK\n"); + return 0; +} +``` + +- [ ] **Step 4 : RED** + +Run : +```bash +cc -DDIALER_HOST_TEST -o /tmp/test_dialer -I /Users/electron/RTC_BL_PHONE/components/dialer/include \ + /Users/electron/RTC_BL_PHONE/components/dialer/test_dialer.c \ + /Users/electron/RTC_BL_PHONE/components/dialer/dialer.c +``` +Expected : si la garde host de Step 2 n'est pas encore en place, échec de compilation (esp_timer.h introuvable). C'est le RED qui motive la garde. Une fois la garde en place, recompiler. + +- [ ] **Step 5 : GREEN** + +Après la garde (Step 2), run la même commande + exécution : +```bash +cc -DDIALER_HOST_TEST -o /tmp/test_dialer -I /Users/electron/RTC_BL_PHONE/components/dialer/include \ + /Users/electron/RTC_BL_PHONE/components/dialer/test_dialer.c \ + /Users/electron/RTC_BL_PHONE/components/dialer/dialer.c && /tmp/test_dialer +``` +Expected : `dialer: 6/6 assertions OK`. (Si `MAX_DIGITS` ou le format diffère, ajuster le test aux constantes réelles de `dialer.c`, sans changer la logique.) + +- [ ] **Step 6 : Créer `components/dialer/CMakeLists.txt`** + +```cmake +idf_component_register( + SRCS "dialer.c" + INCLUDE_DIRS "include" + REQUIRES esp_timer +) +``` + +- [ ] **Step 7 : Référencer dans `main/CMakeLists.txt` et builder** + +Ajouter `dialer` aux REQUIRES de `main`. `idf.py build` → vert. + +- [ ] **Step 8 : Commit** + +```bash +git add components/dialer main/CMakeLists.txt +git commit -m "feat(tel): dialer accumulateur + test host" +``` + +--- + +### Task 5 : `hook_monitor` (surveillance décroché + impulsions cadran) + +**Files:** +- Create: `components/hook_monitor/include/hook_monitor.h` +- Create: `components/hook_monitor/hook_monitor.c` +- Create: `components/hook_monitor/CMakeLists.txt` +- Modify: `main/CMakeLists.txt` (REQUIRES += `hook_monitor`) + +**Interfaces:** +- Consumes : `slic_is_offhook()` (Task 1) ; `dialer_push_digit()` (Task 4). +- Produces : + - `typedef void (*hook_change_cb_t)(bool offhook);` + - `esp_err_t hook_monitor_start(hook_change_cb_t cb);` — lance la tâche de surveillance ; appelle `cb(offhook)` sur chaque transition décroché/raccroché stable, et `dialer_push_digit()` sur chaque chiffre composé au cadran (impulsions). + - `bool hook_monitor_offhook(void);` — état hook débouncé courant. + +- [ ] **Step 1 : Créer `components/hook_monitor/include/hook_monitor.h`** + +```c +#pragma once +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*hook_change_cb_t)(bool offhook); + +/* Start the hook-monitor task. Calls cb(offhook) on each stable hook + * transition, and dialer_push_digit() for each rotary-dialed digit. */ +esp_err_t hook_monitor_start(hook_change_cb_t cb); + +/* Current debounced off-hook state. */ +bool hook_monitor_offhook(void); + +#ifdef __cplusplus +} +#endif +``` + +> **Risque & repli :** la logique d'impulsion ci-dessous est une version simplifiée. La détection d'impulsion du cadran est sensible aux timings réels de la boucle SLIC. Si la validation matérielle (Task 6 Step 7) montre des chiffres manqués/parasites, le repli est de porter plus fidèlement la logique éprouvée de `/Users/electron/code/zacus/ESP32_ZACUS/plip_voice/main/phone.c` (vérification multi-échantillons du raccroché `HANGUP_VERIFY_COUNT`, resync auto-correctif `RESYNC_PICKUP/HANGUP_MS`, filtrage glitch). Conserver l'interface `hook_monitor_start(cb)` / `hook_monitor_offhook()` inchangée. + +- [ ] **Step 2 : Créer `components/hook_monitor/hook_monitor.c`** (adapté de plip_voice phone.c, dépouillé du couplage conversation/audio/ring) + +```c +/* + * hook_monitor.c — surveille le crochet (SHK via slic) et décode la + * numérotation par impulsions du cadran. Adapté de plip_voice/main/phone.c + * (logique de pulse/hangup) sans le couplage conversation/audio. + * + * Cadran à impulsions : pendant que le combiné est décroché, le cadran ouvre + * la boucle N fois pour le chiffre N (10 impulsions = 0). Un raccroché réel se + * distingue d'une impulsion par sa durée (> HANGUP_THRESHOLD_MS). + */ +#include "hook_monitor.h" +#include "slic.h" +#include "dialer.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_timer.h" +#include "esp_log.h" + +#define TAG "hook_monitor" + +#define POLL_MS 10 +#define DEBOUNCE_MS 30 +#define PULSE_MIN_WIDTH_MS 20 /* filtre anti-glitch */ +#define INTER_DIGIT_GAP_MS 200 /* fin d'un chiffre au cadran */ +#define HANGUP_THRESHOLD_MS 2500 /* ouverture > seuil = vrai raccroché */ + +static hook_change_cb_t s_cb = NULL; +static volatile bool s_offhook = false; + +/* slic_is_offhook() renvoie true si la boucle est fermée (combiné décroché, + * cadran au repos). Pendant une impulsion, la boucle s'ouvre brièvement → + * slic_is_offhook() repasse false un court instant. On distingue : + * - impulsion : ouverture courte (~60 ms) répétée, puis gap inter-chiffre + * - raccroché : ouverture longue (> HANGUP_THRESHOLD_MS) */ +static void hook_task(void *arg) +{ + (void)arg; + bool stable = slic_is_offhook(); + s_offhook = stable; + if (s_cb) s_cb(stable); + + int pulse_count = 0; + bool in_open = false; + int64_t open_start_us = 0; + int64_t last_close_us = esp_timer_get_time(); + + for (;;) { + bool raw = slic_is_offhook(); + int64_t now = esp_timer_get_time(); + + if (!stable) { + /* Combiné raccroché : n'attendre qu'un décroché franc et débouncé. */ + if (raw) { + vTaskDelay(pdMS_TO_TICKS(DEBOUNCE_MS)); + if (slic_is_offhook()) { + stable = true; s_offhook = true; + pulse_count = 0; in_open = false; + last_close_us = esp_timer_get_time(); + dialer_reset(); + if (s_cb) s_cb(true); + ESP_LOGI(TAG, "décroché"); + } + } + } else { + /* Combiné décroché : décoder impulsions + détecter raccroché. */ + if (!raw && !in_open) { + in_open = true; open_start_us = now; /* début ouverture */ + } else if (raw && in_open) { + in_open = false; /* fin ouverture */ + int open_ms = (int)((now - open_start_us) / 1000); + if (open_ms >= PULSE_MIN_WIDTH_MS && open_ms < HANGUP_THRESHOLD_MS) { + pulse_count++; /* impulsion valide */ + } + last_close_us = now; + } else if (!raw && in_open) { + /* ouverture en cours : vérifier un raccroché (ouverture longue) */ + int open_ms = (int)((now - open_start_us) / 1000); + if (open_ms >= HANGUP_THRESHOLD_MS) { + stable = false; s_offhook = false; + in_open = false; pulse_count = 0; + if (s_cb) s_cb(false); + ESP_LOGI(TAG, "raccroché"); + } + } + + /* Fin d'un chiffre : gap inter-impulsions dépassé avec des pulses. */ + if (!in_open && pulse_count > 0 && + (int)((now - last_close_us) / 1000) >= INTER_DIGIT_GAP_MS) { + int digit = (pulse_count >= 10) ? 0 : pulse_count; + ESP_LOGI(TAG, "cadran: %d impulsions -> chiffre %d", pulse_count, digit); + dialer_push_digit(digit); + pulse_count = 0; + } + } + vTaskDelay(pdMS_TO_TICKS(POLL_MS)); + } +} + +esp_err_t hook_monitor_start(hook_change_cb_t cb) +{ + s_cb = cb; + if (xTaskCreatePinnedToCore(hook_task, "hook", 3072, NULL, 6, NULL, 1) != pdPASS) { + ESP_LOGE(TAG, "échec création tâche hook"); + return ESP_FAIL; + } + return ESP_OK; +} + +bool hook_monitor_offhook(void) { return s_offhook; } +``` + +- [ ] **Step 3 : Créer `components/hook_monitor/CMakeLists.txt`** + +```cmake +idf_component_register( + SRCS "hook_monitor.c" + INCLUDE_DIRS "include" + REQUIRES esp_timer slic_ks0835 dialer +) +``` + +- [ ] **Step 4 : Référencer dans `main/CMakeLists.txt` et builder** + +Ajouter `hook_monitor` aux REQUIRES de `main`. `idf.py build` → vert. (Pas de câblage app_main ici ; ce sera fait en Task 6.) + +- [ ] **Step 5 : Commit** + +```bash +git add components/hook_monitor main/CMakeLists.txt +git commit -m "feat(tel): hook_monitor decroche + impulsions" +``` + +--- + +### Task 6 : `call_manager` + intégration (critère de phase) + +**Files:** +- Create: `components/call_manager/include/call_manager.h` +- Create: `components/call_manager/call_manager.c` +- Create: `components/call_manager/CMakeLists.txt` +- Modify: `components/hal_es8388/es8388.c` (retirer le forçage PA — propriétaire unique) +- Modify: `main/CMakeLists.txt` (REQUIRES += `call_manager`) +- Modify: `main/app_main.c` (retirer tonalité-au-boot + sondes temporaires ; lancer `call_manager_start()`) + +**Interfaces:** +- Consumes : `hook_monitor_start()` + `hook_monitor_offhook()` (Task 5) ; `dialer_*` (Task 4) ; `dtmf_start/stop` (Task 3) ; `audio_router_dialtone_start/tones_stop/busy_start` + `hal_audio_pa_set` (Phase 2). +- Produces : + - `void call_manager_start(void);` — lance la machine d'état d'appel locale (sans BT). + +- [ ] **Step 1 : Retirer le forçage PA de `es8388_init` (propriétaire unique)** + +Dans `components/hal_es8388/es8388.c`, supprimer les deux lignes qui forcent la PA en fin d'init : +```c + gpio_set_direction(PLIP_PA_ENABLE, GPIO_MODE_OUTPUT); + gpio_set_level(PLIP_PA_ENABLE, 1); +``` +et les remplacer par la seule configuration en sortie (sans l'activer) : +```c + gpio_set_direction(PLIP_PA_ENABLE, GPIO_MODE_OUTPUT); + gpio_set_level(PLIP_PA_ENABLE, 0); /* PA pilotée par call_manager (off au repos) */ +``` +Ainsi `hal_audio_pa_set` (hal_i2s) devient le pilote unique de la PA, et l'ampli démarre coupé. + +- [ ] **Step 2 : Créer `components/call_manager/include/call_manager.h`** + +```c +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +/* Start the local call state machine (no Bluetooth yet): + * on-hook -> idle (silence, PA off) + * off-hook -> dial tone + collect digits (pulse + DTMF) + * number complete (3 s silence) -> log number + * Call once after audio + slic + dialer + dtmf + hook_monitor are ready. */ +void call_manager_start(void); + +#ifdef __cplusplus +} +#endif +``` + +- [ ] **Step 3 : Créer `components/call_manager/call_manager.c`** (adapté de plip_voice conversation.c, sans routing/greet/BT/WiFi) + +```c +/* + * call_manager.c — machine d'état d'appel LOCALE (sans Bluetooth). + * Adapté de plip_voice/main/conversation.c, dépouillé du routing/greet/scene. + * + * IDLE : raccroché — silence, PA coupée + * DIALTONE : décroché — tonalité de numérotation, attente du 1er chiffre + * DIALING : chiffres en cours — attente de 3 s de silence -> numéro figé/logué + */ +#include "call_manager.h" +#include "hook_monitor.h" +#include "dialer.h" +#include "dtmf.h" +#include "audio_router.h" +#include "hal_i2s.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include + +#define TAG "call_manager" + +#define DIAL_COMPLETE_SILENCE_MS 3000 + +typedef enum { ST_IDLE, ST_DIALTONE, ST_DIALING } call_state_t; + +static volatile bool s_offhook = false; +static volatile bool s_hook_dirty = false; + +static void on_hook_change(bool offhook) +{ + s_offhook = offhook; + s_hook_dirty = true; +} + +static void go_idle(void) +{ + audio_router_tones_stop(); + dtmf_stop(); + hal_audio_pa_set(false); + dialer_reset(); + ESP_LOGI(TAG, "-> IDLE (raccroché)"); +} + +static void call_task(void *arg) +{ + (void)arg; + call_state_t st = ST_IDLE; + + for (;;) { + if (s_hook_dirty) { + s_hook_dirty = false; + if (!s_offhook) { + go_idle(); + st = ST_IDLE; + } else if (st == ST_IDLE) { + /* décroché depuis le repos : tonalité + armement numérotation */ + dialer_reset(); + audio_router_dialtone_start(); /* active la PA en interne */ + dtmf_start(); /* arme la capture DTMF */ + ESP_LOGI(TAG, "-> DIALTONE (décroché)"); + st = ST_DIALTONE; + } + } + + if (st == ST_DIALTONE && !dialer_idle()) { + /* premier chiffre composé : couper la tonalité, passer en DIALING */ + audio_router_tones_stop(); + ESP_LOGI(TAG, "-> DIALING, numéro: \"%s\"", dialer_current()); + st = ST_DIALING; + } else if (st == ST_DIALING) { + if (!dialer_idle() && dialer_ms_since_last() > DIAL_COMPLETE_SILENCE_MS) { + ESP_LOGI(TAG, "NUMÉRO COMPOSÉ: \"%s\" (impulsion + DTMF)", + dialer_current()); + /* Phase 4 : ici on lancera l'appel HFP. Pour l'instant on fige. */ + dtmf_stop(); + st = ST_IDLE; /* on attend le raccroché pour revenir proprement */ + } else { + /* log incrémental du numéro en cours (chiffres additionnels) */ + } + } + + vTaskDelay(pdMS_TO_TICKS(50)); + } +} + +void call_manager_start(void) +{ + ESP_ERROR_CHECK(hal_i2s_capture_begin()); + if (hook_monitor_start(on_hook_change) != ESP_OK) { + ESP_LOGE(TAG, "hook_monitor_start a échoué"); + return; + } + xTaskCreatePinnedToCore(call_task, "call_mgr", 4096, NULL, 5, NULL, 1); + ESP_LOGI(TAG, "call_manager démarré (mode local, sans BT)"); +} +``` + +- [ ] **Step 4 : Créer `components/call_manager/CMakeLists.txt`** + +```cmake +idf_component_register( + SRCS "call_manager.c" + INCLUDE_DIRS "include" + REQUIRES hook_monitor dialer dtmf audio_router hal_i2s +) +``` + +- [ ] **Step 5 : Nettoyer `main/app_main.c` et lancer `call_manager`** + +Dans `main/app_main.c` : +1. RETIRER la tonalité jouée au boot (lignes `audio_router_init(); audio_router_dialtone_start(); ESP_LOGI(... tonalite de numerotation demarree ...)`) — la tonalité est désormais déclenchée au décroché. +2. RETIRER les sondes temporaires (boucle `MIC sonde`/`HOOK sonde` + `hal_i2s_capture_begin` de la sonde) ajoutées en Tasks 1-2. +3. Conserver `ESP_ERROR_CHECK(hal_i2s_init());` et `ESP_ERROR_CHECK(slic_init());`. Ajouter `audio_router_init();` (création de la tâche de tonalités, sans la démarrer). +4. Ajouter les includes `#include "call_manager.h"` et `#include "dialer.h"`, et après `slic_init()` : +```c + dialer_init(); + call_manager_start(); + ESP_LOGI(TAG, "téléphonie locale prête — décroche pour composer"); +``` +5. Mettre à jour `main/CMakeLists.txt` : REQUIRES final = `nvs_flash config_store hal_es8388 hal_i2s audio_router slic_ks0835 dtmf dialer hook_monitor call_manager`. + +- [ ] **Step 6 : Builder** + +Run : `. /Users/electron/esp/esp-idf/export.sh && idf.py build` → `Project build complete.` + +- [ ] **Step 7 : Flasher et VALIDER LE CRITÈRE DE PHASE (matériel)** + +Run : flash, puis lire la série en **manipulant le combiné** : +1. **Décrocher** → log `-> DIALTONE (décroché)` + tonalité audible dans l'écouteur + `PA ON`. +2. **Composer au cadran** (impulsions) quelques chiffres → logs `cadran: N impulsions -> chiffre X` puis `-> DIALING, numéro: "..."`. +3. **Composer au clavier DTMF** (si combiné à touches) → les chiffres DTMF s'ajoutent au numéro (logs dialer `digit ...`). +4. Attendre 3 s → log `NUMÉRO COMPOSÉ: "..." (impulsion + DTMF)`. +5. **Raccrocher** → log `-> IDLE (raccroché)`, tonalité coupée, `PA OFF`. +Expected : la séquence complète fonctionne ; le numéro journalisé correspond aux chiffres composés (cadran et/ou clavier). C'est le critère de sortie de la Phase 3. +Reporter DONE_WITH_CONCERNS avec les logs si un chemin (impulsion ou DTMF) ne capture pas correctement — c'est une validation matérielle pouvant nécessiter l'utilisateur. + +- [ ] **Step 8 : Commit** + +```bash +git add components/call_manager components/hal_es8388/es8388.c main/CMakeLists.txt main/app_main.c +git commit -m "feat(tel): call_manager local + PA single-owner" +``` + +--- + +## Critère de sortie de Phase 3 + +Au décroché : tonalité de numérotation ; la numérotation au **cadran à impulsions** ET au **clavier DTMF** est capturée et le numéro composé est journalisé ; au raccroché : silence et ampli coupé. PA à propriétaire unique. Aucun Bluetooth — c'est l'objet de la Phase 4 (le numéro figé sera passé à `esp_hf_client_dial`). + +## Notes sur les tests + +- `dtmf` (`dtmf_detect_frame`) et `dialer` sont couverts par des **tests host** (`cc`, synthèse de tonalités DTMF / séquences de chiffres) — vrai TDD sur la logique pure. +- `slic_ks0835`, capture micro, `hook_monitor`, `call_manager` sont validés par **build + flash + manipulation du combiné** (logs série + écoute). La polarité SHK est tranchée empiriquement en Task 1. La validation finale du parcours décroché→numérotation→raccroché nécessite l'utilisateur sur le matériel.