feat(idf): POST /game/scenario hot-load

game_endpoint gains a /game/scenario handler that accepts a Runtime 3
IR JSON (<=64 KiB), validates schema_version + non-empty steps array,
rotates the existing scenario to .bak, writes to LittleFS at
/littlefs/scenario.json on partition 'storage', and reboots after
the HTTP response flushes so the engine picks up the new IR.

ota_server bumps max_uri_handlers 8 -> 16 to fit the extra handlers
(/game/scenario plus headroom).

sdkconfig.qemu disables PSRAM + esp-sr so the firmware boots inside
qemu-system-xtensa without crashing on the unemulated octal PSRAM.
QEMU_HOWTO.md documents the workflow and the WiFi/Ethernet gap that
still blocks full HTTP smoke under emulation.
This commit is contained in:
L'électron rare
2026-05-24 10:46:29 +02:00
parent a743cfc87f
commit 2a596b9fcb
7 changed files with 310 additions and 4 deletions
+6
View File
@@ -55,3 +55,9 @@ data/hotline_tts/**/*.mp3
# Local venvs
.venv*/
# IDF managed components + lock + per-build sdkconfig
managed_components/
dependencies.lock
sdkconfig
.cache/
+77
View File
@@ -0,0 +1,77 @@
# QEMU smoke test for `idf_zacus`
## What QEMU can do today
- Boot the firmware end-to-end (NVS init, partition table, app_main).
- Validate that new components do not break boot.
- Surface any link-time / runtime init crashes that escape the build.
Tested 2026-05-24: firmware with the new `POST /game/scenario` handler boots
cleanly to `app_main()` in QEMU 9.0.0 (esp_develop build).
## What QEMU can NOT do (yet)
- **WiFi radio**: stubbed. The board comes up in AP fallback but no station
ever associates, so the IP netif never gets an address and the HTTP server
(which waits for `IP_EVENT_STA_GOT_IP`) doesn't bind.
- **PSRAM**: QEMU's esp32s3 machine does not emulate the Octal PSRAM the
Freenove N16R8 ships with. Use `sdkconfig.qemu` to disable.
- **esp-sr / WakeNet**: depends on PSRAM, also disabled in `sdkconfig.qemu`.
- **WiFi-driven HTTP smoke**: see the "future work" section below.
## Run
```bash
. $HOME/esp/esp-idf/export.sh
export PATH=$HOME/.espressif/tools/qemu-xtensa/esp_develop_9.0.0_20240606/qemu/bin:$PATH
# clean reconfigure with the QEMU overrides
rm -rf build sdkconfig
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" set-target esp32s3
idf.py build
# launch with port forward for the future ethernet integration
idf.py qemu --qemu-extra-args="-nic user,model=open_eth,hostfwd=tcp::8580-:80"
# in another terminal — when HTTP arrives, this is the smoke test
curl -sS http://127.0.0.1:8580/healthz # → "ok"
curl -sS -X POST -H "Content-Type: application/json" \
--data @../../../game/scenarios/zacus_cond_demo.ir.json \
http://127.0.0.1:8580/game/scenario
```
Press `Ctrl-A x` to exit the QEMU console.
## Restore the production build
The `sdkconfig.qemu` overrides break the real board (no PSRAM = no esp-sr =
no voice pipeline). To return to the canonical config:
```bash
rm -rf build sdkconfig
idf.py set-target esp32s3 # picks sdkconfig.defaults only
idf.py build flash monitor # real board path
```
`sdkconfig.qemu` is committed but never used by the default build — only when
explicitly listed in `SDKCONFIG_DEFAULTS`.
## Future work — HTTP smoke in QEMU
The main blocker is `main.c`: it gates `ota_server_init()` / `game_endpoint_init()`
on a WiFi `IP_EVENT_STA_GOT_IP` callback. To unblock HTTP testing under QEMU
without WiFi:
1. Add a `CONFIG_ZACUS_QEMU_ETHERNET=y` Kconfig option in `main/Kconfig.projbuild`
(default `n`).
2. In `app_main()`, if the option is set, initialise the `esp_eth` driver against
the `open_eth` NIC and use its `IP_EVENT_ETH_GOT_IP` event to start the HTTP
stack — same lifecycle as the WiFi path, different transport.
3. Add `CONFIG_ZACUS_QEMU_ETHERNET=y` to `sdkconfig.qemu`.
Once that lands, `curl http://127.0.0.1:8580/game/scenario` from the host hits
the real handler inside QEMU and we get a true integration test of the
hot-load path (scenario validation, LittleFS write, deferred reboot).
Estimated effort: ~80 LOC + Kconfig + one `esp_eth_open_eth_new()` glue —
half a day of work.
@@ -11,4 +11,5 @@ idf_component_register(
ota_server
freertos
log
joltwallet__littlefs
)
@@ -6,12 +6,21 @@
#include "game_endpoint.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "cJSON.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_log.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
@@ -159,6 +168,169 @@ static esp_err_t handle_group_profile_post(httpd_req_t *req) {
return send_json(req, "200 OK", buf);
}
// ─── LittleFS lazy mount (shared with media_manager — idempotent) ──────────
static bool s_storage_mounted = false;
static esp_err_t mount_storage_lazy(void) {
if (s_storage_mounted) return ESP_OK;
esp_vfs_littlefs_conf_t conf = {
.base_path = GAME_ENDPOINT_STORAGE_BASE,
.partition_label = GAME_ENDPOINT_STORAGE_LABEL,
.format_if_mount_failed = true,
.dont_mount = false,
};
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) {
// INVALID_STATE = already registered by another component → fine.
s_storage_mounted = true;
ESP_LOGI(TAG, "littlefs '%s' mounted at %s",
conf.partition_label, conf.base_path);
return ESP_OK;
}
ESP_LOGE(TAG, "esp_vfs_littlefs_register(%s) failed: %s",
conf.partition_label, esp_err_to_name(err));
return err;
}
// ─── deferred reboot (lets the HTTP response flush first) ──────────────────
static void deferred_restart_task(void *arg) {
(void) arg;
vTaskDelay(pdMS_TO_TICKS(800));
ESP_LOGW(TAG, "scenario hot-load: rebooting to apply new IR");
esp_restart();
}
static void schedule_restart(void) {
xTaskCreate(deferred_restart_task, "scenario_restart",
4096, NULL, tskIDLE_PRIORITY + 1, NULL);
}
// ─── POST /game/scenario — accept a Runtime 3 IR JSON ──────────────────────
static esp_err_t handle_scenario_post(httpd_req_t *req) {
if (req->content_len <= 0 ||
req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
ESP_LOGW(TAG, "POST /game/scenario: bad body length %d",
(int) req->content_len);
return send_error(req, "413 Payload Too Large",
"body must be 1..65536 bytes");
}
if (mount_storage_lazy() != ESP_OK) {
return send_error(req, "500 Internal Server Error",
"littlefs mount failed");
}
char *body = (char *) malloc((size_t) req->content_len + 1);
if (!body) {
return send_error(req, "500 Internal Server Error",
"out of memory");
}
int total = 0;
while (total < (int) req->content_len) {
int got = httpd_req_recv(req, body + total,
req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
free(body);
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
// Minimal validation: parse + schema_version + non-empty steps array.
// The runtime3_common.py validator is the strict source of truth on
// the gateway side; here we keep the firmware permissive but safe.
cJSON *root = cJSON_Parse(body);
if (!root) {
ESP_LOGW(TAG, "POST /game/scenario: malformed JSON (len=%d)", total);
free(body);
return send_error(req, "400 Bad Request", "malformed json");
}
const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version");
if (!cJSON_IsString(schema) ||
strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) {
cJSON_Delete(root);
free(body);
return send_error(req, "400 Bad Request",
"schema_version must be zacus.runtime3.v1");
}
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
cJSON_Delete(root);
free(body);
return send_error(req, "400 Bad Request",
"steps must be a non-empty array");
}
const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario");
const cJSON *entry = scenario_obj
? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id")
: NULL;
char entry_str[64] = {0};
if (cJSON_IsString(entry) && entry->valuestring) {
strncpy(entry_str, entry->valuestring, sizeof(entry_str) - 1);
}
int steps_count = cJSON_GetArraySize(steps);
cJSON_Delete(root);
// Rotate existing scenario -> .bak so a broken push can be rolled back
// by a future scenario_engine_reload() failure path.
struct stat st;
if (stat(GAME_ENDPOINT_SCENARIO_PATH, &st) == 0) {
// Best-effort: ignore rename failure (e.g. .bak already exists from
// a previous push — overwrite via unlink+rename).
unlink(GAME_ENDPOINT_SCENARIO_BAK);
if (rename(GAME_ENDPOINT_SCENARIO_PATH,
GAME_ENDPOINT_SCENARIO_BAK) != 0) {
ESP_LOGW(TAG, "rename current scenario -> .bak failed (errno=%d)",
errno);
}
}
FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "wb");
if (!f) {
ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)",
GAME_ENDPOINT_SCENARIO_PATH, errno);
free(body);
return send_error(req, "500 Internal Server Error",
"scenario write open failed");
}
size_t written = fwrite(body, 1, (size_t) total, f);
fclose(f);
free(body);
if ((int) written != total) {
ESP_LOGE(TAG, "scenario write short: %zu/%d bytes (rolling back)",
written, total);
unlink(GAME_ENDPOINT_SCENARIO_PATH);
if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) {
rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH);
}
return send_error(req, "500 Internal Server Error",
"scenario write short");
}
ESP_LOGI(TAG, "scenario hot-load OK: %d bytes, %d steps, entry=%s",
total, steps_count, entry_str);
char buf[256];
snprintf(buf, sizeof(buf),
"{\"status\":\"ok\",\"steps_count\":%d,"
"\"entry_step_id\":\"%s\",\"bytes\":%d,"
"\"reload\":\"reboot_pending\"}",
steps_count, entry_str, total);
esp_err_t ret = send_json(req, "200 OK", buf);
// Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3).
// The HTTP response is queued by send_json above; the deferred task
// gives the TCP stack 800 ms to flush before yanking the rug.
schedule_restart();
return ret;
}
// ─── public init ────────────────────────────────────────────────────────────
esp_err_t game_endpoint_init(httpd_handle_t server) {
@@ -180,6 +352,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
.handler = handle_group_profile_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_scenario_post = {
.uri = "/game/scenario",
.method = HTTP_POST,
.handler = handle_scenario_post,
.user_ctx = NULL,
};
esp_err_t err = httpd_register_uri_handler(server, &uri_get);
if (err != ESP_OK) {
@@ -193,8 +371,14 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
esp_err_to_name(err));
return err;
}
err = httpd_register_uri_handler(server, &uri_scenario_post);
if (err != ESP_OK) {
ESP_LOGE(TAG, "register POST /game/scenario: %s",
esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "game endpoint registered "
"(GET + POST /game/group_profile)");
"(GET+POST /game/group_profile, POST /game/scenario)");
return ESP_OK;
}
@@ -34,9 +34,29 @@ extern "C" {
// "NON_TECH"} (~30 bytes) plus future additive fields.
#define GAME_ENDPOINT_MAX_BODY_BYTES 256
// Larger cap for the Runtime 3 IR scenario blob. 64 KiB lets a
// reasonable escape-room scenario (~50 steps, dialogues + actions)
// fit comfortably. Scenarios that exceed this should be split
// across multiple boards or trimmed.
#define GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 * 1024)
// LittleFS partition label declared in partitions.csv. game_endpoint
// mounts lazily on first scenario POST. media_manager may also mount
// the same label — esp_vfs_littlefs_register is idempotent per label.
#define GAME_ENDPOINT_STORAGE_LABEL "storage"
// main.c mounts the storage partition at /littlefs at boot — we reuse the
// same mount point instead of registering a second base path for the same
// partition (which fails silently with INVALID_STATE).
#define GAME_ENDPOINT_STORAGE_BASE "/littlefs"
#define GAME_ENDPOINT_SCENARIO_PATH GAME_ENDPOINT_STORAGE_BASE "/scenario.json"
#define GAME_ENDPOINT_SCENARIO_BAK GAME_ENDPOINT_STORAGE_BASE "/scenario.bak"
/**
* @brief Attach `/game/group_profile` (GET + POST) handlers to an
* existing esp_http_server.
* @brief Attach all game endpoint handlers to an existing esp_http_server.
*
* Registers:
* - GET/POST /game/group_profile (slice 12, runtime hints profile)
* - POST /game/scenario (slice 13, Runtime 3 IR hot-load)
*
* Pass the handle returned by `ota_server_get_handle()`. Returns
* ESP_ERR_INVALID_ARG if `server` is NULL, or any error propagated
+1 -1
View File
@@ -310,7 +310,7 @@ 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 = 8;
config.max_uri_handlers = 16; // ota (3) + voice_hook (2) + game (3) + headroom
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
+18
View File
@@ -0,0 +1,18 @@
# QEMU-specific overrides. Layered on top of sdkconfig.defaults via
# idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build qemu
# Reasons:
# - QEMU's esp32s3 machine does not emulate Octal PSRAM → boot crashes on
# "PSRAM chip not found" without these overrides.
# - WiFi / BT radios are stubbed in QEMU, the Ethernet open_eth NIC stands in
# for IP connectivity (hostfwd=tcp::8580-:80).
# - esp-sr / wake-word buffers depend on PSRAM, so they must also be disabled.
CONFIG_SPIRAM=n
CONFIG_SPIRAM_MODE_OCT=n
CONFIG_SPIRAM_SPEED_80M=n
# esp-sr disabled because its buffers expect PSRAM. The HTTP scenario endpoint
# we're smoke-testing has no dependency on the voice pipeline.
CONFIG_USE_AFE=n
CONFIG_USE_WAKENET=n
CONFIG_USE_MULTINET=n