feat(box3): implement ESP-NOW CMD executor (D5 contract)
CI / platformio (pull_request) Failing after 4m36s

Add cmd_exec.c / cmd_exec.h providing cmd_exec_handle() — a tolerant
cJSON-based dispatcher for structured ESP-NOW CMD frames per the D5
wire contract ({"op":"<verb>","a":{<args>}}, ≤200 bytes).

Supported ops (all logged):
  screen  — renders title/subtitle/symbol on an LVGL full-screen
             overlay via bsp_display_lock; "pulse"/"flash" effect
             tints the background blue.
  play    — logs the path + loop flag, emits an 880 Hz beep via
             audio_play_tone() (no media_manager on box3_voice).
  evt     — logs event name (card→master direction, unusual here).
  led     — logs pattern (stub, no LED ring on box3_voice).
  unknown — ESP_LOGW + ESP_ERR_NOT_SUPPORTED, no crash.

Wire-up in main.c:
  on_mesh_text() callback installed via scenario_mesh_set_text_cb()
  immediately after scenario_mesh_init(). Every SCENARIO_MESH_TEXT_CMD
  frame is decoded and dispatched; EVT frames are logged and ignored.
  cmd_exec.c added to CMakeLists.txt SRCS.

Channel fix in sdkconfig.defaults:
  Document that CONFIG_ZACUS_WIFI_CHANNEL must match the master's
  connected WiFi channel (lab: ch11) for ESP-NOW co-channel operation.

Tested on hardware (BOX-3 MAC 90:e5:b1:cc:05:f8, /dev/cu.usbmodem11301):
  - Build: 2104/2104 targets, 0 errors, 0 warnings on new files.
  - Flash: idf.py -p /dev/cu.usbmodem11301 flash — Done.
  - Serial proof:
      I (10115) zacus-voice: ESP-NOW CMD executor registered (op: screen/play/evt/led)
      I (10115) cmd_exec: CMD op=screen t="ZACUS" u="" y="LA" e="pulse"
      I (13327) cmd_exec: CMD op=play path="/spiffs/ambiance.wav" loop=0 (beep fallback)
      W (16497) cmd_exec: CMD op="explode" unknown — ignored
    All three via POST /game/espnow/cmd on master ({"ok":true,"status":"ESP_OK"}).
This commit is contained in:
2026-06-14 11:20:45 +02:00
parent 7f52d3d759
commit 735aaed648
5 changed files with 262 additions and 1 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
idf_component_register(
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c"
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
+190
View File
@@ -0,0 +1,190 @@
// cmd_exec.c — BOX-3 executor for structured ESP-NOW CMD frames (D5 contract).
// See cmd_exec.h for the wire format spec.
#include "cmd_exec.h"
#include <string.h>
#include "bsp/esp-bsp.h"
#include "cJSON.h"
#include "esp_log.h"
#include "lvgl.h"
// audio_play_tone is defined in main.c and declared here to avoid a circular
// header dependency. It plays a sine tone on the speaker (blocking).
extern void audio_play_tone(float frequency, int duration_ms);
#define TAG "cmd_exec"
// ─── screen overlay ──────────────────────────────────────────────────────────
//
// We maintain a single full-screen overlay that appears on `screen` CMDs.
// It is built lazily on first use so the display lock is only taken when a CMD
// actually arrives.
#define SCREEN_LOCK_MS 1000
static lv_obj_t *s_overlay = NULL;
static lv_obj_t *s_title_lbl = NULL;
static lv_obj_t *s_sub_lbl = NULL;
static lv_obj_t *s_sym_lbl = NULL;
static void build_overlay_locked(void)
{
if (s_overlay) return; // already built
lv_obj_t *scr = lv_screen_active();
// Semi-transparent dark panel over whatever is already on screen.
s_overlay = lv_obj_create(scr);
lv_obj_set_size(s_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(0x0a0a1a), 0);
lv_obj_set_style_bg_opa(s_overlay, LV_OPA_90, 0);
lv_obj_set_style_border_width(s_overlay, 0, 0);
lv_obj_set_style_pad_all(s_overlay, 12, 0);
lv_obj_set_flex_flow(s_overlay, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(s_overlay, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
s_sym_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_sym_lbl, "");
lv_obj_set_style_text_font(s_sym_lbl, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(s_sym_lbl, lv_palette_main(LV_PALETTE_CYAN), 0);
lv_obj_set_style_pad_bottom(s_sym_lbl, 8, 0);
s_title_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_title_lbl, "");
lv_obj_set_style_text_font(s_title_lbl, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(s_title_lbl, lv_color_white(), 0);
lv_obj_set_style_pad_bottom(s_title_lbl, 6, 0);
lv_label_set_long_mode(s_title_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_title_lbl, LV_PCT(90));
lv_obj_set_style_text_align(s_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
s_sub_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_sub_lbl, "");
lv_obj_set_style_text_font(s_sub_lbl, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(s_sub_lbl, lv_palette_lighten(LV_PALETTE_GREY, 1), 0);
lv_label_set_long_mode(s_sub_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_sub_lbl, LV_PCT(90));
lv_obj_set_style_text_align(s_sub_lbl, LV_TEXT_ALIGN_CENTER, 0);
}
// op=screen: {"t":"title","u":"subtitle","y":"symbol","e":"effect"}
// All keys are optional — present = set, absent = leave empty.
static esp_err_t exec_screen(const cJSON *a)
{
const cJSON *title = a ? cJSON_GetObjectItemCaseSensitive(a, "t") : NULL;
const cJSON *sub = a ? cJSON_GetObjectItemCaseSensitive(a, "u") : NULL;
const cJSON *sym = a ? cJSON_GetObjectItemCaseSensitive(a, "y") : NULL;
const cJSON *eff = a ? cJSON_GetObjectItemCaseSensitive(a, "e") : NULL;
const char *t_str = cJSON_IsString(title) ? title->valuestring : "";
const char *u_str = cJSON_IsString(sub) ? sub->valuestring : "";
const char *y_str = cJSON_IsString(sym) ? sym->valuestring : "";
const char *e_str = cJSON_IsString(eff) ? eff->valuestring : "";
ESP_LOGI(TAG, "CMD op=screen t=\"%s\" u=\"%s\" y=\"%s\" e=\"%s\"",
t_str, u_str, y_str, e_str);
if (!bsp_display_lock(SCREEN_LOCK_MS)) {
ESP_LOGW(TAG, "screen: display lock timeout");
return ESP_FAIL;
}
build_overlay_locked();
lv_label_set_text(s_title_lbl, t_str);
lv_label_set_text(s_sub_lbl, u_str);
lv_label_set_text(s_sym_lbl, y_str);
// Bring overlay to top so it is visible over plip_ui widgets.
lv_obj_move_foreground(s_overlay);
// Simple pulse effect: briefly tint the background for "pulse"/"flash".
if (strcmp(e_str, "pulse") == 0 || strcmp(e_str, "flash") == 0) {
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(0x1a1a3a), 0);
} else {
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(0x0a0a1a), 0);
}
bsp_display_unlock();
return ESP_OK;
}
// op=play: {"p":"<path>","l":0|1}
// Real file playback is not available on BOX-3 (no media_manager in box3_voice).
// We emit a 440 Hz beep as an audible acknowledgment and log the path.
static esp_err_t exec_play(const cJSON *a)
{
const cJSON *path_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
const cJSON *loop_j = a ? cJSON_GetObjectItemCaseSensitive(a, "l") : NULL;
const char *path = cJSON_IsString(path_j) ? path_j->valuestring : "<none>";
int loop = cJSON_IsNumber(loop_j) ? (int) loop_j->valuedouble : 0;
ESP_LOGI(TAG, "CMD op=play path=\"%s\" loop=%d (beep fallback — no media_manager on box3_voice)",
path, loop);
// Acknowledgment beep: short 880 Hz tone (distinct from the 440 Hz boot tone).
audio_play_tone(880.0f, 200);
return ESP_OK;
}
// op=evt: {"n":"<name>"} — card→master direction; when arriving at the BOX-3
// this is unusual (it means the master echoed an evt back). Log and ignore.
static esp_err_t exec_evt(const cJSON *a)
{
const cJSON *name_j = a ? cJSON_GetObjectItemCaseSensitive(a, "n") : NULL;
const char *name = cJSON_IsString(name_j) ? name_j->valuestring : "<none>";
ESP_LOGI(TAG, "CMD op=evt n=\"%s\" (evt received on BOX-3 — logging only)", name);
return ESP_OK;
}
// op=led: {"p":"<pattern>"} — stub on BOX-3 (no dedicated LED ring in box3_voice).
static esp_err_t exec_led(const cJSON *a)
{
const cJSON *pat_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
const char *pat = cJSON_IsString(pat_j) ? pat_j->valuestring : "<none>";
ESP_LOGI(TAG, "CMD op=led pattern=\"%s\" (stub — no LED driver in box3_voice)", pat);
return ESP_OK;
}
// ─── public entry point ──────────────────────────────────────────────────────
esp_err_t cmd_exec_handle(const char *data, size_t len)
{
if (!data || len == 0) return ESP_ERR_INVALID_ARG;
cJSON *root = cJSON_ParseWithLength(data, len);
if (!root) {
ESP_LOGW(TAG, "CMD parse error: not valid JSON (%.40s...)", data);
return ESP_ERR_INVALID_ARG;
}
const cJSON *op_j = cJSON_GetObjectItemCaseSensitive(root, "op");
if (!cJSON_IsString(op_j) || !op_j->valuestring || !op_j->valuestring[0]) {
ESP_LOGW(TAG, "CMD missing or invalid 'op' field");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
const char *op = op_j->valuestring;
const cJSON *a = cJSON_GetObjectItemCaseSensitive(root, "a"); // args, may be NULL
esp_err_t err;
if (strcmp(op, "screen") == 0) {
err = exec_screen(a);
} else if (strcmp(op, "play") == 0) {
err = exec_play(a);
} else if (strcmp(op, "evt") == 0) {
err = exec_evt(a);
} else if (strcmp(op, "led") == 0) {
err = exec_led(a);
} else {
ESP_LOGW(TAG, "CMD op=\"%s\" unknown — ignored", op);
err = ESP_ERR_NOT_SUPPORTED;
}
cJSON_Delete(root);
return err;
}
+33
View File
@@ -0,0 +1,33 @@
// cmd_exec.h — BOX-3 executor for structured ESP-NOW CMD frames (D5 contract).
//
// Wire format (SCENARIO_MESH_TEXT_CMD, ≤200 bytes):
// {"op":"<verb>","a":{<args>}}
//
// Supported ops:
// screen {"t":"title","u":"subtitle","y":"symbol","e":"effect"} — display on LCD
// play {"p":"<path>","l":0|1} — play media; falls back to beep + log if no file
// evt {"n":"<name>"} — log the event (future: relay to master)
// led {"p":"<pattern>"} — log the pattern (stub)
// <any> unknown op — log warn, ignore
//
// Tolerant by design: malformed JSON, missing keys, or unknown ops log a warning
// and return without crashing. Never asserts on network input.
#pragma once
#include <stddef.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// Parse and execute one CMD frame. `data` is the raw UTF-8 payload received via
// scenario_mesh text callback (NUL-terminated). Returns ESP_OK if the op was
// recognised and dispatched (even as a stub), ESP_ERR_INVALID_ARG on bad JSON
// or missing `op`, ESP_ERR_NOT_SUPPORTED for unknown ops (all logged, no crash).
esp_err_t cmd_exec_handle(const char *data, size_t len);
#ifdef __cplusplus
}
#endif
+32
View File
@@ -30,6 +30,7 @@
#include "plip_ui.h"
#include "stimulus.h"
#include "scenario_mesh.h"
#include "cmd_exec.h"
/* BSP header — provided by espressif/esp-box component */
#include "bsp/esp-bsp.h"
@@ -372,6 +373,28 @@ static void button_task(void *arg)
}
}
/* --------------- ESP-NOW CMD text callback --------------- */
// Called from the scenario_mesh text worker task for every CMD/EVT frame
// received via ESP-NOW. On the BOX-3 we only care about CMD (master→us).
static void on_mesh_text(uint8_t kind, const uint8_t src_mac[6], const char *text)
{
if (kind == SCENARIO_MESH_TEXT_CMD) {
ESP_LOGI(TAG, "ESP-NOW CMD from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
src_mac[0], src_mac[1], src_mac[2],
src_mac[3], src_mac[4], src_mac[5], text);
esp_err_t err = cmd_exec_handle(text, strlen(text));
if (err != ESP_OK && err != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(TAG, "cmd_exec_handle: %s", esp_err_to_name(err));
}
} else {
// EVT arriving at the BOX-3 (unusual — master echoing back).
ESP_LOGI(TAG, "ESP-NOW EVT from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
src_mac[0], src_mac[1], src_mac[2],
src_mac[3], src_mac[4], src_mac[5], text);
}
}
/* --------------- Application entry point --------------- */
void app_main(void)
@@ -450,6 +473,15 @@ void app_main(void)
esp_err_to_name(mesh_err));
} else {
ESP_LOGI(TAG, "ESP-NOW scenario receiver active");
/* Register CMD text callback so the master can drive screen / audio / leds
* via POST /game/espnow/cmd {peer, command:<json>} (D5 contract). */
esp_err_t tcb_err = scenario_mesh_set_text_cb(on_mesh_text);
if (tcb_err != ESP_OK) {
ESP_LOGW(TAG, "scenario_mesh_set_text_cb: %s — CMD executor unavailable",
esp_err_to_name(tcb_err));
} else {
ESP_LOGI(TAG, "ESP-NOW CMD executor registered (op: screen/play/evt/led)");
}
}
/* TODO: Initialize ESP-SR WakeNet for wake-word detection
+6
View File
@@ -51,6 +51,12 @@ CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768
# CONFIG_ZACUS_WIFI_SSID="..."
# CONFIG_ZACUS_WIFI_PASSWORD="..."
# WiFi channel: must match the master ESP32's connected channel for ESP-NOW
# co-channel operation. The master (idf_zacus) connects to channel 11 on the
# lab multi-AP "Les cils" network. Set to 0 to scan all channels and connect
# to whichever AP responds first (may differ from master → ESP-NOW will break).
# CONFIG_ZACUS_WIFI_CHANNEL=11
# esp-box BSP uses the legacy i2c driver; esp_codec_dev uses driver_ng.
# Force-enable legacy mode so both coexist (and prevent the runtime abort
# in bsp_display_start when both drivers race to register on the same bus).