From 2a596b9fcb83ade1f39b90df5488a96c61316442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 24 May 2026 10:46:29 +0200 Subject: [PATCH] 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. --- .gitignore | 6 + idf_zacus/QEMU_HOWTO.md | 77 ++++++++ .../components/game_endpoint/CMakeLists.txt | 1 + .../components/game_endpoint/game_endpoint.c | 186 +++++++++++++++++- .../game_endpoint/include/game_endpoint.h | 24 ++- idf_zacus/components/ota_server/ota_server.c | 2 +- idf_zacus/sdkconfig.qemu | 18 ++ 7 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 idf_zacus/QEMU_HOWTO.md create mode 100644 idf_zacus/sdkconfig.qemu diff --git a/.gitignore b/.gitignore index 20f8d59..acad090 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,9 @@ data/hotline_tts/**/*.mp3 # Local venvs .venv*/ + +# IDF managed components + lock + per-build sdkconfig +managed_components/ +dependencies.lock +sdkconfig +.cache/ diff --git a/idf_zacus/QEMU_HOWTO.md b/idf_zacus/QEMU_HOWTO.md new file mode 100644 index 0000000..77896c8 --- /dev/null +++ b/idf_zacus/QEMU_HOWTO.md @@ -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. diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index 4059f76..6c1327c 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -11,4 +11,5 @@ idf_component_register( ota_server freertos log + joltwallet__littlefs ) diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index c6dd9b0..536bccc 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -6,12 +6,21 @@ #include "game_endpoint.h" +#include +#include +#include #include +#include +#include #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; } diff --git a/idf_zacus/components/game_endpoint/include/game_endpoint.h b/idf_zacus/components/game_endpoint/include/game_endpoint.h index 3825c09..41f4db1 100644 --- a/idf_zacus/components/game_endpoint/include/game_endpoint.h +++ b/idf_zacus/components/game_endpoint/include/game_endpoint.h @@ -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 diff --git a/idf_zacus/components/ota_server/ota_server.c b/idf_zacus/components/ota_server/ota_server.c index d1a8e1e..aeae175 100644 --- a/idf_zacus/components/ota_server/ota_server.c +++ b/idf_zacus/components/ota_server/ota_server.c @@ -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; diff --git a/idf_zacus/sdkconfig.qemu b/idf_zacus/sdkconfig.qemu new file mode 100644 index 0000000..786d630 --- /dev/null +++ b/idf_zacus/sdkconfig.qemu @@ -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