feat(plip): turn_client + GREET state — fetch & play NPC greeting from gateway /v1/voice/turn (stage 2)
CI / platformio (pull_request) Failing after 11m23s

This commit is contained in:
2026-06-14 22:43:46 +02:00
parent e68e9f5f66
commit c740bfe20a
6 changed files with 256 additions and 4 deletions
+1
View File
@@ -10,6 +10,7 @@ idf_component_register(
"tones.c"
"dialer.c"
"conversation.c"
"turn_client.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
+17
View File
@@ -62,4 +62,21 @@ menu "PLIP Voice Configuration"
a pulse train, the train is considered complete and the digit is
emitted. 200 ms is standard for French rotary dials.
config PLIP_GATEWAY_URL
string "NPC Gateway Base URL"
default "http://192.168.0.50:8401"
help
Base URL of the PLIP voice gateway (zacus-gateway FastAPI), as seen
from the PLIP on the local LAN. Override at build time or via
sdkconfig.defaults. The turn_client appends /v1/voice/turn.
Example: http://192.168.0.10:8401 (IP of the Mac running the gateway).
config PLIP_GATEWAY_TOKEN
string "NPC Gateway Bearer Token"
default ""
help
Bearer token sent as "Authorization: Bearer <token>" on every
/v1/voice/turn request. Leave empty to skip the header.
endmenu
+67 -4
View File
@@ -2,12 +2,15 @@
* conversation.c — PLIP telephone conversation state machine.
*
* States: IDLE → DIALTONE → DIALING → RINGBACK | BUSY
* RINGBACK (after ~2 s) → GREET → CONNECTED
*
* Transitions:
* IDLE + off-hook → DIALTONE (tones_dialtone_start)
* DIALTONE + first digit → DIALING (tones_stop)
* IDLE + off-hook → DIALTONE (tones_dialtone_start)
* DIALTONE + first digit → DIALING (tones_stop)
* DIALING + ms_since_last > 3000 → RINGBACK or BUSY (route decision)
* any state + on-hook IDLE (tones_stop, audio_stop, PA off)
* RINGBACK + 2 s elapsedGREET (tones_stop, turn_client_greeting, play WAV)
* GREET + play enqueued → CONNECTED (Stage 3: listen loop)
* any state + on-hook → IDLE (tones_stop, audio_stop, PA off)
*
* Routing table (known numbers → ringback; unknown → busy after 3 s silence):
* "12", "3615", "15", "17", "18", "0142738200"
@@ -17,27 +20,41 @@
#include "dialer.h"
#include "tones.h"
#include "audio.h"
#include "turn_client.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_timer.h"
#include <stdio.h>
#include <string.h>
#define TAG "conversation"
/* Duration of ringback before picking up and fetching the greeting */
#define RINGBACK_GREET_MS 2000
typedef enum {
STATE_IDLE,
STATE_DIALTONE,
STATE_DIALING,
STATE_RINGBACK,
STATE_BUSY,
STATE_GREET, /* fetching + playing NPC greeting (Stage 2) */
STATE_CONNECTED, /* in-call — Stage 3 will add listen loop */
} conv_state_t;
static volatile conv_state_t s_state = STATE_IDLE;
static volatile bool s_offhook = false;
static volatile bool s_hook_changed = false;
/* Ringback start timestamp (µs, from esp_timer_get_time) */
static int64_t s_ringback_start_us = 0;
/* Session ID for the current call (generated at ringback → greet transition) */
static char s_sid[32] = {0};
/* Known numbers: ringback when dialed */
static const char *KNOWN[] = {
"12", "3615", "15", "17", "18", "0142738200", NULL
@@ -120,6 +137,7 @@ static void conv_task(void *arg)
if (is_known(num)) {
ESP_LOGI(TAG, "route %s -> known (ringback)", num);
tones_ringback_start();
s_ringback_start_us = esp_timer_get_time();
s_state = STATE_RINGBACK;
} else {
ESP_LOGI(TAG, "route %s -> unknown (busy)", num);
@@ -130,6 +148,49 @@ static void conv_task(void *arg)
break;
case STATE_RINGBACK:
if (!s_offhook) {
go_idle();
break;
}
{
int64_t elapsed_ms =
(esp_timer_get_time() - s_ringback_start_us) / 1000;
if (elapsed_ms >= RINGBACK_GREET_MS) {
/* Stop ringback tone synchronously before fetching */
tones_stop();
/* Generate a session ID from timer ticks */
snprintf(s_sid, sizeof(s_sid), "%lld",
(long long)esp_timer_get_time());
ESP_LOGI(TAG, "ringback done -> GREET (sid=%s num=%s)",
s_sid, dialer_current());
s_state = STATE_GREET;
}
}
break;
case STATE_GREET:
if (!s_offhook) {
go_idle();
break;
}
/* Fetch greeting WAV from gateway and enqueue playback */
if (turn_client_greeting(s_sid, dialer_current(),
"/spiffs/turn.wav")) {
audio_play_async("/spiffs/turn.wav");
} else {
ESP_LOGW(TAG, "turn_client_greeting failed — proceeding silent");
}
s_state = STATE_CONNECTED;
ESP_LOGI(TAG, "-> CONNECTED");
break;
case STATE_CONNECTED:
/* Stage 3 will add listen/speak loop here */
if (!s_offhook) {
go_idle();
}
break;
case STATE_BUSY:
if (!s_offhook) {
go_idle();
@@ -144,7 +205,9 @@ void conversation_init(void)
s_state = STATE_IDLE;
s_offhook = false;
s_hook_changed = false;
xTaskCreate(conv_task, "conv", 3072, NULL, 4, NULL);
/* Stack bumped to 6144: STATE_GREET calls esp_http_client (blocking HTTP
* + file I/O) which needs more stack than the baseline 3072. */
xTaskCreate(conv_task, "conv", 6144, NULL, 4, NULL);
ESP_LOGI(TAG, "conversation init");
}
+131
View File
@@ -0,0 +1,131 @@
/*
* turn_client.c — NPC gateway client for /v1/voice/turn.
*
* Uses esp_http_client open/write/fetch/read streaming so the binary WAV
* body can be written directly to SPIFFS without a large heap buffer.
*
* Pattern mirrors hook_client.c but synchronous (called from conv_task).
* Timeout is generous (30 s) because TTS synthesis adds latency.
*/
#include "turn_client.h"
#include "sdkconfig.h"
#include <stdio.h>
#include <string.h>
#include "esp_http_client.h"
#include "esp_log.h"
#define TAG "turn_client"
/* WAV header is 44 bytes; anything shorter is certainly not a valid response. */
#define MIN_WAV_BYTES 44
/* Read chunks when streaming the response body. */
#define CHUNK_SIZE 1024
/* Timeout for the full TTS round-trip (connect + synthesis + transfer). */
#define TIMEOUT_MS 30000
bool turn_client_greeting(const char *session_id,
const char *number,
const char *out_path)
{
/* --- Build URL -------------------------------------------------------- */
char url[256];
snprintf(url, sizeof(url), "%s/v1/voice/turn",
CONFIG_PLIP_GATEWAY_URL);
/* --- Build JSON body -------------------------------------------------- */
char body[256];
int body_len = snprintf(body, sizeof(body),
"{\"session_id\":\"%s\",\"number\":\"%s\",\"kind\":\"greeting\"}",
session_id ? session_id : "",
number ? number : "");
/* --- Configure client ------------------------------------------------- */
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = TIMEOUT_MS,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) {
ESP_LOGE(TAG, "esp_http_client_init failed");
return false;
}
esp_http_client_set_header(client, "Content-Type", "application/json");
/* Authorization header — skip if token is empty */
const char *token = CONFIG_PLIP_GATEWAY_TOKEN;
if (token && token[0] != '\0') {
char auth[128];
snprintf(auth, sizeof(auth), "Bearer %s", token);
esp_http_client_set_header(client, "Authorization", auth);
}
/* --- Open connection and write body (streaming POST) ------------------ */
esp_err_t err = esp_http_client_open(client, body_len);
if (err != ESP_OK) {
ESP_LOGW(TAG, "open %s failed: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return false;
}
int written = esp_http_client_write(client, body, body_len);
if (written < 0) {
ESP_LOGW(TAG, "write body failed (written=%d)", written);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return false;
}
/* --- Fetch response headers ------------------------------------------ */
int content_len = esp_http_client_fetch_headers(client);
int status_code = esp_http_client_get_status_code(client);
if (status_code != 200) {
ESP_LOGW(TAG, "gateway returned HTTP %d (url=%s)", status_code, url);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return false;
}
ESP_LOGI(TAG, "HTTP 200 from %s (content_length=%d)", url, content_len);
/* --- Stream binary body into SPIFFS file ------------------------------ */
FILE *fp = fopen(out_path, "wb");
if (!fp) {
ESP_LOGE(TAG, "fopen(%s, wb) failed", out_path);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return false;
}
static uint8_t s_chunk[CHUNK_SIZE]; /* static: avoids stack pressure */
int total = 0;
int rd;
while ((rd = esp_http_client_read(client, (char *)s_chunk, sizeof(s_chunk))) > 0) {
size_t fw = fwrite(s_chunk, 1, (size_t)rd, fp);
if ((int)fw != rd) {
ESP_LOGW(TAG, "fwrite short: wrote %d of %d bytes", (int)fw, rd);
break;
}
total += rd;
}
fclose(fp);
esp_http_client_close(client);
esp_http_client_cleanup(client);
if (total <= MIN_WAV_BYTES) {
ESP_LOGW(TAG, "greeting WAV too short (%d bytes) — ignoring", total);
return false;
}
ESP_LOGI(TAG, "greeting WAV %d bytes written to %s", total, out_path);
return true;
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
/*
* turn_client.h — POST /v1/voice/turn to the NPC gateway and retrieve a WAV response.
*
* Stage 2: greeting fetch (kind="greeting").
* Stage 3 will add kind="listen" / "speak".
*/
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* turn_client_greeting — POST /v1/voice/turn with kind="greeting".
*
* Sends: { "session_id": session_id, "number": number, "kind": "greeting" }
* Bearer: CONFIG_PLIP_GATEWAY_TOKEN (skipped if empty)
* URL: CONFIG_PLIP_GATEWAY_URL/v1/voice/turn
*
* On success (HTTP 200, body > 44 bytes written to out_path):
* - Streams the binary WAV response into fopen(out_path, "wb").
* - Returns true.
*
* On failure (network error, non-200, short body):
* - Logs a warning and returns false.
* - Does not crash; caller may proceed silently.
*/
bool turn_client_greeting(const char *session_id,
const char *number,
const char *out_path);
#ifdef __cplusplus
}
#endif
+5
View File
@@ -43,3 +43,8 @@ CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=2048
# Zacus master URL — default matches the lab IP
# CONFIG_PLIP_MASTER_URL="http://192.168.0.188"
# NPC gateway (Stage 2) — override in sdkconfig (NOT committed) for local testing.
# Example: CONFIG_PLIP_GATEWAY_URL="http://192.168.0.175:8401"
# CONFIG_PLIP_GATEWAY_TOKEN="testtoken"
# Default (Kconfig): http://192.168.0.50:8401 with empty token.