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.
This commit is contained in:
L'électron rare
2026-04-03 07:36:51 +02:00
parent 8a83ebaa0e
commit 6129375916
31 changed files with 3487 additions and 39 deletions
-22
View File
@@ -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"
}
}
+215
View File
@@ -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 <string.h>
#include <stdio.h>
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);
}
+63
View File
@@ -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 <stdint.h>
#include <stdbool.h>
#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
+125
View File
@@ -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 <string.h>
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));
}
}
}
+79
View File
@@ -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 <stdint.h>
#include <stdbool.h>
#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);
-17
View File
@@ -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"
}
}
+14
View File
@@ -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
)
@@ -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)
+311
View File
@@ -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 <string.h>
#include <math.h>
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);
}
+31
View File
@@ -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 =
+<main.c>
+<../../../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
+13
View File
@@ -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
)
+15
View File
@@ -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
)
+299
View File
@@ -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 <string.h>
#include <stdbool.h>
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);
}
+22
View File
@@ -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 =
+<main.c>
+<../../../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
+13
View File
@@ -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
)
@@ -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
)
+408
View File
@@ -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 <string.h>
#include <stdint.h>
#include <stdbool.h>
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);
}
+23
View File
@@ -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 =
+<main.c>
+<../../../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
+13
View File
@@ -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
)
+15
View File
@@ -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
)
+369
View File
@@ -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 <string.h>
#include <stdio.h>
#include <stdbool.h>
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);
}
+23
View File
@@ -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 =
+<main.c>
+<../../../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
@@ -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 <stdint.h>
#include <stdbool.h>
#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
@@ -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 <stdint.h>
#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
+111
View File
@@ -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 <stdint.h>
#include <stdbool.h>
#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 (3090 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
@@ -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
@@ -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 <cstring>
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);
}
@@ -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 <cstring>
#include <cstdio>
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<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] = {
static_cast<uint8_t>(MSG_PUZZLE_CONFIG),
puzzle_id,
static_cast<uint8_t>(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<uint8_t>(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<espnow_msg_type_t>(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<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);
node.solve_time_ms = (static_cast<uint32_t>(item.data[6]) << 24)
| (static_cast<uint32_t>(item.data[7]) << 16)
| (static_cast<uint32_t>(item.data[8]) << 8)
| (static_cast<uint32_t>(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);
}
@@ -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 <cstring>
#include <cstdio>
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;
}
+225
View File
@@ -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 <cstring>
#include <cstdio>
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;
}
+166
View File
@@ -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;
}