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:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user