diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index e23e64e..26b7d31 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -645,6 +645,71 @@ static esp_err_t handle_peers_get(httpd_req_t *req) { 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"} @@ -1013,6 +1078,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { .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 @@ -1050,6 +1121,17 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { 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); diff --git a/lib/scenario_mesh/include/scenario_mesh.h b/lib/scenario_mesh/include/scenario_mesh.h index cef0ad7..abca984 100644 --- a/lib/scenario_mesh/include/scenario_mesh.h +++ b/lib/scenario_mesh/include/scenario_mesh.h @@ -85,6 +85,33 @@ int scenario_mesh_peer_count(void); esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], const char *data, size_t len); +// ─── short text frames: commands & events (spec 2026-06-11) ──────────────── +// +// Single-frame messages riding the same ESP-NOW link as IR transfers. Wire +// compat: header seq=0xFFFF (sentinel) + total=kind — old receivers drop +// them via their `seq >= total` malformed check. Payload: UTF-8 text, +// NUL excluded, <= SCENARIO_MESH_TEXT_MAX bytes. + +#define SCENARIO_MESH_TEXT_CMD 1 // master -> annex: do something +#define SCENARIO_MESH_TEXT_EVT 2 // annex -> master: something happened +#define SCENARIO_MESH_TEXT_MAX 200 + +// kind, sender MAC, NUL-terminated text. Runs on a dedicated worker task +// (never the Wi-Fi callback); keep it reasonably short anyway. +typedef void (*scenario_mesh_text_cb_t)(uint8_t kind, + const uint8_t src_mac[6], + const char *text); + +// Send one CMD/EVT frame (kind = SCENARIO_MESH_TEXT_*). Unicast awaits the +// radio ack (ESP_ERR_TIMEOUT if the peer is silent); broadcast returns once +// transmitted. dest_mac may be the broadcast address FF:FF:FF:FF:FF:FF. +esp_err_t scenario_mesh_send_text(const uint8_t dest_mac[6], uint8_t kind, + const char *text); + +// Install (or replace, NULL to clear) the receive handler for CMD/EVT +// frames. Spawns the dispatch worker on first use. +esp_err_t scenario_mesh_set_text_cb(scenario_mesh_text_cb_t cb); + #ifdef __cplusplus } #endif diff --git a/lib/scenario_mesh/scenario_mesh.c b/lib/scenario_mesh/scenario_mesh.c index e845fb9..2cbbddf 100644 --- a/lib/scenario_mesh/scenario_mesh.c +++ b/lib/scenario_mesh/scenario_mesh.c @@ -72,6 +72,30 @@ typedef struct { static QueueHandle_t s_apply_queue; +// ─── CMD/EVT text frames (header sentinel seq=0xFFFF, total=kind) ─────────── + +#define MESH_TEXT_SEQ_SENTINEL 0xFFFFu + +typedef struct { + uint8_t kind; + uint8_t src[6]; + char text[SCENARIO_MESH_TEXT_MAX + 1]; +} mesh_text_job_t; + +static scenario_mesh_text_cb_t s_text_cb; +static QueueHandle_t s_text_queue; + +static void text_worker_task(void *arg) { + (void) arg; + mesh_text_job_t job; + for (;;) { + if (xQueueReceive(s_text_queue, &job, portMAX_DELAY) == pdTRUE) { + scenario_mesh_text_cb_t cb = s_text_cb; + if (cb) cb(job.kind, job.src, job.text); + } + } +} + static void apply_worker_task(void *arg) { (void) arg; mesh_apply_job_t job; @@ -220,6 +244,22 @@ static void on_recv(const esp_now_recv_info_t *info, uint16_t total = (uint16_t) (data[2] | (data[3] << 8)); const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES; int payload_len = len - SCENARIO_MESH_HEADER_BYTES; + + // CMD/EVT text frame: sentinel seq + kind in `total`. Old receivers fall + // through to the malformed check below and drop it silently. + if (seq == MESH_TEXT_SEQ_SENTINEL && + (total == SCENARIO_MESH_TEXT_CMD || total == SCENARIO_MESH_TEXT_EVT)) { + if (!s_text_queue || payload_len > SCENARIO_MESH_TEXT_MAX) return; + mesh_text_job_t job; + job.kind = (uint8_t) total; + memcpy(job.src, info->src_addr, 6); + memcpy(job.text, payload, payload_len); + job.text[payload_len] = '\0'; + BaseType_t hp = pdFALSE; + xQueueSendFromISR(s_text_queue, &job, &hp); + if (hp) portYIELD_FROM_ISR(); + return; + } if (seq >= total) return; // malformed if (!s_reasm_lock) return; @@ -395,3 +435,67 @@ esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], } return result; } + +// ─── CMD/EVT text API ─────────────────────────────────────────────────────── + +esp_err_t scenario_mesh_set_text_cb(scenario_mesh_text_cb_t cb) { + if (!s_text_queue) { + s_text_queue = xQueueCreate(8, sizeof(mesh_text_job_t)); + if (!s_text_queue) return ESP_ERR_NO_MEM; + BaseType_t ok = xTaskCreate(text_worker_task, "scn_mesh_text", + 3072, NULL, tskIDLE_PRIORITY + 2, NULL); + if (ok != pdPASS) { + vQueueDelete(s_text_queue); + s_text_queue = NULL; + return ESP_ERR_NO_MEM; + } + } + s_text_cb = cb; + return ESP_OK; +} + +esp_err_t scenario_mesh_send_text(const uint8_t dest_mac[6], uint8_t kind, + const char *text) { + if (!dest_mac || !text || + (kind != SCENARIO_MESH_TEXT_CMD && kind != SCENARIO_MESH_TEXT_EVT)) { + return ESP_ERR_INVALID_ARG; + } + size_t len = strlen(text); + if (len == 0 || len > SCENARIO_MESH_TEXT_MAX) return ESP_ERR_INVALID_SIZE; + + const bool broadcast = (memcmp(dest_mac, kBroadcast, 6) == 0); + + if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) return ESP_FAIL; + + uint8_t frame[SCENARIO_MESH_FRAME_MAX]; + frame[0] = (uint8_t) (MESH_TEXT_SEQ_SENTINEL & 0xFF); + frame[1] = (uint8_t) ((MESH_TEXT_SEQ_SENTINEL >> 8) & 0xFF); + frame[2] = kind; + frame[3] = 0; + memcpy(frame + SCENARIO_MESH_HEADER_BYTES, text, len); + + xSemaphoreTake(s_send_done, 0); // drain stale ack + s_last_send_status = ESP_NOW_SEND_FAIL; + + esp_err_t result = esp_now_send(dest_mac, frame, + SCENARIO_MESH_HEADER_BYTES + len); + if (result == ESP_OK) { + if (xSemaphoreTake(s_send_done, + pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS)) + != pdTRUE) { + result = ESP_ERR_TIMEOUT; + } else if (!broadcast && + s_last_send_status != ESP_NOW_SEND_SUCCESS) { + result = ESP_ERR_TIMEOUT; // unicast: peer silent + } + } + + xSemaphoreGive(s_send_lock); + + ESP_LOGI(TAG, "text %s \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x: %s", + kind == SCENARIO_MESH_TEXT_CMD ? "CMD" : "EVT", text, + dest_mac[0], dest_mac[1], dest_mac[2], + dest_mac[3], dest_mac[4], dest_mac[5], + esp_err_to_name(result)); + return result; +}