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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 (30–90 min)
|
||||
uint32_t game_start_ms;
|
||||
uint32_t profiling_p1_time_ms; // P1 solve time (0 = not yet solved)
|
||||
uint32_t profiling_p2_time_ms; // P2 solve time (0 = not yet solved)
|
||||
uint8_t puzzles_solved;
|
||||
uint8_t total_puzzles;
|
||||
uint8_t bonus_puzzles_added;
|
||||
bool duration_warning_sent;
|
||||
} npc_v3_state_t;
|
||||
|
||||
// ============================================================
|
||||
// V3 adaptive actions
|
||||
// ============================================================
|
||||
|
||||
typedef enum {
|
||||
NPC_ACTION_NONE = 0,
|
||||
NPC_ACTION_ADD_BONUS = 1, // add bonus puzzle for fast group
|
||||
NPC_ACTION_SKIP_PUZZLE = 2, // skip optional puzzle for slow group
|
||||
NPC_ACTION_PROACTIVE_HINT = 3, // unsolicited hint for stuck group
|
||||
NPC_ACTION_DURATION_WARN = 4, // 80% time consumed warning
|
||||
NPC_ACTION_SET_PROFILE = 5, // classify group after profiling
|
||||
} npc_v3_action_t;
|
||||
|
||||
typedef struct {
|
||||
npc_v3_action_t action;
|
||||
group_profile_t new_profile; // valid for NPC_ACTION_SET_PROFILE
|
||||
uint8_t puzzle_to_skip; // valid for NPC_ACTION_SKIP_PUZZLE (puzzle id 1-7)
|
||||
char phrase_key[64]; // npc_phrases.yaml key to play
|
||||
} npc_v3_decision_t;
|
||||
|
||||
// ============================================================
|
||||
// Puzzle selection helpers
|
||||
// ============================================================
|
||||
|
||||
// Maximum parcours length (MIXED has 6 puzzles)
|
||||
#define NPC_V3_MAX_PARCOURS 8
|
||||
|
||||
// Populate parcours array based on group profile.
|
||||
// out_puzzles: array of at least NPC_V3_MAX_PARCOURS uint8_t.
|
||||
// Returns length of populated parcours.
|
||||
uint8_t npc_v3_select_puzzles(group_profile_t profile,
|
||||
uint8_t out_puzzles[NPC_V3_MAX_PARCOURS]);
|
||||
|
||||
// ============================================================
|
||||
// V3 NPC API
|
||||
// ============================================================
|
||||
|
||||
/// Initialize V3 state.
|
||||
/// target_duration_ms: configured by game master at boot (1800000 = 30 min, max 5400000).
|
||||
/// now_ms: current tick count in milliseconds.
|
||||
void npc_v3_init(npc_v3_state_t* v3, uint32_t target_duration_ms, uint32_t now_ms);
|
||||
|
||||
/// Record profiling puzzle solve time.
|
||||
/// puzzle_id: 1 (P1_SON) or 2 (P2_CIRCUIT).
|
||||
/// solve_time_ms: elapsed ms from puzzle activation to solve.
|
||||
void npc_v3_on_profiling_solved(npc_v3_state_t* v3, uint8_t puzzle_id,
|
||||
uint32_t solve_time_ms);
|
||||
|
||||
/// Classify group profile from P1/P2 solve times.
|
||||
/// Returns GROUP_UNKNOWN if both profiling times are not yet recorded.
|
||||
group_profile_t npc_v3_classify_group(const npc_v3_state_t* v3);
|
||||
|
||||
/// Evaluate V3 adaptive rules; produces at most one action per call.
|
||||
/// Returns true if an action should be executed (out is populated).
|
||||
/// Call from game_coordinator_tick() after npc_evaluate().
|
||||
bool npc_v3_evaluate(npc_v3_state_t* v3, const npc_state_t* base,
|
||||
uint32_t now_ms, npc_v3_decision_t* out);
|
||||
|
||||
/// Check duration budget: returns true and populates out if 80% of
|
||||
/// target_duration_ms has elapsed with puzzles still remaining.
|
||||
/// Marks duration_warning_sent to prevent repeats.
|
||||
bool npc_v3_check_duration(npc_v3_state_t* v3, uint32_t now_ms,
|
||||
npc_v3_decision_t* out);
|
||||
|
||||
/// Notify V3 engine that an adaptive-phase puzzle was solved.
|
||||
/// Updates puzzles_solved counter and bonus_puzzles_added if applicable.
|
||||
void npc_v3_on_puzzle_solved(npc_v3_state_t* v3, uint8_t puzzle_id);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user