diff --git a/box3_voice/main/CMakeLists.txt b/box3_voice/main/CMakeLists.txt index 68e4582..714a878 100644 --- a/box3_voice/main/CMakeLists.txt +++ b/box3_voice/main/CMakeLists.txt @@ -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 diff --git a/box3_voice/main/cmd_exec.c b/box3_voice/main/cmd_exec.c new file mode 100644 index 0000000..79b3539 --- /dev/null +++ b/box3_voice/main/cmd_exec.c @@ -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 + +#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":"","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 : ""; + 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":""} — 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 : ""; + ESP_LOGI(TAG, "CMD op=evt n=\"%s\" (evt received on BOX-3 — logging only)", name); + return ESP_OK; +} + +// op=led: {"p":""} — 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 : ""; + 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; +} diff --git a/box3_voice/main/cmd_exec.h b/box3_voice/main/cmd_exec.h new file mode 100644 index 0000000..3d22089 --- /dev/null +++ b/box3_voice/main/cmd_exec.h @@ -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":"","a":{}} +// +// Supported ops: +// screen {"t":"title","u":"subtitle","y":"symbol","e":"effect"} — display on LCD +// play {"p":"","l":0|1} — play media; falls back to beep + log if no file +// evt {"n":""} — log the event (future: relay to master) +// led {"p":""} — log the pattern (stub) +// 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 +#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 diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c index c66d713..99493c0 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -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:} (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 diff --git a/box3_voice/sdkconfig.defaults b/box3_voice/sdkconfig.defaults index b096919..bfabc39 100644 --- a/box3_voice/sdkconfig.defaults +++ b/box3_voice/sdkconfig.defaults @@ -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).