7893c32bf6
Frames courtes commandes/evenements sur la pile scenario_mesh
(sentinel seq=0xFFFF + kind, retrocompatible : les anciens
recepteurs les jettent via le check malformed). API additive
send_text/set_text_cb (dispatch hors callback WiFi via tache
dediee). Master : POST /game/espnow/cmd {peer|broadcast,command}
+ log des CMD/EVT recus. Teste sur FNK0102H : broadcast ESP_OK,
unicast sans recepteur ESP_ERR_TIMEOUT, alias inconnu 404.
Spec : docs/superpowers/specs/2026-06-11-espnow-cmd-evt-design.md
1166 lines
47 KiB
C
1166 lines
47 KiB
C
// 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 <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"
|
|
|
|
#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": { <runtime3 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/<id>/<file> — 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;
|
|
}
|