feat(espnow): canal texte CMD/EVT + endpoint debug

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
This commit is contained in:
Claude Worker claude2
2026-06-11 05:47:20 +02:00
parent 31dcb28c62
commit 7893c32bf6
3 changed files with 213 additions and 0 deletions
@@ -645,6 +645,71 @@ static esp_err_t handle_peers_get(httpd_req_t *req) {
return ret; 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 ─────────────────── // ─── POST /game/step — arm a puzzle for the given step id ───────────────────
// //
// Body: {"step_id":"STEP_X"} // Body: {"step_id":"STEP_X"}
@@ -1013,6 +1078,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
.handler = handle_file_post, .handler = handle_file_post,
.user_ctx = NULL, .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 // 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 // (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_LOGW(TAG, "register POST /game/scenario/relay: %s",
esp_err_to_name(err)); 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. // Peer registry management — non-fatal if registration fails.
err = httpd_register_uri_handler(server, &uri_peers_get); err = httpd_register_uri_handler(server, &uri_peers_get);
+27
View File
@@ -85,6 +85,33 @@ int scenario_mesh_peer_count(void);
esp_err_t scenario_mesh_send(const uint8_t dest_mac[6], esp_err_t scenario_mesh_send(const uint8_t dest_mac[6],
const char *data, size_t len); 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 #ifdef __cplusplus
} }
#endif #endif
+104
View File
@@ -72,6 +72,30 @@ typedef struct {
static QueueHandle_t s_apply_queue; 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) { static void apply_worker_task(void *arg) {
(void) arg; (void) arg;
mesh_apply_job_t job; 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)); uint16_t total = (uint16_t) (data[2] | (data[3] << 8));
const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES; const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES;
int payload_len = len - 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 (seq >= total) return; // malformed
if (!s_reasm_lock) return; if (!s_reasm_lock) return;
@@ -395,3 +435,67 @@ esp_err_t scenario_mesh_send(const uint8_t dest_mac[6],
} }
return result; 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;
}