docs(plip): implementation plan — téléphone conversationnel (4 stages, TDD)
Repo State / repo-state (push) Failing after 32s
Repo State / repo-state (pull_request) Failing after 36s
Validate Zacus refactor / validate (pull_request) Failing after 5m47s

This commit is contained in:
2026-06-14 21:23:00 +02:00
parent 6ccbf2c5c7
commit 5da6ec4c11
@@ -0,0 +1,948 @@
# PLIP — Téléphone conversationnel France Télécom — Plan d'implémentation
> **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:** Faire du PLIP un téléphone France Télécom interactif : décroché → tonalité → numérotation (cadran/DTMF) → annuaire → PNJ vocal conversationnel (Zacus & services d'époque).
**Architecture:** Le firmware PLIP possède la machine à états téléphonique ; la gateway expose `POST /v1/voice/turn` (stateless/tour) qui enchaîne whisper(STT) → ailiance(chat) → ailiance(TTS) → resample 16k. Le PLIP ne fait ni TLS, ni prompt, ni resample.
**Tech Stack:** ESP-IDF (esp32, plip_voice : net.c httpd, audio.c I2S/ES8388, phone.c hook), SLIC classe AG1171 (SHK = hook+impulsions). Gateway FastAPI/httpx (`tools/zacus-gateway/main.py`), pytest. TTS+LLM via `gateway.ailiance.fr` (OpenAI-compatible).
**Spec:** `docs/superpowers/specs/2026-06-14-plip-telephone-conversation-design.md`
**Référence (numéros de ligne au moment de la rédaction) :** patterns dans `ESP32_ZACUS/plip_voice/main/{net.c,audio.c,phone.c,cmd_exec.c,hook_client.c,Kconfig.projbuild,board_config.h,CMakeLists.txt}` et `tools/zacus-gateway/main.py` (`_synthesise_wav` ~l.701, `_wav_to_16k_mono` ~l.734, route `/v1/voice/say` ~l.1358).
**Convention build/flash firmware :** `. ~/esp/esp-idf/export.sh ; cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`. Série : `/dev/cu.usbserial-0001` (un lecteur à la fois). PLIP IP 192.168.0.138.
**Convention commits firmware (submodule) :** `git -C ESP32_ZACUS -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "..."`. JAMAIS `--no-verify`. Commits gateway : depuis la racine repo.
---
## STAGE 1 — Tonalités + dialer + machine à états (firmware, zéro IA)
Livrable : décroché → tonalité 440 Hz ; composer un numéro (debug HTTP + impulsions SHK) → ringback si numéro connu / occupé sinon ; raccroché = mute. Aucune conversation encore.
### Task 1.1 : Module `tones` (tonalités France Télécom)
**Files:**
- Create: `ESP32_ZACUS/plip_voice/main/tones.h`
- Create: `ESP32_ZACUS/plip_voice/main/tones.c`
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt` (ajouter `tones.c` aux SRCS)
- [ ] **Step 1 : Écrire `tones.h`**
```c
#pragma once
#include "esp_err.h"
// Tonalités France Télécom — générées via le DAC/I2S, non bloquant (tâche dédiée).
// Toutes forcent le PA on (audio_pa_set(true)) le temps de jouer.
void tones_dialtone_start(void); // 440 Hz continu (invitation à numéroter)
void tones_busy_start(void); // 440 Hz 0,5 s on / 0,5 s off (occupé / non attribué)
void tones_ringback_start(void); // 440 Hz 1,5 s on / 3,5 s off (retour d'appel)
void tones_stop(void); // arrête toute tonalité en cours
```
- [ ] **Step 2 : Écrire `tones.c`**
Génère un sinus 440 Hz en PCM 16 kHz mono et l'écrit en boucle sur l'I2S TX (`audio_spk_handle()`), avec un masque on/off selon la tonalité. Une seule tâche FreeRTOS `s_tone_task`, un `volatile tone_mode_t s_mode` (NONE/DIAL/BUSY/RINGBACK). `tones_stop` met NONE + `audio_pa_set` géré par l'appelant (la machine à états). Modèle : la génération de tone dans `audio.c::audio_play_tone` (sinus + `i2s_channel_write` sur `audio_spk_handle()`).
```c
#include "tones.h"
#include "audio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2s_std.h"
#include <math.h>
#include <string.h>
typedef enum { TONE_NONE, TONE_DIAL, TONE_BUSY, TONE_RINGBACK } tone_mode_t;
static volatile tone_mode_t s_mode = TONE_NONE;
static TaskHandle_t s_task;
#define SR 16000
#define FRAME 320 // 20 ms
static void fill_440(int16_t *buf, int n, int *phase) {
for (int i = 0; i < n; i++) {
buf[i] = (int16_t)(8000.0f * sinf(2.0f * (float)M_PI * 440.0f * (*phase) / SR));
(*phase)++;
}
}
static void tone_task(void *arg) {
int16_t buf[FRAME];
int phase = 0;
int64_t t = 0; // ms écoulées dans le cycle on/off
for (;;) {
tone_mode_t m = s_mode;
if (m == TONE_NONE) { vTaskDelay(pdMS_TO_TICKS(20)); t = 0; continue; }
bool on = true;
if (m == TONE_BUSY) on = (t % 1000) < 500;
else if (m == TONE_RINGBACK) on = (t % 5000) < 1500;
if (on) fill_440(buf, FRAME, &phase);
else memset(buf, 0, sizeof(buf));
size_t w;
i2s_channel_write(audio_spk_handle(), buf, sizeof(buf), &w, portMAX_DELAY);
t += 20;
}
}
static void ensure_task(void) {
if (!s_task) xTaskCreate(tone_task, "tones", 3072, NULL, 5, &s_task);
}
void tones_dialtone_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_DIAL; }
void tones_busy_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_BUSY; }
void tones_ringback_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_RINGBACK; }
void tones_stop(void){ s_mode = TONE_NONE; }
```
- [ ] **Step 3 : Ajouter `tones.c` au CMakeLists**
Dans `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`, ajouter `"tones.c"` dans `SRCS` (à côté de `"audio.c"`).
- [ ] **Step 4 : Build**
Run: `. ~/esp/esp-idf/export.sh ; cd ESP32_ZACUS/plip_voice ; idf.py build`
Expected: build vert, `tones.c` compilé.
- [ ] **Step 5 : Commit**
```bash
git -C ESP32_ZACUS -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -am "feat(plip): tones module — France Télécom dial/busy/ringback"
```
### Task 1.2 : Module `dialer` (numéro composé : debug + impulsions SHK)
**Files:**
- Create: `ESP32_ZACUS/plip_voice/main/dialer.h`
- Create: `ESP32_ZACUS/plip_voice/main/dialer.c`
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`
- Modify: `ESP32_ZACUS/plip_voice/main/net.c` (handler `/debug/dial`)
- [ ] **Step 1 : Écrire `dialer.h`**
```c
#pragma once
#include "esp_err.h"
#include <stdbool.h>
// Le dialer accumule des chiffres (source : impulsions SHK, DTMF, ou /debug/dial)
// et expose le numéro courant. La machine à états (conversation.c) lit
// dialer_current()/dialer_complete() et appelle dialer_reset() à la fin d'un appel.
void dialer_init(void);
void dialer_push_digit(int d); // 0..9 ; appelé par les sources (debug, pulse, dtmf)
void dialer_reset(void); // vide le numéro (raccroché / fin d'appel)
const char *dialer_current(void); // ex. "17" (string null-terminée, max 8)
bool dialer_idle(void); // true si aucun chiffre depuis le reset
int dialer_ms_since_last(void); // ms depuis le dernier chiffre (pour timeout inter-chiffre)
```
- [ ] **Step 2 : Écrire `dialer.c`** (accumulateur + horodatage)
```c
#include "dialer.h"
#include "freertos/FreeRTOS.h"
#include "esp_timer.h"
#include <string.h>
static char s_num[9];
static int s_len;
static int64_t s_last_us;
void dialer_init(void){ dialer_reset(); }
void dialer_reset(void){ s_num[0]=0; s_len=0; s_last_us=0; }
void dialer_push_digit(int d){
if (d<0||d>9||s_len>=8) return;
s_num[s_len++] = (char)('0'+d); s_num[s_len]=0;
s_last_us = esp_timer_get_time();
}
const char *dialer_current(void){ return s_num; }
bool dialer_idle(void){ return s_len==0; }
int dialer_ms_since_last(void){
if (!s_last_us) return 0;
return (int)((esp_timer_get_time()-s_last_us)/1000);
}
```
- [ ] **Step 3 : Handler `/debug/dial?number=NNNN` dans `net.c`**
Calque le pattern `handle_status`/`handle_cmd_post` (net.c:127, 407). Parse le query `number`, appelle `dialer_push_digit` pour chaque chiffre. Enregistre `uri_debug_dial` (HTTP_GET) et **incrémente `cfg.max_uri_handlers`** (actuellement 12 → 14, on ajoutera aussi `/debug/onhook` et plus tard rien d'autre côté net). Include `dialer.h`.
```c
static esp_err_t handle_debug_dial(httpd_req_t *req) {
char q[64], val[16];
if (httpd_req_get_url_query_str(req, q, sizeof(q)) == ESP_OK &&
httpd_query_key_value(q, "number", val, sizeof(val)) == ESP_OK) {
for (const char *p = val; *p; p++) if (*p>='0'&&*p<='9') dialer_push_digit(*p-'0');
return send_json(req, "200 OK", "{\"ok\":true}");
}
return send_json(req, "400 Bad Request", "{\"error\":\"number?\"}");
}
static const httpd_uri_t uri_debug_dial = {
.uri="/debug/dial", .method=HTTP_GET, .handler=handle_debug_dial, .user_ctx=NULL };
// dans la fonction d'enregistrement : httpd_register_uri_handler(s_httpd, &uri_debug_dial);
```
- [ ] **Step 4 : Ajouter `dialer.c` au CMakeLists + build**
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build`
Expected: vert.
- [ ] **Step 5 : Flash + test debug dial**
```bash
idf.py -p /dev/cu.usbserial-0001 flash
curl -s "http://192.168.0.138/debug/dial?number=17" # -> {"ok":true}
```
Expected: `{"ok":true}`, série logue les chiffres (ajouter un `ESP_LOGI` dans push_digit pour vérifier).
- [ ] **Step 6 : Commit**
```bash
git -C ESP32_ZACUS ... commit -am "feat(plip): dialer module + /debug/dial (digit accumulator)"
```
### Task 1.3 : Impulsions cadran sur SHK (comptage)
**Files:**
- Modify: `ESP32_ZACUS/plip_voice/main/phone.c` (compter les impulsions sur le GPIO hook = SHK)
- Modify: `ESP32_ZACUS/plip_voice/main/Kconfig.projbuild` (option pulse)
- [ ] **Step 1 : Kconfig pulse**
Dans `Kconfig.projbuild`, ajouter sous le menu PLIP :
```
config PLIP_DIAL_PULSE
bool "Enable rotary pulse dialing on the hook/SHK GPIO"
default y
config PLIP_DIAL_PULSE_MAX_GAP_MS
int "Inter-pulse max gap (ms) — above this = digit complete"
depends on PLIP_DIAL_PULSE
default 200
```
- [ ] **Step 2 : Compter les impulsions dans `phone.c`**
Le cadran génère des ouvertures/fermetures rapides de la boucle pendant la composition (le SHK pulse), distinctes du décroché/raccroché (état stable). Dans la task hook (phone.c:86-111) : si `PLIP_DIAL_PULSE` et qu'on est **off-hook**, compter les transitions rapides ; quand le gap depuis la dernière impulsion dépasse `CONFIG_PLIP_DIAL_PULSE_MAX_GAP_MS`, le nombre d'impulsions = le chiffre (10 impulsions = 0) → `dialer_push_digit`. Garder la détection on/off-hook stable (raccroché = boucle ouverte >~500 ms). Include `dialer.h`.
```c
// pseudo-intégration dans la task hook, après lecture du niveau débouncé :
#if CONFIG_PLIP_DIAL_PULSE
static int s_pulse_count = 0;
static int64_t s_last_pulse_us = 0;
// sur front descendant bref (impulsion) pendant off-hook : s_pulse_count++; s_last_pulse_us=now;
// périodiquement : si s_pulse_count>0 && (now - s_last_pulse_us) > gap_ms :
// int digit = (s_pulse_count==10)?0:s_pulse_count; dialer_push_digit(digit); s_pulse_count=0;
#endif
```
(Note implémenteur : le débounce hook actuel 30 ms peut masquer les impulsions cadran ~ 60-100 ms ; ajuster la logique pour distinguer impulsion brève vs raccroché stable. Tester au banc avec un vrai cadran ; sans cadran, `/debug/dial` couvre la suite.)
- [ ] **Step 3 : Build + flash**
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
Expected: vert ; au banc avec cadran (si dispo) composer "1" → série logue digit=1. Sans cadran : skip, `/debug/dial` reste la voie de test.
- [ ] **Step 4 : Commit**
```bash
git -C ESP32_ZACUS ... commit -am "feat(plip): rotary pulse dialing on SHK GPIO"
```
### Task 1.4 : Machine à états `conversation` jusqu'à RINGBACK/BUSY
**Files:**
- Create: `ESP32_ZACUS/plip_voice/main/conversation.h`
- Create: `ESP32_ZACUS/plip_voice/main/conversation.c`
- Modify: `ESP32_ZACUS/plip_voice/main/phone.c` (notifier conversation des transitions hook)
- Modify: `ESP32_ZACUS/plip_voice/main/main.c` (init conversation)
- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`
- [ ] **Step 1 : Écrire `conversation.h`**
```c
#pragma once
#include <stdbool.h>
void conversation_init(void);
void conversation_on_hook_change(bool offhook); // appelé par phone.c
// Stage 1 : IDLE → DIAL_TONE → DIALING → ROUTING → RINGBACK|BUSY → (Stage 2: CONNECT…)
```
- [ ] **Step 2 : Écrire `conversation.c`** (tâche d'état, Stage 1)
Tâche FreeRTOS qui poll l'état. Au décroché : `tones_dialtone_start()`. Dès qu'un chiffre apparaît (`!dialer_idle()`) : `tones_stop()`, passe en DIALING. Quand `dialer_ms_since_last() > 3000` (timeout inter-chiffre) OU le numéro matche une entrée connue : ROUTING. Routing Stage 1 = table locale des numéros valides (`"12","3615","15","17","18"` + Zacus) ; connu → `tones_ringback_start()` (2 s puis stop, Stage 2 enchaînera CONNECT) ; inconnu → `tones_busy_start()` (5 s) → IDLE. Au raccroché : `tones_stop()`, `audio_stop()`, `audio_pa_set(false)`, `dialer_reset()`, IDLE.
```c
#include "conversation.h"
#include "tones.h"
#include "dialer.h"
#include "audio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <string.h>
static const char *TAG = "conv";
typedef enum { ST_IDLE, ST_DIALTONE, ST_DIALING, ST_RINGBACK, ST_BUSY } st_t;
static volatile bool s_offhook;
static volatile st_t s_st = ST_IDLE;
static const char *KNOWN[] = { "12","3615","15","17","18", NULL };
static bool number_known(const char *n){
for (int i=0; KNOWN[i]; i++) if (!strcmp(n, KNOWN[i])) return true;
return false;
}
static void conv_task(void *arg){
for (;;){
vTaskDelay(pdMS_TO_TICKS(50));
if (!s_offhook){
if (s_st != ST_IDLE){ tones_stop(); audio_stop(); audio_pa_set(false); dialer_reset(); s_st=ST_IDLE; }
continue;
}
switch (s_st){
case ST_IDLE: tones_dialtone_start(); s_st=ST_DIALTONE; break;
case ST_DIALTONE:
if (!dialer_idle()){ tones_stop(); s_st=ST_DIALING; }
break;
case ST_DIALING: {
const char *n = dialer_current();
if (number_known(n)){
ESP_LOGI(TAG, "route %s -> known", n);
tones_ringback_start(); s_st=ST_RINGBACK; // Stage 2: enchaîner CONNECT
} else if (dialer_ms_since_last() > 3000){
ESP_LOGI(TAG, "route %s -> unknown", n);
tones_busy_start(); s_st=ST_BUSY;
}
break; }
case ST_RINGBACK: /* Stage 2 remplira CONNECT/GREET ici */ break;
case ST_BUSY: break; // reste occupé jusqu'au raccroché
}
}
}
void conversation_init(void){ xTaskCreate(conv_task,"conv",4096,NULL,5,NULL); }
void conversation_on_hook_change(bool offhook){ s_offhook = offhook; if(!offhook) s_st=ST_IDLE; }
```
- [ ] **Step 3 : Brancher phone.c → conversation**
Dans la task hook de `phone.c` (là où `hook_client_report` est appelé), ajouter `conversation_on_hook_change(offhook)` (offhook = `phone_is_offhook()`). Include `conversation.h`.
- [ ] **Step 4 : Init dans main.c**
Dans `ESP32_ZACUS/plip_voice/main/main.c`, après `audio_init()` et l'init réseau, ajouter `dialer_init(); conversation_init();`. Include les headers.
- [ ] **Step 5 : Ajouter conversation.c au CMakeLists + build + flash**
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
- [ ] **Step 6 : Test au banc (séquence complète Stage 1)**
Décroché (ou simuler off-hook) → tonalité au HP. Puis :
```bash
curl -s "http://192.168.0.138/debug/dial?number=17" # numéro connu
# série attendue : "route 17 -> known" + ringback au HP
curl -s "http://192.168.0.138/debug/dial?number=99" # après reset/raccroché
# série attendue : "route 99 -> unknown" + busy au HP (après timeout 3s)
```
Raccroché → silence (PA off). Expected : tonalité → ringback (connu) / occupé (inconnu) → silence au raccroché.
- [ ] **Step 7 : Commit**
```bash
git -C ESP32_ZACUS ... commit -am "feat(plip): conversation state machine — dialtone/dialing/routing/ringback/busy (stage 1)"
```
### Task 1.5 : Lander Stage 1 (PR submodule + bump pointeur)
- [ ] **Step 1 : PR submodule ESP32_ZACUS** (branche `feat/plip-phone-stage1`), merge via API Gitea (voir conventions de session : push avec token, PR, merge, ff-only main).
- [ ] **Step 2 : Bump pointeur** dans le superprojet (`git add ESP32_ZACUS`, commit `chore: bump ESP32_ZACUS — PLIP phone stage 1`, PR, merge).
---
## STAGE 2 — Gateway `/v1/voice/turn` (voie greeting) + annuaire (téléphone jouable sens unique)
Livrable : composer un numéro → **entendre le PNJ débiter son accueil** (TTS ailiance). Sans micro ni whisper.
### Task 2.1 : Annuaire `phone_directory.yaml` + chargement gateway
**Files:**
- Create: `game/scenarios/phone_directory.yaml`
- Modify: `tools/zacus-gateway/main.py` (loader + Settings)
- Test: `tests/gateway/test_phone_directory.py`
- [ ] **Step 1 : Écrire l'annuaire**
```yaml
# game/scenarios/phone_directory.yaml — numéro → persona vocale
# persona = prompt système (personnage) ; voice = voix ailiance ; greeting = 1re réplique forcée (optionnel)
numbers:
"12":
label: "Renseignements"
voice: "nova"
persona: >
Tu es l'opératrice des renseignements téléphoniques de France Télécom, années 80.
Tu es polie, un peu pressée. Tu orientes l'appelant vers les bons services sans
jamais donner directement la solution d'une énigme.
greeting: "Renseignements, bonjour. Quel numéro demandez-vous ?"
"15":
label: "SAMU"
voice: "nova"
persona: >
Tu es un régulateur du SAMU. Tu réponds avec calme et autorité. Dans cet escape game
tu peux donner un indice médical déguisé, mais jamais la réponse directe.
greeting: "SAMU, j'écoute, quelle est votre urgence ?"
"17":
label: "Police secours"
voice: "alloy"
persona: >
Tu es un agent de Police secours, factuel et sérieux. Indices déguisés, jamais la solution.
greeting: "Police secours, je vous écoute."
"18":
label: "Pompiers"
voice: "alloy"
persona: >
Tu es un sapeur-pompier, direct et rassurant. Indices déguisés, jamais la solution.
greeting: "Sapeurs-pompiers, bonjour, quel est le problème ?"
"3615":
label: "Minitel"
voice: "nova"
minitel: true
persona: >
Tu es un service Minitel automatisé (3615). Tu parles de façon datée, robotique,
télématique. Réponses courtes façon serveur télétel.
greeting: "Trois mille six cent quinze. Service en ligne. Tapez votre requête."
"0142738200":
label: "Professeur Zacus"
voice: "nova"
persona: >
Tu es le Professeur Zacus, savant excentrique et théâtral. Tu guides les joueurs
par des indices déguisés et des énigmes, jamais la solution directe. Tu connais le
scénario en cours mais restes mystérieux.
greeting: "Ah... vous avez réussi à me joindre. Ici le Professeur Zacus."
```
- [ ] **Step 2 : Écrire le test (échoue d'abord)**
`tests/gateway/test_phone_directory.py` :
```python
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
def test_directory_loads_known_numbers():
d = main.load_phone_directory()
assert "17" in d and d["17"]["label"] == "Police secours"
assert "3615" in d and d["3615"].get("minitel") is True
assert "voice" in d["12"] and "persona" in d["12"]
def test_directory_unknown_number_absent():
d = main.load_phone_directory()
assert "99" not in d
```
- [ ] **Step 3 : Lancer le test → échoue**
Run: `cd /Users/electron/code/zacus && python3 -m pytest tests/gateway/test_phone_directory.py -v`
Expected: FAIL (`load_phone_directory` n'existe pas).
- [ ] **Step 4 : Implémenter le loader**
Dans `tools/zacus-gateway/main.py`, près du `_load_boards_registry` (~l.32) :
```python
def load_phone_directory() -> dict:
import yaml
p = SCENARIOS_DIR / "phone_directory.yaml" # SCENARIOS_DIR = REPO_ROOT/game/scenarios
if not p.is_file():
return {}
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return data.get("numbers", {})
PHONE_DIRECTORY = load_phone_directory()
```
- [ ] **Step 5 : Lancer le test → passe**
Run: `python3 -m pytest tests/gateway/test_phone_directory.py -v`
Expected: PASS (2 tests).
- [ ] **Step 6 : Commit**
```bash
git add game/scenarios/phone_directory.yaml tools/zacus-gateway/main.py tests/gateway/test_phone_directory.py
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): phone directory loader (numéro → persona/voix)"
```
### Task 2.2 : Sessions en mémoire (historique conversation)
**Files:**
- Modify: `tools/zacus-gateway/main.py`
- Test: `tests/gateway/test_voice_session.py`
- [ ] **Step 1 : Test (échoue)**
```python
import sys, time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
def test_session_appends_and_caps():
s = main.VoiceSessions(ttl_s=600, max_turns=8)
s.append("sid1", "user", "bonjour")
s.append("sid1", "assistant", "ici Zacus")
msgs = s.history("sid1")
assert msgs[-2:] == [{"role":"user","content":"bonjour"},
{"role":"assistant","content":"ici Zacus"}]
for i in range(40): s.append("sid1", "user", f"m{i}")
assert len(s.history("sid1")) <= 16 # 8 tours * 2
def test_session_end_clears():
s = main.VoiceSessions()
s.append("sid2","user","x"); s.end("sid2")
assert s.history("sid2") == []
```
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_voice_session.py -v` → FAIL.
- [ ] **Step 3 : Implémenter `VoiceSessions`** dans `main.py` :
```python
class VoiceSessions:
def __init__(self, ttl_s: int = 600, max_turns: int = 8):
self._d: dict[str, dict] = {}
self._ttl = ttl_s
self._max = max_turns * 2
def append(self, sid: str, role: str, content: str) -> None:
import time
e = self._d.setdefault(sid, {"msgs": [], "ts": 0.0})
e["msgs"].append({"role": role, "content": content})
e["msgs"] = e["msgs"][-self._max:]
e["ts"] = time.time()
def history(self, sid: str) -> list[dict]:
import time
e = self._d.get(sid)
if not e: return []
if time.time() - e["ts"] > self._ttl:
self._d.pop(sid, None); return []
return list(e["msgs"])
def end(self, sid: str) -> None:
self._d.pop(sid, None)
VOICE_SESSIONS = VoiceSessions()
```
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_session.py -v` → PASS.
- [ ] **Step 5 : Commit**
```bash
git add tools/zacus-gateway/main.py tests/gateway/test_voice_session.py
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): in-memory voice sessions (history + TTL)"
```
### Task 2.3 : LLM chat helper (ailiance)
**Files:**
- Modify: `tools/zacus-gateway/main.py` (Settings + `_chat_reply`)
- Test: `tests/gateway/test_chat_reply.py`
- [ ] **Step 1 : Test (mock httpx)**
```python
import sys, asyncio
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
class FakeResp:
status_code = 200
def json(self): return {"choices":[{"message":{"content":"Ici Zacus."}}]}
class FakeHTTP:
async def post(self, *a, **k): return FakeResp()
def test_chat_reply_returns_content():
out = asyncio.run(main._chat_reply(FakeHTTP(), "persona", [{"role":"user","content":"allo"}]))
assert out == "Ici Zacus."
```
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_chat_reply.py -v` → FAIL.
- [ ] **Step 3 : Implémenter** dans `main.py` (Settings : `ailiance_chat_model: str = "gpt-4o-mini"`) :
```python
async def _chat_reply(http, persona: str, history: list[dict]) -> str:
messages = [{"role": "system", "content": persona}] + history
resp = await http.post(
f"{settings.ailiance_tts_url.rstrip('/')}/v1/chat/completions",
json={"model": settings.ailiance_chat_model, "messages": messages, "max_tokens": 200},
timeout=60.0,
)
if resp.status_code != 200:
raise RuntimeError(f"chat HTTP {resp.status_code}")
return resp.json()["choices"][0]["message"]["content"].strip()
```
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_chat_reply.py -v` → PASS.
- [ ] **Step 5 : Commit**
```bash
git add tools/zacus-gateway/main.py tests/gateway/test_chat_reply.py
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): ailiance chat helper for NPC replies"
```
### Task 2.4 : Route `/v1/voice/turn` (voie greeting) + `/v1/voice/end`
**Files:**
- Modify: `tools/zacus-gateway/main.py`
- Test: `tests/gateway/test_voice_turn.py`
- [ ] **Step 1 : Test voie greeting (mock chat + TTS)**
```python
import sys, asyncio
from pathlib import Path
from fastapi.testclient import TestClient
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
def test_turn_greeting_returns_wav(monkeypatch):
async def fake_chat(http, persona, history): return "Police secours, j'écoute."
async def fake_synth(http, text, voice):
# WAV 16k mono minuscule valide
import struct
pcm = b"\x00\x00" * 1600
hdr = b"RIFF" + struct.pack("<I", 36+len(pcm)) + b"WAVEfmt " + struct.pack("<IHHIIHH",16,1,1,16000,32000,2,16) + b"data" + struct.pack("<I", len(pcm))
return hdr + pcm, 0.1, False
monkeypatch.setattr(main, "_chat_reply", fake_chat)
monkeypatch.setattr(main, "_voice_tts_16k", fake_synth)
c = TestClient(main.app)
r = c.post("/v1/voice/turn",
headers={"Authorization": f"Bearer {main.settings.token}"},
json={"session_id":"s1","number":"17","kind":"greeting"})
assert r.status_code == 200
assert r.headers["content-type"] == "audio/wav"
assert r.headers.get("x-zacus-said") # texte de réponse en header
assert r.content[:4] == b"RIFF"
def test_turn_unknown_number_404():
c = TestClient(main.app)
r = c.post("/v1/voice/turn",
headers={"Authorization": f"Bearer {main.settings.token}"},
json={"session_id":"s1","number":"99","kind":"greeting"})
assert r.status_code == 404
```
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → FAIL.
- [ ] **Step 3 : Implémenter le helper TTS 16k + la route**
Helper (réutilise `_synthesise_wav` ailiance + `_wav_to_16k_mono`) :
```python
async def _voice_tts_16k(http, text: str, voice: str | None) -> tuple[bytes, float, bool]:
from types import SimpleNamespace
raw = await _synthesise_wav(http, SimpleNamespace(backend="ailiance", text=text, voice=voice))
return _wav_to_16k_mono(raw, max_seconds=7.5)
```
Route :
```python
from fastapi import Response
class VoiceTurnRequest(BaseModel):
session_id: str
number: str
kind: str = "reply" # "greeting" | "reply"
@app.post("/v1/voice/turn")
async def voice_turn(body: VoiceTurnRequest, request: Request, _: None = Depends(require_token)):
entry = PHONE_DIRECTORY.get(body.number)
if not entry:
raise HTTPException(404, f"numéro {body.number} non attribué")
persona = entry.get("persona", "")
voice = entry.get("voice")
http = request.app.state.http
heard = ""
if body.kind == "greeting":
said = entry.get("greeting") or await _chat_reply(http, persona, [])
else:
# Stage 3 remplira la transcription depuis le corps audio ; ici fallback vide
heard = ""
VOICE_SESSIONS.append(body.session_id, "user", heard or "(silence)")
said = await _chat_reply(http, persona, VOICE_SESSIONS.history(body.session_id))
VOICE_SESSIONS.append(body.session_id, "assistant", said)
wav16, _dur, _trunc = await _voice_tts_16k(http, said, voice)
return Response(content=wav16, media_type="audio/wav",
headers={"X-Zacus-Heard": heard[:200], "X-Zacus-Said": said[:200]})
class VoiceEndRequest(BaseModel):
session_id: str
@app.post("/v1/voice/end")
async def voice_end(body: VoiceEndRequest, _: None = Depends(require_token)) -> dict:
VOICE_SESSIONS.end(body.session_id)
return {"ok": True}
```
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → PASS (2 tests).
- [ ] **Step 5 : Test live contre ailiance (manuel, hors CI)**
Monter le gateway local (venv + `ZACUS_HUB_TOKEN`), puis :
```bash
curl -s -XPOST http://127.0.0.1:8400/v1/voice/turn \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"session_id":"t1","number":"17","kind":"greeting"}' -o /tmp/greet.wav -D -
file /tmp/greet.wav # RIFF WAVE 16000 Hz
```
Expected : 200, header `X-Zacus-Said`, WAV 16k jouable.
- [ ] **Step 6 : Commit**
```bash
git add tools/zacus-gateway/main.py tests/gateway/test_voice_turn.py
git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): POST /v1/voice/turn (greeting path) + /v1/voice/end"
```
### Task 2.5 : Firmware — CONNECT/GREET appelle `/v1/voice/turn` et joue l'accueil
**Files:**
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (état CONNECT/GREET)
- Create: `ESP32_ZACUS/plip_voice/main/turn_client.h` / `turn_client.c` (POST /v1/voice/turn, reçoit WAV → SPIFFS → play)
- Modify: `Kconfig.projbuild` (URL gateway + token)
- [ ] **Step 1 : Kconfig gateway**
```
config PLIP_GATEWAY_URL
string "Zacus gateway base URL"
default "http://192.168.0.188:8400" # ajuster à l'hôte gateway joignable du PLIP
config PLIP_GATEWAY_TOKEN
string "Zacus gateway bearer token"
default ""
```
- [ ] **Step 2 : `turn_client.c`** — POST greeting, écrit le WAV reçu dans `/spiffs/turn.wav`, retourne le chemin.
Calque `hook_client.c` (esp_http_client POST) mais : body JSON `{"session_id","number","kind":"greeting"}`, header `Authorization: Bearer <token>`, et **lecture de la réponse binaire** (WAV) en streaming → `fopen("/spiffs/turn.wav","wb")` + `esp_http_client_read`. Signature :
```c
// turn_client.h
#pragma once
#include <stdbool.h>
// POST /v1/voice/turn (greeting). Écrit le WAV dans out_path (/spiffs/...). true si OK.
bool turn_client_greeting(const char *session_id, const char *number, const char *out_path);
```
- [ ] **Step 3 : État CONNECT/GREET dans conversation.c**
Après RINGBACK (~2 s), au lieu de rester : générer un `session_id` (ex. `snprintf("%lld", esp_timer_get_time())`), appeler `turn_client_greeting(sid, dialer_current(), "/spiffs/turn.wav")`, puis `tones_stop(); audio_play_async("/spiffs/turn.wav");` → état CONNECTED. (Stage 3 ajoutera la boucle LISTEN.)
```c
case ST_RINGBACK:
if (ringback_elapsed_ms() > 2000){
tones_stop();
snprintf(s_sid, sizeof(s_sid), "%lld", esp_timer_get_time());
if (turn_client_greeting(s_sid, dialer_current(), "/spiffs/turn.wav"))
audio_play_async("/spiffs/turn.wav");
s_st = ST_CONNECTED;
}
break;
case ST_CONNECTED: /* Stage 3: LISTEN → turn(reply) → play, en boucle */ break;
```
- [ ] **Step 4 : Build + flash + test bout-en-bout (sens unique)**
Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`
Test : gateway prod/local joignable depuis le PLIP. Décroché → tonalité → `curl "http://192.168.0.138/debug/dial?number=17"` → série : route known → ringback → POST turn → **le combiné dit « Police secours, je vous écoute »**. (combiné décroché pour entendre, mute-on-hook oblige.)
Expected : le PNJ parle.
- [ ] **Step 5 : Commit + PR submodule + bump** (branche `feat/plip-phone-stage2-fw`).
### Task 2.6 : Lander Stage 2 (gateway + déploiement)
- [ ] **Step 1 : PR gateway** (route + annuaire + sessions) → merge.
- [ ] **Step 2 : Déployer le gateway** (scp `main.py` + `phone_directory.yaml``/home/electron/zacus-hub/...`, restart `zacus-hub-gateway`, `.bak` avant) — **nécessite autorisation explicite de déploiement prod**.
- [ ] **Step 3 : Vérif prod** : `POST /v1/voice/turn {number:17,kind:greeting}` → WAV.
---
## STAGE 3 — Micro + whisper → conversation deux sens
### Task 3.1 : Vérifier la capture micro (dépendance hardware)
**Files:** aucun changement code tant que non diagnostiqué.
- [ ] **Step 1 : Test capture combiné DÉCROCHÉ**
Combiné branché + **décroché** (SLIC connecte l'audio). Parler dans le combiné pendant :
```bash
curl -s -XPOST "http://192.168.0.138/voice/capture?max_ms=4000" -o /tmp/cap.wav
python3 - <<'PY'
import wave,struct
w=wave.open('/tmp/cap.wav'); n=w.getnframes(); d=w.readframes(n)
s=struct.unpack('<%dh'%(len(d)//2), d)
rms=(sum(x*x for x in s)/max(1,len(s)))**0.5
print("samples",len(s),"rms",round(rms,1))
PY
```
Expected : RMS nettement > 0 (voix captée). **Si RMS ≈ 0** : passer au Step 2.
- [ ] **Step 2 : Si zéros — basculer l'entrée ES8388 (LIN2/RIN2)**
Dans `es8388.c`, la config ADC sélectionne l'entrée (LINPUT1/RINPUT1). Si le SLIC est câblé sur l'entrée 2, changer la sélection sur LINPUT2/RINPUT2 (registre ADCCONTROL2/3 selon datasheet ES8388). Rebuild + flash + re-tester le Step 1. Documenter le pin réel. (Diagnostic possible via `GET /debug/regs` ajouté précédemment.)
- [ ] **Step 3 : Commit** (si changement entrée) : `git -C ESP32_ZACUS ... commit -am "fix(plip): ES8388 ADC input selection matches SLIC audio wiring"`.
### Task 3.2 : Endpoint STT vivant (whisper)
**Files:**
- Modify: `tools/zacus-gateway/main.py` (Settings `whisper_url` + `_transcribe`)
- Test: `tests/gateway/test_transcribe.py`
- [ ] **Step 1 : Relancer whisper** : soit la voice-bridge sur studio (`/voice/transcribe` → whisper.cpp :8300), soit whisper.cpp seul. Vérifier `curl -F file=@/tmp/cap.wav <whisper_url>` → JSON `{text}`. Noter l'URL joignable depuis le gateway host. (Infra — hors code ; documenter la commande de démarrage.)
- [ ] **Step 2 : Test `_transcribe` (mock)** `tests/gateway/test_transcribe.py` :
```python
import sys, asyncio
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tools" / "zacus-gateway"))
import main
class FakeResp:
status_code = 200
def json(self): return {"text":"bonjour professeur"}
class FakeHTTP:
async def post(self,*a,**k): return FakeResp()
def test_transcribe_returns_text():
out = asyncio.run(main._transcribe(FakeHTTP(), b"RIFF....", "x.wav"))
assert out == "bonjour professeur"
```
- [ ] **Step 3 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_transcribe.py -v` → FAIL.
- [ ] **Step 4 : Implémenter `_transcribe`** (Settings `whisper_url: str = "http://studio:8200"`, endpoint `/voice/transcribe` multipart) :
```python
async def _transcribe(http, wav_bytes: bytes, filename: str = "speech.wav") -> str:
files = {"file": (filename, wav_bytes, "audio/wav")}
resp = await http.post(f"{settings.whisper_url.rstrip('/')}/voice/transcribe",
files=files, timeout=60.0)
if resp.status_code != 200:
raise RuntimeError(f"stt HTTP {resp.status_code}")
return (resp.json().get("text") or "").strip()
```
- [ ] **Step 5 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_transcribe.py -v` → PASS.
- [ ] **Step 6 : Commit** : `git add ... && git commit -m "feat(gateway): whisper STT helper (_transcribe)"`.
### Task 3.3 : Voie `reply` de `/v1/voice/turn` (audio → STT → chat → TTS)
**Files:**
- Modify: `tools/zacus-gateway/main.py` (la route accepte le corps audio en `kind=reply`)
- Test: `tests/gateway/test_voice_turn.py` (ajouter un cas reply)
- [ ] **Step 1 : Test reply (mock _transcribe + _chat_reply + _voice_tts_16k)**
Ajouter à `test_voice_turn.py` un test qui POST `kind=reply` avec un corps WAV (multipart ou raw body) et `number`/`session_id` en query, mocke `_transcribe`→"allo", `_chat_reply`→"oui ?", `_voice_tts_16k`→WAV ; assert 200, header `X-Zacus-Heard: allo`.
- [ ] **Step 2 : Lancer → échoue** (la route ignore encore l'audio). Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → le nouveau test FAIL.
- [ ] **Step 3 : Adapter la route** : pour `kind=reply`, lire le corps audio (le PLIP enverra le WAV en body brut + `number`/`session_id`/`kind` en query params, car le body est l'audio). Appeler `_transcribe(http, audio_bytes)``heard`, l'ajouter à l'historique, puis chat + TTS comme avant.
```python
@app.post("/v1/voice/turn")
async def voice_turn(request: Request, session_id: str, number: str,
kind: str = "reply", _: None = Depends(require_token)):
entry = PHONE_DIRECTORY.get(number)
if not entry: raise HTTPException(404, f"numéro {number} non attribué")
http = request.app.state.http; persona = entry.get("persona",""); voice = entry.get("voice")
heard = ""
if kind == "greeting":
said = entry.get("greeting") or await _chat_reply(http, persona, [])
else:
audio = await request.body()
try:
heard = await _transcribe(http, audio)
except Exception:
heard = ""
VOICE_SESSIONS.append(session_id, "user", heard or "(inaudible)")
if not heard:
said = "Allô ? Je vous entends très mal, pouvez-vous répéter ?"
else:
said = await _chat_reply(http, persona, VOICE_SESSIONS.history(session_id))
VOICE_SESSIONS.append(session_id, "assistant", said)
wav16,_d,_t = await _voice_tts_16k(http, said, voice)
return Response(content=wav16, media_type="audio/wav",
headers={"X-Zacus-Heard": heard[:200], "X-Zacus-Said": said[:200]})
```
(Note : `session_id`/`number`/`kind` passent en query params puisque le body porte l'audio. Mettre à jour le test greeting en conséquence : query params au lieu de json.)
- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → PASS (tous).
- [ ] **Step 5 : Commit** : `git commit -m "feat(gateway): /v1/voice/turn reply path (audio→STT→chat→TTS)"`.
### Task 3.4 : Firmware — boucle LISTEN → turn(reply) → SPEAK
**Files:**
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (état CONNECTED → boucle)
- Modify: `ESP32_ZACUS/plip_voice/main/turn_client.c` (fonction reply : capture → POST WAV → reçoit WAV)
- [ ] **Step 1 : `turn_client_reply`** — capture le micro (réutilise l'API `audio_capture_begin/read_frame/end`), assemble un WAV en mémoire/SPIFFS, POST en body brut à `/v1/voice/turn?session_id=..&number=..&kind=reply`, écrit la réponse WAV dans `/spiffs/turn.wav`.
```c
// turn_client.h : bool turn_client_reply(const char *sid, const char *number, const char *out_path);
```
(Capture : ~8 s max, VAD comme `/voice/capture`. Upload : `esp_http_client` POST streaming. Réutiliser le code de capture de `net.c::handle_voice_capture`.)
- [ ] **Step 2 : Boucle dans conversation.c**
```c
case ST_CONNECTED:
// après la fin de lecture de l'accueil/réponse :
if (audio_idle()){
if (turn_client_reply(s_sid, dialer_current(), "/spiffs/turn.wav"))
audio_play_async("/spiffs/turn.wav");
// reste en CONNECTED → reboucle ; raccroché géré globalement
}
break;
```
(Ajouter `audio_idle()` si absent : true quand la queue est vide et aucune lecture en cours.)
- [ ] **Step 3 : Build + flash + test conversation deux sens** (combiné décroché, micro vérifié Task 3.1, whisper up) : décroché → numéro → accueil → parler → Zacus répond → reboucle → raccroché = fin.
- [ ] **Step 4 : Commit + PR submodule + bump.**
---
## STAGE 4 — 3615 Minitel (handshake + agent robotique)
### Task 4.1 : Sample handshake Minitel + lecture sur 3615
**Files:**
- Create: `ESP32_ZACUS/plip_voice/main/minitel_tone.h` (PCM embarqué, court) OU pousser un WAV sur SPIFFS
- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (si `number=="3615"`, jouer le handshake avant GREET)
- [ ] **Step 1 : Obtenir le son handshake Minitel** : générer/approximer le handshake (séquence de tonalités modem) en WAV 16k court (~3 s), le pousser sur `/spiffs/minitel.wav` via `/game/file`, OU l'embarquer. (Sans fichier, approximer par une séquence de tones via `tones`/`audio_play_tone` : porteuse ~1300/2100 Hz alternées.)
- [ ] **Step 2 : Cas 3615 dans conversation.c** : à la connexion, si `dialer_current()=="3615"`, jouer `/spiffs/minitel.wav` (ou la séquence), PUIS enchaîner `turn_client_greeting` (l'annuaire a déjà la persona robotique + `minitel:true`). Le reste de la boucle est identique.
- [ ] **Step 3 : Build + flash + test** : composer 3615 → son modem Minitel → « Trois mille six cent quinze. Service en ligne… » → conversation robotique.
- [ ] **Step 4 : Commit + PR submodule + bump.**
### Task 4.2 : Test annuaire complet au banc
- [ ] **Step 1 :** Pour chaque numéro (12/15/17/18/3615/Zacus) : composer → accueil du bon PNJ (voix/persona). Numéro inconnu → occupé. Raccroché à tout moment → silence immédiat.
- [ ] **Step 2 :** Mode dégradé : whisper down → l'accueil joue quand même, reply → « je vous entends mal » (fallback). Documenter.
---
## Self-Review (couverture spec)
- **§2 Architecture (A, /v1/voice/turn unique)** → Tasks 2.4, 3.3 ✓
- **§3 Séquence (tonalité/dialing/routing/ringback/busy/mute)** → Tasks 1.1, 1.4 ✓
- **§4 Dialer (impulsions SHK + DTMF + debug)** → Tasks 1.2 (debug), 1.3 (impulsions) ✓ ; **DTMF Goertzel = non couvert explicitement** (voir note ci-dessous).
- **§5 Annuaire (12/3615/15/17/18 + Zacus)** → Task 2.1 ✓
- **§6 Contrat /v1/voice/turn (session_id/number/kind/audio, headers debug, /end)** → Tasks 2.4, 3.3 ✓
- **§7 Services IA (ailiance chat+TTS, whisper STT)** → Tasks 2.3, 3.2 ✓
- **§8 Dépendances (micro, whisper)** → Tasks 3.1, 3.2 ✓
- **§9 Mode dégradé** → Task 4.2 Step 2 ✓
- **§10 Erreurs (STT vide, réseau, raccroché, inconnu, cap)** → Tasks 2.4 (404 inconnu), 3.3 (fallback STT vide), 1.4 (raccroché/busy) ✓
- **§12 Build incrémental** → Stages 1-4 ✓
- **§13 Tests** → tests gateway pytest (2.x, 3.x) + tests banc firmware ✓
**Gap identifié — DTMF Goertzel** : le spec liste le DTMF comme 2ᵉ source de numérotation, mais le plan ne le détaille pas (seuls impulsions + debug sont couverts). **Décision** : le DTMF est repoussé en option post-Stage-1 (le cadran à impulsions + debug couvrent la cible « vintage à cadran » prioritaire). À ajouter comme Task 1.6 ultérieure si un téléphone à touches est utilisé : décodage Goertzel des 8 fréquences sur les frames de `audio_capture_read_frame`, derrière `CONFIG_PLIP_DIAL_DTMF`. Ce report est cohérent avec la cible (téléphone à cadran) confirmée au brainstorming.