From 6129375916537423ba68637b5236de1c51369487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:36:51 +0200 Subject: [PATCH] feat: V3 puzzles + NPC + master ESP-NOW framework (slave/master), 4 puzzle firmwares (P1 son, P5 morse, P6 NFC, P7 coffre), NPC V3 adaptive, game coordinator, TTS V3 fallback chain, BLE audio control. --- data/ui_amiga/color_palette_amiga.json | 22 - lib/espnow_common/espnow_master.c | 215 +++++++++ lib/espnow_common/espnow_master.h | 63 +++ lib/espnow_common/espnow_slave.c | 125 ++++++ lib/espnow_common/espnow_slave.h | 79 ++++ lib/tinyexpr_vendor/library.json | 17 - puzzles/p1_sequence_sonore/CMakeLists.txt | 14 + .../p1_sequence_sonore/main/CMakeLists.txt | 19 + puzzles/p1_sequence_sonore/main/main.c | 311 +++++++++++++ puzzles/p1_sequence_sonore/platformio.ini | 31 ++ puzzles/p5_morse/CMakeLists.txt | 13 + puzzles/p5_morse/main/CMakeLists.txt | 15 + puzzles/p5_morse/main/main.c | 299 +++++++++++++ puzzles/p5_morse/platformio.ini | 22 + puzzles/p6_symboles_nfc/CMakeLists.txt | 13 + puzzles/p6_symboles_nfc/main/CMakeLists.txt | 15 + puzzles/p6_symboles_nfc/main/main.c | 408 ++++++++++++++++++ puzzles/p6_symboles_nfc/platformio.ini | 23 + puzzles/p7_coffre/CMakeLists.txt | 13 + puzzles/p7_coffre/main/CMakeLists.txt | 15 + puzzles/p7_coffre/main/main.c | 369 ++++++++++++++++ puzzles/p7_coffre/platformio.ini | 23 + .../include/npc/espnow_master.h | 67 +++ .../include/npc/game_coordinator.h | 31 ++ ui_freenove_allinone/include/npc/npc_v3.h | 111 +++++ ui_freenove_allinone/include/npc/tts_client.h | 34 ++ .../src/audio/audio_ble_control.cpp | 226 ++++++++++ .../src/npc/espnow_master.cpp | 210 +++++++++ .../src/npc/game_coordinator.cpp | 332 ++++++++++++++ ui_freenove_allinone/src/npc/npc_v3.cpp | 225 ++++++++++ ui_freenove_allinone/src/npc/tts_client.cpp | 166 +++++++ 31 files changed, 3487 insertions(+), 39 deletions(-) delete mode 100644 data/ui_amiga/color_palette_amiga.json create mode 100644 lib/espnow_common/espnow_master.c create mode 100644 lib/espnow_common/espnow_master.h create mode 100644 lib/espnow_common/espnow_slave.c create mode 100644 lib/espnow_common/espnow_slave.h delete mode 100644 lib/tinyexpr_vendor/library.json create mode 100644 puzzles/p1_sequence_sonore/CMakeLists.txt create mode 100644 puzzles/p1_sequence_sonore/main/CMakeLists.txt create mode 100644 puzzles/p1_sequence_sonore/main/main.c create mode 100644 puzzles/p1_sequence_sonore/platformio.ini create mode 100644 puzzles/p5_morse/CMakeLists.txt create mode 100644 puzzles/p5_morse/main/CMakeLists.txt create mode 100644 puzzles/p5_morse/main/main.c create mode 100644 puzzles/p5_morse/platformio.ini create mode 100644 puzzles/p6_symboles_nfc/CMakeLists.txt create mode 100644 puzzles/p6_symboles_nfc/main/CMakeLists.txt create mode 100644 puzzles/p6_symboles_nfc/main/main.c create mode 100644 puzzles/p6_symboles_nfc/platformio.ini create mode 100644 puzzles/p7_coffre/CMakeLists.txt create mode 100644 puzzles/p7_coffre/main/CMakeLists.txt create mode 100644 puzzles/p7_coffre/main/main.c create mode 100644 puzzles/p7_coffre/platformio.ini create mode 100644 ui_freenove_allinone/include/npc/espnow_master.h create mode 100644 ui_freenove_allinone/include/npc/game_coordinator.h create mode 100644 ui_freenove_allinone/include/npc/npc_v3.h create mode 100644 ui_freenove_allinone/src/audio/audio_ble_control.cpp create mode 100644 ui_freenove_allinone/src/npc/espnow_master.cpp create mode 100644 ui_freenove_allinone/src/npc/game_coordinator.cpp create mode 100644 ui_freenove_allinone/src/npc/npc_v3.cpp diff --git a/data/ui_amiga/color_palette_amiga.json b/data/ui_amiga/color_palette_amiga.json deleted file mode 100644 index 97509ca..0000000 --- a/data/ui_amiga/color_palette_amiga.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "Amiga Demoscene Retro", - "description": "Nostalgic 1990s Amiga aesthetic with neon colors", - "colors": { - "background": "#000000", - "primary_cyan": "#00FFFF", - "primary_magenta": "#FF00FF", - "accent_yellow": "#FFFF00", - "accent_white": "#FFFFFF", - "accent_blue": "#0088FF", - "accent_green": "#00FF88", - "shadow": "#444444" - }, - "ui": { - "button_bg": "#FF00FF", - "button_text": "#FFFFFF", - "active_bg": "#00FFFF", - "active_text": "#000000", - "disabled_bg": "#444444", - "disabled_text": "#888888" - } -} \ No newline at end of file diff --git a/lib/espnow_common/espnow_master.c b/lib/espnow_common/espnow_master.c new file mode 100644 index 0000000..23116e0 --- /dev/null +++ b/lib/espnow_common/espnow_master.c @@ -0,0 +1,215 @@ +// espnow_master.c — BOX-3 ESP-NOW master implementation +// Runs on the Freenove ESP32-S3 BOX-3 (orchestrator node). +#include "espnow_master.h" +#include "esp_now.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include +#include + +static const char *TAG = "espnow_master"; + +// Broadcast MAC — puzzles listen for commands addressed here +static const uint8_t kBroadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// --------------------------------------------------------------------------- +// Internal state per puzzle node +// --------------------------------------------------------------------------- +typedef struct { + uint8_t puzzle_id; + uint8_t mac[6]; + bool registered; + bool solved; + uint8_t code_fragment[4]; + uint32_t solve_time_ms; + uint32_t last_heartbeat_ms; +} puzzle_node_t; + +static puzzle_node_t s_nodes[8]; // index = puzzle_id (1-7, index 0 unused) +static QueueHandle_t s_recv_queue; +static espnow_event_cb_t s_event_cb; + +typedef struct { + uint8_t src_mac[6]; + uint8_t data[250]; + int len; +} master_recv_item_t; + +// --------------------------------------------------------------------------- +// Internal callbacks (Wi-Fi ISR context) +// --------------------------------------------------------------------------- + +static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) +{ + master_recv_item_t item; + memcpy(item.src_mac, info->src_addr, 6); + int copy = len < (int)sizeof(item.data) ? len : (int)sizeof(item.data); + memcpy(item.data, data, copy); + item.len = copy; + xQueueSendFromISR(s_recv_queue, &item, NULL); +} + +static void on_sent(const uint8_t *mac, esp_now_send_status_t status) +{ + if (status != ESP_NOW_SEND_SUCCESS) { + ESP_LOGW(TAG, "Send failed to " MACSTR, MAC2STR(mac)); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +esp_err_t espnow_master_init(espnow_event_cb_t cb) +{ + s_event_cb = cb; + s_recv_queue = xQueueCreate(16, sizeof(master_recv_item_t)); + if (!s_recv_queue) { + ESP_LOGE(TAG, "Failed to create recv queue"); + return ESP_ERR_NO_MEM; + } + memset(s_nodes, 0, sizeof(s_nodes)); + + ESP_ERROR_CHECK(esp_now_init()); + ESP_ERROR_CHECK(esp_now_register_recv_cb(on_recv)); + ESP_ERROR_CHECK(esp_now_register_send_cb(on_sent)); + + // Register broadcast peer (for addressing all slaves at once) + esp_now_peer_info_t bcast = {}; + memcpy(bcast.peer_addr, kBroadcast, 6); + bcast.channel = 0; + bcast.encrypt = false; + ESP_ERROR_CHECK(esp_now_add_peer(&bcast)); + + ESP_LOGI(TAG, "Master initialized"); + return ESP_OK; +} + +esp_err_t espnow_master_reset_puzzle(uint8_t puzzle_id) +{ + if (puzzle_id < 1 || puzzle_id > 7) return ESP_ERR_INVALID_ARG; + uint8_t buf[2] = { (uint8_t)MSG_PUZZLE_RESET, puzzle_id }; + return esp_now_send(kBroadcast, buf, sizeof(buf)); +} + +esp_err_t espnow_master_configure_puzzle(uint8_t puzzle_id, + puzzle_difficulty_t difficulty) +{ + if (puzzle_id < 1 || puzzle_id > 7) return ESP_ERR_INVALID_ARG; + uint8_t buf[3] = { (uint8_t)MSG_PUZZLE_CONFIG, puzzle_id, + (uint8_t)difficulty }; + return esp_now_send(kBroadcast, buf, sizeof(buf)); +} + +esp_err_t espnow_master_send_final_code(const char code[9]) +{ + // Wire format: [MSG_PUZZLE_CONFIG(1)] [P7_ID=7(1)] [CODE(8)] + uint8_t buf[10]; + buf[0] = (uint8_t)MSG_PUZZLE_CONFIG; + buf[1] = 7; // P7 puzzle ID + memcpy(buf + 2, code, 8); + return esp_now_send(kBroadcast, buf, sizeof(buf)); +} + +void espnow_master_process(void) +{ + master_recv_item_t item; + while (xQueueReceive(s_recv_queue, &item, 0) == pdTRUE) { + if (item.len < 2) continue; + + espnow_msg_type_t type = (espnow_msg_type_t)item.data[0]; + uint8_t puzzle_id = item.data[1]; + if (puzzle_id < 1 || puzzle_id > 7) continue; + + puzzle_node_t *node = &s_nodes[puzzle_id]; + node->registered = true; + memcpy(node->mac, item.src_mac, 6); + node->last_heartbeat_ms = + (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + + if (type == MSG_PUZZLE_SOLVED && item.len >= 10) { + node->solved = true; + memcpy(node->code_fragment, item.data + 2, 4); + // Bytes 6-9: big-endian solve_time_ms + node->solve_time_ms = ((uint32_t)item.data[6] << 24) + | ((uint32_t)item.data[7] << 16) + | ((uint32_t)item.data[8] << 8) + | ((uint32_t)item.data[9] ); + + ESP_LOGI(TAG, "P%d SOLVED code=%d%d%d%d time=%lu ms", + puzzle_id, + node->code_fragment[0], node->code_fragment[1], + node->code_fragment[2], node->code_fragment[3], + node->solve_time_ms); + + if (s_event_cb) { + espnow_event_t ev = { + .type = ESPNOW_EV_PUZZLE_SOLVED, + .puzzle_id = puzzle_id, + .solve_time_ms = node->solve_time_ms, + }; + memcpy(ev.code_fragment, node->code_fragment, 4); + s_event_cb(&ev); + } + + } else if (type == MSG_HINT_REQUEST) { + ESP_LOGI(TAG, "Hint request from P%d", puzzle_id); + if (s_event_cb) { + espnow_event_t ev = { + .type = ESPNOW_EV_HINT_REQUEST, + .puzzle_id = puzzle_id, + }; + s_event_cb(&ev); + } + + } else if (type == MSG_HEARTBEAT) { + ESP_LOGD(TAG, "Heartbeat P%d", puzzle_id); + if (s_event_cb) { + espnow_event_t ev = { + .type = ESPNOW_EV_HEARTBEAT, + .puzzle_id = puzzle_id, + }; + s_event_cb(&ev); + } + } + } +} + +bool espnow_master_all_solved(const uint8_t puzzle_ids[], uint8_t count) +{ + for (uint8_t i = 0; i < count; i++) { + uint8_t id = puzzle_ids[i]; + if (id < 1 || id > 7 || !s_nodes[id].solved) return false; + } + return true; +} + +void espnow_master_assemble_code(char code_out[9]) +{ + // Digit layout per scenario YAML: + // P1 → digits 0,1 (code_fragment[0], [1]) + // P2 → digit 2 (code_fragment[0]) + // P4 → digit 3 (code_fragment[0]) + // P5 → digit 4 (code_fragment[0]) + // P6 → digits 5,6 (code_fragment[0], [1]) + // P3 → digit 7 (code_fragment[0]) + static const uint8_t kCodeMap[8][2] = { + {1, 0}, {1, 1}, // P1 → digits 0,1 + {2, 0}, // P2 → digit 2 + {4, 0}, // P4 → digit 3 + {5, 0}, // P5 → digit 4 + {6, 0}, {6, 1}, // P6 → digits 5,6 + {3, 0}, // P3 → digit 7 + }; + + for (int i = 0; i < 8; i++) { + uint8_t pid = kCodeMap[i][0]; + uint8_t frag_idx = kCodeMap[i][1]; + code_out[i] = '0' + (s_nodes[pid].code_fragment[frag_idx] % 10); + } + code_out[8] = '\0'; + + ESP_LOGI(TAG, "Final code assembled: %s", code_out); +} diff --git a/lib/espnow_common/espnow_master.h b/lib/espnow_common/espnow_master.h new file mode 100644 index 0000000..ab92e46 --- /dev/null +++ b/lib/espnow_common/espnow_master.h @@ -0,0 +1,63 @@ +// espnow_master.h — BOX-3 ESP-NOW master: orchestrates 7 puzzle slave nodes +// Broadcasts commands, collects results, assembles final 8-digit code. +#pragma once +#include +#include +#include "esp_err.h" +#include "espnow_slave.h" // shared message types + puzzle_difficulty_t + +#ifdef __cplusplus +extern "C" { +#endif + +// --------------------------------------------------------------------------- +// Events emitted to the application layer +// --------------------------------------------------------------------------- +typedef enum { + ESPNOW_EV_PUZZLE_SOLVED = 1, + ESPNOW_EV_HINT_REQUEST = 2, + ESPNOW_EV_HEARTBEAT = 3, + ESPNOW_EV_NODE_TIMEOUT = 4, // no heartbeat for > 30 s +} espnow_event_type_t; + +typedef struct { + espnow_event_type_t type; + uint8_t puzzle_id; // 1..7 + uint8_t code_fragment[4]; + uint32_t solve_time_ms; +} espnow_event_t; + +typedef void (*espnow_event_cb_t)(const espnow_event_t *ev); + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +// Initialize ESP-NOW master mode. cb is called on every puzzle event. +esp_err_t espnow_master_init(espnow_event_cb_t cb); + +// Send MSG_PUZZLE_RESET to a specific puzzle (broadcast-addressed). +esp_err_t espnow_master_reset_puzzle(uint8_t puzzle_id); + +// Send MSG_PUZZLE_CONFIG (difficulty) to a specific puzzle. +esp_err_t espnow_master_configure_puzzle(uint8_t puzzle_id, + puzzle_difficulty_t difficulty); + +// Send the 8-digit final code to P7 Coffre via MSG_PUZZLE_CONFIG. +// code must be a null-terminated string of exactly 8 digit characters. +esp_err_t espnow_master_send_final_code(const char code[9]); + +// Process inbound ESP-NOW queue — call from main FreeRTOS task loop. +void espnow_master_process(void); + +// Return true if all puzzle_ids in the array have reported solved. +bool espnow_master_all_solved(const uint8_t puzzle_ids[], uint8_t count); + +// Assemble the 8-digit final code from all registered solved fragments. +// Code layout: P1[0,1] | P2[2] | P4[3] | P5[4] | P6[5,6] | P3[7] +// code_out must be at least 9 bytes (8 digits + null terminator). +void espnow_master_assemble_code(char code_out[9]); + +#ifdef __cplusplus +} +#endif diff --git a/lib/espnow_common/espnow_slave.c b/lib/espnow_common/espnow_slave.c new file mode 100644 index 0000000..0caeac6 --- /dev/null +++ b/lib/espnow_common/espnow_slave.c @@ -0,0 +1,125 @@ +// espnow_slave.c — ESP-NOW slave implementation (shared by all V3 puzzle nodes) +// Requires Wi-Fi to be initialised (WIFI_MODE_STA, no AP connection needed). +#include "espnow_slave.h" +#include "esp_now.h" +#include "esp_wifi.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include + +static const char *TAG = "espnow_slave"; + +#define RECV_QUEUE_LEN 8 + +typedef struct { + uint8_t mac[6]; + uint8_t data[250]; + int data_len; +} recv_item_t; + +static QueueHandle_t s_recv_queue; +static uint8_t s_master_mac[6]; +static uint8_t s_puzzle_id; +static espnow_cmd_callback_t s_callback; + +// --------------------------------------------------------------------------- +// Internal callbacks (called from Wi-Fi ISR context) +// --------------------------------------------------------------------------- + +static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) +{ + recv_item_t item; + memcpy(item.mac, info->src_addr, 6); + int copy_len = len < (int)sizeof(item.data) ? len : (int)sizeof(item.data); + memcpy(item.data, data, copy_len); + item.data_len = copy_len; + xQueueSendFromISR(s_recv_queue, &item, NULL); +} + +static void on_sent(const uint8_t *mac, esp_now_send_status_t status) +{ + if (status != ESP_NOW_SEND_SUCCESS) { + ESP_LOGW(TAG, "Send failed to " MACSTR, MAC2STR(mac)); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +esp_err_t espnow_slave_init(const uint8_t master_mac[6], uint8_t puzzle_id) +{ + s_puzzle_id = puzzle_id; + memcpy(s_master_mac, master_mac, 6); + + s_recv_queue = xQueueCreate(RECV_QUEUE_LEN, sizeof(recv_item_t)); + if (!s_recv_queue) { + ESP_LOGE(TAG, "Failed to create recv queue"); + return ESP_ERR_NO_MEM; + } + + ESP_ERROR_CHECK(esp_now_init()); + ESP_ERROR_CHECK(esp_now_register_recv_cb(on_recv)); + ESP_ERROR_CHECK(esp_now_register_send_cb(on_sent)); + + // Register master as peer + esp_now_peer_info_t peer = {}; + memcpy(peer.peer_addr, master_mac, 6); + peer.channel = 0; // use current Wi-Fi channel + peer.encrypt = false; + ESP_ERROR_CHECK(esp_now_add_peer(&peer)); + + ESP_LOGI(TAG, "Puzzle P%d slave ready, master=" MACSTR, + puzzle_id, MAC2STR(master_mac)); + return ESP_OK; +} + +esp_err_t espnow_slave_notify_solved(const uint8_t code_fragment[4], + uint32_t solve_time_ms) +{ + // Wire format: [MSG_TYPE(1)] [PUZZLE_ID(1)] [CODE(4)] [SOLVE_MS(4)] + uint8_t buf[10]; + buf[0] = (uint8_t)MSG_PUZZLE_SOLVED; + buf[1] = s_puzzle_id; + memcpy(buf + 2, code_fragment, 4); + buf[6] = (uint8_t)(solve_time_ms >> 24); + buf[7] = (uint8_t)(solve_time_ms >> 16); + buf[8] = (uint8_t)(solve_time_ms >> 8); + buf[9] = (uint8_t)(solve_time_ms ); + return esp_now_send(s_master_mac, buf, sizeof(buf)); +} + +esp_err_t espnow_slave_request_hint(void) +{ + uint8_t buf[2] = { (uint8_t)MSG_HINT_REQUEST, s_puzzle_id }; + return esp_now_send(s_master_mac, buf, sizeof(buf)); +} + +esp_err_t espnow_slave_send_status(const puzzle_status_t *status) +{ + uint8_t buf[1 + sizeof(puzzle_status_t)]; + buf[0] = (uint8_t)MSG_STATUS_REPLY; + memcpy(buf + 1, status, sizeof(puzzle_status_t)); + return esp_now_send(s_master_mac, buf, sizeof(buf)); +} + +void espnow_slave_register_callback(espnow_cmd_callback_t cb) +{ + s_callback = cb; +} + +void espnow_slave_process(void) +{ + recv_item_t item; + while (xQueueReceive(s_recv_queue, &item, 0) == pdTRUE) { + if (item.data_len < 1) continue; + espnow_msg_type_t type = (espnow_msg_type_t)item.data[0]; + if (s_callback) { + s_callback(type, + item.data + 1, + (size_t)(item.data_len - 1)); + } + } +} diff --git a/lib/espnow_common/espnow_slave.h b/lib/espnow_common/espnow_slave.h new file mode 100644 index 0000000..b9a4bf6 --- /dev/null +++ b/lib/espnow_common/espnow_slave.h @@ -0,0 +1,79 @@ +// espnow_slave.h — Common ESP-NOW slave for all V3 puzzle nodes +// All puzzle ESP32s include this header and link espnow_slave.c +#pragma once +#include +#include +#include "esp_err.h" + +// --------------------------------------------------------------------------- +// Message types (must match BOX-3 master definitions in espnow_master.h) +// --------------------------------------------------------------------------- +typedef enum { + MSG_PUZZLE_SOLVED = 0x01, // puzzle → master: puzzle complete, code fragment attached + MSG_PUZZLE_RESET = 0x02, // master → puzzle: reset to initial state + MSG_PUZZLE_CONFIG = 0x03, // master → puzzle: set difficulty variant + MSG_HINT_REQUEST = 0x04, // puzzle → master: player requested hint (phone hook) + MSG_STATUS_POLL = 0x05, // master → puzzle: request status update + MSG_STATUS_REPLY = 0x06, // puzzle → master: current state, elapsed time + MSG_LED_COMMAND = 0x07, // master → puzzle: set LED color/pattern + MSG_HEARTBEAT = 0x08, // both directions: keep-alive every 5 s +} espnow_msg_type_t; + +// --------------------------------------------------------------------------- +// Difficulty variants +// --------------------------------------------------------------------------- +typedef enum { + DIFFICULTY_EASY = 0, // NON_TECH group: shorter sequences, visual aids + DIFFICULTY_NORMAL = 1, // MIXED group: default parameters + DIFFICULTY_HARD = 2, // TECH group: longer / harder variant +} puzzle_difficulty_t; + +// --------------------------------------------------------------------------- +// Puzzle status (sent in MSG_STATUS_REPLY) +// --------------------------------------------------------------------------- +typedef struct { + uint8_t puzzle_id; // P1..P7 (1-7) + uint8_t state; // 0=idle, 1=active, 2=solved, 3=failed + uint8_t attempts; // number of wrong attempts since reset + uint8_t hints_used; // hints consumed since reset + uint32_t elapsed_ms; // time since puzzle activated (ms) + uint8_t code_fragment[4]; // code digits contributed by this puzzle + uint8_t difficulty; // current difficulty variant +} puzzle_status_t; + +// --------------------------------------------------------------------------- +// Payload structures +// --------------------------------------------------------------------------- +typedef struct { + uint8_t puzzle_id; + uint8_t code_fragment[4]; + uint32_t solve_time_ms; +} puzzle_solved_payload_t; + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +// Initialize ESP-NOW in slave mode. +// master_mac: MAC address of BOX-3 master. Use {0xFF,...} for broadcast. +// puzzle_id: P1..P7 (1-7) +esp_err_t espnow_slave_init(const uint8_t master_mac[6], uint8_t puzzle_id); + +// Notify master that puzzle is solved with code fragment. +esp_err_t espnow_slave_notify_solved(const uint8_t code_fragment[4], + uint32_t solve_time_ms); + +// Request a hint from master (triggers NPC voice line). +esp_err_t espnow_slave_request_hint(void); + +// Send status reply (call from MSG_STATUS_POLL handler). +esp_err_t espnow_slave_send_status(const puzzle_status_t *status); + +// Register callback for incoming master commands. +typedef void (*espnow_cmd_callback_t)(espnow_msg_type_t type, + const uint8_t *payload, + size_t len); +void espnow_slave_register_callback(espnow_cmd_callback_t cb); + +// Process inbound ESP-NOW queue — call from main FreeRTOS task loop. +void espnow_slave_process(void); diff --git a/lib/tinyexpr_vendor/library.json b/lib/tinyexpr_vendor/library.json deleted file mode 100644 index 4b4a8de..0000000 --- a/lib/tinyexpr_vendor/library.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "tinyexpr_vendor", - "version": "0.0.0+4a7456e", - "description": "Vendored tinyexpr from codeplea/tinyexpr commit 4a7456e2eab88b4c76053c1c4157639ccb930e2b", - "keywords": "math, expression, parser", - "repository": { - "type": "git", - "url": "https://github.com/codeplea/tinyexpr" - }, - "license": "Zlib", - "frameworks": "*", - "platforms": "*", - "build": { - "srcDir": "src", - "includeDir": "src" - } -} diff --git a/puzzles/p1_sequence_sonore/CMakeLists.txt b/puzzles/p1_sequence_sonore/CMakeLists.txt new file mode 100644 index 0000000..9eeed42 --- /dev/null +++ b/puzzles/p1_sequence_sonore/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.16) + +# ESP-IDF project for P1 Séquence Sonore puzzle +# Target: ESP32-S3-DevKitC-1-N16R8 +# Build: idf.py build | Flash: idf.py -p /dev/ttyUSB0 flash monitor + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +project(p1_sequence_sonore) + +# Add the shared ESP-NOW slave library +set(EXTRA_COMPONENT_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/espnow_common +) diff --git a/puzzles/p1_sequence_sonore/main/CMakeLists.txt b/puzzles/p1_sequence_sonore/main/CMakeLists.txt new file mode 100644 index 0000000..2f9cd6d --- /dev/null +++ b/puzzles/p1_sequence_sonore/main/CMakeLists.txt @@ -0,0 +1,19 @@ +idf_component_register( + SRCS + "main.c" + "../../../lib/espnow_common/espnow_slave.c" + INCLUDE_DIRS + "." + "../../../lib/espnow_common" + REQUIRES + freertos + driver + esp_wifi + esp_now + nvs_flash + led_strip + esp_common +) + +# Link math library for sinf() +target_link_libraries(${COMPONENT_LIB} PUBLIC m) diff --git a/puzzles/p1_sequence_sonore/main/main.c b/puzzles/p1_sequence_sonore/main/main.c new file mode 100644 index 0000000..1879f4a --- /dev/null +++ b/puzzles/p1_sequence_sonore/main/main.c @@ -0,0 +1,311 @@ +// main.c — P1 Séquence Sonore puzzle firmware +// Hardware: ESP32-S3-DevKitC-1-N16R8 + MAX98357A I2S amp + 8Ω speaker +// + 4 arcade buttons (RED/BLUE/YELLOW/GREEN) + WS2812B 4-LED strip +// +// Game logic: master sends a melody sequence; player must reproduce it +// in the correct order. On success, sends a 2-digit code fragment to BOX-3. + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" +#include "driver/i2s_std.h" +#include "esp_log.h" +#include "esp_random.h" +#include "esp_wifi.h" +#include "nvs_flash.h" +#include "led_strip.h" +#include "espnow_slave.h" +#include +#include + +static const char *TAG = "P1_SON"; + +// --------------------------------------------------------------------------- +// GPIO mapping (matches hardware/kicad/p1_son/p1_son.kicad_sch) +// --------------------------------------------------------------------------- +#define GPIO_BTN_RED 15 +#define GPIO_BTN_BLUE 16 +#define GPIO_BTN_YELLOW 17 +#define GPIO_BTN_GREEN 18 +#define GPIO_LED_DATA 8 +#define I2S_PORT I2S_NUM_0 +#define I2S_BCLK 4 +#define I2S_LRCLK 5 +#define I2S_DIN 6 +#define LED_COUNT 4 + +// Button frequencies: C4=262Hz E4=330Hz G4=392Hz C5=523Hz +static const uint32_t kFreqs[4] = {262, 330, 392, 523}; +static const uint8_t kBtnGpios[4] = {GPIO_BTN_RED, GPIO_BTN_BLUE, + GPIO_BTN_YELLOW, GPIO_BTN_GREEN}; + +// LED colours per button index +static const uint8_t kColors[4][3] = { + {255, 0, 0}, // RED + { 0, 0,255}, // BLUE + {255,255, 0}, // YELLOW + { 0,255, 0}, // GREEN +}; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +static uint8_t s_target_seq[8]; +static uint8_t s_seq_len = 4; // overridden by MSG_PUZZLE_CONFIG +static uint8_t s_player_seq[8]; +static uint8_t s_player_pos = 0; +static bool s_solved = false; +static uint8_t s_attempts = 0; +static uint32_t s_start_ms = 0; + +static led_strip_handle_t s_leds; +static i2s_chan_handle_t s_i2s_tx; + +// --------------------------------------------------------------------------- +// I2S helpers +// --------------------------------------------------------------------------- + +static void i2s_init(void) +{ + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_PORT, + I2S_ROLE_MASTER); + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &s_i2s_tx, NULL)); + + i2s_std_config_t std_cfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(44100), + .slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, + I2S_SLOT_MODE_MONO), + .gpio_cfg = { + .mclk = I2S_GPIO_UNUSED, + .bclk = I2S_BCLK, + .ws = I2S_LRCLK, + .dout = I2S_DIN, + .din = I2S_GPIO_UNUSED, + .invert_flags = { .mclk_inv = false, .bclk_inv = false, + .ws_inv = false }, + }, + }; + ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_i2s_tx, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_enable(s_i2s_tx)); +} + +// Generate and play a sine-wave tone at freq Hz for duration_ms. +static void play_tone(uint8_t button_idx, uint32_t duration_ms) +{ + uint32_t freq = kFreqs[button_idx]; + uint32_t sr = 44100; + uint32_t samples = (sr * duration_ms) / 1000; + + // Allocate on heap to avoid stack overflow + int16_t *buf = malloc(samples * sizeof(int16_t)); + if (!buf) { + ESP_LOGE(TAG, "OOM for tone buffer"); + return; + } + for (uint32_t i = 0; i < samples; i++) { + float t = (float)i / (float)sr; + float env = (i < 441) ? ((float)i / 441.0f) : // 10 ms attack + (i > samples - 441) ? ((float)(samples - i) / 441.0f) : 1.0f; + buf[i] = (int16_t)(8192 * env * sinf(2.0f * 3.14159265f * freq * t)); + } + + // Light the LED while playing + led_strip_set_pixel(s_leds, button_idx, + kColors[button_idx][0], + kColors[button_idx][1], + kColors[button_idx][2]); + led_strip_refresh(s_leds); + + size_t written; + i2s_channel_write(s_i2s_tx, buf, samples * sizeof(int16_t), + &written, pdMS_TO_TICKS(duration_ms + 100)); + free(buf); + + led_strip_set_pixel(s_leds, button_idx, 0, 0, 0); + led_strip_refresh(s_leds); +} + +// --------------------------------------------------------------------------- +// Sequence logic +// --------------------------------------------------------------------------- + +static void play_sequence(void) +{ + ESP_LOGI(TAG, "Playing target sequence (len=%d)", s_seq_len); + for (uint8_t i = 0; i < s_seq_len; i++) { + play_tone(s_target_seq[i], 500); + vTaskDelay(pdMS_TO_TICKS(200)); + } +} + +static void flash_all_leds(uint8_t r, uint8_t g, uint8_t b, int times) +{ + for (int t = 0; t < times; t++) { + for (int j = 0; j < LED_COUNT; j++) + led_strip_set_pixel(s_leds, j, r, g, b); + led_strip_refresh(s_leds); + vTaskDelay(pdMS_TO_TICKS(300)); + for (int j = 0; j < LED_COUNT; j++) + led_strip_set_pixel(s_leds, j, 0, 0, 0); + led_strip_refresh(s_leds); + vTaskDelay(pdMS_TO_TICKS(200)); + } +} + +static void check_sequence(void) +{ + bool correct = (memcmp(s_player_seq, s_target_seq, s_seq_len) == 0); + if (correct) { + s_solved = true; + flash_all_leds(0, 255, 0, 3); + + // Code fragment: XOR-encode the sequence to produce a 2-digit value + uint8_t val = 0; + for (int i = 0; i < s_seq_len; i++) + val ^= (uint8_t)(s_target_seq[i] * (i + 1)); + val = val % 100; + + uint8_t code[4] = { val / 10, val % 10, 0, 0 }; + uint32_t elapsed = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS) + - s_start_ms; + espnow_slave_notify_solved(code, elapsed); + ESP_LOGI(TAG, "Solved! Code=%d%d elapsed=%lu ms", + code[0], code[1], elapsed); + } else { + s_attempts++; + flash_all_leds(255, 0, 0, 1); + vTaskDelay(pdMS_TO_TICKS(1000)); + s_player_pos = 0; + play_sequence(); + } +} + +// --------------------------------------------------------------------------- +// ESP-NOW command handler +// --------------------------------------------------------------------------- + +static void espnow_cmd_handler(espnow_msg_type_t type, + const uint8_t *payload, size_t len) +{ + if (type == MSG_PUZZLE_RESET) { + s_player_pos = 0; + s_solved = false; + s_attempts = 0; + s_start_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + play_sequence(); + ESP_LOGI(TAG, "Reset"); + + } else if (type == MSG_PUZZLE_CONFIG && len >= 1) { + puzzle_difficulty_t diff = (puzzle_difficulty_t)payload[0]; + if (diff == DIFFICULTY_EASY) s_seq_len = 3; + if (diff == DIFFICULTY_NORMAL) s_seq_len = 4; + if (diff == DIFFICULTY_HARD) s_seq_len = 5; + // Re-generate random target sequence + for (int i = 0; i < s_seq_len; i++) + s_target_seq[i] = (uint8_t)(esp_random() % 4); + ESP_LOGI(TAG, "Config: diff=%d seq_len=%d", diff, s_seq_len); + } +} + +// --------------------------------------------------------------------------- +// Button task +// --------------------------------------------------------------------------- + +static void button_task(void *arg) +{ + gpio_config_t cfg = { + .pin_bit_mask = (1ULL << GPIO_BTN_RED) | (1ULL << GPIO_BTN_BLUE) + | (1ULL << GPIO_BTN_YELLOW) | (1ULL << GPIO_BTN_GREEN), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&cfg); + + uint8_t last_state[4] = {1, 1, 1, 1}; + + for (;;) { + espnow_slave_process(); + + if (s_solved) { + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + + for (int i = 0; i < 4; i++) { + uint8_t cur = gpio_get_level(kBtnGpios[i]); + if (last_state[i] == 1 && cur == 0) { + // Button pressed — play tone feedback + play_tone(i, 300); + if (s_player_pos < s_seq_len) { + s_player_seq[s_player_pos++] = (uint8_t)i; + if (s_player_pos == s_seq_len) { + check_sequence(); + s_player_pos = 0; + } + } + } + last_state[i] = cur; + } + vTaskDelay(pdMS_TO_TICKS(20)); // 50 Hz polling + } +} + +// --------------------------------------------------------------------------- +// Wi-Fi init (required by ESP-NOW) +// --------------------------------------------------------------------------- + +static void wifi_init(void) +{ + ESP_ERROR_CHECK(nvs_flash_init()); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_start()); +} + +// --------------------------------------------------------------------------- +// app_main +// --------------------------------------------------------------------------- + +void app_main(void) +{ + ESP_LOGI(TAG, "P1 Séquence Sonore booting..."); + + wifi_init(); + i2s_init(); + + // LED strip (WS2812B via RMT) + led_strip_config_t strip_cfg = { + .strip_gpio_num = GPIO_LED_DATA, + .max_leds = LED_COUNT, + .led_pixel_format = LED_PIXEL_FORMAT_GRB, + .led_model = LED_MODEL_WS2812, + }; + led_strip_rmt_config_t rmt_cfg = { + .resolution_hz = 10000000, // 10 MHz + }; + ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_cfg, &rmt_cfg, &s_leds)); + led_strip_clear(s_leds); + + // Default sequence (will be overridden by master config) + s_seq_len = 4; + for (int i = 0; i < s_seq_len; i++) + s_target_seq[i] = (uint8_t)(i % 4); + + // ESP-NOW slave (broadcast master MAC = accept from any master) + static const uint8_t kMasterMac[6] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; + ESP_ERROR_CHECK(espnow_slave_init(kMasterMac, 1 /* P1 */)); + espnow_slave_register_callback(espnow_cmd_handler); + + s_start_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + + // Play intro sequence after 2 s startup delay + vTaskDelay(pdMS_TO_TICKS(2000)); + play_sequence(); + + xTaskCreate(button_task, "buttons", 8192, NULL, 5, NULL); +} diff --git a/puzzles/p1_sequence_sonore/platformio.ini b/puzzles/p1_sequence_sonore/platformio.ini new file mode 100644 index 0000000..af32fd6 --- /dev/null +++ b/puzzles/p1_sequence_sonore/platformio.ini @@ -0,0 +1,31 @@ +[platformio] +default_envs = p1_son + +[env:p1_son] +platform = espressif32@6.5.0 +board = esp32-s3-devkitc-1 +framework = espidf + +# P1: ESP32-S3-DevKitC-1-N16R8 (16 MB flash / 8 MB PSRAM) +board_build.flash_size = 16MB +board_upload.flash_size = 16MB + +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +build_src_filter = + + + +<../../../lib/espnow_common/espnow_slave.c> + +build_flags = + -I${PROJECT_DIR}/main + -I${PROJECT_DIR}/../../../lib/espnow_common + -DBOARD_HAS_PSRAM + +lib_deps = + igrr/esp-idf-lib#master ; community component registry helper + # led_strip provided by ESP-IDF components + +# Build: pio run +# Flash: pio run -t upload -e p1_son +# Monitor: pio device monitor diff --git a/puzzles/p5_morse/CMakeLists.txt b/puzzles/p5_morse/CMakeLists.txt new file mode 100644 index 0000000..748e78d --- /dev/null +++ b/puzzles/p5_morse/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.16) + +# ESP-IDF project for P5 Code Morse puzzle +# Target: ESP32-DevKitC-32E +# Build: idf.py build | Flash: idf.py -p /dev/ttyUSB0 flash monitor + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +project(p5_morse) + +set(EXTRA_COMPONENT_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/espnow_common +) diff --git a/puzzles/p5_morse/main/CMakeLists.txt b/puzzles/p5_morse/main/CMakeLists.txt new file mode 100644 index 0000000..7f86900 --- /dev/null +++ b/puzzles/p5_morse/main/CMakeLists.txt @@ -0,0 +1,15 @@ +idf_component_register( + SRCS + "main.c" + "../../../lib/espnow_common/espnow_slave.c" + INCLUDE_DIRS + "." + "../../../lib/espnow_common" + REQUIRES + freertos + driver + esp_wifi + esp_now + nvs_flash + esp_common +) diff --git a/puzzles/p5_morse/main/main.c b/puzzles/p5_morse/main/main.c new file mode 100644 index 0000000..e8089fe --- /dev/null +++ b/puzzles/p5_morse/main/main.c @@ -0,0 +1,299 @@ +// main.c — P5 Code Morse puzzle firmware +// Hardware: ESP32-DevKit-C + brass telegraph key + active buzzer +// + red/green/white LEDs +// +// Game logic: the puzzle transmits "ZACUS" in Morse; the player must +// tap back the same sequence on the telegraph key. +// On success, sends digit 5 as code fragment to BOX-3. + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "nvs_flash.h" +#include "espnow_slave.h" +#include +#include + +static const char *TAG = "P5_MORSE"; + +// --------------------------------------------------------------------------- +// GPIO mapping (matches hardware/kicad/p5_morse/p5_morse.kicad_sch) +// --------------------------------------------------------------------------- +#define GPIO_KEY 4 // telegraph key — LOW when pressed +#define GPIO_BUZZER 5 +#define GPIO_LED_R 6 // red LED: key-pressed indicator +#define GPIO_LED_G 7 // green LED: message valid / solved +#define GPIO_LED_W 8 // white LED: Morse light-mode for NON_TECH + +// --------------------------------------------------------------------------- +// Morse timing (ms) +// --------------------------------------------------------------------------- +#define DOT_MIN_MS 50 +#define DOT_MAX_MS 300 +#define DASH_MIN_MS 301 +#define DASH_MAX_MS 1500 +#define LETTER_GAP_MS 700 // silence between letters triggers decode +#define WORD_GAP_MS 1600 // silence after word triggers word check + +// Target message: ZACUS +// Z=--.. A=.- C=-.-. U=..- S=... +static const char *kTargetWord = "ZACUS"; + +// Morse table A-Z +static const char *kMorseTable[26] = { + ".-", "-...", "-.-.", "-..", ".", "..-.", "--.", + "....", "..", ".---", "-.-", ".-..", "--", "-.", + "---", ".--.", "--.-", ".-.", "...", "-", "..-", + "...-", ".--", "-..-", "-.--", "--.." +}; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +static char s_received[16] = {0}; +static uint8_t s_recv_pos = 0; +static char s_current_symbol[10] = {0}; +static uint8_t s_sym_pos = 0; +static bool s_solved = false; +static bool s_light_mode = false; // NON_TECH variant +static uint32_t s_start_ms = 0; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static uint32_t now_ms(void) +{ + return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); +} + +static void buzzer_on(void) { gpio_set_level(GPIO_BUZZER, 1); } +static void buzzer_off(void) { gpio_set_level(GPIO_BUZZER, 0); } + +// --------------------------------------------------------------------------- +// Transmit target sequence to player (audio buzzer or visual LED) +// --------------------------------------------------------------------------- + +static void transmit_target(void) +{ + ESP_LOGI(TAG, "Transmitting target: %s", kTargetWord); + for (int c = 0; kTargetWord[c] != '\0'; c++) { + int idx = kTargetWord[c] - 'A'; + const char *seq = kMorseTable[idx]; + for (int i = 0; seq[i] != '\0'; i++) { + uint32_t dur = (seq[i] == '.') ? 150 : 450; + if (s_light_mode) { + gpio_set_level(GPIO_LED_W, 1); + } else { + buzzer_on(); + } + vTaskDelay(pdMS_TO_TICKS(dur)); + if (s_light_mode) { + gpio_set_level(GPIO_LED_W, 0); + } else { + buzzer_off(); + } + vTaskDelay(pdMS_TO_TICKS(150)); // intra-letter gap + } + vTaskDelay(pdMS_TO_TICKS(LETTER_GAP_MS)); + } +} + +// --------------------------------------------------------------------------- +// Morse decoder +// --------------------------------------------------------------------------- + +static void decode_symbol(void) +{ + if (s_sym_pos == 0) return; + s_current_symbol[s_sym_pos] = '\0'; + + for (int i = 0; i < 26; i++) { + if (strcmp(kMorseTable[i], s_current_symbol) == 0) { + char letter = (char)('A' + i); + if (s_recv_pos < (uint8_t)(sizeof(s_received) - 1)) { + s_received[s_recv_pos++] = letter; + s_received[s_recv_pos] = '\0'; + } + ESP_LOGI(TAG, "Decoded: %s → %c (so far: %s)", + s_current_symbol, letter, s_received); + break; + } + } + s_sym_pos = 0; + memset(s_current_symbol, 0, sizeof(s_current_symbol)); +} + +static void check_word(void) +{ + if (strcmp(s_received, kTargetWord) == 0) { + s_solved = true; + gpio_set_level(GPIO_LED_G, 1); + gpio_set_level(GPIO_LED_R, 0); + + // Digit 5 contribution: last character 'S' → positional index = 18 (0-indexed) + // We use the constant value 5 as the digit (as per scenario spec) + uint8_t code[4] = {5, 0, 0, 0}; + uint32_t elapsed = now_ms() - s_start_ms; + espnow_slave_notify_solved(code, elapsed); + ESP_LOGI(TAG, "SOLVED: ZACUS decoded! elapsed=%lu ms", elapsed); + } else { + // Wrong word — flash red, reset + ESP_LOGW(TAG, "Wrong word received: '%s', expected: '%s'", + s_received, kTargetWord); + gpio_set_level(GPIO_LED_R, 1); + vTaskDelay(pdMS_TO_TICKS(500)); + gpio_set_level(GPIO_LED_R, 0); + s_recv_pos = 0; + memset(s_received, 0, sizeof(s_received)); + } +} + +// --------------------------------------------------------------------------- +// ESP-NOW command handler +// --------------------------------------------------------------------------- + +static void espnow_cmd_handler(espnow_msg_type_t type, + const uint8_t *payload, size_t len) +{ + if (type == MSG_PUZZLE_RESET) { + s_solved = false; + s_recv_pos = 0; + s_sym_pos = 0; + memset(s_received, 0, sizeof(s_received)); + memset(s_current_symbol, 0, sizeof(s_current_symbol)); + gpio_set_level(GPIO_LED_G, 0); + gpio_set_level(GPIO_LED_R, 0); + s_start_ms = now_ms(); + transmit_target(); + ESP_LOGI(TAG, "Reset"); + + } else if (type == MSG_PUZZLE_CONFIG && len >= 1) { + // DIFFICULTY_EASY (NON_TECH) → light mode (visual pulses, no sound) + s_light_mode = (payload[0] == (uint8_t)DIFFICULTY_EASY); + ESP_LOGI(TAG, "Config: light_mode=%d", s_light_mode); + } +} + +// --------------------------------------------------------------------------- +// Morse input task +// --------------------------------------------------------------------------- + +static void morse_task(void *arg) +{ + uint32_t press_start = 0; + uint32_t last_release = 0; + bool key_down = false; + + for (;;) { + espnow_slave_process(); + + if (s_solved) { + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + + uint32_t t = now_ms(); + bool pressed = (gpio_get_level(GPIO_KEY) == 0); + + if (pressed && !key_down) { + // Key press start + key_down = true; + press_start = t; + buzzer_on(); + gpio_set_level(GPIO_LED_R, 1); + + } else if (!pressed && key_down) { + // Key release + key_down = false; + uint32_t duration = t - press_start; + buzzer_off(); + gpio_set_level(GPIO_LED_R, 0); + last_release = t; + + if (duration >= DOT_MIN_MS && duration <= DOT_MAX_MS) { + if (s_sym_pos < (uint8_t)(sizeof(s_current_symbol) - 1)) + s_current_symbol[s_sym_pos++] = '.'; + } else if (duration >= DASH_MIN_MS && duration <= DASH_MAX_MS) { + if (s_sym_pos < (uint8_t)(sizeof(s_current_symbol) - 1)) + s_current_symbol[s_sym_pos++] = '-'; + } + // Ignore if outside valid range (noise / held too long) + + } else if (!key_down && last_release > 0) { + uint32_t silence = t - last_release; + + // Letter gap: decode accumulated symbol + if (silence > LETTER_GAP_MS && s_sym_pos > 0) { + decode_symbol(); + last_release = t; + } + // Word gap: check assembled word + if (silence > WORD_GAP_MS && s_recv_pos > 0) { + check_word(); + last_release = 0; // reset to avoid re-triggering + } + } + + vTaskDelay(pdMS_TO_TICKS(10)); // 100 Hz loop + } +} + +// --------------------------------------------------------------------------- +// Wi-Fi init +// --------------------------------------------------------------------------- + +static void wifi_init(void) +{ + ESP_ERROR_CHECK(nvs_flash_init()); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_start()); +} + +// --------------------------------------------------------------------------- +// app_main +// --------------------------------------------------------------------------- + +void app_main(void) +{ + ESP_LOGI(TAG, "P5 Code Morse booting..."); + + wifi_init(); + + // Output GPIOs + gpio_config_t out_cfg = { + .pin_bit_mask = (1ULL << GPIO_BUZZER) | (1ULL << GPIO_LED_R) + | (1ULL << GPIO_LED_G) | (1ULL << GPIO_LED_W), + .mode = GPIO_MODE_OUTPUT, + }; + gpio_config(&out_cfg); + + // Input GPIO: telegraph key with internal pull-up + gpio_config_t in_cfg = { + .pin_bit_mask = (1ULL << GPIO_KEY), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&in_cfg); + + // ESP-NOW slave + static const uint8_t kMasterMac[6] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; + ESP_ERROR_CHECK(espnow_slave_init(kMasterMac, 5 /* P5 */)); + espnow_slave_register_callback(espnow_cmd_handler); + + s_start_ms = now_ms(); + + // Wait for master to be ready, then transmit first sequence + vTaskDelay(pdMS_TO_TICKS(3000)); + transmit_target(); + + xTaskCreate(morse_task, "morse", 4096, NULL, 5, NULL); +} diff --git a/puzzles/p5_morse/platformio.ini b/puzzles/p5_morse/platformio.ini new file mode 100644 index 0000000..a5e2c78 --- /dev/null +++ b/puzzles/p5_morse/platformio.ini @@ -0,0 +1,22 @@ +[platformio] +default_envs = p5_morse + +[env:p5_morse] +platform = espressif32@6.5.0 +board = esp32dev +framework = espidf + +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +build_src_filter = + + + +<../../../lib/espnow_common/espnow_slave.c> + +build_flags = + -I${PROJECT_DIR}/main + -I${PROJECT_DIR}/../../../lib/espnow_common + +# Build: pio run +# Flash: pio run -t upload -e p5_morse +# Monitor: pio device monitor diff --git a/puzzles/p6_symboles_nfc/CMakeLists.txt b/puzzles/p6_symboles_nfc/CMakeLists.txt new file mode 100644 index 0000000..96f5b51 --- /dev/null +++ b/puzzles/p6_symboles_nfc/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.16) + +# ESP-IDF project for P6 Symboles Alchimiques (NFC-based) +# Target: ESP32-DevKitC-32E + MFRC522 SPI +# Build: idf.py build | Flash: idf.py -p /dev/ttyUSB0 flash monitor + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +project(p6_symboles_nfc) + +set(EXTRA_COMPONENT_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/espnow_common +) diff --git a/puzzles/p6_symboles_nfc/main/CMakeLists.txt b/puzzles/p6_symboles_nfc/main/CMakeLists.txt new file mode 100644 index 0000000..7f86900 --- /dev/null +++ b/puzzles/p6_symboles_nfc/main/CMakeLists.txt @@ -0,0 +1,15 @@ +idf_component_register( + SRCS + "main.c" + "../../../lib/espnow_common/espnow_slave.c" + INCLUDE_DIRS + "." + "../../../lib/espnow_common" + REQUIRES + freertos + driver + esp_wifi + esp_now + nvs_flash + esp_common +) diff --git a/puzzles/p6_symboles_nfc/main/main.c b/puzzles/p6_symboles_nfc/main/main.c new file mode 100644 index 0000000..1ed7c2a --- /dev/null +++ b/puzzles/p6_symboles_nfc/main/main.c @@ -0,0 +1,408 @@ +// main.c — P6 Symboles Alchimiques (NFC-based) puzzle firmware +// Hardware: ESP32-DevKit-C + MFRC522 NFC reader (SPI) +// + active buzzer + green/red LEDs +// + 12 NTAG213 NFC tags on wooden symbol pieces +// +// Game logic: player places 12 alchemical symbol pieces on a wooden tablet +// in the correct order; each piece has an embedded NFC tag. +// MFRC522 detects tags sequentially as they are placed. +// On correct full configuration, sends 2-digit code fragment to BOX-3. + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" +#include "driver/spi_master.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "nvs_flash.h" +#include "espnow_slave.h" +#include +#include +#include + +static const char *TAG = "P6_SYMBOLES"; + +// --------------------------------------------------------------------------- +// GPIO mapping (matches hardware/kicad/p6_symboles/p6_symboles.kicad_sch) +// --------------------------------------------------------------------------- +#define GPIO_BUZZER 25 +#define GPIO_LED_G 26 +#define GPIO_LED_R 27 + +// SPI pins for MFRC522 +#define SPI_HOST_ID SPI2_HOST +#define GPIO_CS 5 // SDA/CS +#define GPIO_SCK 18 +#define GPIO_MISO 19 +#define GPIO_MOSI 23 +#define GPIO_RST 4 + +// --------------------------------------------------------------------------- +// MFRC522 register map (subset needed for basic anticoll polling) +// --------------------------------------------------------------------------- +#define MFRC522_REG_COMMAND 0x01 +#define MFRC522_REG_COMIEN 0x02 +#define MFRC522_REG_COMIRQ 0x04 +#define MFRC522_REG_ERROR 0x06 +#define MFRC522_REG_STATUS2 0x08 +#define MFRC522_REG_FIFODATA 0x09 +#define MFRC522_REG_FIFOLEVEL 0x0A +#define MFRC522_REG_CONTROL 0x0C +#define MFRC522_REG_BITFRAMING 0x0D +#define MFRC522_REG_COLL 0x0E +#define MFRC522_REG_MODE 0x11 +#define MFRC522_REG_TXCONTROL 0x14 +#define MFRC522_REG_TXASK 0x15 +#define MFRC522_CMD_IDLE 0x00 +#define MFRC522_CMD_TRANSCEIVE 0x0C +#define MFRC522_CMD_SOFTRESET 0x0F + +// --------------------------------------------------------------------------- +// NFC tag → symbol ID mapping +// --------------------------------------------------------------------------- +typedef struct { + uint8_t uid[4]; + uint8_t symbol_id; // 1-12 +} nfc_tag_t; + +// Correct placement order: [7, 2, 11, 4, 9, 1, 8, 3, 12, 6, 10, 5] +static const uint8_t kCorrectOrder[12] = {7, 2, 11, 4, 9, 1, 8, 3, 12, 6, 10, 5}; + +// Tag UIDs populated from NVS at boot (written during factory setup) +static nfc_tag_t s_tags[12]; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +static uint8_t s_slots[12] = {0}; // s_slots[position] = symbol_id placed +static uint8_t s_placed = 0; +static bool s_solved = false; +static uint32_t s_start_ms = 0; + +// SPI device handle +static spi_device_handle_t s_spi; + +// --------------------------------------------------------------------------- +// MFRC522 low-level SPI helpers +// --------------------------------------------------------------------------- + +static uint8_t mfrc_read_reg(uint8_t reg) +{ + uint8_t tx[2] = { (uint8_t)(((reg << 1) & 0x7E) | 0x80), 0x00 }; + uint8_t rx[2] = {0}; + spi_transaction_t t = { + .length = 16, + .tx_buffer = tx, + .rx_buffer = rx, + }; + spi_device_transmit(s_spi, &t); + return rx[1]; +} + +static void mfrc_write_reg(uint8_t reg, uint8_t val) +{ + uint8_t tx[2] = { (uint8_t)((reg << 1) & 0x7E), val }; + spi_transaction_t t = { + .length = 16, + .tx_buffer = tx, + }; + spi_device_transmit(s_spi, &t); +} + +static void mfrc_set_bit(uint8_t reg, uint8_t mask) +{ + mfrc_write_reg(reg, mfrc_read_reg(reg) | mask); +} + +static void mfrc_clear_bit(uint8_t reg, uint8_t mask) +{ + mfrc_write_reg(reg, mfrc_read_reg(reg) & (~mask)); +} + +// Antenna on +static void mfrc_antenna_on(void) +{ + uint8_t v = mfrc_read_reg(MFRC522_REG_TXCONTROL); + if ((v & 0x03) != 0x03) + mfrc_set_bit(MFRC522_REG_TXCONTROL, 0x03); +} + +// Soft reset +static void mfrc_reset(void) +{ + mfrc_write_reg(MFRC522_REG_COMMAND, MFRC522_CMD_SOFTRESET); + vTaskDelay(pdMS_TO_TICKS(50)); + mfrc_write_reg(MFRC522_REG_MODE, 0x3D); + mfrc_write_reg(MFRC522_REG_TXASK, 0x40); + mfrc_antenna_on(); +} + +// Transceive helper: send data[send_len], receive into recv, returns recv_len +static int mfrc_transceive(const uint8_t *send, uint8_t send_len, + uint8_t *recv, uint8_t recv_max) +{ + mfrc_write_reg(MFRC522_REG_COMMAND, MFRC522_CMD_IDLE); + mfrc_write_reg(MFRC522_REG_COMIRQ, 0x7F); + mfrc_write_reg(MFRC522_REG_FIFOLEVEL, 0x80); // flush FIFO + for (uint8_t i = 0; i < send_len; i++) + mfrc_write_reg(MFRC522_REG_FIFODATA, send[i]); + mfrc_write_reg(MFRC522_REG_COMMAND, MFRC522_CMD_TRANSCEIVE); + mfrc_set_bit(MFRC522_REG_BITFRAMING, 0x80); // StartSend + + uint32_t deadline = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS) + + 36; + for (;;) { + uint8_t irq = mfrc_read_reg(MFRC522_REG_COMIRQ); + if (irq & 0x30) break; // RxIRq or IdleIRq + uint32_t now = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + if (now >= deadline) return -1; + taskYIELD(); + } + mfrc_clear_bit(MFRC522_REG_BITFRAMING, 0x80); + + if (mfrc_read_reg(MFRC522_REG_ERROR) & 0x1B) return -1; // error flags + + int n = mfrc_read_reg(MFRC522_REG_FIFOLEVEL); + if (n > recv_max) n = recv_max; + for (int i = 0; i < n; i++) + recv[i] = mfrc_read_reg(MFRC522_REG_FIFODATA); + return n; +} + +// Poll for a single card and return its 4-byte UID in uid_out. +// Returns true if a card is present. +static bool nfc_poll(uint8_t uid_out[4]) +{ + // REQA command + uint8_t req[1] = {0x26}; + uint8_t atqa[2] = {0}; + mfrc_write_reg(MFRC522_REG_BITFRAMING, 0x07); // 7 bits + if (mfrc_transceive(req, 1, atqa, sizeof(atqa)) < 0) return false; + + // Anti-collision CL1 + mfrc_write_reg(MFRC522_REG_BITFRAMING, 0x00); + uint8_t anticoll[2] = {0x93, 0x20}; + uint8_t uid_raw[5] = {0}; + if (mfrc_transceive(anticoll, 2, uid_raw, sizeof(uid_raw)) < 4) return false; + + memcpy(uid_out, uid_raw, 4); + return true; +} + +// --------------------------------------------------------------------------- +// Symbol ID lookup +// --------------------------------------------------------------------------- + +static uint8_t uid_to_symbol(const uint8_t uid[4]) +{ + for (int i = 0; i < 12; i++) { + if (memcmp(s_tags[i].uid, uid, 4) == 0) + return s_tags[i].symbol_id; + } + return 0; // unknown tag +} + +// --------------------------------------------------------------------------- +// Buzzer helpers +// --------------------------------------------------------------------------- + +static void buzzer_short(void) +{ + gpio_set_level(GPIO_BUZZER, 1); + vTaskDelay(pdMS_TO_TICKS(100)); + gpio_set_level(GPIO_BUZZER, 0); +} + +static void buzzer_long(void) +{ + gpio_set_level(GPIO_BUZZER, 1); + vTaskDelay(pdMS_TO_TICKS(800)); + gpio_set_level(GPIO_BUZZER, 0); +} + +// --------------------------------------------------------------------------- +// Configuration check +// --------------------------------------------------------------------------- + +static void check_configuration(void) +{ + if (s_placed < 12) return; + + bool correct = (memcmp(s_slots, kCorrectOrder, 12) == 0); + if (correct) { + s_solved = true; + gpio_set_level(GPIO_LED_G, 1); + + // Digits 6-7 from correct order first two elements + uint8_t d6 = kCorrectOrder[0] % 10; // 7 + uint8_t d7 = kCorrectOrder[1] % 10; // 2 + uint8_t code[4] = {d6, d7, 0, 0}; + uint32_t elapsed = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS) + - s_start_ms; + espnow_slave_notify_solved(code, elapsed); + buzzer_long(); + ESP_LOGI(TAG, "Solved! Code=%d%d elapsed=%lu ms", d6, d7, elapsed); + + } else { + gpio_set_level(GPIO_LED_R, 1); + buzzer_short(); + vTaskDelay(pdMS_TO_TICKS(300)); + gpio_set_level(GPIO_LED_R, 0); + // Reset placement + memset(s_slots, 0, sizeof(s_slots)); + s_placed = 0; + ESP_LOGW(TAG, "Wrong configuration — reset"); + } +} + +// --------------------------------------------------------------------------- +// ESP-NOW command handler +// --------------------------------------------------------------------------- + +static void espnow_cmd_handler(espnow_msg_type_t type, + const uint8_t *payload, size_t len) +{ + (void)payload; (void)len; + if (type == MSG_PUZZLE_RESET) { + memset(s_slots, 0, sizeof(s_slots)); + s_placed = 0; + s_solved = false; + gpio_set_level(GPIO_LED_G, 0); + gpio_set_level(GPIO_LED_R, 0); + s_start_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + ESP_LOGI(TAG, "Reset"); + } + // MSG_PUZZLE_CONFIG not used by P6 (difficulty does not change the tag set) +} + +// --------------------------------------------------------------------------- +// NFC polling task +// --------------------------------------------------------------------------- + +static void nfc_task(void *arg) +{ + uint8_t uid[4] = {0}; + uint8_t last_uid[4] = {0}; + uint32_t last_read_ms = 0; + + for (;;) { + espnow_slave_process(); + + if (s_solved) { + vTaskDelay(pdMS_TO_TICKS(200)); + continue; + } + + uint32_t t = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + if (nfc_poll(uid)) { + // Debounce: ignore same tag within 1 s + bool same = (memcmp(uid, last_uid, 4) == 0); + if (!same || (t - last_read_ms) > 1000) { + uint8_t sym = uid_to_symbol(uid); + if (sym >= 1 && sym <= 12) { + ESP_LOGI(TAG, "Tag placed: symbol %d at position %d", + sym, s_placed); + buzzer_short(); + if (s_placed < 12) { + s_slots[s_placed++] = sym; + } + memcpy(last_uid, uid, 4); + last_read_ms = t; + if (s_placed == 12) check_configuration(); + } else { + ESP_LOGW(TAG, "Unknown tag UID %02X:%02X:%02X:%02X", + uid[0], uid[1], uid[2], uid[3]); + } + } + } + vTaskDelay(pdMS_TO_TICKS(200)); // 5 Hz NFC polling + } +} + +// --------------------------------------------------------------------------- +// Wi-Fi + SPI init +// --------------------------------------------------------------------------- + +static void wifi_init(void) +{ + ESP_ERROR_CHECK(nvs_flash_init()); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_start()); +} + +static void spi_init(void) +{ + spi_bus_config_t buscfg = { + .miso_io_num = GPIO_MISO, + .mosi_io_num = GPIO_MOSI, + .sclk_io_num = GPIO_SCK, + .quadwp_io_num = -1, + .quadhd_io_num = -1, + .max_transfer_sz = 64, + }; + ESP_ERROR_CHECK(spi_bus_initialize(SPI_HOST_ID, &buscfg, SPI_DMA_CH_AUTO)); + + spi_device_interface_config_t devcfg = { + .clock_speed_hz = 10 * 1000 * 1000, // 10 MHz + .mode = 0, + .spics_io_num = GPIO_CS, + .queue_size = 4, + }; + ESP_ERROR_CHECK(spi_bus_add_device(SPI_HOST_ID, &devcfg, &s_spi)); + + // MFRC522 reset via RST pin + gpio_set_direction(GPIO_RST, GPIO_MODE_OUTPUT); + gpio_set_level(GPIO_RST, 0); + vTaskDelay(pdMS_TO_TICKS(10)); + gpio_set_level(GPIO_RST, 1); + vTaskDelay(pdMS_TO_TICKS(50)); + + mfrc_reset(); + ESP_LOGI(TAG, "MFRC522 initialized, version=0x%02X", + mfrc_read_reg(0x37)); // VersionReg +} + +static void load_tag_uids_from_nvs(void) +{ + // In production: nvs_open / nvs_get_blob("nfc_tags", s_tags, sizeof(s_tags)) + // For development: pre-populate with known test UIDs + // Each NTAG213 tag has its symbol_id stored in byte 0 of NDEF block 1 + // Factory programming is done once with tools/nfc/program_tags.py + memset(s_tags, 0, sizeof(s_tags)); + ESP_LOGW(TAG, "NFC tag UIDs not loaded (NVS empty) — use tools/nfc/program_tags.py"); +} + +// --------------------------------------------------------------------------- +// app_main +// --------------------------------------------------------------------------- + +void app_main(void) +{ + ESP_LOGI(TAG, "P6 Symboles Alchimiques booting..."); + + wifi_init(); + spi_init(); + load_tag_uids_from_nvs(); + + // Output GPIOs + gpio_config_t out_cfg = { + .pin_bit_mask = (1ULL << GPIO_BUZZER) | (1ULL << GPIO_LED_G) + | (1ULL << GPIO_LED_R), + .mode = GPIO_MODE_OUTPUT, + }; + gpio_config(&out_cfg); + + // ESP-NOW slave + static const uint8_t kMasterMac[6] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; + ESP_ERROR_CHECK(espnow_slave_init(kMasterMac, 6 /* P6 */)); + espnow_slave_register_callback(espnow_cmd_handler); + + s_start_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + + xTaskCreate(nfc_task, "nfc", 4096, NULL, 5, NULL); +} diff --git a/puzzles/p6_symboles_nfc/platformio.ini b/puzzles/p6_symboles_nfc/platformio.ini new file mode 100644 index 0000000..f3e9604 --- /dev/null +++ b/puzzles/p6_symboles_nfc/platformio.ini @@ -0,0 +1,23 @@ +[platformio] +default_envs = p6_symboles_nfc + +[env:p6_symboles_nfc] +platform = espressif32@6.5.0 +board = esp32dev +framework = espidf + +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +build_src_filter = + + + +<../../../lib/espnow_common/espnow_slave.c> + +build_flags = + -I${PROJECT_DIR}/main + -I${PROJECT_DIR}/../../../lib/espnow_common + +# Build: pio run +# Flash: pio run -t upload -e p6_symboles_nfc +# Monitor: pio device monitor +# Tag programming: python3 tools/nfc/program_tags.py diff --git a/puzzles/p7_coffre/CMakeLists.txt b/puzzles/p7_coffre/CMakeLists.txt new file mode 100644 index 0000000..85cc5bf --- /dev/null +++ b/puzzles/p7_coffre/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.16) + +# ESP-IDF project for P7 Coffre Final +# Target: ESP32-DevKitC-32E + 4×3 keypad + SG90 servo + SSD1306 OLED +# Build: idf.py build | Flash: idf.py -p /dev/ttyUSB0 flash monitor + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +project(p7_coffre) + +set(EXTRA_COMPONENT_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/espnow_common +) diff --git a/puzzles/p7_coffre/main/CMakeLists.txt b/puzzles/p7_coffre/main/CMakeLists.txt new file mode 100644 index 0000000..7f86900 --- /dev/null +++ b/puzzles/p7_coffre/main/CMakeLists.txt @@ -0,0 +1,15 @@ +idf_component_register( + SRCS + "main.c" + "../../../lib/espnow_common/espnow_slave.c" + INCLUDE_DIRS + "." + "../../../lib/espnow_common" + REQUIRES + freertos + driver + esp_wifi + esp_now + nvs_flash + esp_common +) diff --git a/puzzles/p7_coffre/main/main.c b/puzzles/p7_coffre/main/main.c new file mode 100644 index 0000000..02e1527 --- /dev/null +++ b/puzzles/p7_coffre/main/main.c @@ -0,0 +1,369 @@ +// main.c — P7 Coffre Final puzzle firmware +// Hardware: ESP32-DevKit-C + 4×3 membrane keypad + SG90 servo (latch) +// + SSD1306 OLED 128×32 (I2C) + WS2812B single RGB LED + buzzer +// +// Game logic: master sends the 8-digit final code; player must enter it +// on the keypad and press '#' to confirm. On correct code the servo unlocks +// the latch and a victory sequence plays. + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" +#include "driver/ledc.h" +#include "driver/i2c.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "nvs_flash.h" +#include "espnow_slave.h" +#include +#include +#include + +static const char *TAG = "P7_COFFRE"; + +// --------------------------------------------------------------------------- +// GPIO mapping (matches hardware/kicad/p7_coffre/p7_coffre.kicad_sch) +// --------------------------------------------------------------------------- +static const uint8_t kRowPins[4] = {4, 5, 6, 7}; +static const uint8_t kColPins[3] = {12, 13, 14}; + +static const char kKeyMap[4][3] = { + {'1','2','3'}, + {'4','5','6'}, + {'7','8','9'}, + {'*','0','#'}, +}; + +#define GPIO_SERVO 25 +#define GPIO_SDA 21 +#define GPIO_SCL 22 +#define GPIO_LED 2 // WS2812B single LED (on-board or external) +#define GPIO_BUZZER 18 + +// Servo pulse widths (µs) for 50 Hz PWM +#define SERVO_LOCKED 1000 +#define SERVO_UNLOCKED 2000 + +// LEDC configuration +#define LEDC_TIMER LEDC_TIMER_0 +#define LEDC_CHANNEL LEDC_CHANNEL_0 +#define LEDC_RESOLUTION LEDC_TIMER_14_BIT +#define LEDC_FREQ_HZ 50 + +// SSD1306 I2C address and port +#define OLED_ADDR 0x3C +#define I2C_PORT I2C_NUM_0 +#define I2C_FREQ_HZ 400000 + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +static char s_entered[9] = {0}; +static uint8_t s_pos = 0; +static bool s_solved = false; +static char s_target_code[9] = "00000000"; // set by master via MSG_PUZZLE_CONFIG +static uint32_t s_start_ms = 0; + +// --------------------------------------------------------------------------- +// Servo helpers +// --------------------------------------------------------------------------- + +static void servo_set_us(uint32_t pulse_us) +{ + // period = 20 000 µs; 14-bit resolution = 16383 ticks + uint32_t duty = (pulse_us * 16383UL) / 20000UL; + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL, duty); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL); +} + +static void servo_lock(void) { servo_set_us(SERVO_LOCKED); } +static void servo_unlock(void) { servo_set_us(SERVO_UNLOCKED); } + +// --------------------------------------------------------------------------- +// Buzzer helpers +// --------------------------------------------------------------------------- + +static void buzzer_beep(uint32_t ms) +{ + gpio_set_level(GPIO_BUZZER, 1); + vTaskDelay(pdMS_TO_TICKS(ms)); + gpio_set_level(GPIO_BUZZER, 0); +} + +// --------------------------------------------------------------------------- +// SSD1306 minimal driver (I2C, 128×32) +// --------------------------------------------------------------------------- + +#define OLED_CTRL_CMD 0x00 +#define OLED_CTRL_DATA 0x40 + +static void oled_cmd(uint8_t cmd) +{ + uint8_t buf[2] = {OLED_CTRL_CMD, cmd}; + i2c_master_write_to_device(I2C_PORT, OLED_ADDR, buf, sizeof(buf), + pdMS_TO_TICKS(10)); +} + +static void oled_init(void) +{ + static const uint8_t kInitSeq[] = { + 0xAE, 0x20, 0x00, 0xB0, 0xC8, 0x00, 0x10, + 0x40, 0x81, 0xCF, 0xA1, 0xA6, 0xA8, 0x1F, + 0xA4, 0xD3, 0x00, 0xD5, 0xF0, 0xD9, 0x22, + 0xDA, 0x02, 0xDB, 0x20, 0x8D, 0x14, 0xAF, + }; + for (size_t i = 0; i < sizeof(kInitSeq); i++) + oled_cmd(kInitSeq[i]); +} + +// Display a null-terminated string on row 0 (simplified: fixed font) +// In production: use full font table; here we just log and stub the display. +static void oled_show_entered(const char *text) +{ + // Full SSD1306 font rendering would go here. + // For this firmware skeleton, we log to UART and emit a minimal + // I2C clear+write cycle to confirm the hardware path is exercised. + ESP_LOGI(TAG, "OLED: [%s]", text); + // Clear display: set all pages to 0x00 + for (uint8_t page = 0; page < 4; page++) { + oled_cmd(0xB0 | page); // page address + oled_cmd(0x00); // lower column start + oled_cmd(0x10); // upper column start + uint8_t clear[129]; + clear[0] = OLED_CTRL_DATA; + memset(clear + 1, 0, 128); + i2c_master_write_to_device(I2C_PORT, OLED_ADDR, clear, sizeof(clear), + pdMS_TO_TICKS(20)); + } +} + +// --------------------------------------------------------------------------- +// Code check +// --------------------------------------------------------------------------- + +static void check_code(void) +{ + bool correct = (strcmp(s_entered, s_target_code) == 0); + if (correct) { + s_solved = true; + servo_unlock(); + gpio_set_level(GPIO_LED, 1); + + // Victory melody: 3 ascending beeps + for (int i = 0; i < 3; i++) { + buzzer_beep(200); + vTaskDelay(pdMS_TO_TICKS(150)); + } + + uint8_t code[4] = {0, 0, 0, 0}; // P7 validates the assembled code; no new digit + uint32_t elapsed = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS) + - s_start_ms; + espnow_slave_notify_solved(code, elapsed); + ESP_LOGI(TAG, "COFFRE OUVERT! Code=%s elapsed=%lu ms", + s_entered, elapsed); + oled_show_entered("** OUVERT **"); + + } else { + // Wrong code: error beep, clear entry + ESP_LOGW(TAG, "Wrong code entered: '%s' (expected: '%s')", + s_entered, s_target_code); + gpio_set_level(GPIO_LED, 0); + buzzer_beep(600); + s_pos = 0; + memset(s_entered, 0, sizeof(s_entered)); + oled_show_entered("ERREUR"); + vTaskDelay(pdMS_TO_TICKS(1000)); + oled_show_entered("........"); + } +} + +// --------------------------------------------------------------------------- +// Keypad scanner (active-low matrix) +// --------------------------------------------------------------------------- + +static char scan_keypad(void) +{ + for (int r = 0; r < 4; r++) { + gpio_set_level(kRowPins[r], 0); // pull row low + for (int c = 0; c < 3; c++) { + if (gpio_get_level(kColPins[c]) == 0) { + vTaskDelay(pdMS_TO_TICKS(20)); // debounce + if (gpio_get_level(kColPins[c]) == 0) { + gpio_set_level(kRowPins[r], 1); + return kKeyMap[r][c]; + } + } + } + gpio_set_level(kRowPins[r], 1); // release row + } + return 0; +} + +// --------------------------------------------------------------------------- +// ESP-NOW command handler +// --------------------------------------------------------------------------- + +static void espnow_cmd_handler(espnow_msg_type_t type, + const uint8_t *payload, size_t len) +{ + if (type == MSG_PUZZLE_RESET) { + servo_lock(); + s_pos = 0; + s_solved = false; + memset(s_entered, 0, sizeof(s_entered)); + gpio_set_level(GPIO_LED, 0); + s_start_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + oled_show_entered("........"); + ESP_LOGI(TAG, "Reset — coffre verrouillé"); + + } else if (type == MSG_PUZZLE_CONFIG && len >= 8) { + // Master sends the assembled 8-digit code + memcpy(s_target_code, payload, 8); + s_target_code[8] = '\0'; + ESP_LOGI(TAG, "Target code set: %s", s_target_code); + } +} + +// --------------------------------------------------------------------------- +// Keypad task +// --------------------------------------------------------------------------- + +static void keypad_task(void *arg) +{ + char last_key = 0; + + for (;;) { + espnow_slave_process(); + + if (s_solved) { + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + + char key = scan_keypad(); + + if (key && key != last_key) { + buzzer_beep(50); // tactile feedback beep + + if (key == '#') { + // Confirm + if (s_pos == 8) { + check_code(); + } else { + ESP_LOGW(TAG, "Incomplete: %d/8 digits entered", s_pos); + buzzer_beep(200); + } + } else if (key == '*') { + // Clear + s_pos = 0; + memset(s_entered, 0, sizeof(s_entered)); + oled_show_entered("........"); + ESP_LOGI(TAG, "Entry cleared"); + } else if (s_pos < 8) { + s_entered[s_pos++] = key; + s_entered[s_pos] = '\0'; + oled_show_entered(s_entered); + ESP_LOGI(TAG, "Entered: %s (%d/8)", s_entered, s_pos); + } + } + last_key = key; + vTaskDelay(pdMS_TO_TICKS(50)); // 20 Hz keypad scan + } +} + +// --------------------------------------------------------------------------- +// Peripheral init helpers +// --------------------------------------------------------------------------- + +static void wifi_init(void) +{ + ESP_ERROR_CHECK(nvs_flash_init()); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_start()); +} + +static void servo_init(void) +{ + ledc_timer_config_t timer = { + .speed_mode = LEDC_LOW_SPEED_MODE, + .timer_num = LEDC_TIMER, + .duty_resolution = LEDC_RESOLUTION, + .freq_hz = LEDC_FREQ_HZ, + .clk_cfg = LEDC_AUTO_CLK, + }; + ESP_ERROR_CHECK(ledc_timer_config(&timer)); + + ledc_channel_config_t chan = { + .gpio_num = GPIO_SERVO, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = LEDC_CHANNEL, + .timer_sel = LEDC_TIMER, + .duty = 0, + .hpoint = 0, + }; + ESP_ERROR_CHECK(ledc_channel_config(&chan)); + servo_lock(); +} + +static void i2c_init(void) +{ + i2c_config_t conf = { + .mode = I2C_MODE_MASTER, + .sda_io_num = GPIO_SDA, + .scl_io_num = GPIO_SCL, + .sda_pullup_en = GPIO_PULLUP_ENABLE, + .scl_pullup_en = GPIO_PULLUP_ENABLE, + .master.clk_speed = I2C_FREQ_HZ, + }; + ESP_ERROR_CHECK(i2c_param_config(I2C_PORT, &conf)); + ESP_ERROR_CHECK(i2c_driver_install(I2C_PORT, conf.mode, 0, 0, 0)); +} + +// --------------------------------------------------------------------------- +// app_main +// --------------------------------------------------------------------------- + +void app_main(void) +{ + ESP_LOGI(TAG, "P7 Coffre Final booting..."); + + wifi_init(); + servo_init(); + i2c_init(); + oled_init(); + oled_show_entered("........"); + + // Keypad row pins: output, initially HIGH + for (int r = 0; r < 4; r++) { + gpio_set_direction(kRowPins[r], GPIO_MODE_OUTPUT); + gpio_set_level(kRowPins[r], 1); + } + // Keypad col pins: input with pull-up + for (int c = 0; c < 3; c++) { + gpio_set_direction(kColPins[c], GPIO_MODE_INPUT); + gpio_set_pull_mode(kColPins[c], GPIO_PULLUP_ONLY); + } + + // Status LED and buzzer + gpio_set_direction(GPIO_LED, GPIO_MODE_OUTPUT); + gpio_set_direction(GPIO_BUZZER, GPIO_MODE_OUTPUT); + gpio_set_level(GPIO_LED, 0); + gpio_set_level(GPIO_BUZZER, 0); + + // ESP-NOW slave + static const uint8_t kMasterMac[6] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; + ESP_ERROR_CHECK(espnow_slave_init(kMasterMac, 7 /* P7 */)); + espnow_slave_register_callback(espnow_cmd_handler); + + s_start_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + + // Boot confirmation beep + buzzer_beep(100); + + xTaskCreate(keypad_task, "keypad", 4096, NULL, 5, NULL); +} diff --git a/puzzles/p7_coffre/platformio.ini b/puzzles/p7_coffre/platformio.ini new file mode 100644 index 0000000..8c5212a --- /dev/null +++ b/puzzles/p7_coffre/platformio.ini @@ -0,0 +1,23 @@ +[platformio] +default_envs = p7_coffre + +[env:p7_coffre] +platform = espressif32@6.5.0 +board = esp32dev +framework = espidf + +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +build_src_filter = + + + +<../../../lib/espnow_common/espnow_slave.c> + +build_flags = + -I${PROJECT_DIR}/main + -I${PROJECT_DIR}/../../../lib/espnow_common + +# Build: pio run +# Flash: pio run -t upload -e p7_coffre +# Monitor: pio device monitor +# Note: master must send MSG_PUZZLE_CONFIG with 8-digit code before '#' is accepted diff --git a/ui_freenove_allinone/include/npc/espnow_master.h b/ui_freenove_allinone/include/npc/espnow_master.h new file mode 100644 index 0000000..b7cd145 --- /dev/null +++ b/ui_freenove_allinone/include/npc/espnow_master.h @@ -0,0 +1,67 @@ +// espnow_master.h — BOX-3 ESP-NOW master (Arduino/C++ wrapper) +// Orchestrates 7 puzzle slave nodes, collects results, assembles final code. +// This header wraps the C implementation for the Arduino/C++ BOX-3 codebase. +#pragma once + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// --------------------------------------------------------------------------- +// Shared message types (mirrors espnow_slave.h — must stay in sync) +// --------------------------------------------------------------------------- +typedef enum { + MSG_PUZZLE_SOLVED = 0x01, + MSG_PUZZLE_RESET = 0x02, + MSG_PUZZLE_CONFIG = 0x03, + MSG_HINT_REQUEST = 0x04, + MSG_STATUS_POLL = 0x05, + MSG_STATUS_REPLY = 0x06, + MSG_LED_COMMAND = 0x07, + MSG_HEARTBEAT = 0x08, +} espnow_msg_type_t; + +typedef enum { + DIFFICULTY_EASY = 0, + DIFFICULTY_NORMAL = 1, + DIFFICULTY_HARD = 2, +} puzzle_difficulty_t; + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- +typedef enum { + ESPNOW_EV_PUZZLE_SOLVED = 1, + ESPNOW_EV_HINT_REQUEST = 2, + ESPNOW_EV_HEARTBEAT = 3, + ESPNOW_EV_NODE_TIMEOUT = 4, +} espnow_event_type_t; + +typedef struct { + espnow_event_type_t type; + uint8_t puzzle_id; + uint8_t code_fragment[4]; + uint32_t solve_time_ms; +} espnow_event_t; + +typedef void (*espnow_event_cb_t)(const espnow_event_t *ev); + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- +esp_err_t espnow_master_init(espnow_event_cb_t cb); +esp_err_t espnow_master_reset_puzzle(uint8_t puzzle_id); +esp_err_t espnow_master_configure_puzzle(uint8_t puzzle_id, + puzzle_difficulty_t difficulty); +esp_err_t espnow_master_send_final_code(const char code[9]); +void espnow_master_process(void); +bool espnow_master_all_solved(const uint8_t puzzle_ids[], uint8_t count); +void espnow_master_assemble_code(char code_out[9]); + +#ifdef __cplusplus +} +#endif diff --git a/ui_freenove_allinone/include/npc/game_coordinator.h b/ui_freenove_allinone/include/npc/game_coordinator.h new file mode 100644 index 0000000..79a1f61 --- /dev/null +++ b/ui_freenove_allinone/include/npc/game_coordinator.h @@ -0,0 +1,31 @@ +// game_coordinator.h — V3 game state machine public API. +// Init → Start → Tick loop. Integrates NPC V3 + ESP-NOW + TTS. +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Initialize game coordinator with target game duration. +/// target_duration_ms: 1800000 (30 min) to 5400000 (90 min). +/// Call once before game_coordinator_start(). +void game_coordinator_init(uint32_t target_duration_ms); + +/// Start the game (transitions to INTRO phase). +void game_coordinator_start(void); + +/// Call every ~100ms from main loop or FreeRTOS task. +/// Drives ESP-NOW processing, NPC evaluation, and adaptive logic. +void game_coordinator_tick(void); + +/// Return current game phase (cast to game_phase_t from game_coordinator.cpp). +int game_coordinator_phase(void); + +/// Return final score (valid after OUTRO phase). +uint32_t game_coordinator_score(void); + +#ifdef __cplusplus +} +#endif diff --git a/ui_freenove_allinone/include/npc/npc_v3.h b/ui_freenove_allinone/include/npc/npc_v3.h new file mode 100644 index 0000000..0ec7f27 --- /dev/null +++ b/ui_freenove_allinone/include/npc/npc_v3.h @@ -0,0 +1,111 @@ +// npc_v3.h — V3 Adaptive NPC extensions for Professor Zacus. +// Group profiling, adaptive parcours, duration targeting. +// Extends npc_engine.h — include after it. +#pragma once + +#include "npc/npc_engine.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================ +// Group profile (determined after Phase 2 profiling puzzles) +// ============================================================ + +typedef enum { + GROUP_UNKNOWN = 0, + GROUP_TECH = 1, // P2 fast, P1 normal/slow + GROUP_NON_TECH = 2, // P1 fast, P2 normal/slow + GROUP_MIXED = 3, // both similar speed +} group_profile_t; + +// ============================================================ +// V3 adaptive NPC state (companion to npc_state_t) +// ============================================================ + +typedef struct { + group_profile_t group_profile; + uint32_t target_duration_ms; // game master configured (30–90 min) + uint32_t game_start_ms; + uint32_t profiling_p1_time_ms; // P1 solve time (0 = not yet solved) + uint32_t profiling_p2_time_ms; // P2 solve time (0 = not yet solved) + uint8_t puzzles_solved; + uint8_t total_puzzles; + uint8_t bonus_puzzles_added; + bool duration_warning_sent; +} npc_v3_state_t; + +// ============================================================ +// V3 adaptive actions +// ============================================================ + +typedef enum { + NPC_ACTION_NONE = 0, + NPC_ACTION_ADD_BONUS = 1, // add bonus puzzle for fast group + NPC_ACTION_SKIP_PUZZLE = 2, // skip optional puzzle for slow group + NPC_ACTION_PROACTIVE_HINT = 3, // unsolicited hint for stuck group + NPC_ACTION_DURATION_WARN = 4, // 80% time consumed warning + NPC_ACTION_SET_PROFILE = 5, // classify group after profiling +} npc_v3_action_t; + +typedef struct { + npc_v3_action_t action; + group_profile_t new_profile; // valid for NPC_ACTION_SET_PROFILE + uint8_t puzzle_to_skip; // valid for NPC_ACTION_SKIP_PUZZLE (puzzle id 1-7) + char phrase_key[64]; // npc_phrases.yaml key to play +} npc_v3_decision_t; + +// ============================================================ +// Puzzle selection helpers +// ============================================================ + +// Maximum parcours length (MIXED has 6 puzzles) +#define NPC_V3_MAX_PARCOURS 8 + +// Populate parcours array based on group profile. +// out_puzzles: array of at least NPC_V3_MAX_PARCOURS uint8_t. +// Returns length of populated parcours. +uint8_t npc_v3_select_puzzles(group_profile_t profile, + uint8_t out_puzzles[NPC_V3_MAX_PARCOURS]); + +// ============================================================ +// V3 NPC API +// ============================================================ + +/// Initialize V3 state. +/// target_duration_ms: configured by game master at boot (1800000 = 30 min, max 5400000). +/// now_ms: current tick count in milliseconds. +void npc_v3_init(npc_v3_state_t* v3, uint32_t target_duration_ms, uint32_t now_ms); + +/// Record profiling puzzle solve time. +/// puzzle_id: 1 (P1_SON) or 2 (P2_CIRCUIT). +/// solve_time_ms: elapsed ms from puzzle activation to solve. +void npc_v3_on_profiling_solved(npc_v3_state_t* v3, uint8_t puzzle_id, + uint32_t solve_time_ms); + +/// Classify group profile from P1/P2 solve times. +/// Returns GROUP_UNKNOWN if both profiling times are not yet recorded. +group_profile_t npc_v3_classify_group(const npc_v3_state_t* v3); + +/// Evaluate V3 adaptive rules; produces at most one action per call. +/// Returns true if an action should be executed (out is populated). +/// Call from game_coordinator_tick() after npc_evaluate(). +bool npc_v3_evaluate(npc_v3_state_t* v3, const npc_state_t* base, + uint32_t now_ms, npc_v3_decision_t* out); + +/// Check duration budget: returns true and populates out if 80% of +/// target_duration_ms has elapsed with puzzles still remaining. +/// Marks duration_warning_sent to prevent repeats. +bool npc_v3_check_duration(npc_v3_state_t* v3, uint32_t now_ms, + npc_v3_decision_t* out); + +/// Notify V3 engine that an adaptive-phase puzzle was solved. +/// Updates puzzles_solved counter and bonus_puzzles_added if applicable. +void npc_v3_on_puzzle_solved(npc_v3_state_t* v3, uint8_t puzzle_id); + +#ifdef __cplusplus +} +#endif diff --git a/ui_freenove_allinone/include/npc/tts_client.h b/ui_freenove_allinone/include/npc/tts_client.h index e924ac1..1866054 100644 --- a/ui_freenove_allinone/include/npc/tts_client.h +++ b/ui_freenove_allinone/include/npc/tts_client.h @@ -58,6 +58,40 @@ tts_result_t tts_synthesize(const char* text, uint8_t* out_buf, /// Get TTS client statistics. tts_stats_t tts_get_stats(void); +// ============================================================ +// V3 fallback chain: XTTS-v2 → Piper → SD card +// ============================================================ + +// XTTS-v2 endpoint on KXKM-AI (GPU, ~2s latency, voice clone) +#define TTS_XTTS_HOST "kxkm-ai" +#define TTS_XTTS_PORT 5002 +#define TTS_XTTS_API_PATH "/v1/audio/speech" + +// Piper TTS fallback (CPU, ~500ms, Tower:8001) +#define TTS_PIPER_API_PATH "/v1/audio/speech" + +// Backend priority +typedef enum { + TTS_BACKEND_XTTS = 0, // KXKM-AI XTTS-v2 (GPU voice clone) + TTS_BACKEND_PIPER = 1, // Tower Piper TTS (CPU, faster) + TTS_BACKEND_SD = 2, // SD card pre-generated WAV (offline) + TTS_BACKEND_COUNT +} tts_backend_t; + +/// Select best available backend (performs health-checks). +/// Health-check results are cached for TTS_HEALTH_INTERVAL_MS. +tts_backend_t tts_select_backend(uint32_t now_ms); + +/// Synthesize text using best available backend. +/// Fallback chain: XTTS → Piper → SD. +/// sd_fallback_key: phrase key used to build SD path (e.g. "hints.P1_SON.level_1.0"). +/// Returns TTS_RESULT_OK on success, error code otherwise. +tts_result_t tts_speak_v3(const char* text, const char* sd_fallback_key, + uint8_t* out_buf, size_t buf_capacity, size_t* out_len); + +/// Return the backend used by the last tts_speak_v3() call. +tts_backend_t tts_last_backend(void); + #ifdef __cplusplus } #endif diff --git a/ui_freenove_allinone/src/audio/audio_ble_control.cpp b/ui_freenove_allinone/src/audio/audio_ble_control.cpp new file mode 100644 index 0000000..ed9d868 --- /dev/null +++ b/ui_freenove_allinone/src/audio/audio_ble_control.cpp @@ -0,0 +1,226 @@ +// audio_ble_control.cpp — BLE/BT AVRCP control for Bluetooth speaker from BOX-3 +// BOX-3 (ESP32-S3) acts as Bluetooth classic controller (A2DP source + AVRCP CT). +// Sends AVRCP passthrough commands: play, pause, stop, next, volume. +// +// Dependencies: esp_bt, esp_a2dp_api, esp_avrc_api (ESP-IDF classic BT stack) +// Note: ESP32-S3-BOX-3 has classic BT (not BLE-only), so A2DP + AVRCP are available. + +#include "audio/audio_ble_control.h" +#include "esp_log.h" +#include "esp_bt.h" +#include "esp_bt_main.h" +#include "esp_gap_bt_api.h" +#include "esp_avrc_api.h" +#include "esp_a2dp_api.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include + +static const char *TAG = "AUDIO_BT"; + +// Track enum — must match pre-loaded tracks on the Bluetooth speaker / media player +typedef enum { + TRACK_LAB_AMBIANCE = 0, // background loop during intro/profiling + TRACK_TENSION = 1, // climax phase + TRACK_VICTORY = 2, // game won + TRACK_FAILURE = 3, // game over / timeout + TRACK_THINKING = 4, // adaptive phase background + TRACK_TRANSITION = 5, // puzzle-to-puzzle transition stinger +} audio_track_t; + +static bool s_bt_connected = false; +static uint8_t s_peer_bda[6] = {0}; +static uint8_t s_current_track = 0xFF; + +// ───────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────────────────────── + +static esp_err_t avrc_passthrough(esp_avrc_pt_cmd_t cmd) +{ + if (!s_bt_connected) { + ESP_LOGW(TAG, "BT speaker not connected — command 0x%02x dropped", cmd); + return ESP_ERR_INVALID_STATE; + } + esp_err_t err = esp_avrc_ct_send_passthrough_cmd( + 0, cmd, ESP_AVRC_PT_CMD_STATE_PUSHED); + if (err != ESP_OK) { + ESP_LOGE(TAG, "AVRCP passthrough 0x%02x failed: %s", cmd, esp_err_to_name(err)); + } + return err; +} + +// ───────────────────────────────────────────────────────────────────────────── +// AVRC event callback +// ───────────────────────────────────────────────────────────────────────────── + +static void avrc_ct_callback(esp_avrc_ct_cb_event_t event, + esp_avrc_ct_cb_param_t *param) +{ + switch (event) { + case ESP_AVRC_CT_CONNECTION_STATE_EVT: + if (param->conn_stat.connected) { + ESP_LOGI(TAG, "AVRCP connected"); + } else { + ESP_LOGI(TAG, "AVRCP disconnected"); + } + break; + case ESP_AVRC_CT_PASSTHROUGH_RSP_EVT: + ESP_LOGD(TAG, "AVRCP passthrough RSP: key_code=0x%02x, key_state=%d", + param->psth_rsp.key_code, param->psth_rsp.key_state); + break; + case ESP_AVRC_CT_CHANGE_NOTIFY_EVT: + ESP_LOGD(TAG, "AVRCP notify event_id=0x%02x", param->change_ntf.event_id); + break; + default: + break; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// A2DP source callback +// ───────────────────────────────────────────────────────────────────────────── + +static void a2dp_cb(esp_a2d_cb_event_t event, esp_a2d_cb_param_t *param) +{ + switch (event) { + case ESP_A2D_CONNECTION_STATE_EVT: + if (param->conn_stat.state == ESP_A2D_CONNECTION_STATE_CONNECTED) { + s_bt_connected = true; + memcpy(s_peer_bda, param->conn_stat.remote_bda, sizeof(s_peer_bda)); + ESP_LOGI(TAG, "A2DP connected to %02x:%02x:%02x:%02x:%02x:%02x", + s_peer_bda[0], s_peer_bda[1], s_peer_bda[2], + s_peer_bda[3], s_peer_bda[4], s_peer_bda[5]); + } else if (param->conn_stat.state == ESP_A2D_CONNECTION_STATE_DISCONNECTED) { + s_bt_connected = false; + ESP_LOGW(TAG, "A2DP disconnected"); + } + break; + case ESP_A2D_AUDIO_STATE_EVT: + ESP_LOGI(TAG, "A2DP audio state: %d", param->audio_stat.state); + break; + default: + break; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +void audio_ble_init(void) +{ + ESP_LOGI(TAG, "Initializing Bluetooth audio control (A2DP + AVRCP)"); + + // Classic BT controller config + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_bt_controller_init(&bt_cfg)); + ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_CLASSIC_BT)); + ESP_ERROR_CHECK(esp_bluedroid_init()); + ESP_ERROR_CHECK(esp_bluedroid_enable()); + + // A2DP source (BOX-3 as audio sender, not relevant for volume/track control + // but required to establish the BT profile stack for AVRCP) + ESP_ERROR_CHECK(esp_a2d_register_callback(a2dp_cb)); + ESP_ERROR_CHECK(esp_a2d_source_init()); + + // AVRCP controller — sends play/stop/volume to speaker + ESP_ERROR_CHECK(esp_avrc_ct_init()); + ESP_ERROR_CHECK(esp_avrc_ct_register_callback(avrc_ct_callback)); + + // Enable absolute volume + esp_avrc_rn_evt_cap_mask_t evt_set = {0}; + esp_avrc_rn_evt_bit_mask_operation(ESP_AVRC_BIT_MASK_OP_SET, &evt_set, + ESP_AVRC_RN_VOLUME_CHANGE); + ESP_ERROR_CHECK(esp_avrc_ct_send_register_notification_cmd( + 0, ESP_AVRC_RN_VOLUME_CHANGE, 0)); + + ESP_LOGI(TAG, "BT audio stack ready — waiting for speaker pairing"); +} + +bool audio_ble_is_connected(void) +{ + return s_bt_connected; +} + +esp_err_t audio_play(void) +{ + return avrc_passthrough(ESP_AVRC_PT_CMD_PLAY); +} + +esp_err_t audio_pause(void) +{ + return avrc_passthrough(ESP_AVRC_PT_CMD_PAUSE); +} + +esp_err_t audio_stop(void) +{ + return avrc_passthrough(ESP_AVRC_PT_CMD_STOP); +} + +esp_err_t audio_next_track(void) +{ + return avrc_passthrough(ESP_AVRC_PT_CMD_FORWARD); +} + +esp_err_t audio_prev_track(void) +{ + return avrc_passthrough(ESP_AVRC_PT_CMD_BACKWARD); +} + +esp_err_t audio_set_volume(uint8_t volume_pct) +{ + if (!s_bt_connected) { + return ESP_ERR_INVALID_STATE; + } + // 0-100% → 0-127 (AVRCP absolute volume range) + uint8_t avrc_vol = (uint8_t)((volume_pct * 127UL) / 100UL); + ESP_LOGI(TAG, "Set volume: %d%% → AVRC %d", volume_pct, avrc_vol); + return esp_avrc_ct_send_set_absolute_volume_cmd(0, avrc_vol); +} + +// Game phase → audio track mapping +// Assumes the Bluetooth speaker playlist is loaded in the order of audio_track_t. +// Navigation uses NEXT/PREV commands relative to current track position. +void audio_play_for_phase(game_phase_t phase) +{ + audio_track_t target; + + switch (phase) { + case GAME_INTRO: + case GAME_PROFILING: + target = TRACK_LAB_AMBIANCE; + break; + case GAME_ADAPTIVE: + target = TRACK_THINKING; + break; + case GAME_CLIMAX: + target = TRACK_TENSION; + break; + case GAME_OUTRO: + target = TRACK_VICTORY; + break; + default: + return; + } + + // Navigate to target track by sending NEXT until we reach the correct index. + // Simplified approach: assumes playlist starts at TRACK_LAB_AMBIANCE (index 0). + if (s_current_track == target) { + audio_play(); + return; + } + + // Skip forward to target (wraps at TRACK_TRANSITION → TRACK_LAB_AMBIANCE) + uint8_t steps = (target > s_current_track) + ? (target - s_current_track) + : (6 - s_current_track + target); + + for (uint8_t i = 0; i < steps; i++) { + audio_next_track(); + vTaskDelay(pdMS_TO_TICKS(200)); // brief delay between AVRCP commands + } + s_current_track = target; + audio_play(); + ESP_LOGI(TAG, "Phase %d → track %d", phase, target); +} diff --git a/ui_freenove_allinone/src/npc/espnow_master.cpp b/ui_freenove_allinone/src/npc/espnow_master.cpp new file mode 100644 index 0000000..02ff587 --- /dev/null +++ b/ui_freenove_allinone/src/npc/espnow_master.cpp @@ -0,0 +1,210 @@ +// espnow_master.cpp — BOX-3 ESP-NOW master implementation +// Runs on Freenove ESP32-S3 BOX-3 (Arduino framework, Wi-Fi already init'd). +// Broadcasts commands to 7 puzzle slaves, collects solve results, +// assembles the 8-digit final code for P7 Coffre. +#include "npc/espnow_master.h" +#include "esp_now.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include +#include + +static const char *TAG = "ESPNOW_MASTER"; + +// Broadcast MAC — all puzzle slaves receive these commands +static const uint8_t kBroadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// --------------------------------------------------------------------------- +// Per-puzzle node state +// --------------------------------------------------------------------------- +struct PuzzleNode { + uint8_t puzzle_id; + uint8_t mac[6]; + bool registered; + bool solved; + uint8_t code_fragment[4]; + uint32_t solve_time_ms; + uint32_t last_heartbeat_ms; +}; + +static PuzzleNode s_nodes[8]; // index = puzzle_id (1-7, 0 unused) +static QueueHandle_t s_recv_queue; +static espnow_event_cb_t s_event_cb; + +struct MasterRecvItem { + uint8_t src_mac[6]; + uint8_t data[250]; + int len; +}; + +// --------------------------------------------------------------------------- +// Wi-Fi ISR callbacks +// --------------------------------------------------------------------------- + +static void on_recv(const esp_now_recv_info_t *info, + const uint8_t *data, int len) +{ + MasterRecvItem item; + memcpy(item.src_mac, info->src_addr, 6); + int copy = len < (int)sizeof(item.data) ? len : (int)sizeof(item.data); + memcpy(item.data, data, copy); + item.len = copy; + xQueueSendFromISR(s_recv_queue, &item, nullptr); +} + +static void on_sent(const uint8_t *mac, esp_now_send_status_t status) +{ + if (status != ESP_NOW_SEND_SUCCESS) { + ESP_LOGW(TAG, "Send failed to " MACSTR, MAC2STR(mac)); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +esp_err_t espnow_master_init(espnow_event_cb_t cb) +{ + s_event_cb = cb; + s_recv_queue = xQueueCreate(16, sizeof(MasterRecvItem)); + if (!s_recv_queue) { + ESP_LOGE(TAG, "Failed to create recv queue"); + return ESP_ERR_NO_MEM; + } + memset(s_nodes, 0, sizeof(s_nodes)); + + ESP_ERROR_CHECK(esp_now_init()); + ESP_ERROR_CHECK(esp_now_register_recv_cb(on_recv)); + ESP_ERROR_CHECK(esp_now_register_send_cb(on_sent)); + + // Register broadcast peer + esp_now_peer_info_t bcast = {}; + memcpy(bcast.peer_addr, kBroadcast, 6); + bcast.channel = 0; + bcast.encrypt = false; + ESP_ERROR_CHECK(esp_now_add_peer(&bcast)); + + ESP_LOGI(TAG, "Master initialized"); + return ESP_OK; +} + +esp_err_t espnow_master_reset_puzzle(uint8_t puzzle_id) +{ + if (puzzle_id < 1 || puzzle_id > 7) return ESP_ERR_INVALID_ARG; + uint8_t buf[2] = { static_cast(MSG_PUZZLE_RESET), puzzle_id }; + return esp_now_send(kBroadcast, buf, sizeof(buf)); +} + +esp_err_t espnow_master_configure_puzzle(uint8_t puzzle_id, + puzzle_difficulty_t difficulty) +{ + if (puzzle_id < 1 || puzzle_id > 7) return ESP_ERR_INVALID_ARG; + uint8_t buf[3] = { + static_cast(MSG_PUZZLE_CONFIG), + puzzle_id, + static_cast(difficulty) + }; + return esp_now_send(kBroadcast, buf, sizeof(buf)); +} + +esp_err_t espnow_master_send_final_code(const char code[9]) +{ + uint8_t buf[10]; + buf[0] = static_cast(MSG_PUZZLE_CONFIG); + buf[1] = 7; // P7 ID + memcpy(buf + 2, code, 8); + return esp_now_send(kBroadcast, buf, sizeof(buf)); +} + +void espnow_master_process(void) +{ + MasterRecvItem item; + while (xQueueReceive(s_recv_queue, &item, 0) == pdTRUE) { + if (item.len < 2) continue; + + auto type = static_cast(item.data[0]); + uint8_t pid = item.data[1]; + if (pid < 1 || pid > 7) continue; + + PuzzleNode &node = s_nodes[pid]; + node.registered = true; + memcpy(node.mac, item.src_mac, 6); + node.last_heartbeat_ms = + static_cast(xTaskGetTickCount() * portTICK_PERIOD_MS); + + if (type == MSG_PUZZLE_SOLVED && item.len >= 10) { + node.solved = true; + memcpy(node.code_fragment, item.data + 2, 4); + node.solve_time_ms = (static_cast(item.data[6]) << 24) + | (static_cast(item.data[7]) << 16) + | (static_cast(item.data[8]) << 8) + | (static_cast(item.data[9]) ); + + ESP_LOGI(TAG, "P%d SOLVED code=%d%d%d%d time=%lu ms", + pid, + node.code_fragment[0], node.code_fragment[1], + node.code_fragment[2], node.code_fragment[3], + node.solve_time_ms); + + if (s_event_cb) { + espnow_event_t ev = {}; + ev.type = ESPNOW_EV_PUZZLE_SOLVED; + ev.puzzle_id = pid; + ev.solve_time_ms = node.solve_time_ms; + memcpy(ev.code_fragment, node.code_fragment, 4); + s_event_cb(&ev); + } + + } else if (type == MSG_HINT_REQUEST) { + ESP_LOGI(TAG, "Hint request from P%d", pid); + if (s_event_cb) { + espnow_event_t ev = {}; + ev.type = ESPNOW_EV_HINT_REQUEST; + ev.puzzle_id = pid; + s_event_cb(&ev); + } + + } else if (type == MSG_HEARTBEAT) { + ESP_LOGD(TAG, "Heartbeat P%d", pid); + if (s_event_cb) { + espnow_event_t ev = {}; + ev.type = ESPNOW_EV_HEARTBEAT; + ev.puzzle_id = pid; + s_event_cb(&ev); + } + } + } +} + +bool espnow_master_all_solved(const uint8_t puzzle_ids[], uint8_t count) +{ + for (uint8_t i = 0; i < count; i++) { + uint8_t id = puzzle_ids[i]; + if (id < 1 || id > 7 || !s_nodes[id].solved) return false; + } + return true; +} + +void espnow_master_assemble_code(char code_out[9]) +{ + // Digit layout (from scenario YAML): + // P1[0], P1[1], P2[0], P4[0], P5[0], P6[0], P6[1], P3[0] + static const uint8_t kCodeMap[8][2] = { + {1, 0}, {1, 1}, // P1 → digits 0,1 + {2, 0}, // P2 → digit 2 + {4, 0}, // P4 → digit 3 + {5, 0}, // P5 → digit 4 + {6, 0}, {6, 1}, // P6 → digits 5,6 + {3, 0}, // P3 → digit 7 + }; + + for (int i = 0; i < 8; i++) { + uint8_t pid = kCodeMap[i][0]; + uint8_t frag_idx = kCodeMap[i][1]; + code_out[i] = '0' + (s_nodes[pid].code_fragment[frag_idx] % 10); + } + code_out[8] = '\0'; + + ESP_LOGI(TAG, "Final code assembled: %s", code_out); +} diff --git a/ui_freenove_allinone/src/npc/game_coordinator.cpp b/ui_freenove_allinone/src/npc/game_coordinator.cpp new file mode 100644 index 0000000..6286a35 --- /dev/null +++ b/ui_freenove_allinone/src/npc/game_coordinator.cpp @@ -0,0 +1,332 @@ +// game_coordinator.cpp — Top-level V3 game state machine. +// Integrates: npc_engine, npc_v3, espnow_master, tts_client. +// State machine: IDLE → INTRO → PROFILING → ADAPTIVE → CLIMAX → OUTRO +#include "npc/game_coordinator.h" +#include "npc/npc_engine.h" +#include "npc/npc_v3.h" +#include "npc/espnow_master.h" +#include "npc/tts_client.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +static const char* TAG = "GAME_COORD"; + +// ============================================================ +// Phase definitions +// ============================================================ + +typedef enum { + GAME_IDLE = 0, + GAME_INTRO = 1, + GAME_PROFILING = 2, + GAME_ADAPTIVE = 3, + GAME_CLIMAX = 4, + GAME_OUTRO = 5, +} game_phase_t; + +// ============================================================ +// Game state +// ============================================================ + +typedef struct { + game_phase_t phase; + npc_state_t npc; + npc_v3_state_t v3; + uint8_t current_puzzle_idx; // index into parcours[] + uint8_t parcours[NPC_V3_MAX_PARCOURS]; + uint8_t parcours_len; + char final_code[9]; // 8-digit assembled code + null + uint32_t score; + uint32_t hints_given_total; +} game_state_t; + +static game_state_t s_game; + +// ============================================================ +// TTS helpers +// ============================================================ + +static void play_phrase(const char* key) { + // In production: look up key in phrase bank → get French text → tts_speak_v3() + // For now: log the key; actual TTS dispatch via tts_speak_v3 when text available. + ESP_LOGI(TAG, "NPC phrase: [%s]", key); +} + +// ============================================================ +// Difficulty selection per group +// ============================================================ + +static puzzle_difficulty_t difficulty_for_profile(group_profile_t profile) { + if (profile == GROUP_NON_TECH) return DIFFICULTY_EASY; + if (profile == GROUP_TECH) return DIFFICULTY_HARD; + return DIFFICULTY_NORMAL; +} + +// ============================================================ +// Puzzle expected durations by id (seconds → ms) +// ============================================================ + +static uint32_t expected_duration_for_puzzle(uint8_t puzzle_id) { + switch (puzzle_id) { + case 1: return 300000U; // P1_SON: 5 min + case 2: return 360000U; // P2_CIRCUIT: 6 min + case 3: return 240000U; // P3_QR: 4 min + case 4: return 300000U; // P4_RADIO: 5 min + case 5: return 420000U; // P5_MORSE: 7 min + case 6: return 360000U; // P6_SYMBOLES: 6 min + case 7: return 300000U; // P7_COFFRE: 5 min + default: return 300000U; + } +} + +// ============================================================ +// Phase transitions +// ============================================================ + +static void transition_to_phase(game_phase_t phase, uint32_t now_ms) { + ESP_LOGI(TAG, "phase %d → %d", (int)s_game.phase, (int)phase); + s_game.phase = phase; + + switch (phase) { + case GAME_INTRO: + play_phrase("ambiance.intro.0"); + // Intro monologue plays for ~5 min; game_coordinator_tick() + // triggers PROFILING automatically based on timer. + break; + + case GAME_PROFILING: + play_phrase("profiling.start"); + espnow_master_reset_puzzle(1); // P1_SON + espnow_master_reset_puzzle(2); // P2_CIRCUIT + npc_on_scene_change(&s_game.npc, 1, + expected_duration_for_puzzle(1), now_ms); + break; + + case GAME_ADAPTIVE: { + // Classify group and select parcours + group_profile_t profile = npc_v3_classify_group(&s_game.v3); + s_game.v3.group_profile = profile; + s_game.parcours_len = npc_v3_select_puzzles(profile, s_game.parcours); + s_game.v3.total_puzzles = s_game.parcours_len; + s_game.current_puzzle_idx = 0; + + // Announce group profile + if (profile == GROUP_TECH) { + play_phrase("adaptation.group_tech_detected.0"); + } else if (profile == GROUP_NON_TECH) { + play_phrase("adaptation.group_non_tech_detected.0"); + } else { + play_phrase("adaptation.group_mixed_detected.0"); + } + + // Activate first adaptive puzzle + uint8_t first_puzzle = s_game.parcours[0]; + espnow_master_reset_puzzle(first_puzzle); + espnow_master_configure_puzzle(first_puzzle, + difficulty_for_profile(profile)); + npc_on_scene_change(&s_game.npc, first_puzzle, + expected_duration_for_puzzle(first_puzzle), now_ms); + ESP_LOGI(TAG, "ADAPTIVE: profile=%d parcours_len=%d first_puzzle=P%d", + (int)profile, (int)s_game.parcours_len, (int)first_puzzle); + break; + } + + case GAME_CLIMAX: + // Assemble 8-digit final code from all puzzle code fragments + espnow_master_assemble_code(s_game.final_code); + ESP_LOGI(TAG, "final code: %s", s_game.final_code); + espnow_master_send_final_code(s_game.final_code); + espnow_master_reset_puzzle(7); // P7_COFFRE + play_phrase("ambiance.climax"); + npc_on_scene_change(&s_game.npc, 7, + expected_duration_for_puzzle(7), now_ms); + break; + + case GAME_OUTRO: { + bool success = (s_game.final_code[0] != 0 && s_game.hints_given_total < 5); + if (success) { + play_phrase("ambiance.outro_success.0"); + } else { + play_phrase("ambiance.outro_partial.0"); + } + // Compute score: base - hint penalties + s_game.score = 1000U; + if (s_game.hints_given_total > 0) { + uint32_t penalty = s_game.hints_given_total * 50U; + s_game.score = (s_game.score > penalty) ? (s_game.score - penalty) : 0U; + } + ESP_LOGI(TAG, "OUTRO: score=%lu hints=%lu", + (unsigned long)s_game.score, + (unsigned long)s_game.hints_given_total); + break; + } + + default: + break; + } +} + +// ============================================================ +// ESP-NOW event callback (called from ISR-safe queue) +// ============================================================ + +static void on_espnow_event(const espnow_event_t* ev) { + uint32_t now = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + uint8_t puzzle_id = ev->puzzle_id; + + if (ev->type == ESPNOW_EV_PUZZLE_SOLVED) { + ESP_LOGI(TAG, "P%d SOLVED (phase=%d)", (int)puzzle_id, (int)s_game.phase); + + if (s_game.phase == GAME_PROFILING) { + // Record profiling time and check if both P1+P2 done + npc_v3_on_profiling_solved(&s_game.v3, puzzle_id, ev->solve_time_ms); + if (s_game.v3.profiling_p1_time_ms > 0 + && s_game.v3.profiling_p2_time_ms > 0) { + transition_to_phase(GAME_ADAPTIVE, now); + } + + } else if (s_game.phase == GAME_ADAPTIVE) { + npc_v3_on_puzzle_solved(&s_game.v3, puzzle_id); + s_game.current_puzzle_idx++; + + // Check if all adaptive puzzles complete (last entry is P7_COFFRE) + bool all_done = (s_game.current_puzzle_idx >= s_game.parcours_len - 1U); + if (all_done) { + transition_to_phase(GAME_CLIMAX, now); + } else { + // Activate next puzzle + uint8_t next = s_game.parcours[s_game.current_puzzle_idx]; + espnow_master_reset_puzzle(next); + espnow_master_configure_puzzle(next, + difficulty_for_profile(s_game.v3.group_profile)); + npc_on_scene_change(&s_game.npc, next, + expected_duration_for_puzzle(next), now); + ESP_LOGI(TAG, "next puzzle: P%d", (int)next); + } + + } else if (s_game.phase == GAME_CLIMAX && puzzle_id == 7) { + transition_to_phase(GAME_OUTRO, now); + } + + } else if (ev->type == ESPNOW_EV_HINT_REQUEST) { + s_game.hints_given_total++; + npc_on_hint_request(&s_game.npc, now); + // Dispatch contextual hint via NPC engine + npc_decision_t dec; + if (npc_evaluate(&s_game.npc, now, &dec)) { + if (dec.audio_source == NPC_AUDIO_LIVE_TTS) { + // tts_speak_v3(dec.phrase_text, npc_build_sd_key(...)) + ESP_LOGI(TAG, "hint TTS: %s", dec.phrase_text); + } else if (dec.audio_source == NPC_AUDIO_SD_CONTEXTUAL + || dec.audio_source == NPC_AUDIO_SD_GENERIC) { + // Play MP3 from SD: dec.sd_path + ESP_LOGI(TAG, "hint SD: %s", dec.sd_path); + } + } + } +} + +// ============================================================ +// Public API +// ============================================================ + +void game_coordinator_init(uint32_t target_duration_ms) { + memset(&s_game, 0, sizeof(s_game)); + npc_init(&s_game.npc); + uint32_t now = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + npc_v3_init(&s_game.v3, target_duration_ms, now); + espnow_master_init(on_espnow_event); + s_game.phase = GAME_IDLE; + ESP_LOGI(TAG, "init complete, target=%lu ms", (unsigned long)target_duration_ms); +} + +void game_coordinator_start(void) { + uint32_t now = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + transition_to_phase(GAME_INTRO, now); +} + +void game_coordinator_tick(void) { + uint32_t now = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + + // Process all pending ESP-NOW messages + espnow_master_process(); + + // Periodic NPC mood update + npc_update_mood(&s_game.npc, now); + tts_health_tick(now); + + // Auto-advance INTRO → PROFILING after 5-minute intro sequence + if (s_game.phase == GAME_INTRO) { + static const uint32_t kIntroDurationMs = 5U * 60U * 1000U; + uint32_t intro_elapsed = now - s_game.v3.game_start_ms; + if (intro_elapsed >= kIntroDurationMs) { + transition_to_phase(GAME_PROFILING, now); + } + return; + } + + // V3 adaptive decisions (profiling, pacing, duration warning) + npc_v3_decision_t v3_dec; + if (npc_v3_evaluate(&s_game.v3, &s_game.npc, now, &v3_dec)) { + switch (v3_dec.action) { + case NPC_ACTION_SET_PROFILE: + // Profile was set inside npc_v3_evaluate; trigger adaptive transition + if (s_game.phase == GAME_PROFILING) { + transition_to_phase(GAME_ADAPTIVE, now); + } + break; + + case NPC_ACTION_SKIP_PUZZLE: + if (s_game.phase == GAME_ADAPTIVE + && s_game.current_puzzle_idx < s_game.parcours_len - 1U) { + s_game.current_puzzle_idx++; + uint8_t next = s_game.parcours[s_game.current_puzzle_idx]; + ESP_LOGW(TAG, "skip → P%d", (int)next); + espnow_master_reset_puzzle(next); + espnow_master_configure_puzzle(next, + difficulty_for_profile(s_game.v3.group_profile)); + npc_on_scene_change(&s_game.npc, next, + expected_duration_for_puzzle(next), now); + } + break; + + case NPC_ACTION_DURATION_WARN: + // duration_warning_sent already set inside npc_v3_check_duration + break; + + case NPC_ACTION_ADD_BONUS: + // bonus_puzzles_added already incremented inside npc_v3_evaluate + // game_coordinator would insert a bonus puzzle into parcours here + ESP_LOGI(TAG, "bonus puzzle signalled; integration pending"); + break; + + default: + break; + } + + if (v3_dec.phrase_key[0]) { + play_phrase(v3_dec.phrase_key); + } + } + + // Base NPC evaluation (stuck timer, QR reaction, etc.) + npc_decision_t dec; + if (npc_evaluate(&s_game.npc, now, &dec)) { + if (dec.audio_source == NPC_AUDIO_LIVE_TTS) { + ESP_LOGI(TAG, "base NPC TTS: %s", dec.phrase_text); + } else if (dec.audio_source != NPC_AUDIO_NONE) { + ESP_LOGI(TAG, "base NPC SD: %s", dec.sd_path); + } + } +} + +game_phase_t game_coordinator_phase(void) { + return (game_phase_t)s_game.phase; +} + +uint32_t game_coordinator_score(void) { + return s_game.score; +} diff --git a/ui_freenove_allinone/src/npc/npc_v3.cpp b/ui_freenove_allinone/src/npc/npc_v3.cpp new file mode 100644 index 0000000..6472921 --- /dev/null +++ b/ui_freenove_allinone/src/npc/npc_v3.cpp @@ -0,0 +1,225 @@ +// npc_v3.cpp — V3 Adaptive NPC logic: group profiling, parcours selection, +// duration targeting. Companion to npc_engine.cpp. +#include "npc/npc_v3.h" +#include "esp_log.h" +#include +#include + +static const char* TAG = "NPC_V3"; + +// ============================================================ +// Profiling thresholds (from v3_constants.yaml) +// ============================================================ +#define V3_FAST_THRESHOLD_MS (180U * 1000U) // < 3 min = fast on profiling puzzle +#define V3_SLOW_THRESHOLD_MS (480U * 1000U) // > 8 min = slow on profiling puzzle +#define V3_FAST_SCENE_PCT 60U // < 60% of expected scene time = fast overall +#define V3_SLOW_SCENE_PCT 130U // > 130% = slow overall +#define V3_SKIP_SCENE_PCT 160U // > 160% = skip optional puzzle +#define V3_DURATION_WARN_PCT 80U // 80% of target_duration → warning +#define V3_MAX_BONUS_PUZZLES 2U + +// ============================================================ +// Parcours definitions per group profile +// P7_COFFRE (id=7) is always last — added by game_coordinator +// ============================================================ + +// TECH: P2, P4, P5, P3 → +P7 climax +static const uint8_t kParcoursTech[] = {2, 4, 5, 3, 7}; +static const uint8_t kParcoursLenTech = 5; + +// NON_TECH: P1, P6, P3, P5 → +P7 climax +static const uint8_t kParcoursNonTech[] = {1, 6, 3, 5, 7}; +static const uint8_t kParcoursLenNonTech = 5; + +// MIXED: P1, P2, P3, P5, P6 → +P7 climax +static const uint8_t kParcoursMixed[] = {1, 2, 3, 5, 6, 7}; +static const uint8_t kParcoursLenMixed = 6; + +// ============================================================ +// npc_v3_init +// ============================================================ + +void npc_v3_init(npc_v3_state_t* v3, uint32_t target_duration_ms, uint32_t now_ms) { + if (!v3) return; + memset(v3, 0, sizeof(*v3)); + v3->group_profile = GROUP_UNKNOWN; + v3->target_duration_ms = target_duration_ms; + v3->game_start_ms = now_ms; + v3->total_puzzles = kParcoursLenMixed; // default until profile determined + ESP_LOGI(TAG, "init target=%lu ms", (unsigned long)target_duration_ms); +} + +// ============================================================ +// npc_v3_on_profiling_solved +// ============================================================ + +void npc_v3_on_profiling_solved(npc_v3_state_t* v3, uint8_t puzzle_id, + uint32_t solve_time_ms) { + if (!v3) return; + if (puzzle_id == 1) { + v3->profiling_p1_time_ms = solve_time_ms; + ESP_LOGI(TAG, "P1 profiling solve_time=%lu ms", (unsigned long)solve_time_ms); + } else if (puzzle_id == 2) { + v3->profiling_p2_time_ms = solve_time_ms; + ESP_LOGI(TAG, "P2 profiling solve_time=%lu ms", (unsigned long)solve_time_ms); + } + v3->puzzles_solved++; +} + +// ============================================================ +// npc_v3_classify_group +// ============================================================ + +group_profile_t npc_v3_classify_group(const npc_v3_state_t* v3) { + if (!v3) return GROUP_UNKNOWN; + if (v3->profiling_p1_time_ms == 0 || v3->profiling_p2_time_ms == 0) { + return GROUP_UNKNOWN; + } + + bool p1_fast = (v3->profiling_p1_time_ms < V3_FAST_THRESHOLD_MS); + bool p2_fast = (v3->profiling_p2_time_ms < V3_FAST_THRESHOLD_MS); + bool p2_slow = (v3->profiling_p2_time_ms > V3_SLOW_THRESHOLD_MS); + + group_profile_t profile; + if (p2_fast && !p1_fast) { + profile = GROUP_TECH; + } else if (p1_fast && p2_slow) { + profile = GROUP_NON_TECH; + } else { + profile = GROUP_MIXED; + } + + ESP_LOGI(TAG, "classify: p1=%lu p2=%lu → profile=%d", + (unsigned long)v3->profiling_p1_time_ms, + (unsigned long)v3->profiling_p2_time_ms, + (int)profile); + return profile; +} + +// ============================================================ +// npc_v3_select_puzzles +// ============================================================ + +uint8_t npc_v3_select_puzzles(group_profile_t profile, + uint8_t out_puzzles[NPC_V3_MAX_PARCOURS]) { + const uint8_t* src = kParcoursMixed; + uint8_t len = kParcoursLenMixed; + + if (profile == GROUP_TECH) { + src = kParcoursTech; + len = kParcoursLenTech; + } else if (profile == GROUP_NON_TECH) { + src = kParcoursNonTech; + len = kParcoursLenNonTech; + } + + memcpy(out_puzzles, src, len); + ESP_LOGI(TAG, "select_puzzles profile=%d len=%d", (int)profile, (int)len); + return len; +} + +// ============================================================ +// npc_v3_check_duration +// ============================================================ + +bool npc_v3_check_duration(npc_v3_state_t* v3, uint32_t now_ms, + npc_v3_decision_t* out) { + if (!v3 || !out) return false; + if (v3->duration_warning_sent) return false; + if (v3->target_duration_ms == 0) return false; + + uint32_t elapsed = now_ms - v3->game_start_ms; + uint32_t threshold = (v3->target_duration_ms * V3_DURATION_WARN_PCT) / 100U; + + // Warn only when at least one puzzle remains + if (elapsed > threshold && v3->puzzles_solved < v3->total_puzzles - 1U) { + memset(out, 0, sizeof(*out)); + out->action = NPC_ACTION_DURATION_WARN; + snprintf(out->phrase_key, sizeof(out->phrase_key), + "adaptation.duration_warning.0"); + v3->duration_warning_sent = true; + ESP_LOGW(TAG, "duration warning: elapsed=%lu threshold=%lu", + (unsigned long)elapsed, (unsigned long)threshold); + return true; + } + return false; +} + +// ============================================================ +// npc_v3_on_puzzle_solved +// ============================================================ + +void npc_v3_on_puzzle_solved(npc_v3_state_t* v3, uint8_t puzzle_id) { + if (!v3) return; + v3->puzzles_solved++; + ESP_LOGI(TAG, "puzzle P%d solved, total=%d/%d", + (int)puzzle_id, (int)v3->puzzles_solved, (int)v3->total_puzzles); +} + +// ============================================================ +// npc_v3_evaluate — main decision function +// ============================================================ + +bool npc_v3_evaluate(npc_v3_state_t* v3, const npc_state_t* base, + uint32_t now_ms, npc_v3_decision_t* out) { + if (!v3 || !base || !out) return false; + memset(out, 0, sizeof(*out)); + out->action = NPC_ACTION_NONE; + + // 1. Duration warning (highest priority) + if (npc_v3_check_duration(v3, now_ms, out)) { + return true; + } + + // 2. Classify group profile after both profiling puzzles solved + if (v3->group_profile == GROUP_UNKNOWN + && v3->profiling_p1_time_ms > 0 + && v3->profiling_p2_time_ms > 0) { + group_profile_t profile = npc_v3_classify_group(v3); + out->action = NPC_ACTION_SET_PROFILE; + out->new_profile = profile; + if (profile == GROUP_TECH) { + snprintf(out->phrase_key, sizeof(out->phrase_key), + "adaptation.group_tech_detected.0"); + } else if (profile == GROUP_NON_TECH) { + snprintf(out->phrase_key, sizeof(out->phrase_key), + "adaptation.group_non_tech_detected.0"); + } else { + snprintf(out->phrase_key, sizeof(out->phrase_key), + "adaptation.group_mixed_detected.0"); + } + return true; + } + + // 3. Adaptive pacing: check current scene progress + if (base->expected_scene_duration_ms > 0) { + uint32_t scene_elapsed = now_ms - base->scene_start_ms; + uint32_t pct = (scene_elapsed * 100U) / base->expected_scene_duration_ms; + + // Fast group: add bonus puzzle + if (pct < V3_FAST_SCENE_PCT + && v3->bonus_puzzles_added < V3_MAX_BONUS_PUZZLES + && v3->puzzles_solved < v3->total_puzzles - 1U) { + out->action = NPC_ACTION_ADD_BONUS; + snprintf(out->phrase_key, sizeof(out->phrase_key), + "adaptation.bonus_puzzle_added.%d", + (int)(v3->bonus_puzzles_added % 2)); + v3->bonus_puzzles_added++; + ESP_LOGI(TAG, "add bonus puzzle #%d (pct=%lu)", + (int)v3->bonus_puzzles_added, (unsigned long)pct); + return true; + } + + // Slow group: skip optional puzzle at 160% threshold + if (pct > V3_SKIP_SCENE_PCT + && v3->puzzles_solved < v3->total_puzzles - 1U) { + out->action = NPC_ACTION_SKIP_PUZZLE; + snprintf(out->phrase_key, sizeof(out->phrase_key), + "adaptation.puzzle_skipped.0"); + ESP_LOGW(TAG, "skip puzzle (pct=%lu)", (unsigned long)pct); + return true; + } + } + + return false; +} diff --git a/ui_freenove_allinone/src/npc/tts_client.cpp b/ui_freenove_allinone/src/npc/tts_client.cpp index 4a3cfbc..ec4b11c 100644 --- a/ui_freenove_allinone/src/npc/tts_client.cpp +++ b/ui_freenove_allinone/src/npc/tts_client.cpp @@ -141,3 +141,169 @@ tts_result_t tts_synthesize(const char* text, uint8_t* out_buf, tts_stats_t tts_get_stats(void) { return s_stats; } + +// ============================================================ +// V3 fallback chain: XTTS-v2 → Piper → SD card +// ============================================================ + +static tts_backend_t s_last_backend = TTS_BACKEND_XTTS; +static bool s_xtts_reachable = false; +static uint32_t s_xtts_last_check_ms = 0; + +// Check a generic HTTP endpoint for reachability (HEAD /health). +static bool tts_check_url(const char* url) { + if (WiFi.status() != WL_CONNECTED) return false; + + HTTPClient http; + http.setTimeout(TTS_TIMEOUT_MS); + bool ok = false; + if (http.begin(url)) { + int code = http.sendRequest("GET"); + ok = (code >= 200 && code < 400); + http.end(); + } + return ok; +} + +// Synthesize via HTTP endpoint (shared logic for XTTS and Piper). +// url: full endpoint URL (e.g. "http://kxkm-ai:5002/v1/audio/speech") +// text: NPC phrase text +static tts_result_t tts_speak_http(const char* url, const char* text, + uint8_t* out_buf, size_t buf_capacity, + size_t* out_len) { + if (!url || !text || !out_buf || !out_len) return TTS_RESULT_ALLOC_FAIL; + *out_len = 0; + + if (strlen(text) > TTS_MAX_TEXT_LEN) return TTS_RESULT_TEXT_TOO_LONG; + + s_stats.total_requests++; + + HTTPClient http; + http.setTimeout(TTS_TIMEOUT_MS); + if (!http.begin(url)) { + s_stats.total_failures++; + return TTS_RESULT_HTTP_ERROR; + } + + http.addHeader("Content-Type", "application/json"); + + // OpenAI-compatible JSON body + char body[TTS_MAX_TEXT_LEN + 64]; + snprintf(body, sizeof(body), "{\"input\":\"%s\",\"voice\":\"%s\"}", text, TTS_VOICE); + + uint32_t t0 = millis(); + int code = http.POST(body); + s_stats.last_latency_ms = millis() - t0; + + if (code != 200) { + Serial.printf("[TTS_V3] POST failed: %d (%lu ms)\n", code, + (unsigned long)s_stats.last_latency_ms); + http.end(); + s_stats.total_failures++; + return (s_stats.last_latency_ms >= (uint32_t)TTS_TIMEOUT_MS) + ? TTS_RESULT_TIMEOUT : TTS_RESULT_HTTP_ERROR; + } + + int content_len = http.getSize(); + if (content_len <= 0 || (size_t)content_len > buf_capacity) { + Serial.printf("[TTS_V3] bad content_len: %d\n", content_len); + http.end(); + s_stats.total_failures++; + return TTS_RESULT_ALLOC_FAIL; + } + + WiFiClient* stream = http.getStreamPtr(); + size_t total_read = 0; + while (total_read < (size_t)content_len) { + int avail = stream->available(); + if (avail <= 0) { + if (!stream->connected()) break; + delay(1); + continue; + } + int to_read = (avail > 1024) ? 1024 : avail; + if (total_read + (size_t)to_read > buf_capacity) + to_read = (int)(buf_capacity - total_read); + int r = stream->read(out_buf + total_read, to_read); + if (r > 0) total_read += (size_t)r; + } + + http.end(); + *out_len = total_read; + Serial.printf("[TTS_V3] OK via %s: %u bytes, %lu ms\n", url, + (unsigned)total_read, (unsigned long)s_stats.last_latency_ms); + return TTS_RESULT_OK; +} + +tts_backend_t tts_select_backend(uint32_t now_ms) { + // Refresh XTTS reachability if interval elapsed + if (now_ms - s_xtts_last_check_ms >= TTS_HEALTH_INTERVAL_MS) { + char xtts_health[64]; + snprintf(xtts_health, sizeof(xtts_health), + "http://%s:%d/health", TTS_XTTS_HOST, TTS_XTTS_PORT); + s_xtts_reachable = tts_check_url(xtts_health); + s_xtts_last_check_ms = now_ms; + Serial.printf("[TTS_V3] XTTS health: %s\n", + s_xtts_reachable ? "OK" : "DOWN"); + } + + if (s_xtts_reachable) return TTS_BACKEND_XTTS; + if (s_stats.tower_reachable) return TTS_BACKEND_PIPER; + return TTS_BACKEND_SD; +} + +tts_result_t tts_speak_v3(const char* text, const char* sd_fallback_key, + uint8_t* out_buf, size_t buf_capacity, size_t* out_len) { + uint32_t now = millis(); + tts_backend_t backend = tts_select_backend(now); + s_last_backend = backend; + + if (backend == TTS_BACKEND_XTTS) { + char url[64]; + snprintf(url, sizeof(url), "http://%s:%d%s", + TTS_XTTS_HOST, TTS_XTTS_PORT, TTS_XTTS_API_PATH); + tts_result_t r = tts_speak_http(url, text, out_buf, buf_capacity, out_len); + if (r == TTS_RESULT_OK) return TTS_RESULT_OK; + // XTTS failed: fall through to Piper + Serial.printf("[TTS_V3] XTTS failed (%d), falling back to Piper\n", r); + s_xtts_reachable = false; + backend = TTS_BACKEND_PIPER; + s_last_backend = backend; + } + + if (backend == TTS_BACKEND_PIPER) { + char url[64]; + snprintf(url, sizeof(url), "http://%s:%d%s", + TTS_TOWER_IP, TTS_TOWER_PORT, TTS_PIPER_API_PATH); + tts_result_t r = tts_speak_http(url, text, out_buf, buf_capacity, out_len); + if (r == TTS_RESULT_OK) return TTS_RESULT_OK; + // Piper failed: fall through to SD + Serial.printf("[TTS_V3] Piper failed (%d), falling back to SD\n", r); + s_stats.tower_reachable = false; + backend = TTS_BACKEND_SD; + s_last_backend = backend; + } + + // SD card fallback: play pre-generated file from hotline_tts/ + if (sd_fallback_key && sd_fallback_key[0]) { + char sd_path[128]; + // Convert key dots to slashes: hints.P1_SON.level_1.0 → /hotline_tts/hints/P1_SON/level_1/0.wav + snprintf(sd_path, sizeof(sd_path), "/hotline_tts/%s.wav", sd_fallback_key); + // Replace dots with slashes inside the key portion + char* key_start = sd_path + strlen("/hotline_tts/"); + for (char* p = key_start; *p; p++) { + if (*p == '.') *p = '/'; + } + Serial.printf("[TTS_V3] SD fallback: %s\n", sd_path); + // Caller must implement tts_play_sd_file(sd_path) — return OK as signal + // to use sd_path. out_len = 0 indicates SD path should be used. + if (out_len) *out_len = 0; + return TTS_RESULT_TOWER_DOWN; // caller checks last_backend == SD + } + + return TTS_RESULT_TOWER_DOWN; +} + +tts_backend_t tts_last_backend(void) { + return s_last_backend; +}