// game_endpoint — see include/game_endpoint.h for design notes. // // Slice 12 of the IDF migration: live-tunable group profile so the // game master can switch the hints engine policy from the dashboard // (or any HTTP client) without reflashing NVS. #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" #include "hints_client.h" #include "scenario_mesh.h" #include "puzzle_binding.h" #include "local_puzzles.h" static const char *TAG = "game_endpoint"; // ─── puzzle state + step tracking (Task C) ────────────────────────────────── // Pointer supplied by game_endpoint_set_puzzle_state(); NULL until then. static puzzle_state_t *s_pstate = NULL; // Last step id armed via POST /game/step. Empty string = none. static char s_current_step_id[64] = {0}; // Last armed puzzle type: "qr" | "sound" | "none" | "" (empty = nothing armed yet). static char s_current_armed[8] = {0}; // Display scene metadata of the current step (lenient parse; defaults safe). static scene_binding_t s_current_scene = {0}; // Whitelist mirrored in the 4xx error message so the operator can // recover without grepping the source. Keep aligned with // hints_client_set_group_profile() validation. #define GAME_ENDPOINT_PROFILE_HELP \ "invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]" // ─── small JSON response helper (mirrors voice_hook_endpoint) ─────────────── static esp_err_t send_json(httpd_req_t *req, const char *status_line, const char *body) { httpd_resp_set_status(req, status_line); httpd_resp_set_type(req, "application/json"); httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); return httpd_resp_sendstr(req, body); } static esp_err_t send_error(httpd_req_t *req, const char *status_line, const char *message) { char buf[192]; snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : ""); return send_json(req, status_line, buf); } // ─── NVS persistence helper ───────────────────────────────────────────────── // Writes the (already-validated) profile into NVS namespace "zacus", // key "group_profile". Logs and returns the underlying error on // failure — the caller decides whether to surface it to the client. static esp_err_t persist_group_profile(const char *profile) { nvs_handle_t h; esp_err_t err = nvs_open("zacus", NVS_READWRITE, &h); if (err != ESP_OK) { ESP_LOGW(TAG, "nvs_open(zacus, RW): %s", esp_err_to_name(err)); return err; } err = nvs_set_str(h, "group_profile", profile); if (err != ESP_OK) { ESP_LOGW(TAG, "nvs_set_str(group_profile=\"%s\"): %s", profile, esp_err_to_name(err)); nvs_close(h); return err; } err = nvs_commit(h); if (err != ESP_OK) { ESP_LOGW(TAG, "nvs_commit(zacus): %s", esp_err_to_name(err)); } nvs_close(h); return err; } // ─── GET /game/group_profile ──────────────────────────────────────────────── static esp_err_t handle_group_profile_get(httpd_req_t *req) { const char *current = hints_client_group_profile(); char buf[96]; snprintf(buf, sizeof(buf), "{\"group_profile\":\"%s\"}", current ? current : "MIXED"); return send_json(req, "200 OK", buf); } // ─── POST /game/group_profile ─────────────────────────────────────────────── static esp_err_t handle_group_profile_post(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len > GAME_ENDPOINT_MAX_BODY_BYTES) { ESP_LOGW(TAG, "POST /game/group_profile: bad body length %d", (int) req->content_len); return send_error(req, "413 Payload Too Large", "body must be 1..256 bytes"); } char body[GAME_ENDPOINT_MAX_BODY_BYTES + 1] = {0}; 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; return send_error(req, "400 Bad Request", "recv failed"); } total += got; } body[total] = '\0'; cJSON *root = cJSON_Parse(body); if (!root) { ESP_LOGW(TAG, "POST /game/group_profile: malformed JSON: %s", body); return send_error(req, "400 Bad Request", "malformed json"); } const cJSON *profile = cJSON_GetObjectItemCaseSensitive(root, "group_profile"); if (!cJSON_IsString(profile) || profile->valuestring == NULL || profile->valuestring[0] == '\0') { cJSON_Delete(root); return send_error(req, "400 Bad Request", "missing 'group_profile'"); } // Push to the hints client first — its built-in whitelist is the // source of truth for valid profiles. If it accepts the value we // then persist; if it rejects we never touch NVS so a bad POST // cannot brick the boot-time seed. esp_err_t set_err = hints_client_set_group_profile(profile->valuestring); if (set_err != ESP_OK) { ESP_LOGW(TAG, "hints_client_set_group_profile(\"%s\") rejected: %s", profile->valuestring, esp_err_to_name(set_err)); cJSON_Delete(root); return send_error(req, "400 Bad Request", GAME_ENDPOINT_PROFILE_HELP); } // Best-effort NVS persistence. If it fails the in-RAM hints client // is still updated, but we surface a 500 so the operator knows the // change won't survive reboot. Log line above already captured the // underlying NVS error. esp_err_t nvs_err = persist_group_profile(profile->valuestring); if (nvs_err != ESP_OK) { char buf[192]; snprintf(buf, sizeof(buf), "{\"status\":\"runtime_only\"," "\"group_profile\":\"%s\"," "\"warning\":\"nvs write failed: %s\"}", profile->valuestring, esp_err_to_name(nvs_err)); cJSON_Delete(root); return send_json(req, "500 Internal Server Error", buf); } ESP_LOGI(TAG, "group_profile updated -> %s (NVS persisted)", profile->valuestring); char buf[128]; snprintf(buf, sizeof(buf), "{\"status\":\"ok\",\"group_profile\":\"%s\"}", profile->valuestring); cJSON_Delete(root); 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; // Already mounted by main.c (or media_manager)? Checking first avoids // esp_littlefs's own "Partition already used" ERROR logs — the register // call below would still succeed-as-INVALID_STATE, but noisily. if (esp_littlefs_mounted(GAME_ENDPOINT_STORAGE_LABEL)) { s_storage_mounted = true; 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); } // ─── _scenario_apply — shared validate + atomic-write path ────────────────── // // The single internal entry point both the HTTP POST /game/scenario handler // and the ESP-NOW receiver (scenario_mesh) funnel through, per the spec. It // validates the IR (schema_version + non-empty steps), rotates the current // scenario to .bak, and atomically writes the new blob to LittleFS. On // success it schedules the hot-reload reboot and returns ESP_OK; the optional // out-params report the parsed step count / entry id for the HTTP response. // // On failure it returns a specific esp_err_t and leaves the previous scenario // in place (rolled back from .bak when a short write corrupted the new file). // `err_msg_out` (if non-NULL) receives a static human-readable reason. static esp_err_t scenario_apply_buffer(const char *body, size_t len, int *steps_count_out, char *entry_out, size_t entry_cap, const char **err_msg_out) { if (err_msg_out) *err_msg_out = NULL; if (steps_count_out) *steps_count_out = 0; if (entry_out && entry_cap) entry_out[0] = '\0'; if (!body || len == 0 || len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes"; return ESP_ERR_INVALID_SIZE; } if (mount_storage_lazy() != ESP_OK) { if (err_msg_out) *err_msg_out = "littlefs mount failed"; return ESP_FAIL; } // 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, "scenario apply: malformed JSON (len=%d)", (int) len); if (err_msg_out) *err_msg_out = "malformed json"; return ESP_ERR_INVALID_ARG; } const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version"); if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) { cJSON_Delete(root); if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1"; return ESP_ERR_INVALID_ARG; } const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { cJSON_Delete(root); if (err_msg_out) *err_msg_out = "steps must be a non-empty array"; return ESP_ERR_INVALID_ARG; } const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario"); const cJSON *entry = scenario_obj ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL; if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) { strncpy(entry_out, entry->valuestring, entry_cap - 1); entry_out[entry_cap - 1] = '\0'; } int steps_count = cJSON_GetArraySize(steps); cJSON_Delete(root); if (steps_count_out) *steps_count_out = steps_count; // 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); if (err_msg_out) *err_msg_out = "scenario write open failed"; return ESP_FAIL; } size_t written = fwrite(body, 1, len, f); fclose(f); if (written != len) { ESP_LOGE(TAG, "scenario write short: %zu/%zu bytes (rolling back)", written, len); unlink(GAME_ENDPOINT_SCENARIO_PATH); if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) { rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH); } if (err_msg_out) *err_msg_out = "scenario write short"; return ESP_FAIL; } ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s", len, steps_count, (entry_out && entry_cap) ? entry_out : ""); // New scenario supersedes any live puzzle — disarm and clear remembered step. local_puzzles_disarm(); s_current_step_id[0] = '\0'; memset(&s_current_scene, 0, sizeof(s_current_scene)); // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3). schedule_restart(); return ESP_OK; } // scenario_mesh apply callback: a received-over-ESP-NOW scenario takes the // exact same path as an HTTP POST. The mesh frees `data` after this returns. static esp_err_t scenario_mesh_apply_adapter(const char *data, size_t len) { int steps = 0; char entry[64] = {0}; const char *emsg = NULL; esp_err_t err = scenario_apply_buffer(data, len, &steps, entry, sizeof(entry), &emsg); if (err != ESP_OK) { ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s", emsg ? emsg : esp_err_to_name(err)); } return err; } // ─── 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"); } 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'; int steps_count = 0; char entry_str[64] = {0}; const char *emsg = NULL; esp_err_t aerr = scenario_apply_buffer(body, (size_t) total, &steps_count, entry_str, sizeof(entry_str), &emsg); free(body); if (aerr != ESP_OK) { const char *status = (aerr == ESP_ERR_INVALID_ARG || aerr == ESP_ERR_INVALID_SIZE) ? "400 Bad Request" : "500 Internal Server Error"; return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr)); } 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); // The HTTP response is queued by send_json; scenario_apply_buffer already // scheduled the deferred restart, which waits 800 ms before rebooting so // the TCP stack can flush this response first. return send_json(req, "200 OK", buf); } // ─── POST /game/scenario/relay (master only) ─────────────────────────────── // // Body: { "peers": ["box3","plip",...], "ir": { } } // // Resolves each alias to a MAC via the scenario_mesh peer registry, then // chunks + sends the IR over ESP-NOW. A failure on one peer (unknown alias, // ack timeout) does not abort the others — it lands in "skipped" with a // reason. Responds 200 with { "relayed":[...], "skipped":[{name,reason}] }. static esp_err_t handle_scenario_relay_post(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { ESP_LOGW(TAG, "POST /game/scenario/relay: bad body length %d", (int) req->content_len); return send_error(req, "413 Payload Too Large", "body must be 1..65536 bytes"); } 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'; cJSON *root = cJSON_Parse(body); free(body); if (!root) { return send_error(req, "400 Bad Request", "malformed json"); } const cJSON *peers = cJSON_GetObjectItemCaseSensitive(root, "peers"); const cJSON *ir = cJSON_GetObjectItemCaseSensitive(root, "ir"); if (!cJSON_IsArray(peers) || cJSON_GetArraySize(peers) == 0) { cJSON_Delete(root); return send_error(req, "400 Bad Request", "'peers' must be a non-empty array"); } if (!cJSON_IsObject(ir)) { cJSON_Delete(root); return send_error(req, "400 Bad Request", "'ir' must be an object"); } // Serialize the IR object once (unformatted, compact) — this is the exact // byte stream the receiver reassembles and feeds to _scenario_apply(). char *ir_str = cJSON_PrintUnformatted(ir); if (!ir_str) { cJSON_Delete(root); return send_error(req, "500 Internal Server Error", "ir serialize failed"); } size_t ir_len = strlen(ir_str); // Build the two result arrays into a response cJSON tree. cJSON *resp = cJSON_CreateObject(); cJSON *relayed = cJSON_AddArrayToObject(resp, "relayed"); cJSON *skipped = cJSON_AddArrayToObject(resp, "skipped"); int n = cJSON_GetArraySize(peers); for (int i = 0; i < n; i++) { const cJSON *p = cJSON_GetArrayItem(peers, i); if (!cJSON_IsString(p) || !p->valuestring) continue; const char *alias = p->valuestring; uint8_t mac[6]; esp_err_t rerr = scenario_mesh_mac_for_alias(alias, mac); if (rerr != ESP_OK) { cJSON *s = cJSON_CreateObject(); cJSON_AddStringToObject(s, "name", alias); cJSON_AddStringToObject(s, "reason", "unknown_peer"); cJSON_AddItemToArray(skipped, s); ESP_LOGW(TAG, "relay: alias \"%s\" not in peer registry", alias); continue; } esp_err_t serr = scenario_mesh_send(mac, ir_str, ir_len); if (serr == ESP_OK) { cJSON_AddItemToArray(relayed, cJSON_CreateString(alias)); ESP_LOGI(TAG, "relay: \"%s\" OK (%u bytes)", alias, (unsigned) ir_len); } else { cJSON *s = cJSON_CreateObject(); cJSON_AddStringToObject(s, "name", alias); cJSON_AddStringToObject(s, "reason", serr == ESP_ERR_TIMEOUT ? "timeout" : esp_err_to_name(serr)); cJSON_AddItemToArray(skipped, s); ESP_LOGW(TAG, "relay: \"%s\" failed: %s", alias, esp_err_to_name(serr)); } } cJSON_free(ir_str); cJSON_Delete(root); char *resp_str = cJSON_PrintUnformatted(resp); cJSON_Delete(resp); if (!resp_str) { return send_error(req, "500 Internal Server Error", "response serialize failed"); } esp_err_t ret = send_json(req, "200 OK", resp_str); cJSON_free(resp_str); return ret; } // ─── GET/POST /game/peers ─────────────────────────────────────────────────── // Relay peer registry management over HTTP — replaces the desktop // NvsConfigurator round-trip for ESP-NOW provisioning. // POST { "alias": "plip", "mac": "AA:BB:CC:DD:EE:FF" } // → persists to NVS namespace "peers" (key = alias, blob = 6-byte MAC, // the exact format main.c seeds from at boot) AND registers the peer // live in the scenario_mesh table (no reboot needed). // GET → { "peers": [ { "alias": "...", "mac": "..." }, ... ] } static esp_err_t handle_peers_post(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len > 256) { return send_error(req, "413 Payload Too Large", "body must be 1..256 bytes"); } char body[257]; 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; return send_error(req, "400 Bad Request", "recv failed"); } total += got; } body[total] = '\0'; cJSON *root = cJSON_Parse(body); if (!root) { return send_error(req, "400 Bad Request", "invalid JSON"); } const cJSON *alias_j = cJSON_GetObjectItem(root, "alias"); const cJSON *mac_j = cJSON_GetObjectItem(root, "mac"); if (!cJSON_IsString(alias_j) || !cJSON_IsString(mac_j)) { cJSON_Delete(root); return send_error(req, "400 Bad Request", "alias and mac (string) required"); } const char *alias = alias_j->valuestring; size_t alias_len = strlen(alias); if (alias_len == 0 || alias_len > 15) { // NVS key limit cJSON_Delete(root); return send_error(req, "400 Bad Request", "alias must be 1..15 chars (NVS key)"); } uint8_t mac[6]; if (sscanf(mac_j->valuestring, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", &mac[0], &mac[1], &mac[2], &mac[3], &mac[4], &mac[5]) != 6) { cJSON_Delete(root); return send_error(req, "400 Bad Request", "mac must be AA:BB:CC:DD:EE:FF"); } nvs_handle_t h; esp_err_t err = nvs_open("peers", NVS_READWRITE, &h); if (err == ESP_OK) { err = nvs_set_blob(h, alias, mac, sizeof(mac)); if (err == ESP_OK) err = nvs_commit(h); nvs_close(h); } if (err != ESP_OK) { ESP_LOGE(TAG, "peers: NVS write \"%s\" failed: %s", alias, esp_err_to_name(err)); cJSON_Delete(root); return send_error(req, "500 Internal Server Error", "NVS write failed"); } esp_err_t reg = scenario_mesh_register_peer(alias, mac); if (reg != ESP_OK) { ESP_LOGW(TAG, "peers: \"%s\" stored in NVS but live registration " "failed: %s (effective after reboot)", alias, esp_err_to_name(reg)); } ESP_LOGI(TAG, "peers: \"%s\" -> %02X:%02X:%02X:%02X:%02X:%02X (%s)", alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], reg == ESP_OK ? "live" : "reboot needed"); // Build the response BEFORE deleting root: `alias` points into the cJSON // tree (use-after-free observed on-device as a garbled alias echo). char resp[128]; snprintf(resp, sizeof(resp), "{\"ok\":true,\"alias\":\"%s\",\"live\":%s}", alias, reg == ESP_OK ? "true" : "false"); cJSON_Delete(root); return send_json(req, "200 OK", resp); } static esp_err_t handle_peers_get(httpd_req_t *req) { cJSON *resp = cJSON_CreateObject(); cJSON *arr = cJSON_AddArrayToObject(resp, "peers"); nvs_handle_t h; if (nvs_open("peers", NVS_READONLY, &h) == ESP_OK) { nvs_iterator_t it = NULL; esp_err_t err = nvs_entry_find("nvs", "peers", NVS_TYPE_BLOB, &it); while (err == ESP_OK && it != NULL) { nvs_entry_info_t info; nvs_entry_info(it, &info); uint8_t mac[6]; size_t len = sizeof(mac); if (nvs_get_blob(h, info.key, mac, &len) == ESP_OK && len == 6) { cJSON *p = cJSON_CreateObject(); cJSON_AddStringToObject(p, "alias", info.key); char mac_str[18]; snprintf(mac_str, sizeof(mac_str), "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); cJSON_AddStringToObject(p, "mac", mac_str); cJSON_AddItemToArray(arr, p); } err = nvs_entry_next(&it); } nvs_release_iterator(it); nvs_close(h); } char *resp_str = cJSON_PrintUnformatted(resp); cJSON_Delete(resp); if (!resp_str) { return send_error(req, "500 Internal Server Error", "response serialize failed"); } esp_err_t ret = send_json(req, "200 OK", resp_str); cJSON_free(resp_str); return ret; } // ─── POST /game/espnow/cmd — debug/manual CMD injection ───────────────────── // // Body: {"peer":"alias","command":"ping"} or {"broadcast":true,"command":"x"}. // Sends one CMD text frame (spec 2026-06-11) and reports the radio status. // The scenario-driven executor will share this exact send path. static const uint8_t kEspnowBroadcastMac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // Receive side: log every CMD/EVT so two-board bring-up is observable on the // serial console. The step executor will hook waits in here later. static void master_text_cb(uint8_t kind, const uint8_t src[6], const char *text) { ESP_LOGI(TAG, "espnow %s \"%s\" from %02x:%02x:%02x:%02x:%02x:%02x", kind == SCENARIO_MESH_TEXT_CMD ? "CMD" : "EVT", text, src[0], src[1], src[2], src[3], src[4], src[5]); } static esp_err_t handle_espnow_cmd_post(httpd_req_t *req) { char buf[256]; int total = req->content_len; if (total <= 0 || total >= (int) sizeof(buf)) { return send_error(req, "400 Bad Request", "bad body length"); } int got = httpd_req_recv(req, buf, total); if (got <= 0) { return send_error(req, "400 Bad Request", "body read failed"); } buf[got] = '\0'; cJSON *root = cJSON_Parse(buf); if (!root) { return send_error(req, "400 Bad Request", "invalid JSON"); } const cJSON *cmd_j = cJSON_GetObjectItemCaseSensitive(root, "command"); const cJSON *peer_j = cJSON_GetObjectItemCaseSensitive(root, "peer"); const cJSON *bc_j = cJSON_GetObjectItemCaseSensitive(root, "broadcast"); if (!cJSON_IsString(cmd_j) || !cmd_j->valuestring[0]) { cJSON_Delete(root); return send_error(req, "400 Bad Request", "'command' required"); } uint8_t mac[6]; if (cJSON_IsTrue(bc_j)) { memcpy(mac, kEspnowBroadcastMac, 6); } else if (cJSON_IsString(peer_j) && peer_j->valuestring[0]) { if (scenario_mesh_mac_for_alias(peer_j->valuestring, mac) != ESP_OK) { cJSON_Delete(root); return send_error(req, "404 Not Found", "unknown peer alias"); } } else { cJSON_Delete(root); return send_error(req, "400 Bad Request", "'peer' or 'broadcast':true required"); } esp_err_t serr = scenario_mesh_send_text(mac, SCENARIO_MESH_TEXT_CMD, cmd_j->valuestring); char resp[96]; snprintf(resp, sizeof(resp), "{\"ok\":%s,\"status\":\"%s\"}", serr == ESP_OK ? "true" : "false", esp_err_to_name(serr)); cJSON_Delete(root); return send_json(req, serr == ESP_OK ? "200 OK" : "502 Bad Gateway", resp); } // ─── POST /game/step — arm a puzzle for the given step id ─────────────────── // // Body: {"step_id":"STEP_X"} // Reads /littlefs/scenario.json, calls puzzle_binding_from_ir, then // disarms whatever is running and arms the new puzzle (if any). // Concurrency: httpd task vs. puzzle solved-callbacks writing puzzle_state — // the arm/disarm calls here are the only writers to local_puzzles state; puzzle // tasks only call puzzle_state_report. No mutex needed for this slice. // Core step-change logic, shared by the HTTP handler and internal callers // (e.g. shell app launch on the local display). Error contract: // ESP_OK armed (armed_out = "qr"|"sound"|"none") // ESP_ERR_INVALID_STATE not ready (no puzzle_state wired) // ESP_ERR_NOT_SUPPORTED no scenario stored / storage unavailable // ESP_ERR_NOT_FOUND unknown step id // ESP_ERR_INVALID_ARG invalid puzzle object in the IR // ESP_ERR_TIMEOUT puzzle busy (QR teardown window, after one retry) // others internal failure esp_err_t game_endpoint_apply_step(const char *step_id, char *armed_out, size_t armed_cap) { if (armed_out && armed_cap) armed_out[0] = '\0'; if (!step_id || !step_id[0]) return ESP_ERR_INVALID_ARG; if (s_pstate == NULL) return ESP_ERR_INVALID_STATE; if (mount_storage_lazy() != ESP_OK) return ESP_ERR_NOT_SUPPORTED; FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "rb"); if (!f) return ESP_ERR_NOT_SUPPORTED; fseek(f, 0, SEEK_END); long fsize = ftell(f); rewind(f); if (fsize <= 0 || fsize > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { fclose(f); return ESP_ERR_NOT_SUPPORTED; } char *ir_json = (char *) malloc((size_t) fsize + 1); if (!ir_json) { fclose(f); return ESP_ERR_NO_MEM; } size_t nread = fread(ir_json, 1, (size_t) fsize, f); fclose(f); ir_json[nread] = '\0'; // Disarm current puzzle before re-arming. local_puzzles_disarm(); // Parse binding for this step (+ display scene metadata, lenient). puzzle_binding_t binding; esp_err_t berr = puzzle_binding_from_ir(ir_json, step_id, &binding); if (berr == ESP_OK) { (void) scene_binding_from_ir(ir_json, step_id, &s_current_scene); } free(ir_json); if (berr != ESP_OK) return berr; // NOT_FOUND / INVALID_ARG / other strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1); s_current_step_id[sizeof(s_current_step_id) - 1] = '\0'; const char *armed = "none"; if (binding.type == PB_QR) { const char *ptrs[PB_MAX_CODES]; for (size_t i = 0; i < binding.code_count; i++) ptrs[i] = binding.codes[i]; esp_err_t aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count, binding.fragment, binding.fragment_len); if (aerr == ESP_ERR_INVALID_STATE) { vTaskDelay(pdMS_TO_TICKS(250)); // async QR teardown — retry once aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count, binding.fragment, binding.fragment_len); } if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT; if (aerr != ESP_OK) return aerr; armed = "qr"; } else if (binding.type == PB_SOUND) { esp_err_t aerr = local_puzzles_arm_sound(binding.id, binding.melody, binding.note_count, binding.tolerance, binding.fragment, binding.fragment_len); if (aerr == ESP_ERR_INVALID_STATE) { vTaskDelay(pdMS_TO_TICKS(250)); aerr = local_puzzles_arm_sound(binding.id, binding.melody, binding.note_count, binding.tolerance, binding.fragment, binding.fragment_len); } if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT; if (aerr != ESP_OK) return aerr; armed = "sound"; } strncpy(s_current_armed, armed, sizeof(s_current_armed) - 1); s_current_armed[sizeof(s_current_armed) - 1] = '\0'; if (armed_out && armed_cap) { strncpy(armed_out, armed, armed_cap - 1); armed_out[armed_cap - 1] = '\0'; } return ESP_OK; } static esp_err_t handle_step_post(httpd_req_t *req) { if (s_pstate == NULL) { return send_error(req, "503 Service Unavailable", "not_ready"); } // Read body (≤512 bytes is sufficient for {"step_id":"..."}). char body[513] = {0}; if (req->content_len <= 0 || req->content_len > 512) { return send_error(req, "400 Bad Request", "body must be 1..512 bytes"); } 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; return send_error(req, "400 Bad Request", "recv failed"); } total += got; } body[total] = '\0'; cJSON *root = cJSON_Parse(body); if (!root) { return send_error(req, "400 Bad Request", "malformed json"); } const cJSON *step_id_j = cJSON_GetObjectItemCaseSensitive(root, "step_id"); if (!cJSON_IsString(step_id_j) || !step_id_j->valuestring || step_id_j->valuestring[0] == '\0') { cJSON_Delete(root); return send_error(req, "400 Bad Request", "missing or empty step_id"); } char step_id[64]; strncpy(step_id, step_id_j->valuestring, sizeof(step_id) - 1); step_id[sizeof(step_id) - 1] = '\0'; cJSON_Delete(root); // Delegate to the shared core; map its error contract onto the SAME // HTTP statuses/messages as before (REST contract unchanged). char armed[8]; esp_err_t aerr = game_endpoint_apply_step(step_id, armed, sizeof(armed)); switch (aerr) { case ESP_OK: { char buf[128]; snprintf(buf, sizeof(buf), "{\"step_id\":\"%s\",\"armed\":\"%s\"}", step_id, armed); return send_json(req, "200 OK", buf); } case ESP_ERR_NOT_SUPPORTED: return send_error(req, "409 Conflict", "no_scenario"); case ESP_ERR_NOT_FOUND: return send_error(req, "404 Not Found", "unknown_step"); case ESP_ERR_INVALID_ARG: return send_error(req, "422 Unprocessable Entity", "invalid_puzzle"); case ESP_ERR_TIMEOUT: return send_error(req, "503 Service Unavailable", "puzzle_busy"); case ESP_ERR_INVALID_STATE: return send_error(req, "503 Service Unavailable", "not_ready"); default: return send_error(req, "500 Internal Server Error", esp_err_to_name(aerr)); } } // ─── GET /game/puzzle_state ────────────────────────────────────────────────── // // Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}. // NOTE: reads puzzle_state fields without a mutex — acceptable for this game // (GM dashboard polling). solved[] flags are written monotonically by puzzle // tasks; a concurrent read may see solved=true with fragment mid-write. static esp_err_t handle_puzzle_state_get(httpd_req_t *req) { if (s_pstate == NULL) { return send_error(req, "503 Service Unavailable", "not_ready"); } // Build "solved" array of ids where solved[id] == true (ids 1..PUZZLE_MAX_ID). char solved_buf[64] = "["; int solved_len = 1; bool first = true; for (int id = 1; id <= PUZZLE_MAX_ID; id++) { if (s_pstate->solved[id]) { int n = snprintf(solved_buf + solved_len, sizeof(solved_buf) - (size_t) solved_len - 1, "%s%d", first ? "" : ",", id); if (n > 0) solved_len += n; first = false; } } if (solved_len < (int) sizeof(solved_buf) - 1) { solved_buf[solved_len++] = ']'; solved_buf[solved_len] = '\0'; } char code[PUZZLE_MAX_ID * PUZZLE_MAX_FRAG + 1] = {0}; puzzle_state_code(s_pstate, code, sizeof(code)); // Build response. step_id is null when no step has been armed yet. char resp[256]; if (s_current_step_id[0] != '\0') { snprintf(resp, sizeof(resp), "{\"step_id\":\"%s\",\"solved\":%s,\"code\":\"%s\"}", s_current_step_id, solved_buf, code); } else { snprintf(resp, sizeof(resp), "{\"step_id\":null,\"solved\":%s,\"code\":\"%s\"}", solved_buf, code); } return send_json(req, "200 OK", resp); } // ─── public setter (Task C) ────────────────────────────────────────────────── void game_endpoint_set_puzzle_state(puzzle_state_t *state) { s_pstate = state; ESP_LOGI(TAG, "puzzle_state registered (GET /game/puzzle_state + POST /game/step active)"); } // ─── public getter for display_ui (Phase 1) ────────────────────────────────── // // Copies the statics written by the step handler. Reading without a mutex is // acceptable here — the game master arms one step at a time and the display is // informational; a torn read produces at worst a one-cycle stale value. void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap, char *armed, size_t armed_cap) { if (step_id && step_cap > 0) { strncpy(step_id, s_current_step_id, step_cap - 1); step_id[step_cap - 1] = '\0'; } if (armed && armed_cap > 0) { strncpy(armed, s_current_armed, armed_cap - 1); armed[armed_cap - 1] = '\0'; } } // ─── POST /game/file?path=apps// — provision shell apps ──────────── // Raw body → file under /littlefs/apps/ ONLY (whitelist + no traversal). // Lets the GM push icon.png / step.txt for the display shell's dynamic tiles // without reflashing a littlefs image. #define GAME_ENDPOINT_MAX_FILE_BYTES (256 * 1024) static esp_err_t handle_file_post(httpd_req_t *req) { char query[160], path_param[96]; if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK || httpd_query_key_value(query, "path", path_param, sizeof(path_param)) != ESP_OK) { return send_error(req, "400 Bad Request", "missing path param"); } if (strncmp(path_param, "apps/", 5) != 0 || strstr(path_param, "..") || path_param[strlen(path_param) - 1] == '/') { return send_error(req, "403 Forbidden", "path must be under apps/"); } if (req->content_len <= 0 || req->content_len > GAME_ENDPOINT_MAX_FILE_BYTES) { return send_error(req, "400 Bad Request", "size 1..262144 bytes"); } if (mount_storage_lazy() != ESP_OK) { return send_error(req, "503 Service Unavailable", "storage_unavailable"); } char full[160]; snprintf(full, sizeof(full), "/littlefs/%s", path_param); // mkdir -p for intermediate directories. for (char *p = full + strlen("/littlefs/"); *p; p++) { if (*p == '/') { *p = '\0'; mkdir(full, 0775); // EEXIST is fine *p = '/'; } } FILE *f = fopen(full, "wb"); if (!f) { return send_error(req, "500 Internal Server Error", "open failed"); } char buf[1024]; int remaining = (int) req->content_len; while (remaining > 0) { const int want = remaining < (int) sizeof(buf) ? remaining : (int) sizeof(buf); int got = httpd_req_recv(req, buf, want); if (got <= 0) { if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; fclose(f); unlink(full); return send_error(req, "400 Bad Request", "recv failed"); } if (fwrite(buf, 1, (size_t) got, f) != (size_t) got) { fclose(f); unlink(full); return send_error(req, "500 Internal Server Error", "write failed"); } remaining -= got; } fclose(f); char resp[192]; snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"path\":\"%s\",\"bytes\":%d}", path_param, (int) req->content_len); ESP_LOGI(TAG, "file stored: %s (%d B)", full, (int) req->content_len); return send_json(req, "200 OK", resp); } void game_endpoint_get_scene(scene_binding_t *out) { if (!out) return; // Lock-free snapshot, same class as get_puzzle_status: written on the // httpd task per step change, polled by main's status loop. A torn read // of display text during the copy is cosmetic and self-heals next poll. memcpy(out, &s_current_scene, sizeof(*out)); } // ─── public init ──────────────────────────────────────────────────────────── esp_err_t game_endpoint_init(httpd_handle_t server) { if (server == NULL) { ESP_LOGE(TAG, "game_endpoint_init: NULL httpd handle " "(did ota_server_init() succeed?)"); return ESP_ERR_INVALID_ARG; } static const httpd_uri_t uri_get = { .uri = "/game/group_profile", .method = HTTP_GET, .handler = handle_group_profile_get, .user_ctx = NULL, }; static const httpd_uri_t uri_post = { .uri = "/game/group_profile", .method = HTTP_POST, .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, }; static const httpd_uri_t uri_scenario_relay = { .uri = "/game/scenario/relay", .method = HTTP_POST, .handler = handle_scenario_relay_post, .user_ctx = NULL, }; static const httpd_uri_t uri_peers_get = { .uri = "/game/peers", .method = HTTP_GET, .handler = handle_peers_get, .user_ctx = NULL, }; static const httpd_uri_t uri_peers_post = { .uri = "/game/peers", .method = HTTP_POST, .handler = handle_peers_post, .user_ctx = NULL, }; static const httpd_uri_t uri_step_post = { .uri = "/game/step", .method = HTTP_POST, .handler = handle_step_post, .user_ctx = NULL, }; static const httpd_uri_t uri_puzzle_state_get = { .uri = "/game/puzzle_state", .method = HTTP_GET, .handler = handle_puzzle_state_get, .user_ctx = NULL, }; static const httpd_uri_t uri_file_post = { .uri = "/game/file", .method = HTTP_POST, .handler = handle_file_post, .user_ctx = NULL, }; static const httpd_uri_t uri_espnow_cmd_post = { .uri = "/game/espnow/cmd", .method = HTTP_POST, .handler = handle_espnow_cmd_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 // could push a scenario back over ESP-NOW symmetrically. Non-fatal: if the // Wi-Fi stack isn't ready the relay endpoint will just report send errors. esp_err_t mesh_err = scenario_mesh_init(scenario_mesh_apply_adapter); if (mesh_err != ESP_OK) { ESP_LOGW(TAG, "scenario_mesh_init failed: %s — /game/scenario/relay " "will be unavailable", esp_err_to_name(mesh_err)); } esp_err_t err = httpd_register_uri_handler(server, &uri_get); if (err != ESP_OK) { ESP_LOGE(TAG, "register GET /game/group_profile: %s", esp_err_to_name(err)); return err; } err = httpd_register_uri_handler(server, &uri_post); if (err != ESP_OK) { ESP_LOGE(TAG, "register POST /game/group_profile: %s", 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; } // Relay is best-effort: only register it when the mesh came up, but a // registration failure here is non-fatal to the rest of the surface. if (mesh_err == ESP_OK) { err = httpd_register_uri_handler(server, &uri_scenario_relay); if (err != ESP_OK) { ESP_LOGW(TAG, "register POST /game/scenario/relay: %s", esp_err_to_name(err)); } // CMD/EVT text channel: log inbound traffic + manual CMD injection. esp_err_t terr = scenario_mesh_set_text_cb(master_text_cb); if (terr != ESP_OK) { ESP_LOGW(TAG, "scenario_mesh_set_text_cb: %s", esp_err_to_name(terr)); } err = httpd_register_uri_handler(server, &uri_espnow_cmd_post); if (err != ESP_OK) { ESP_LOGW(TAG, "register POST /game/espnow/cmd: %s", esp_err_to_name(err)); } } // Peer registry management — non-fatal if registration fails. err = httpd_register_uri_handler(server, &uri_peers_get); if (err != ESP_OK) { ESP_LOGW(TAG, "register GET /game/peers: %s", esp_err_to_name(err)); } err = httpd_register_uri_handler(server, &uri_peers_post); if (err != ESP_OK) { ESP_LOGW(TAG, "register POST /game/peers: %s", esp_err_to_name(err)); } // Task C: puzzle arming + state readout — non-fatal. err = httpd_register_uri_handler(server, &uri_step_post); if (err != ESP_OK) { ESP_LOGW(TAG, "register POST /game/step: %s", esp_err_to_name(err)); } err = httpd_register_uri_handler(server, &uri_file_post); if (err != ESP_OK) { ESP_LOGW(TAG, "register /game/file: %s", esp_err_to_name(err)); } err = httpd_register_uri_handler(server, &uri_puzzle_state_get); if (err != ESP_OK) { ESP_LOGW(TAG, "register GET /game/puzzle_state: %s", esp_err_to_name(err)); } ESP_LOGI(TAG, "game endpoint registered " "(GET+POST /game/group_profile, POST /game/scenario%s, " "GET+POST /game/peers, POST /game/step, GET /game/puzzle_state)", mesh_err == ESP_OK ? ", POST /game/scenario/relay" : ""); return ESP_OK; }