Compare commits

...

4 Commits

Author SHA1 Message Date
clement da7b6ace9c feat(plip): play greeting from SD pack on pickup
CI / platformio (pull_request) Failing after 4m2s
CI / platformio (push) Failing after 9m58s
On incoming pickup the GREET state now plays the local clip
/sdcard/voice/greet_<num>.wav when present — the phone greets the
caller with no gateway and no model in RAM. Falls back to the live
gateway greeting (turn_client) only when the pack lacks a clip for
that number.
2026-06-18 18:08:29 +02:00
clement be369fa260 feat(plip): stage files onto SD via /game/file
CI / platformio (push) Failing after 10m32s
CI / platformio (pull_request) Failing after 11m45s
/game/file only wrote SPIFFS (960 KB, too small for a voice pack). A
path prefixed sd/ (or /sdcard/) now routes to the microSD: the card is
mounted on demand (audio_ensure_sd, independent of hook state) and the
parent dir is created. MAX_BODY 256->512 KB so full say() hints fit
(streamed in 2 KB chunks, RAM-safe). Enable FATFS LFN_HEAP so >8.3
filenames work (greet_0142738200.wav etc). Validated: 40-sample FR
voice pack staged to /sdcard/voice on the bench PLIP (960 MB card).
2026-06-18 17:41:53 +02:00
clement ace740a629 fix(npc): wire scene notify into apply_step
CI / platformio (push) Failing after 6m10s
CI / platformio (pull_request) Failing after 17m59s
notify_gateway_scene() + hints puzzle_start lived only in
npc_engine_set_step(), which had NO callers — the runtime->gateway
sync was dead code, so scripted incoming calls would never fire in a
real game. POST /game/step goes through game_endpoint_apply_step(),
which now resolves the step's scene_id (new field on scene_binding,
parsed straight from the IR) and calls the new
npc_engine_set_scene_by_id(). Hardware-validated: STEP_WARNING ->
scene 3 -> gateway notify -> auto-ring Professeur Zacus on the PLIP.
2026-06-18 15:52:03 +02:00
clement 4ac3981845 fix(npc): gateway scene notify non-blocking
CI / platformio (pull_request) Failing after 4m3s
CI / platformio (push) Failing after 14m44s
The scene->gateway POST ran synchronously (2 s timeout) on the scene
transition, so an offline gateway lagged EVERY scene change by 2 s.
Fire it on a detached one-shot task instead, so the game's scene
transitions never wait on the network. Frees the heap scene copy and
self-deletes. Builds clean.
2026-06-18 15:17:31 +02:00
11 changed files with 152 additions and 31 deletions
@@ -22,4 +22,5 @@ idf_component_register(
p7_coffre
p5_morse
p6_nfc
npc_engine
)
@@ -27,6 +27,7 @@
#include "nvs_flash.h"
#include "hints_client.h"
#include "npc_engine.h"
#include "scenario_mesh.h"
#include "puzzle_binding.h"
#include "local_puzzles.h"
@@ -807,6 +808,16 @@ esp_err_t game_endpoint_apply_step(const char *step_id,
strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1);
s_current_step_id[sizeof(s_current_step_id) - 1] = '\0';
// Sync the NPC/hints engine (and, through it, the voice gateway) with the
// scene this step enters. This is what makes the phone NPCs hint on — and
// scripted calls auto-ring for — the current scene, regardless of whether
// the step was driven by the local game loop or an external POST /game/step.
// Best-effort: notify is async + server-side idempotent and never blocks
// the step change. Steps with no scene_id (or an unknown one) are skipped.
if (s_current_scene.scene_id[0] != '\0') {
(void) npc_engine_set_scene_by_id(s_current_scene.scene_id, 0);
}
// P7 coffre: fire the actuator when the final win step is reached.
// p7_coffre_unlock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n.
if (strcmp(step_id, "STEP_FINAL_WIN") == 0) {
@@ -55,6 +55,7 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
#define SB_MAX_TITLE 48
#define SB_MAX_SUBTITLE 64
#define SB_MAX_SYMBOL 16
#define SB_MAX_SCENE_ID 40
typedef enum {
SB_FX_PULSE = 0, // default (original SceneEffect::kPulse)
@@ -65,6 +66,8 @@ typedef enum {
typedef struct {
bool present; // step has a scene object
char scene_id[SB_MAX_SCENE_ID]; // canonical SCENE_* id (sibling of step id;
// set even when no display scene object)
char title[SB_MAX_TITLE];
char subtitle[SB_MAX_SUBTITLE];
char symbol[SB_MAX_SYMBOL];
@@ -239,9 +239,15 @@ esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
}
if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; }
// Canonical scene id (SCENE_*), sibling of the step "id". Captured
// unconditionally so npc_engine / the voice gateway can be synced even
// on steps that carry no display "scene" object.
copy_scene_str(out->scene_id, sizeof(out->scene_id),
cJSON_GetObjectItemCaseSensitive(step, "scene_id"));
const cJSON *scene = cJSON_GetObjectItemCaseSensitive(step, "scene");
if (!scene || !cJSON_IsObject(scene)) {
// No scene — present stays false, ESP_OK.
// No scene object — present stays false, ESP_OK (scene_id may be set).
cJSON_Delete(root);
return ESP_OK;
}
@@ -169,6 +169,16 @@ esp_err_t npc_engine_trigger_cue(const char *cue_id);
// and primes the stuck timer.
esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms);
// Same bridge as npc_engine_set_step(), but keyed by the canonical scene
// string id (e.g. "SCENE_WARNING") rather than its numeric index. Resolves
// the id against the engine's internal scene table and forwards to
// npc_engine_set_step(). Lets the game endpoint sync the NPC/hints engine
// (and the voice gateway) straight from the scenario IR's `scene_id`.
// Returns ESP_ERR_NOT_FOUND when the id matches no known scene (no notify
// is sent in that case), ESP_ERR_INVALID_ARG on a NULL/empty id.
esp_err_t npc_engine_set_scene_by_id(const char *scene_id,
uint32_t expected_duration_ms);
// Request a hint for `puzzle_id` at escalation `level` (0..3). The engine
// invokes `cb` with the resulting text. The current implementation is a
// LOCAL STUB: it returns a hardcoded French placeholder synchronously.
+51 -17
View File
@@ -17,11 +17,14 @@
#include "npc_engine.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "esp_err.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "media_manager.h"
#include "hints_client.h"
@@ -39,11 +42,12 @@ void npc_engine_set_gateway(const char *base_url, const char *token) {
if (token) snprintf(s_gw_token, sizeof(s_gw_token), "%s", token);
}
// Fire a one-shot POST {gateway}/game/step?scene=<scene>. Short timeout,
// best-effort: the puzzle progresses fine without it, it only keeps the phone
// NPCs' hints in sync with the current scene. Never propagates failure.
static void notify_gateway_scene(const char *scene) {
if (!s_gw_url[0] || !scene || !scene[0]) return;
// One-shot worker: POST {gateway}/game/step?scene=<scene> then self-delete.
// Runs on its own task so a slow/unreachable gateway NEVER stalls the scene
// transition (the game must not lag 2 s on every scene change if the gateway is
// offline). `arg` is a heap copy of the scene, freed here. Best-effort.
static void gw_notify_task(void *arg) {
char *scene = (char *) arg;
char url[256];
snprintf(url, sizeof(url), "%s/game/step?scene=%s", s_gw_url, scene);
esp_http_client_config_t cfg = {
@@ -52,20 +56,37 @@ static void notify_gateway_scene(const char *scene) {
.timeout_ms = 2000,
};
esp_http_client_handle_t cli = esp_http_client_init(&cfg);
if (!cli) return;
if (s_gw_token[0]) {
char auth[112];
snprintf(auth, sizeof(auth), "Bearer %s", s_gw_token);
esp_http_client_set_header(cli, "Authorization", auth);
if (cli) {
if (s_gw_token[0]) {
char auth[112];
snprintf(auth, sizeof(auth), "Bearer %s", s_gw_token);
esp_http_client_set_header(cli, "Authorization", auth);
}
esp_err_t err = esp_http_client_perform(cli);
if (err != ESP_OK) {
ESP_LOGW(TAG, "gateway /game/step notify failed: %s", esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "gateway scene -> %s (HTTP %d)", scene,
esp_http_client_get_status_code(cli));
}
esp_http_client_cleanup(cli);
}
esp_err_t err = esp_http_client_perform(cli);
if (err != ESP_OK) {
ESP_LOGW(TAG, "gateway /game/step notify failed: %s", esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "gateway scene -> %s (HTTP %d)", scene,
esp_http_client_get_status_code(cli));
free(scene);
vTaskDelete(NULL);
}
// Fire-and-forget the gateway scene notification on a detached task so a scene
// change is never blocked by the network. No-op if no gateway configured.
static void notify_gateway_scene(const char *scene) {
if (!s_gw_url[0] || !scene || !scene[0]) return;
size_t n = strlen(scene) + 1;
char *copy = malloc(n);
if (!copy) return;
memcpy(copy, scene, n);
if (xTaskCreate(gw_notify_task, "gw_notify", 4096, copy, 3, NULL) != pdPASS) {
ESP_LOGW(TAG, "gw_notify task spawn failed");
free(copy);
}
esp_http_client_cleanup(cli);
}
// ─── Core: scene/trigger/mood lookup tables (verbatim Arduino) ──────────────
@@ -419,6 +440,19 @@ esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) {
return ESP_OK;
}
esp_err_t npc_engine_set_scene_by_id(const char *scene_id,
uint32_t expected_duration_ms) {
if (scene_id == NULL || scene_id[0] == '\0') return ESP_ERR_INVALID_ARG;
for (uint8_t i = 0; i < kSceneCount; i++) {
if (strcmp(scene_id, kSceneIds[i]) == 0) {
return npc_engine_set_step(i, expected_duration_ms);
}
}
ESP_LOGW(TAG, "set_scene_by_id: unknown scene \"%s\" (no kSceneIds match)",
scene_id);
return ESP_ERR_NOT_FOUND;
}
esp_err_t npc_engine_set_group_profile(const char *profile) {
// Thin pass-through. hints_client validates the value and logs the
// outcome. Kept on npc_engine so the rest of the firmware doesn't
+6
View File
@@ -137,6 +137,12 @@ static void ensure_sd_mounted(void)
}
}
bool audio_ensure_sd(void)
{
ensure_sd_mounted();
return s_sd_mounted;
}
/* ── WAV parser ──────────────────────────────────────────────────────────── */
static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *out)
+6
View File
@@ -39,6 +39,12 @@ esp_err_t audio_play_async(const char *path);
/* Stop current playback immediately. */
esp_err_t audio_stop(void);
/* Mount the microSD card at /sdcard on demand (best-effort, idempotent) and
* report whether it is mounted. Unlike playback, this does NOT depend on the
* hook state — it lets the REST layer stage a voice pack onto the SD while the
* handset is down. Returns true if /sdcard is usable. */
bool audio_ensure_sd(void);
/* True while the audio worker is playing a clip (tone/WAV). The conversation
* LISTEN loop polls this to stay half-duplex: never capture while playing
* (avoids the earpiece→mic feedback that saturated the line). */
+19 -6
View File
@@ -257,12 +257,25 @@ static void conv_task(void *arg)
go_idle();
break;
}
/* Fetch greeting WAV from gateway and enqueue playback */
if (turn_client_greeting(s_sid, s_number,
"/spiffs/turn.wav")) {
audio_play_async("/spiffs/turn.wav");
} else {
ESP_LOGW(TAG, "turn_client_greeting failed — proceeding silent");
/* Prefer the local SD voice pack (no gateway, no model in RAM):
* play /sdcard/voice/greet_<number>.wav when present. Fall back to
* the live gateway greeting only when the pack has no clip for this
* NPC number (see tools/tts/generate_plip_sd_pack.py). */
{
char sd_greet[64];
snprintf(sd_greet, sizeof(sd_greet),
"/sdcard/voice/greet_%s.wav", s_number);
FILE *tf = NULL;
if (audio_ensure_sd() && (tf = fopen(sd_greet, "rb")) != NULL) {
fclose(tf);
audio_play_async(sd_greet);
ESP_LOGI(TAG, "GREET: local SD pack %s", sd_greet);
} else if (turn_client_greeting(s_sid, s_number,
"/spiffs/turn.wav")) {
audio_play_async("/spiffs/turn.wav");
} else {
ESP_LOGW(TAG, "no SD clip + gateway greeting failed — silent");
}
}
s_state = STATE_CONNECTED;
ESP_LOGI(TAG, "-> CONNECTED");
+34 -7
View File
@@ -22,6 +22,7 @@
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <sys/stat.h>
#include "audio.h"
#include "tones.h"
@@ -53,7 +54,8 @@
#define SPIFFS_LABEL "storage"
#define SPIFFS_BASE "/spiffs"
#define MAX_BODY (256 * 1024)
#define MAX_BODY (512 * 1024) /* streamed in 2 KB chunks → RAM-safe; raised
so full-length say() hint WAVs fit on SD */
static volatile bool s_connected = false;
static httpd_handle_t s_httpd = NULL;
@@ -209,14 +211,39 @@ static esp_err_t handle_file_post(httpd_req_t *req)
if (req->content_len <= 0 || req->content_len > MAX_BODY)
return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");
ensure_spiffs();
/* Route to the microSD when the path is sd-prefixed ("sd/…", "sdcard/…"
* or "/sdcard/…"); otherwise stage in SPIFFS (default). The SD is mounted
* on demand here so a voice pack can be staged without an off-hook play. */
const char *base;
const char *rel = fpath;
bool to_sd = false;
if (strncmp(fpath, "sd/", 3) == 0) { rel = fpath + 3; to_sd = true; }
else if (strncmp(fpath, "sdcard/", 7) == 0) { rel = fpath + 7; to_sd = true; }
else if (strncmp(fpath, "/sdcard/", 8) == 0) { rel = fpath + 8; to_sd = true; }
/* Build full SPIFFS path. */
char full[160];
if (fpath[0] == '/') {
snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
if (to_sd) {
if (!audio_ensure_sd())
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
base = PLIP_SD_MOUNT; /* "/sdcard" */
} else {
snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
ensure_spiffs();
base = SPIFFS_BASE;
}
while (*rel == '/') rel++; /* avoid a double slash when joining */
char full[192];
snprintf(full, sizeof(full), "%s/%s", base, rel);
/* Create the immediate parent dir (one level) so a pack can live in a
* subfolder like /sdcard/voice/<file>.wav — fopen won't create it. FAT
* supports mkdir; SPIFFS is flat so we only do this for the SD path. */
if (to_sd) {
char *slash = strrchr(full, '/');
if (slash && slash != full) {
*slash = '\0';
mkdir(full, 0775); /* best-effort; ignore EEXIST */
*slash = '/';
}
}
FILE *f = fopen(full, "wb");
+4
View File
@@ -52,3 +52,7 @@ CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
# 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.
# Long file names on the SD (voice sample pack uses >8.3 names)
CONFIG_FATFS_LFN_HEAP=y
CONFIG_FATFS_MAX_LFN=255