Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f58b090a34 | |||
| f3d03c637a | |||
| 39e0498221 | |||
| da7b6ace9c |
@@ -248,6 +248,16 @@ static bool s_browser_isdir[BROWSER_MAX_ENTRIES];
|
||||
static uint8_t s_browser_count = 0;
|
||||
static volatile uint8_t s_browser_sel = 0;
|
||||
static volatile bool s_browser_open = false;
|
||||
|
||||
// ── Gamebook view ("livre dont vous êtes le héros") ─────────────────────────
|
||||
static lv_obj_t *s_scr_gamebook;
|
||||
static lv_obj_t *s_gb_title; // page title, top
|
||||
static lv_obj_t *s_gb_body; // full passage text, wrapped
|
||||
static lv_obj_t *s_gb_menu; // choice lines, bottom
|
||||
static volatile bool s_gamebook_open = false;
|
||||
static char s_gb_title_buf[64];
|
||||
static char s_gb_body_buf[512];
|
||||
static char s_gb_menu_buf[160];
|
||||
static volatile bool s_browser_reload = false;
|
||||
|
||||
// ─── Intro — faithful port of the original cracktro ──────────────────────────
|
||||
@@ -761,6 +771,44 @@ scroller:
|
||||
lv_obj_set_pos(s_intro_scroll, sx, sy);
|
||||
}
|
||||
|
||||
static void build_gamebook_screen(void) {
|
||||
s_scr_gamebook = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_scr_gamebook, lv_color_hex(0x000000), 0);
|
||||
lv_obj_set_style_bg_opa(s_scr_gamebook, LV_OPA_COVER, 0);
|
||||
|
||||
s_gb_title = lv_label_create(s_scr_gamebook);
|
||||
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_VALUE), 0);
|
||||
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_24, 0);
|
||||
lv_label_set_long_mode(s_gb_title, LV_LABEL_LONG_DOT);
|
||||
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 16);
|
||||
lv_obj_set_style_text_align(s_gb_title, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_text(s_gb_title, "");
|
||||
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 6);
|
||||
|
||||
// Body band: top area, height-bounded so it can never run into the menu.
|
||||
s_gb_body = lv_label_create(s_scr_gamebook);
|
||||
lv_obj_set_style_text_color(s_gb_body, lv_color_hex(COL_VALUE), 0);
|
||||
lv_obj_set_style_text_font(s_gb_body, &lv_font_montserrat_14, 0);
|
||||
lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_size(s_gb_body, DUI_HOR_RES - 24, 196);
|
||||
lv_label_set_text(s_gb_body, "");
|
||||
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 12, 44);
|
||||
|
||||
// Menu band: reserved strip at the bottom, drawn last (foreground), with a
|
||||
// faint background so the choices stand out from the body text.
|
||||
s_gb_menu = lv_label_create(s_scr_gamebook);
|
||||
lv_obj_set_style_text_color(s_gb_menu, lv_color_hex(COL_CODE), 0);
|
||||
lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x101820), 0);
|
||||
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_pad_all(s_gb_menu, 6, 0);
|
||||
lv_obj_set_style_text_font(s_gb_menu, &lv_font_montserrat_14, 0);
|
||||
lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
|
||||
lv_label_set_text(s_gb_menu, "");
|
||||
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||
lv_obj_move_foreground(s_gb_menu);
|
||||
}
|
||||
|
||||
static void build_status_screen(void) {
|
||||
lv_obj_t *scr = s_scr_status;
|
||||
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
|
||||
@@ -853,10 +901,19 @@ static void apply_status(const display_status_t *s) {
|
||||
// Active step → scene view; idle → status view. The 5-way buttons can
|
||||
// override (1=force status, 2=force scene; 0=auto). Fade 240 ms, the
|
||||
// original ui_manager default transition (SceneTransition::kFade, 240).
|
||||
// Gamebook page: refresh its labels from the latest show() buffers.
|
||||
if (s_gamebook_open) {
|
||||
lv_label_set_text(s_gb_title, s_gb_title_buf);
|
||||
lv_label_set_text(s_gb_body, s_gb_body_buf);
|
||||
lv_label_set_text(s_gb_menu, s_gb_menu_buf);
|
||||
}
|
||||
|
||||
extern volatile uint8_t g_dui_view_override;
|
||||
lv_obj_t *want;
|
||||
if (!s_intro_done) {
|
||||
want = s_scr_intro; // boot intro plays out first
|
||||
} else if (s_gamebook_open) {
|
||||
want = s_scr_gamebook; // gamebook owns the screen while running
|
||||
} else if (s_browser_open) {
|
||||
want = s_scr_browser; // file browser on top of everything
|
||||
} else if (s_shell_open) {
|
||||
@@ -964,6 +1021,7 @@ static void display_task(void *arg) {
|
||||
build_status_screen();
|
||||
build_scene_screen();
|
||||
build_shell_screen();
|
||||
build_gamebook_screen();
|
||||
build_browser_screen();
|
||||
build_intro_screen();
|
||||
|
||||
@@ -1145,9 +1203,36 @@ extern "C" void display_ui_set_status(const display_status_t *s) {
|
||||
}
|
||||
}
|
||||
|
||||
static display_ui_key_hook_t s_key_hook = nullptr;
|
||||
|
||||
extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) {
|
||||
s_key_hook = hook;
|
||||
}
|
||||
|
||||
extern "C" void display_ui_gamebook_show(const char *title, const char *body,
|
||||
const char *menu) {
|
||||
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
snprintf(s_gb_title_buf, sizeof(s_gb_title_buf), "%s", title ? title : "");
|
||||
snprintf(s_gb_body_buf, sizeof(s_gb_body_buf), "%s", body ? body : "");
|
||||
snprintf(s_gb_menu_buf, sizeof(s_gb_menu_buf), "%s", menu ? menu : "");
|
||||
s_gamebook_open = true;
|
||||
s_dirty = true;
|
||||
if (s_mutex) xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
extern "C" void display_ui_gamebook_hide(void) {
|
||||
s_gamebook_open = false;
|
||||
s_dirty = true;
|
||||
}
|
||||
|
||||
extern "C" void display_ui_handle_key(uint8_t key) {
|
||||
static uint8_t s_brightness = 100;
|
||||
|
||||
// Takeover mode (e.g. gamebook): if the hook consumes the key, stop here.
|
||||
if (s_key_hook && s_key_hook(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Any key skips the boot intro.
|
||||
if (!s_intro_done) {
|
||||
s_intro_done = true;
|
||||
|
||||
@@ -78,6 +78,36 @@ void display_ui_camera_frame(const uint8_t *gray, int width, int height);
|
||||
*/
|
||||
void display_ui_handle_key(uint8_t key);
|
||||
|
||||
/**
|
||||
* @brief Install an optional key interceptor.
|
||||
*
|
||||
* If a hook is set and returns true for a given key, the key is consumed and
|
||||
* the normal shell/scene handling is skipped. Lets a takeover mode (e.g. the
|
||||
* gamebook) own the 5-way pad without display_ui having to depend on it.
|
||||
* Pass NULL to remove the hook. Thread-safe (single pointer write).
|
||||
*/
|
||||
typedef bool (*display_ui_key_hook_t)(uint8_t key);
|
||||
void display_ui_set_key_hook(display_ui_key_hook_t hook);
|
||||
|
||||
/**
|
||||
* @brief Show a full-screen gamebook page (takes over the display).
|
||||
*
|
||||
* Dedicated "livre dont vous êtes le héros" view: a centred title, the full
|
||||
* passage text wrapped over multiple lines, and a choice menu at the bottom.
|
||||
* Forces this view until display_ui_gamebook_hide(). Thread-safe (buffers
|
||||
* copied under the mutex; the actual LVGL render runs on the display task).
|
||||
* Strings should be ASCII (the embedded fonts have no accents).
|
||||
*
|
||||
* @param title short page title (top)
|
||||
* @param body full passage text (wrapped); may be long
|
||||
* @param menu choice lines (bottom), e.g. "[OK] ...\n[<>] ..."
|
||||
*/
|
||||
void display_ui_gamebook_show(const char *title, const char *body,
|
||||
const char *menu);
|
||||
|
||||
/** @brief Leave the gamebook view and return to the normal scene/status flow. */
|
||||
void display_ui_gamebook_hide(void);
|
||||
|
||||
/**
|
||||
* @brief Pop the pending shell-app launch request, if any.
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@ idf_component_register(
|
||||
puzzle_state
|
||||
media_manager
|
||||
sd_storage
|
||||
gamebook
|
||||
PRIV_REQUIRES
|
||||
local_puzzles
|
||||
p7_coffre
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include "hints_client.h"
|
||||
#include "gamebook.h"
|
||||
#include "npc_engine.h"
|
||||
#include "scenario_mesh.h"
|
||||
#include "puzzle_binding.h"
|
||||
@@ -994,6 +995,29 @@ static esp_err_t handle_media_play_post(httpd_req_t *req) {
|
||||
return send_error(req, "500 Internal Server Error", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
// ─── POST /game/gamebook[?action=stop] ───────────────────────────────────────
|
||||
//
|
||||
// Start the "livre dont vous êtes le héros" mode (loads /sdcard/gamebook/
|
||||
// gamebook.json, plays the start passage, takes over the 5-way pad). With
|
||||
// ?action=stop it leaves gamebook mode. No body required.
|
||||
static esp_err_t handle_gamebook_post(httpd_req_t *req) {
|
||||
char query[32] = {0}, action[16] = {0};
|
||||
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
|
||||
httpd_query_key_value(query, "action", action, sizeof(action));
|
||||
}
|
||||
if (strcmp(action, "stop") == 0) {
|
||||
gamebook_stop();
|
||||
return send_json(req, "200 OK", "{\"gamebook\":\"stopped\"}");
|
||||
}
|
||||
esp_err_t err = gamebook_start();
|
||||
if (err != ESP_OK) {
|
||||
return send_error(req, "503 Service Unavailable",
|
||||
err == ESP_ERR_NOT_FOUND ? "gamebook_not_on_sd"
|
||||
: "gamebook_load_failed");
|
||||
}
|
||||
return send_json(req, "200 OK", "{\"gamebook\":\"started\"}");
|
||||
}
|
||||
|
||||
// ─── GET /game/puzzle_state ──────────────────────────────────────────────────
|
||||
//
|
||||
// Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}.
|
||||
@@ -1232,6 +1256,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
|
||||
.handler = handle_media_play_post,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_gamebook_post = {
|
||||
.uri = "/game/gamebook",
|
||||
.method = HTTP_POST,
|
||||
.handler = handle_gamebook_post,
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
|
||||
// Bring up the ESP-NOW mesh transport. The master is primarily a sender
|
||||
// (the relay handler) but we also register the apply adapter so a peer
|
||||
@@ -1308,6 +1338,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register POST /game/media/play: %s", esp_err_to_name(err));
|
||||
}
|
||||
err = httpd_register_uri_handler(server, &uri_gamebook_post);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register POST /game/gamebook: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "game endpoint registered "
|
||||
"(GET+POST /game/group_profile, POST /game/scenario%s, "
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"gamebook.c"
|
||||
INCLUDE_DIRS
|
||||
"include"
|
||||
REQUIRES
|
||||
json
|
||||
display_ui
|
||||
media_manager
|
||||
log
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
// gamebook.c — see gamebook.h. Standalone "livre dont vous êtes le héros".
|
||||
#include "gamebook.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "display_ui.h"
|
||||
#include "media_manager.h"
|
||||
|
||||
static const char *TAG = "gamebook";
|
||||
|
||||
#define GAMEBOOK_DIR "/sdcard/gamebook"
|
||||
#define GAMEBOOK_JSON GAMEBOOK_DIR "/gamebook.json"
|
||||
#define GAMEBOOK_MAX (32 * 1024) /* JSON sanity cap */
|
||||
|
||||
/* 5-way pad codes (mirror display_ui): 1=SELECT 2=DOWN 3=MENU 4=L/R 5=UP. */
|
||||
#define KEY_MENU 3
|
||||
|
||||
static cJSON *s_root = NULL; /* owns the parsed JSON while active */
|
||||
static cJSON *s_passages = NULL; /* borrowed: s_root->"passages" */
|
||||
static char s_title[48] = {0};
|
||||
static char s_current[48] = {0};
|
||||
static int s_sel = 0; /* highlighted choice index */
|
||||
static volatile bool s_active = false;
|
||||
|
||||
bool gamebook_active(void) { return s_active; }
|
||||
|
||||
static const cJSON *cur_passage(void)
|
||||
{
|
||||
return cJSON_GetObjectItem(s_passages, s_current);
|
||||
}
|
||||
|
||||
static int cur_choice_count(void)
|
||||
{
|
||||
const cJSON *ch = cJSON_GetObjectItem(cur_passage(), "choices");
|
||||
return cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
|
||||
}
|
||||
|
||||
/* (Re)draw the current page: title + wrapped text + choice list with a "> "
|
||||
* cursor on s_sel. Does NOT touch audio — call on every cursor move. */
|
||||
static void render_page(void)
|
||||
{
|
||||
const cJSON *p = cur_passage();
|
||||
if (!cJSON_IsObject(p)) return;
|
||||
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
|
||||
const cJSON *text = cJSON_GetObjectItem(p, "text");
|
||||
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
|
||||
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
|
||||
|
||||
char menu[256];
|
||||
if (n == 0) {
|
||||
snprintf(menu, sizeof(menu), "~ Fin ~ (clic = recommencer)");
|
||||
} else {
|
||||
size_t off = 0;
|
||||
for (int i = 0; i < n && off < sizeof(menu); i++) {
|
||||
const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, i),
|
||||
"label");
|
||||
off += snprintf(menu + off, sizeof(menu) - off, "%s%s\n",
|
||||
i == s_sel ? "> " : " ",
|
||||
cJSON_IsString(lbl) ? lbl->valuestring : "?");
|
||||
}
|
||||
}
|
||||
display_ui_gamebook_show(
|
||||
cJSON_IsString(screen) ? screen->valuestring : s_title,
|
||||
cJSON_IsString(text) ? text->valuestring : "",
|
||||
menu);
|
||||
}
|
||||
|
||||
/* Enter a passage: reset the cursor, draw the page, play its WAV from the SD. */
|
||||
static void enter_passage(const char *pid)
|
||||
{
|
||||
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
|
||||
if (!cJSON_IsObject(p)) {
|
||||
ESP_LOGW(TAG, "passage '%s' not found", pid);
|
||||
return;
|
||||
}
|
||||
snprintf(s_current, sizeof(s_current), "%s", pid);
|
||||
s_sel = 0;
|
||||
render_page();
|
||||
|
||||
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
|
||||
if (cJSON_IsString(wav) && wav->valuestring[0]) {
|
||||
char path[96];
|
||||
snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_DIR, wav->valuestring);
|
||||
media_manager_stop(); /* a choice skips the narration */
|
||||
media_manager_play(path);
|
||||
}
|
||||
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, cur_choice_count());
|
||||
}
|
||||
|
||||
/* Key hook (we own the 5-way pad while active). D-pad navigates the choice
|
||||
* list, click (SELECT) confirms. Ladder codes: 1=click 2=down 3/4=left/right
|
||||
* 5=up. up/left → previous, down/right → next. On an ending (no choices) the
|
||||
* click restarts the book. Returns true to consume the key. */
|
||||
static bool gamebook_key_hook(uint8_t key)
|
||||
{
|
||||
if (!s_active) return false;
|
||||
|
||||
int n = cur_choice_count();
|
||||
if (n == 0) { /* ending page */
|
||||
if (key == 1) { /* click → restart */
|
||||
const cJSON *start = cJSON_GetObjectItem(s_root, "start");
|
||||
if (cJSON_IsString(start)) enter_passage(start->valuestring);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 5: case 3: /* up / left → previous */
|
||||
s_sel = (s_sel - 1 + n) % n; render_page(); break;
|
||||
case 2: case 4: /* down / right → next */
|
||||
s_sel = (s_sel + 1) % n; render_page(); break;
|
||||
case 1: { /* click → confirm */
|
||||
const cJSON *choices = cJSON_GetObjectItem(cur_passage(), "choices");
|
||||
const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel),
|
||||
"goto");
|
||||
if (cJSON_IsString(g)) {
|
||||
ESP_LOGI(TAG, "select #%d -> '%s'", s_sel, g->valuestring);
|
||||
enter_passage(g->valuestring);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t gamebook_start(void)
|
||||
{
|
||||
gamebook_stop(); /* clean any previous run */
|
||||
|
||||
FILE *f = fopen(GAMEBOOK_JSON, "rb");
|
||||
if (!f) {
|
||||
ESP_LOGW(TAG, "open %s failed — SD card / pack missing?", GAMEBOOK_JSON);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
rewind(f);
|
||||
if (sz <= 0 || sz > GAMEBOOK_MAX) { fclose(f); return ESP_ERR_INVALID_SIZE; }
|
||||
char *buf = malloc((size_t)sz + 1);
|
||||
if (!buf) { fclose(f); return ESP_ERR_NO_MEM; }
|
||||
size_t rd = fread(buf, 1, (size_t)sz, f);
|
||||
fclose(f);
|
||||
buf[rd] = '\0';
|
||||
|
||||
s_root = cJSON_Parse(buf);
|
||||
free(buf);
|
||||
if (!s_root) { ESP_LOGW(TAG, "malformed gamebook.json"); return ESP_ERR_INVALID_ARG; }
|
||||
|
||||
s_passages = cJSON_GetObjectItem(s_root, "passages");
|
||||
const cJSON *start = cJSON_GetObjectItem(s_root, "start");
|
||||
const cJSON *title = cJSON_GetObjectItem(s_root, "title");
|
||||
if (!cJSON_IsObject(s_passages) || !cJSON_IsString(start)) {
|
||||
ESP_LOGW(TAG, "gamebook.json missing passages/start");
|
||||
cJSON_Delete(s_root); s_root = NULL; s_passages = NULL;
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
snprintf(s_title, sizeof(s_title), "%s",
|
||||
cJSON_IsString(title) ? title->valuestring : "");
|
||||
|
||||
s_active = true;
|
||||
ESP_LOGI(TAG, "start \"%s\" @ '%s'", s_title, start->valuestring);
|
||||
enter_passage(start->valuestring);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void gamebook_stop(void)
|
||||
{
|
||||
if (!s_active && !s_root) return;
|
||||
s_active = false;
|
||||
media_manager_stop();
|
||||
display_ui_gamebook_hide();
|
||||
if (s_root) { cJSON_Delete(s_root); s_root = NULL; }
|
||||
s_passages = NULL;
|
||||
s_current[0] = '\0';
|
||||
ESP_LOGI(TAG, "stopped");
|
||||
}
|
||||
|
||||
void gamebook_init(void)
|
||||
{
|
||||
display_ui_set_key_hook(gamebook_key_hook);
|
||||
ESP_LOGI(TAG, "ready (key hook installed)");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
// gamebook — "livre dont vous êtes le héros" mode for the Freenove master.
|
||||
//
|
||||
// Reads /sdcard/gamebook/gamebook.json (built by tools/gamebook/build_gamebook.py),
|
||||
// plays each passage's WAV narration from the SD via media_manager, shows the
|
||||
// passage title + choice menu on the display, and navigates with the 5-way pad
|
||||
// (it installs a display_ui key hook so it owns the buttons while active).
|
||||
// Fully local: no model, no gateway.
|
||||
//
|
||||
// JSON shape:
|
||||
// { "title": "...", "start": "intro",
|
||||
// "passages": { "intro": { "wav": "intro.wav", "screen": "...",
|
||||
// "menu": "[OK] ... | [<>] ...",
|
||||
// "choices": [ {"key": 1, "goto": "machine"}, ... ] },
|
||||
// ... } }
|
||||
// key codes match the 5-way pad: 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP.
|
||||
// MENU (3) always quits the gamebook.
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Install the display_ui key hook. Call once at boot, after display_ui_init().
|
||||
void gamebook_init(void);
|
||||
|
||||
// Load /sdcard/gamebook/gamebook.json and enter the start passage (show text +
|
||||
// play its WAV). Returns ESP_ERR_* if the SD/JSON is missing or malformed.
|
||||
esp_err_t gamebook_start(void);
|
||||
|
||||
// Leave gamebook mode: stop playback, release the JSON, hand the pad back to
|
||||
// the shell. Idempotent.
|
||||
void gamebook_stop(void);
|
||||
|
||||
// True while a gamebook is running (used by the key hook to claim the pad).
|
||||
bool gamebook_active(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -310,8 +310,9 @@ esp_err_t ota_server_init(void) {
|
||||
// HTTP server config
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.server_port = OTA_SERVER_PORT;
|
||||
config.max_uri_handlers = 20; // ota + voice_hook + game (incl. /game/step,
|
||||
// /game/puzzle_state, /game/file, relay)
|
||||
config.max_uri_handlers = 22; // ota + voice_hook + game (incl. /game/step,
|
||||
// /game/puzzle_state, /game/file, relay,
|
||||
// /game/gamebook)
|
||||
// + headroom
|
||||
config.uri_match_fn = httpd_uri_match_wildcard;
|
||||
config.stack_size = 8192;
|
||||
|
||||
@@ -23,6 +23,7 @@ idf_component_register(
|
||||
esp_event
|
||||
espressif__mdns
|
||||
display_ui
|
||||
gamebook
|
||||
puzzle_state
|
||||
p7_coffre
|
||||
p5_morse
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
#include "mic_broker.h"
|
||||
#include "board_pins_mediakit.h"
|
||||
#include "display_ui.h"
|
||||
#include "gamebook.h"
|
||||
#include "p7_coffre.h"
|
||||
#include "p5_morse.h"
|
||||
#include "p6_nfc.h"
|
||||
@@ -436,6 +437,8 @@ void app_main(void) {
|
||||
if (btn_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "buttons_input_init: %s", esp_err_to_name(btn_err));
|
||||
}
|
||||
// Gamebook mode owns the 5-way pad when started (POST /game/gamebook).
|
||||
gamebook_init();
|
||||
}
|
||||
|
||||
bool sta_ok = wifi_bring_up();
|
||||
|
||||
@@ -84,12 +84,16 @@ static char s_sid[32] = {0};
|
||||
* NOT re-read dialer_current() for the greeting/reply — that polluted number
|
||||
* would 404 at the gateway. Capture the clean routed number here once. */
|
||||
static char s_number[16] = {0};
|
||||
/* Scene this call hints on (e.g. "SCENE_WARNING"), locked at pickup like the
|
||||
* number. Empty for player-dialled calls. Drives the local SD hint clip. */
|
||||
static char s_scene[40] = {0};
|
||||
|
||||
/* Incoming-call mode: an NPC "calls" the phone. conversation_arm_incoming()
|
||||
* stores the caller's persona number and rings; when the handset is then picked
|
||||
* up from IDLE, we skip the outgoing dialtone/dial/ringback and go straight to
|
||||
* GREET with this number (the NPC speaks first). */
|
||||
* stores the caller's persona number (and optional scene) and rings; when the
|
||||
* handset is then picked up from IDLE, we skip the outgoing dialtone/dial/
|
||||
* ringback and go straight to GREET with this number (the NPC speaks first). */
|
||||
static char s_incoming_number[16] = {0};
|
||||
static char s_incoming_scene[40] = {0};
|
||||
static volatile bool s_incoming_armed = false;
|
||||
|
||||
/* Known numbers: ringback when dialed */
|
||||
@@ -105,6 +109,20 @@ static bool is_known(const char *num)
|
||||
return false;
|
||||
}
|
||||
|
||||
/* "SCENE_WARNING" → "warning": strip the SCENE_ prefix and lowercase, matching
|
||||
* the SD pack filenames from tools/tts/generate_plip_sd_pack.py. */
|
||||
static void scene_slug(const char *scene, char *out, size_t cap)
|
||||
{
|
||||
const char *p = scene;
|
||||
if (strncmp(p, "SCENE_", 6) == 0) p += 6;
|
||||
size_t i = 0;
|
||||
for (; p[i] && i + 1 < cap; i++) {
|
||||
char c = p[i];
|
||||
out[i] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
|
||||
}
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
static void go_idle(void)
|
||||
{
|
||||
tones_stop();
|
||||
@@ -114,6 +132,8 @@ static void go_idle(void)
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
dtmf_stop();
|
||||
#endif
|
||||
s_scene[0] = '\0'; /* clear scripted-call scene so a later dial-out call
|
||||
* doesn't inherit it (CONNECTED would skip listening). */
|
||||
s_state = STATE_IDLE;
|
||||
ESP_LOGI(TAG, "-> IDLE");
|
||||
}
|
||||
@@ -134,6 +154,7 @@ static void enter_incoming_greet(void)
|
||||
dialer_reset();
|
||||
s_offhook = true;
|
||||
snprintf(s_number, sizeof(s_number), "%s", s_incoming_number);
|
||||
snprintf(s_scene, sizeof(s_scene), "%s", s_incoming_scene);
|
||||
snprintf(s_sid, sizeof(s_sid), "%lld", (long long)esp_timer_get_time());
|
||||
audio_pa_set(true);
|
||||
s_state = STATE_GREET;
|
||||
@@ -257,18 +278,57 @@ 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");
|
||||
}
|
||||
}
|
||||
/* Scripted offline call: chain this scene's hint from the SD pack
|
||||
* right after the greeting (the audio queue plays them back-to-back).
|
||||
* Fully local — no gateway, no model. */
|
||||
if (s_scene[0] != '\0' && audio_ensure_sd()) {
|
||||
char slug[40], hint[80];
|
||||
scene_slug(s_scene, slug, sizeof(slug));
|
||||
snprintf(hint, sizeof(hint),
|
||||
"/sdcard/voice/hint_%s_l1_0.wav", slug);
|
||||
FILE *hf = fopen(hint, "rb");
|
||||
if (hf != NULL) {
|
||||
fclose(hf);
|
||||
audio_play_async(hint);
|
||||
ESP_LOGI(TAG, "GREET: chained SD hint %s", hint);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "GREET: no SD hint for scene %s (%s)",
|
||||
s_scene, hint);
|
||||
}
|
||||
}
|
||||
s_state = STATE_CONNECTED;
|
||||
ESP_LOGI(TAG, "-> CONNECTED");
|
||||
break;
|
||||
|
||||
case STATE_CONNECTED:
|
||||
/* Scripted offline call (greeting + SD hint already queued): there's
|
||||
* no live two-way conversation to run — just let the clips play out
|
||||
* and wait for the player to hang up. Skips the gateway/model listen
|
||||
* loop entirely. The global on-hook check returns us to IDLE. */
|
||||
if (s_scene[0] != '\0') {
|
||||
vTaskDelay(pdMS_TO_TICKS(REPLY_POLL_MS));
|
||||
break;
|
||||
}
|
||||
#if CONFIG_PLIP_VOICE_REPLY
|
||||
/*
|
||||
* Stage 3 — LISTEN loop.
|
||||
@@ -404,12 +464,14 @@ void conversation_on_hook_change(bool offhook)
|
||||
s_hook_changed = true;
|
||||
}
|
||||
|
||||
void conversation_arm_incoming(const char *number)
|
||||
void conversation_arm_incoming(const char *number, const char *scene)
|
||||
{
|
||||
snprintf(s_incoming_number, sizeof(s_incoming_number), "%s",
|
||||
(number && number[0]) ? number : "17");
|
||||
snprintf(s_incoming_scene, sizeof(s_incoming_scene), "%s",
|
||||
(scene && scene[0]) ? scene : "");
|
||||
s_incoming_armed = true;
|
||||
phone_ring_start();
|
||||
ESP_LOGI(TAG, "incoming call armed (num=%s) — ringing until pickup",
|
||||
s_incoming_number);
|
||||
ESP_LOGI(TAG, "incoming call armed (num=%s scene=%s) — ringing until pickup",
|
||||
s_incoming_number, s_incoming_scene[0] ? s_incoming_scene : "-");
|
||||
}
|
||||
|
||||
@@ -6,5 +6,8 @@ void conversation_on_hook_change(bool offhook);
|
||||
|
||||
/* Arm an INCOMING call from NPC persona `number` and start ringing. When the
|
||||
* handset is picked up from idle, the conversation jumps straight to the
|
||||
* greeting (persona speaks first) instead of the outgoing dial-tone path. */
|
||||
void conversation_arm_incoming(const char *number);
|
||||
* greeting (persona speaks first) instead of the outgoing dial-tone path.
|
||||
* `scene` (optional, e.g. "SCENE_WARNING") makes the phone play that scene's
|
||||
* hint from its local SD pack right after the greeting; pass NULL/"" for a
|
||||
* greeting-only call. */
|
||||
void conversation_arm_incoming(const char *number, const char *scene);
|
||||
|
||||
+49
-3
@@ -281,6 +281,44 @@ static esp_err_t handle_file_post(httpd_req_t *req)
|
||||
return send_json(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
/* ── DELETE /game/file?path=... (remove a staged file; SD or SPIFFS) ─────── */
|
||||
/* Same path routing as the POST handler (sd/ prefix → /sdcard, else /spiffs).
|
||||
* Lets the host clean stale clips when regenerating the SD voice pack. */
|
||||
static esp_err_t handle_file_delete(httpd_req_t *req)
|
||||
{
|
||||
char query[128] = {0}, fpath[128] = {0};
|
||||
httpd_req_get_url_query_str(req, query, sizeof(query));
|
||||
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK)
|
||||
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
|
||||
|
||||
const char *base, *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; }
|
||||
|
||||
if (to_sd) {
|
||||
if (!audio_ensure_sd())
|
||||
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
|
||||
base = PLIP_SD_MOUNT;
|
||||
} else {
|
||||
ensure_spiffs();
|
||||
base = SPIFFS_BASE;
|
||||
}
|
||||
while (*rel == '/') rel++;
|
||||
|
||||
char full[192];
|
||||
snprintf(full, sizeof(full), "%s/%s", base, rel);
|
||||
if (remove(full) != 0) {
|
||||
ESP_LOGW(TAG, "remove %s failed: errno=%d", full, errno);
|
||||
return send_json(req, "404 Not Found", "{\"error\":\"remove failed\"}");
|
||||
}
|
||||
ESP_LOGI(TAG, "file removed: %s", full);
|
||||
char resp[256];
|
||||
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", full);
|
||||
return send_json(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
/* ── POST /voice/capture ──────────────────────────────────────────────────── */
|
||||
|
||||
#define CAP_MAX_MS_DEFAULT 5000
|
||||
@@ -510,10 +548,13 @@ static esp_err_t handle_debug_ring(httpd_req_t *req)
|
||||
{
|
||||
/* /debug/ring[?number=NN] — arm an INCOMING call from persona NN (default
|
||||
* 17) and ring. On pickup the conversation jumps straight to the greeting. */
|
||||
char query[48] = {0}, number[16] = {0};
|
||||
char query[96] = {0}, number[16] = {0}, scene[40] = {0};
|
||||
httpd_req_get_url_query_str(req, query, sizeof(query));
|
||||
httpd_query_key_value(query, "number", number, sizeof(number));
|
||||
conversation_arm_incoming(number); /* defaults to "17" if empty; starts ringing */
|
||||
httpd_query_key_value(query, "scene", scene, sizeof(scene));
|
||||
/* scene (optional) lets the phone play that scene's hint from its SD pack
|
||||
* after the greeting — fully offline, no model. */
|
||||
conversation_arm_incoming(number, scene[0] ? scene : NULL);
|
||||
char resp[64];
|
||||
snprintf(resp, sizeof(resp), "{\"ok\":true,\"ringing\":true,\"incoming\":\"%s\"}",
|
||||
number[0] ? number : "17");
|
||||
@@ -839,7 +880,7 @@ esp_err_t net_init(void)
|
||||
/* Start HTTP server. */
|
||||
httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG();
|
||||
hcfg.server_port = 80;
|
||||
hcfg.max_uri_handlers = 17;
|
||||
hcfg.max_uri_handlers = 18;
|
||||
hcfg.stack_size = 8192;
|
||||
|
||||
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
|
||||
@@ -860,6 +901,10 @@ esp_err_t net_init(void)
|
||||
.uri = "/game/file", .method = HTTP_POST,
|
||||
.handler = handle_file_post, .user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_file_delete = {
|
||||
.uri = "/game/file", .method = HTTP_DELETE,
|
||||
.handler = handle_file_delete, .user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_cmd = {
|
||||
.uri = "/game/cmd", .method = HTTP_POST,
|
||||
.handler = handle_cmd_post, .user_ctx = NULL,
|
||||
@@ -915,6 +960,7 @@ esp_err_t net_init(void)
|
||||
httpd_register_uri_handler(s_httpd, &uri_status);
|
||||
httpd_register_uri_handler(s_httpd, &uri_scenario);
|
||||
httpd_register_uri_handler(s_httpd, &uri_file);
|
||||
httpd_register_uri_handler(s_httpd, &uri_file_delete);
|
||||
httpd_register_uri_handler(s_httpd, &uri_cmd);
|
||||
httpd_register_uri_handler(s_httpd, &uri_capture);
|
||||
httpd_register_uri_handler(s_httpd, &uri_debug_regs);
|
||||
|
||||
Reference in New Issue
Block a user