Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da7b6ace9c | |||
| be369fa260 | |||
| ace740a629 | |||
| 4ac3981845 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user