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:
@@ -55,3 +55,9 @@ data/hotline_tts/**/*.mp3
|
||||
|
||||
# Local venvs
|
||||
.venv*/
|
||||
|
||||
# IDF managed components + lock + per-build sdkconfig
|
||||
managed_components/
|
||||
dependencies.lock
|
||||
sdkconfig
|
||||
.cache/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user