feat(plip): turn_client + GREET state — fetch & play NPC greeting from gateway /v1/voice/turn (stage 2)
CI / platformio (pull_request) Failing after 11m23s
CI / platformio (pull_request) Failing after 11m23s
This commit is contained in:
@@ -10,6 +10,7 @@ idf_component_register(
|
||||
"tones.c"
|
||||
"dialer.c"
|
||||
"conversation.c"
|
||||
"turn_client.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES
|
||||
driver
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 elapsed → GREET (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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user