feat: NPC engine + BOX-3 voice

- npc_engine: state machine, mood, triggers
- tts_client: Piper TTS HTTP + SD fallback
- qr_scanner: HMAC-SHA256, debounce
- box3_voice: WebSocket voice client
- zacus_story_gen_ai: AI story generator

Impact: Sprint 2-3 firmware deliverables.
This commit is contained in:
L'électron rare
2026-04-02 09:20:43 +02:00
parent 5795f5df13
commit 96a4f19a87
31 changed files with 4575 additions and 0 deletions
+23
View File
@@ -32,3 +32,26 @@ artifacts/
upload_port
monitor_port
.env
# Compiled firmware binaries
*.elf
*.bin
*.map
*.a
# PlatformIO library cache
.pio/libdeps/
.pio/build/
# macOS duplicates
**/* 2
**/* 2.*
**/sdkconfig [0-9]
**/sdkconfig [0-9][0-9]
# Generated NPC audio (regenerated by tools/tts/)
data/hotline_tts/**/*.mp3
!data/hotline_tts/manifest.json
# Local venvs
.venv*/
+4
View File
@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(zacus-box3-voice)
+56
View File
@@ -0,0 +1,56 @@
# Zacus BOX-3 Voice Pipeline
ESP-IDF firmware for ESP32-S3-BOX-3 — voice interface for
*Le Mystere du Professeur Zacus* escape room.
## Prerequisites
- ESP-IDF v5.2+ (with ESP32-S3 support)
- Python 3.8+ (for idf.py)
## Build
```bash
# Set target
idf.py set-target esp32s3
# Configure (optional — defaults in sdkconfig.defaults)
idf.py menuconfig
# Build
idf.py build
# Flash (USB-C on BOX-3)
idf.py -p /dev/tty.usbmodem* flash monitor
```
## Configuration
Use `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**:
| Option | Default | Description |
|--------|---------|-------------|
| WiFi SSID | `zacus-net` | Network for voice bridge |
| WiFi Password | *(empty)* | WPA2 password |
| Voice Bridge URL | `ws://192.168.4.1:8080/ws/voice` | mascarade WebSocket |
| Wake Word | `hi esp` | WakeNet9 trigger phrase |
| Speaker Volume | 70 | 0-100 |
| Mic Gain | 80 | 0-100 |
## Hardware
- **Board**: ESP32-S3-BOX-3 (16MB flash, 8MB PSRAM)
- **Codec**: ES8311 (I2C addr 0x18)
- **Display**: ILI9341 320x240
- **Mic**: onboard MEMS
- **Speaker**: onboard 1W with PA
## Architecture
```
[Mic] -> I2S -> WakeNet -> Voice Capture -> WebSocket -> mascarade bridge
|
[Speaker] <- I2S <- TTS audio <------------- WebSocket -------+
|
hints engine <--+
```
+11
View File
@@ -0,0 +1,11 @@
dependencies:
espressif/esp-box:
version: ">=1.2.0"
espressif/esp-sr:
version: ">=1.4.0"
espressif/esp_codec_dev:
version: ">=1.1.0"
espressif/button:
version: ">=3.2.0"
espressif/esp_websocket_client:
version: ">=1.0.0"
+4
View File
@@ -0,0 +1,4 @@
idf_component_register(
SRCS "main.c" "voice_ws_client.c"
INCLUDE_DIRS "."
)
+56
View File
@@ -0,0 +1,56 @@
menu "Zacus BOX-3 Voice Configuration"
config ZACUS_WIFI_SSID
string "WiFi SSID"
default "zacus-net"
help
WiFi network name for connecting to the voice bridge.
config ZACUS_WIFI_PASSWORD
string "WiFi Password"
default ""
help
WiFi password. Leave empty for open networks.
config ZACUS_VOICE_BRIDGE_URL
string "Voice Bridge WebSocket URL"
default "ws://192.168.0.119:8200/voice/ws"
help
WebSocket endpoint for the mascarade voice bridge.
The bridge routes voice commands to the hints engine.
config ZACUS_VOICE_TOKEN
string "Voice Bridge Auth Token"
default ""
help
Authentication token for the voice bridge.
If set, appended as ?token=<value> query parameter.
config ZACUS_WAKE_WORD
string "Wake Word"
default "hi esp"
help
Wake word for ESP-SR WakeNet activation.
Supported: "hi esp", "alexa", "hey siri" (WakeNet9).
config ZACUS_SPEAKER_VOLUME
int "Default Speaker Volume (0-100)"
default 70
range 0 100
help
Default speaker volume at boot.
config ZACUS_MIC_GAIN
int "Microphone Gain (0-100)"
default 80
range 0 100
help
Microphone input gain for speech recognition.
config ZACUS_ENABLE_DISPLAY
bool "Enable LCD Display"
default y
help
Enable the ILI9341 display for status feedback.
endmenu
+34
View File
@@ -0,0 +1,34 @@
#pragma once
/*
* ESP32-S3-BOX-3 Hardware Pin Configuration
* Reference: Espressif ESP-BOX-3 Schematic v1.2
*/
/* ---------- I2S Audio (ES8311 codec) ---------- */
#define BOX3_I2S_MCLK 16
#define BOX3_I2S_BCLK 9
#define BOX3_I2S_WS 45
#define BOX3_I2S_DOUT 15 /* Speaker data out */
#define BOX3_I2S_DIN 10 /* Microphone data in */
#define BOX3_PA_ENABLE 46 /* Power amplifier enable (active high) */
/* ---------- I2C Bus ---------- */
#define BOX3_I2C_SCL 18
#define BOX3_I2C_SDA 8
#define BOX3_I2C_NUM I2C_NUM_0
#define BOX3_I2C_FREQ_HZ 400000
/* ---------- ES8311 Audio Codec ---------- */
#define BOX3_ES8311_ADDR 0x18
/* ---------- Display (ILI9341) ---------- */
#define BOX3_LCD_WIDTH 320
#define BOX3_LCD_HEIGHT 240
/* ---------- Audio Parameters ---------- */
#define AUDIO_SAMPLE_RATE 16000
#define AUDIO_BITS 16
#define AUDIO_CHANNELS 1
#define AUDIO_FRAME_MS 20
#define AUDIO_FRAME_SAMPLES (AUDIO_SAMPLE_RATE * AUDIO_FRAME_MS / 1000)
+399
View File
@@ -0,0 +1,399 @@
/*
* Zacus BOX-3 Voice — ESP-IDF Application Entry Point
*
* ESP32-S3-BOX-3 voice pipeline for "Le Mystere du Professeur Zacus"
* escape room game. Handles wake-word detection, voice capture,
* WebSocket streaming to the mascarade voice bridge, and speaker output.
*/
#include <stdio.h>
#include <string.h>
#include <math.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_err.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "nvs_flash.h"
#include "driver/i2s_std.h"
#include "driver/gpio.h"
#include "board_config.h"
#include "voice_ws_client.h"
/* BSP header — provided by espressif/esp-box component */
#include "bsp/esp-bsp.h"
static const char *TAG = "zacus-voice";
/* --------------- Shared state --------------- */
static i2s_chan_handle_t s_spk_handle = NULL; /* Speaker TX channel */
static i2s_chan_handle_t s_mic_handle = NULL; /* Microphone RX channel */
static volatile bool s_wifi_connected = false;
static volatile bool s_voice_streaming = false;
/* --------------- Forward declarations --------------- */
static esp_err_t wifi_init_sta(void);
static void audio_test_tone(void);
static void mic_monitor_task(void *arg);
static void tts_audio_callback(const uint8_t *data, size_t len);
/* --------------- WiFi event handler --------------- */
static void wifi_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
ESP_LOGW(TAG, "WiFi disconnected, retrying...");
vTaskDelay(pdMS_TO_TICKS(2000));
esp_wifi_connect();
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Connected — IP: " IPSTR, IP2STR(&event->ip_info.ip));
s_wifi_connected = true;
}
}
/* --------------- WiFi station init --------------- */
static esp_err_t wifi_init_sta(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL));
wifi_config_t wifi_config = {
.sta = {
.ssid = CONFIG_ZACUS_WIFI_SSID,
.password = CONFIG_ZACUS_WIFI_PASSWORD,
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
},
};
/* Allow open networks if no password configured */
if (strlen(CONFIG_ZACUS_WIFI_PASSWORD) == 0) {
wifi_config.sta.threshold.authmode = WIFI_AUTH_OPEN;
}
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "WiFi STA started, connecting to '%s'...", CONFIG_ZACUS_WIFI_SSID);
return ESP_OK;
}
/* --------------- Test tone (440 Hz sine) --------------- */
static void audio_test_tone(void)
{
if (!s_spk_handle) {
ESP_LOGW(TAG, "Speaker not initialized, skipping test tone");
return;
}
ESP_LOGI(TAG, "Playing 440 Hz test tone (1 second)...");
/* Generate 1 second of 440 Hz sine wave using persistent speaker handle */
const int duration_ms = 1000;
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
const float frequency = 440.0f;
const float amplitude = 16000.0f;
int16_t buffer[256];
size_t bytes_written = 0;
int sample_idx = 0;
while (sample_idx < total_samples) {
int chunk = (total_samples - sample_idx < 256) ? (total_samples - sample_idx) : 256;
for (int i = 0; i < chunk; i++) {
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
}
i2s_channel_write(s_spk_handle, buffer, chunk * sizeof(int16_t), &bytes_written, portMAX_DELAY);
sample_idx += chunk;
}
vTaskDelay(pdMS_TO_TICKS(100)); /* Let buffer drain */
ESP_LOGI(TAG, "Test tone complete");
}
/* --------------- TTS audio callback --------------- */
static void tts_audio_callback(const uint8_t *data, size_t len)
{
/*
* Called from WebSocket event context with incoming TTS PCM16 data.
* Write directly to the speaker I2S channel.
*/
if (!s_spk_handle || len == 0) {
return;
}
size_t bytes_written = 0;
esp_err_t ret = i2s_channel_write(s_spk_handle, data, len,
&bytes_written, pdMS_TO_TICKS(200));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "TTS write to speaker failed: %s", esp_err_to_name(ret));
}
}
/* --------------- Speaker init --------------- */
static esp_err_t speaker_init(void)
{
/* Enable power amplifier */
gpio_set_direction(BOX3_PA_ENABLE, GPIO_MODE_OUTPUT);
gpio_set_level(BOX3_PA_ENABLE, 1);
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &s_spk_handle, NULL));
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = BOX3_I2S_MCLK,
.bclk = BOX3_I2S_BCLK,
.ws = BOX3_I2S_WS,
.dout = BOX3_I2S_DOUT,
.din = I2S_GPIO_UNUSED,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_spk_handle, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(s_spk_handle));
ESP_LOGI(TAG, "Speaker I2S initialized (I2S_NUM_0)");
return ESP_OK;
}
/* --------------- Microphone task --------------- */
static void mic_monitor_task(void *arg)
{
ESP_LOGI(TAG, "Starting mic capture task...");
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &s_mic_handle));
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = BOX3_I2S_BCLK,
.ws = BOX3_I2S_WS,
.dout = I2S_GPIO_UNUSED,
.din = BOX3_I2S_DIN,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_mic_handle, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(s_mic_handle));
int16_t buffer[AUDIO_FRAME_SAMPLES];
size_t bytes_read = 0;
int rms_log_counter = 0;
while (1) {
esp_err_t ret = i2s_channel_read(s_mic_handle, buffer,
AUDIO_FRAME_SAMPLES * sizeof(int16_t),
&bytes_read, pdMS_TO_TICKS(1000));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Mic read error: %s", esp_err_to_name(ret));
continue;
}
int samples = bytes_read / sizeof(int16_t);
/* If voice streaming is active, send PCM to bridge */
if (s_voice_streaming) {
voice_state_t st = voice_ws_get_state();
if (st == VOICE_STATE_LISTENING || st == VOICE_STATE_READY) {
voice_ws_send_audio(buffer, samples);
}
}
/* Log RMS periodically (every ~500ms = 25 frames at 20ms) */
rms_log_counter++;
if (rms_log_counter >= 25) {
rms_log_counter = 0;
double sum_sq = 0.0;
for (int i = 0; i < samples; i++) {
sum_sq += (double)buffer[i] * (double)buffer[i];
}
double rms = sqrt(sum_sq / samples);
float db = 20.0f * log10f((float)rms + 1.0f);
ESP_LOGI(TAG, "Mic RMS: %.1f (~%.1f dB) streaming=%s",
rms, db, s_voice_streaming ? "yes" : "no");
}
}
}
/* --------------- Voice bridge connection task --------------- */
static void voice_bridge_task(void *arg)
{
/* Wait for WiFi to be connected */
while (!s_wifi_connected) {
vTaskDelay(pdMS_TO_TICKS(500));
}
ESP_LOGI(TAG, "WiFi connected — initializing voice bridge...");
/* Small delay for network stack to settle */
vTaskDelay(pdMS_TO_TICKS(1000));
esp_err_t ret = voice_ws_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Voice WS init failed: %s", esp_err_to_name(ret));
vTaskDelete(NULL);
return;
}
/* Set TTS audio callback to play through speaker */
voice_ws_set_audio_callback(tts_audio_callback);
ret = voice_ws_connect();
if (ret == ESP_OK) {
ESP_LOGI(TAG, "Voice bridge connected and ready!");
/* Enable streaming — for now always on (wake-word stub).
* TODO: Gate this behind actual wake-word detection from ESP-SR.
* For prototype: use BOX-3 BOOT button (GPIO0) to toggle streaming. */
s_voice_streaming = true;
ESP_LOGI(TAG, "Voice streaming enabled (continuous mode)");
} else if (ret == ESP_ERR_TIMEOUT) {
ESP_LOGW(TAG, "Bridge handshake timed out — will keep retrying via reconnect");
/* Streaming will be enabled when hello_ack arrives */
s_voice_streaming = true;
} else {
ESP_LOGE(TAG, "Voice bridge connection failed: %s", esp_err_to_name(ret));
}
vTaskDelete(NULL);
}
/* --------------- Button handler (BOOT button = GPIO0) --------------- */
static void button_task(void *arg)
{
/* Use BOOT button (GPIO0) to toggle voice streaming on/off.
* This is a wake-word placeholder — press to start/stop listening.
*/
gpio_set_direction(GPIO_NUM_0, GPIO_MODE_INPUT);
gpio_set_pull_mode(GPIO_NUM_0, GPIO_PULLUP_ONLY);
bool last_state = true; /* Pull-up: idle = high */
while (1) {
bool current = gpio_get_level(GPIO_NUM_0);
/* Detect falling edge (button press) */
if (last_state && !current) {
s_voice_streaming = !s_voice_streaming;
ESP_LOGI(TAG, "BOOT button pressed — streaming %s",
s_voice_streaming ? "ON" : "OFF");
if (!s_voice_streaming) {
voice_ws_send_listen_stop();
}
/* Simple debounce */
vTaskDelay(pdMS_TO_TICKS(300));
}
last_state = current;
vTaskDelay(pdMS_TO_TICKS(50));
}
}
/* --------------- Application entry point --------------- */
void app_main(void)
{
ESP_LOGI(TAG, "============================================");
ESP_LOGI(TAG, " Zacus BOX-3 Voice Pipeline");
ESP_LOGI(TAG, " Board: ESP32-S3-BOX-3");
ESP_LOGI(TAG, " IDF: %s", esp_get_idf_version());
ESP_LOGI(TAG, " Free heap: %lu bytes", esp_get_free_heap_size());
ESP_LOGI(TAG, "============================================");
/* Initialize NVS (required for WiFi) */
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
/* Initialize BSP (display, codec, buttons) */
ESP_LOGI(TAG, "Initializing BSP...");
bsp_display_start();
ESP_LOGI(TAG, "Display initialized (%dx%d)", BOX3_LCD_WIDTH, BOX3_LCD_HEIGHT);
/* Initialize persistent speaker output (used for test tone + TTS playback) */
speaker_init();
/* Play test tone to verify audio output */
audio_test_tone();
/* Start mic capture task (reads I2S mic, streams to bridge when active) */
xTaskCreatePinnedToCore(mic_monitor_task, "mic_capture", 4096, NULL, 5, NULL, 1);
/* Start button handler (BOOT button toggles voice streaming) */
xTaskCreate(button_task, "button", 2048, NULL, 4, NULL);
/* Connect to WiFi */
ESP_LOGI(TAG, "Connecting to WiFi...");
wifi_init_sta();
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
/* TODO: Initialize ESP-SR WakeNet for wake-word detection
* - Load WakeNet9 model ("hi esp" or custom)
* - Feed audio frames from mic to WakeNet
* - On wake-word detection, set s_voice_streaming = true
* - Replace BOOT button toggle with wake-word trigger
*/
/* TODO: Game logic integration
* - Subscribe to game state events (puzzle solved, time updates)
* - Display status on ILI9341 (puzzle progress, timer)
* - LED ring feedback for voice activity
*/
/* TODO: OTA update support
* - Check for firmware updates on boot
* - Use ota_0 partition for rollback safety
*/
ESP_LOGI(TAG, "Initialization complete. Press BOOT button to toggle voice streaming.");
}
+480
View File
@@ -0,0 +1,480 @@
/*
* voice_ws_client.c — WebSocket client for mascarade voice bridge
*
* Connects to the voice bridge at ws://<host>:8200/voice/ws,
* streams PCM16 audio frames, receives TTS audio and JSON control messages.
*
* Current version: raw PCM16 (no OPUS encoding).
* The bridge supports WAV/PCM fallback so this works out of the box.
*
* TODO(opus): Add OPUS encode/decode when esp-opus component is available.
* - Encode: voice_ws_send_audio() should OPUS-encode before sending
* - Decode: ws_event_handler WEBSOCKET_EVENT_DATA binary path should
* OPUS-decode before forwarding to audio callback
* - Handshake should advertise "codec":"opus" in hello message
*/
#include <string.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/event_groups.h"
#include "esp_log.h"
#include "esp_err.h"
#include "esp_websocket_client.h"
#include "cJSON.h"
#include "voice_ws_client.h"
#include "board_config.h"
static const char *TAG = "voice-ws";
/* --------------- Internal state --------------- */
static esp_websocket_client_handle_t s_ws_client = NULL;
static SemaphoreHandle_t s_state_mutex = NULL;
static EventGroupHandle_t s_events = NULL;
static voice_state_t s_state = VOICE_STATE_IDLE;
static void (*s_audio_cb)(const uint8_t *data, size_t len) = NULL;
/* Event bits */
#define EVT_CONNECTED BIT0
#define EVT_HELLO_ACK BIT1
#define EVT_DISCONNECTED BIT2
/* Reconnect parameters */
#define RECONNECT_DELAY_MS 5000
#define HANDSHAKE_TIMEOUT_MS 5000
/* --------------- State helpers --------------- */
static void set_state(voice_state_t new_state)
{
if (s_state_mutex) {
xSemaphoreTake(s_state_mutex, portMAX_DELAY);
}
s_state = new_state;
if (s_state_mutex) {
xSemaphoreGive(s_state_mutex);
}
}
voice_state_t voice_ws_get_state(void)
{
voice_state_t st;
if (s_state_mutex) {
xSemaphoreTake(s_state_mutex, portMAX_DELAY);
st = s_state;
xSemaphoreGive(s_state_mutex);
} else {
st = s_state;
}
return st;
}
void voice_ws_set_audio_callback(void (*cb)(const uint8_t *audio_data, size_t len))
{
s_audio_cb = cb;
}
/* --------------- JSON helpers --------------- */
static esp_err_t ws_send_json(const char *json_str)
{
if (!s_ws_client || !esp_websocket_client_is_connected(s_ws_client)) {
ESP_LOGW(TAG, "Cannot send JSON — not connected");
return ESP_ERR_INVALID_STATE;
}
int len = strlen(json_str);
int sent = esp_websocket_client_send_text(s_ws_client, json_str, len, pdMS_TO_TICKS(2000));
if (sent < 0) {
ESP_LOGE(TAG, "Failed to send JSON (%d bytes)", len);
return ESP_FAIL;
}
ESP_LOGD(TAG, "Sent JSON: %s", json_str);
return ESP_OK;
}
static void send_hello(void)
{
cJSON *hello = cJSON_CreateObject();
cJSON_AddStringToObject(hello, "type", "hello");
cJSON_AddStringToObject(hello, "device_id", "zacus-box3");
cJSON_AddStringToObject(hello, "codec", "pcm16"); /* TODO(opus): change to "opus" */
cJSON_AddNumberToObject(hello, "sample_rate", AUDIO_SAMPLE_RATE);
cJSON_AddNumberToObject(hello, "channels", AUDIO_CHANNELS);
cJSON_AddNumberToObject(hello, "frame_ms", AUDIO_FRAME_MS);
char *str = cJSON_PrintUnformatted(hello);
if (str) {
ESP_LOGI(TAG, "Sending hello: %s", str);
ws_send_json(str);
free(str);
}
cJSON_Delete(hello);
}
/* --------------- JSON message handler --------------- */
static void handle_json_message(const char *data, int len)
{
cJSON *root = cJSON_ParseWithLength(data, len);
if (!root) {
ESP_LOGW(TAG, "Failed to parse JSON message");
return;
}
cJSON *type = cJSON_GetObjectItem(root, "type");
if (!type || !cJSON_IsString(type)) {
ESP_LOGW(TAG, "JSON message missing 'type' field");
cJSON_Delete(root);
return;
}
const char *msg_type = type->valuestring;
if (strcmp(msg_type, "hello_ack") == 0) {
ESP_LOGI(TAG, "Received hello_ack — bridge ready");
cJSON *caps = cJSON_GetObjectItem(root, "capabilities");
if (caps) {
char *caps_str = cJSON_PrintUnformatted(caps);
if (caps_str) {
ESP_LOGI(TAG, "Bridge capabilities: %s", caps_str);
free(caps_str);
}
}
set_state(VOICE_STATE_READY);
xEventGroupSetBits(s_events, EVT_HELLO_ACK);
} else if (strcmp(msg_type, "listen_ack") == 0) {
ESP_LOGI(TAG, "Bridge acknowledged listen start");
set_state(VOICE_STATE_LISTENING);
} else if (strcmp(msg_type, "stt") == 0) {
cJSON *text = cJSON_GetObjectItem(root, "text");
cJSON *is_final = cJSON_GetObjectItem(root, "final");
if (text && cJSON_IsString(text)) {
ESP_LOGI(TAG, "STT %s: %s",
(is_final && cJSON_IsTrue(is_final)) ? "FINAL" : "partial",
text->valuestring);
}
if (is_final && cJSON_IsTrue(is_final)) {
set_state(VOICE_STATE_PROCESSING);
}
} else if (strcmp(msg_type, "tts_start") == 0) {
ESP_LOGI(TAG, "TTS playback starting");
set_state(VOICE_STATE_PLAYING);
} else if (strcmp(msg_type, "tts_end") == 0) {
ESP_LOGI(TAG, "TTS playback complete");
set_state(VOICE_STATE_READY);
} else if (strcmp(msg_type, "error") == 0) {
cJSON *msg = cJSON_GetObjectItem(root, "message");
ESP_LOGE(TAG, "Bridge error: %s",
(msg && cJSON_IsString(msg)) ? msg->valuestring : "unknown");
set_state(VOICE_STATE_ERROR);
/* Recover to ready after a brief delay */
vTaskDelay(pdMS_TO_TICKS(1000));
if (esp_websocket_client_is_connected(s_ws_client)) {
set_state(VOICE_STATE_READY);
}
} else if (strcmp(msg_type, "hint") == 0) {
cJSON *puzzle = cJSON_GetObjectItem(root, "puzzle");
cJSON *level = cJSON_GetObjectItem(root, "level");
cJSON *text = cJSON_GetObjectItem(root, "text");
ESP_LOGI(TAG, "Hint [puzzle=%s level=%s]: %s",
(puzzle && cJSON_IsString(puzzle)) ? puzzle->valuestring : "?",
(level && cJSON_IsString(level)) ? level->valuestring : "?",
(text && cJSON_IsString(text)) ? text->valuestring : "");
} else {
ESP_LOGD(TAG, "Unhandled message type: %s", msg_type);
}
cJSON_Delete(root);
}
/* --------------- WebSocket event handler --------------- */
static void ws_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data;
switch (event_id) {
case WEBSOCKET_EVENT_CONNECTED:
ESP_LOGI(TAG, "WebSocket connected");
set_state(VOICE_STATE_CONNECTING);
xEventGroupSetBits(s_events, EVT_CONNECTED);
xEventGroupClearBits(s_events, EVT_DISCONNECTED);
/* Send hello handshake immediately */
send_hello();
break;
case WEBSOCKET_EVENT_DISCONNECTED:
ESP_LOGW(TAG, "WebSocket disconnected");
set_state(VOICE_STATE_IDLE);
xEventGroupSetBits(s_events, EVT_DISCONNECTED);
xEventGroupClearBits(s_events, EVT_CONNECTED | EVT_HELLO_ACK);
break;
case WEBSOCKET_EVENT_DATA:
if (data->op_code == 0x01) {
/* Text frame — JSON control message */
if (data->data_ptr && data->data_len > 0) {
handle_json_message(data->data_ptr, data->data_len);
}
} else if (data->op_code == 0x02) {
/* Binary frame — TTS audio data (PCM16 or OPUS) */
/* TODO(opus): OPUS-decode here before forwarding */
if (s_audio_cb && data->data_ptr && data->data_len > 0) {
s_audio_cb((const uint8_t *)data->data_ptr, data->data_len);
}
}
break;
case WEBSOCKET_EVENT_ERROR:
ESP_LOGE(TAG, "WebSocket error");
set_state(VOICE_STATE_ERROR);
break;
default:
break;
}
}
/* --------------- Reconnect task --------------- */
static void reconnect_task(void *arg)
{
ESP_LOGI(TAG, "Reconnect monitor started");
while (1) {
/* Wait for disconnect event */
EventBits_t bits = xEventGroupWaitBits(s_events, EVT_DISCONNECTED,
pdFALSE, pdFALSE, portMAX_DELAY);
if (bits & EVT_DISCONNECTED) {
ESP_LOGI(TAG, "Auto-reconnect in %d ms...", RECONNECT_DELAY_MS);
vTaskDelay(pdMS_TO_TICKS(RECONNECT_DELAY_MS));
/* Only reconnect if still disconnected and client exists */
if (s_ws_client && !esp_websocket_client_is_connected(s_ws_client)) {
ESP_LOGI(TAG, "Attempting reconnect...");
esp_websocket_client_start(s_ws_client);
}
}
}
}
/* --------------- Public API --------------- */
esp_err_t voice_ws_init(void)
{
if (s_ws_client) {
ESP_LOGW(TAG, "Already initialized");
return ESP_ERR_INVALID_STATE;
}
/* Create synchronization primitives */
s_state_mutex = xSemaphoreCreateMutex();
if (!s_state_mutex) {
ESP_LOGE(TAG, "Failed to create state mutex");
return ESP_ERR_NO_MEM;
}
s_events = xEventGroupCreate();
if (!s_events) {
ESP_LOGE(TAG, "Failed to create event group");
return ESP_ERR_NO_MEM;
}
/* Build URL with optional token query param */
char url[256];
const char *base_url = CONFIG_ZACUS_VOICE_BRIDGE_URL;
#ifdef CONFIG_ZACUS_VOICE_TOKEN
if (strlen(CONFIG_ZACUS_VOICE_TOKEN) > 0) {
snprintf(url, sizeof(url), "%s?token=%s", base_url, CONFIG_ZACUS_VOICE_TOKEN);
} else {
snprintf(url, sizeof(url), "%s", base_url);
}
#else
snprintf(url, sizeof(url), "%s", base_url);
#endif
ESP_LOGI(TAG, "Voice bridge URL: %s", url);
/* Configure WebSocket client */
esp_websocket_client_config_t ws_cfg = {
.uri = url,
.buffer_size = 2048,
.reconnect_timeout_ms = RECONNECT_DELAY_MS,
.network_timeout_ms = 10000,
.ping_interval_sec = 15,
};
s_ws_client = esp_websocket_client_init(&ws_cfg);
if (!s_ws_client) {
ESP_LOGE(TAG, "Failed to init WebSocket client");
return ESP_FAIL;
}
/* Register event handler */
esp_err_t ret = esp_websocket_register_events(s_ws_client, WEBSOCKET_EVENT_ANY,
ws_event_handler, NULL);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to register WS events: %s", esp_err_to_name(ret));
return ret;
}
/* Start reconnect monitor task */
xTaskCreate(reconnect_task, "ws_reconnect", 3072, NULL, 3, NULL);
set_state(VOICE_STATE_IDLE);
ESP_LOGI(TAG, "Voice WS client initialized");
return ESP_OK;
}
esp_err_t voice_ws_connect(void)
{
if (!s_ws_client) {
ESP_LOGE(TAG, "Not initialized — call voice_ws_init() first");
return ESP_ERR_INVALID_STATE;
}
set_state(VOICE_STATE_CONNECTING);
ESP_LOGI(TAG, "Connecting to voice bridge...");
esp_err_t ret = esp_websocket_client_start(s_ws_client);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to start WS client: %s", esp_err_to_name(ret));
set_state(VOICE_STATE_ERROR);
return ret;
}
/* Wait for hello_ack */
EventBits_t bits = xEventGroupWaitBits(s_events, EVT_HELLO_ACK,
pdFALSE, pdFALSE,
pdMS_TO_TICKS(HANDSHAKE_TIMEOUT_MS));
if (!(bits & EVT_HELLO_ACK)) {
ESP_LOGW(TAG, "Handshake timeout — bridge may not have replied");
/* Don't disconnect; the bridge might still come up */
return ESP_ERR_TIMEOUT;
}
ESP_LOGI(TAG, "Voice bridge handshake complete — state=READY");
return ESP_OK;
}
esp_err_t voice_ws_disconnect(void)
{
if (!s_ws_client) {
return ESP_ERR_INVALID_STATE;
}
ESP_LOGI(TAG, "Disconnecting from voice bridge...");
/* Send a goodbye message (best-effort) */
ws_send_json("{\"type\":\"bye\"}");
vTaskDelay(pdMS_TO_TICKS(100));
esp_err_t ret = esp_websocket_client_stop(s_ws_client);
set_state(VOICE_STATE_IDLE);
return ret;
}
esp_err_t voice_ws_send_audio(const int16_t *pcm, size_t samples)
{
if (!s_ws_client || !esp_websocket_client_is_connected(s_ws_client)) {
return ESP_ERR_INVALID_STATE;
}
voice_state_t st = voice_ws_get_state();
if (st != VOICE_STATE_LISTENING && st != VOICE_STATE_READY) {
ESP_LOGD(TAG, "Not in listening state, ignoring audio frame");
return ESP_ERR_INVALID_STATE;
}
/*
* TODO(opus): OPUS encode the PCM16 samples here.
* opus_encode(encoder, pcm, samples, opus_buf, opus_buf_size);
* Then send opus_buf as binary frame instead of raw PCM.
*
* For now, send raw PCM16 little-endian — the bridge has WAV/PCM fallback.
*/
size_t byte_len = samples * sizeof(int16_t);
int sent = esp_websocket_client_send_bin(s_ws_client, (const char *)pcm,
byte_len, pdMS_TO_TICKS(500));
if (sent < 0) {
ESP_LOGW(TAG, "Failed to send audio frame (%d samples)", (int)samples);
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t voice_ws_send_text_query(const char *text)
{
if (!text || strlen(text) == 0) {
return ESP_ERR_INVALID_ARG;
}
cJSON *msg = cJSON_CreateObject();
cJSON_AddStringToObject(msg, "type", "text_query");
cJSON_AddStringToObject(msg, "text", text);
char *str = cJSON_PrintUnformatted(msg);
esp_err_t ret = ESP_FAIL;
if (str) {
ret = ws_send_json(str);
free(str);
}
cJSON_Delete(msg);
if (ret == ESP_OK) {
set_state(VOICE_STATE_PROCESSING);
}
return ret;
}
esp_err_t voice_ws_send_listen_stop(void)
{
cJSON *msg = cJSON_CreateObject();
cJSON_AddStringToObject(msg, "type", "listen");
cJSON_AddStringToObject(msg, "state", "stop");
char *str = cJSON_PrintUnformatted(msg);
esp_err_t ret = ESP_FAIL;
if (str) {
ret = ws_send_json(str);
free(str);
}
cJSON_Delete(msg);
return ret;
}
esp_err_t voice_ws_abort(void)
{
cJSON *msg = cJSON_CreateObject();
cJSON_AddStringToObject(msg, "type", "abort");
char *str = cJSON_PrintUnformatted(msg);
esp_err_t ret = ESP_FAIL;
if (str) {
ret = ws_send_json(str);
free(str);
}
cJSON_Delete(msg);
if (ret == ESP_OK) {
set_state(VOICE_STATE_READY);
}
return ret;
}
+83
View File
@@ -0,0 +1,83 @@
/*
* voice_ws_client.h — WebSocket client for mascarade voice bridge
*
* Protocol: ws://<host>:8200/voice/ws
* - JSON handshake (hello / hello_ack)
* - Binary frames: PCM16 16kHz mono 20ms (320 samples, 640 bytes)
* - JSON control messages: listen, stt, tts, error, text_query
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
VOICE_STATE_IDLE,
VOICE_STATE_CONNECTING,
VOICE_STATE_READY,
VOICE_STATE_LISTENING,
VOICE_STATE_PROCESSING,
VOICE_STATE_PLAYING,
VOICE_STATE_ERROR
} voice_state_t;
/**
* Initialize the WebSocket client (does not connect yet).
* Configures the client with URL from Kconfig and registers event handlers.
*/
esp_err_t voice_ws_init(void);
/**
* Connect to the voice bridge and perform the hello handshake.
* Blocks until hello_ack is received or timeout (5s).
*/
esp_err_t voice_ws_connect(void);
/**
* Disconnect from the voice bridge gracefully.
*/
esp_err_t voice_ws_disconnect(void);
/**
* Send a PCM16 audio frame to the bridge.
* @param pcm Pointer to signed 16-bit PCM samples
* @param samples Number of samples (typically AUDIO_FRAME_SAMPLES = 320)
*/
esp_err_t voice_ws_send_audio(const int16_t *pcm, size_t samples);
/**
* Send a text query (bypass voice, direct text input).
*/
esp_err_t voice_ws_send_text_query(const char *text);
/**
* Signal the bridge to stop listening.
*/
esp_err_t voice_ws_send_listen_stop(void);
/**
* Abort current operation (cancel listen/processing).
*/
esp_err_t voice_ws_abort(void);
/**
* Get current voice pipeline state.
*/
voice_state_t voice_ws_get_state(void);
/**
* Register callback for incoming audio data (TTS playback).
* Called from the WebSocket event task — keep it fast.
* @param cb Callback receiving raw PCM16 audio data and byte length.
*/
void voice_ws_set_audio_callback(void (*cb)(const uint8_t *audio_data, size_t len));
#ifdef __cplusplus
}
#endif
+7
View File
@@ -0,0 +1,7 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
ota_data, data, ota, 0xf000, 0x2000,
phy_init, data, phy, 0x11000, 0x1000,
factory, app, factory, 0x20000, 0x400000,
ota_0, app, ota_0, 0x420000, 0x400000,
spiffs, data, spiffs, 0x820000, 0x400000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 ota_data data ota 0xf000 0x2000
4 phy_init data phy 0x11000 0x1000
5 factory app factory 0x20000 0x400000
6 ota_0 app ota_0 0x420000 0x400000
7 spiffs data spiffs 0x820000 0x400000
+46
View File
@@ -0,0 +1,46 @@
# Target: ESP32-S3
CONFIG_IDF_TARGET="esp32s3"
# Flash: 16MB
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
# Partition table
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
# PSRAM: Octal 8MB
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_BOOT_INIT=y
CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y
# ESP-SR: WakeNet + MultiNet
CONFIG_ESP_SR_WAKENET_ENABLE=y
CONFIG_ESP_SR_WAKENET9_HIESP=y
CONFIG_ESP_SR_MULTINET_ENABLE=y
# WiFi
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=16
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=64
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=64
# FreeRTOS
CONFIG_FREERTOS_HZ=1000
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096
# Logging
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# Watchdog: extend for model loading
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
# Enable HTTPS / TLS for voice bridge
CONFIG_ESP_TLS_USING_MBEDTLS=y
CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
# Heap: place large buffers in PSRAM
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768
@@ -0,0 +1,5 @@
"""Story generation library (Yamale + Jinja2) for Zacus firmware."""
from .generator import StoryGenerationError
__all__ = ["StoryGenerationError"]
@@ -0,0 +1,155 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from .generator import (
StoryGenerationError,
default_paths,
run_generate_bundle,
run_generate_cpp,
run_sync_screens,
run_validate,
)
def _path_or_none(value: str | None) -> Path | None:
if value is None or value == "":
return None
return Path(value)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Zacus story generator (Yamale + Jinja2)")
sub = parser.add_subparsers(dest="command", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--spec-dir", default="", help="Story spec directory")
common.add_argument("--game-dir", default="", help="Game scenario directory")
validate_p = sub.add_parser("validate", parents=[common], help="Validate game + story specs")
cpp_p = sub.add_parser("generate-cpp", parents=[common], help="Generate scenarios_gen/apps_gen C++")
cpp_p.add_argument("--out-dir", default="", help="Output directory for generated C++")
generate_alias = sub.add_parser("generate", parents=[common], help="Alias of generate-cpp")
generate_alias.add_argument("--out-dir", default="", help="Output directory for generated C++")
bundle_p = sub.add_parser("generate-bundle", parents=[common], help="Generate story bundle JSON+sha")
bundle_p.add_argument("--out-dir", default="", help="Bundle root directory")
bundle_p.add_argument("--archive", default="", help="Optional .tar.gz archive output")
sync_screens_p = sub.add_parser(
"sync-screens",
parents=[common],
help="Synchronize data/story/screens and legacy_payloads/fs_excluded/screens from palette",
)
sync_screens_p.add_argument(
"--check",
action="store_true",
help="Check drift only (no writes); non-zero exit on mismatch",
)
deploy_alias = sub.add_parser("deploy", parents=[common], help="Alias of generate-bundle")
deploy_alias.add_argument("--out-dir", default="", help="Bundle root directory")
deploy_alias.add_argument("--archive", default="", help="Optional .tar.gz archive output")
all_p = sub.add_parser("all", parents=[common], help="Validate + generate-cpp + generate-bundle")
all_p.add_argument("--cpp-out-dir", default="", help="Output directory for generated C++")
all_p.add_argument("--bundle-out-dir", default="", help="Bundle root directory")
all_p.add_argument("--archive", default="", help="Optional .tar.gz archive output")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
paths = default_paths()
spec_dir = _path_or_none(args.spec_dir)
game_dir = _path_or_none(args.game_dir)
try:
if args.command == "validate":
result = run_validate(paths, spec_dir=spec_dir, game_dir=game_dir)
print(
f"[story-gen] OK validate scenarios={result['scenario_count']} "
f"game_scenarios={result['game_scenario_count']}"
)
return 0
if args.command in {"generate-cpp", "generate"}:
result = run_generate_cpp(
paths,
out_dir=_path_or_none(args.out_dir),
spec_dir=spec_dir,
game_dir=game_dir,
)
print(
f"[story-gen] OK generate-cpp out={result['out_dir']} "
f"scenarios={result['scenario_count']} spec_hash={result['spec_hash']}"
)
return 0
if args.command in {"generate-bundle", "deploy"}:
archive = _path_or_none(args.archive)
result = run_generate_bundle(
paths,
out_dir=_path_or_none(args.out_dir),
archive=archive,
spec_dir=spec_dir,
game_dir=game_dir,
)
print(
f"[story-gen] OK generate-bundle out={result['out_dir']} "
f"scenarios={result['scenario_count']} spec_hash={result['spec_hash']}"
)
if result["archive"] is not None:
print(f"[story-gen] archive={result['archive']}")
return 0
if args.command == "sync-screens":
result = run_sync_screens(paths, check_only=bool(args.check))
mode = "check" if args.check else "write"
print(
f"[story-gen] OK sync-screens mode={mode} story={result['story_count']} "
f"story_written={result['story_written']} legacy_written={result['legacy_written']} "
f"palette={result['palette_path']} legacy_dir={result['legacy_dir']}"
)
return 0
if args.command == "all":
validate = run_validate(paths, spec_dir=spec_dir, game_dir=game_dir)
cpp = run_generate_cpp(
paths,
out_dir=_path_or_none(args.cpp_out_dir),
spec_dir=spec_dir,
game_dir=game_dir,
)
bundle = run_generate_bundle(
paths,
out_dir=_path_or_none(args.bundle_out_dir),
archive=_path_or_none(args.archive),
spec_dir=spec_dir,
game_dir=game_dir,
)
print(
"[story-gen] OK all "
f"validate={validate['scenario_count']} cpp={cpp['scenario_count']} "
f"bundle={bundle['scenario_count']} spec_hash={cpp['spec_hash']}"
)
if bundle["archive"] is not None:
print(f"[story-gen] archive={bundle['archive']}")
return 0
parser.error(f"Unknown command: {args.command}")
return 2
except StoryGenerationError as exc:
print(f"[story-gen] ERR {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
id: str()
version: int(min=1)
title: str()
theme: str()
players: include('players')
ages: str()
duration_minutes: include('duration')
canon: include('canon')
stations: list(include('station'), min=1)
puzzles: list(include('puzzle'), min=1)
solution: include('solution')
solution_unique: bool()
finale_statement: str(required=False)
notes: str(required=False)
---
players:
min: int(min=1)
max: int(min=1)
duration:
min: int(min=1)
max: int(min=1)
canon:
introduction: str(required=False)
timeline: list(include('timeline_entry'), min=1)
stakes: str(required=False)
timeline_entry:
label: str()
note: str()
station:
name: str()
focus: str()
clue: str()
puzzle:
id: str()
type: str()
clue: str()
effect: str()
solution:
culprit: str()
motive: str()
method: str()
proof: list(str(), min=1)
@@ -0,0 +1,2 @@
prompt_input: any()
current_firmware_snapshot: any(required=False)
@@ -0,0 +1,37 @@
id: str()
version: int(min=1)
initial_step: str()
estimated_duration_s: int(required=False, min=0)
debug_transition_bypass_enabled: bool(required=False)
app_bindings: list(include('app_binding'), min=1)
steps: list(include('step'), min=1)
---
app_binding:
id: str()
app: enum('LA_DETECTOR', 'AUDIO_PACK', 'SCREEN_SCENE', 'MP3_GATE', 'WIFI_STACK', 'ESPNOW_STACK', 'QR_UNLOCK_APP')
config: include('la_config', required=False)
la_config:
hold_ms: int(required=False, min=100)
unlock_event: str(required=False)
require_listening: bool(required=False)
step:
step_id: str()
screen_scene_id: str()
audio_pack_id: str(required=False)
actions: list(str(), required=False)
apps: list(str(), min=1)
mp3_gate_open: bool()
transitions: list(include('transition'))
transition:
id: str(required=False)
trigger: enum('on_event', 'after_ms', 'immediate')
event_type: enum('none', 'unlock', 'audio_done', 'timer', 'serial', 'button', 'espnow', 'action')
event_name: str()
target_step_id: str()
after_ms: int(min=0)
priority: int(min=0)
debug_only: bool(required=False)
@@ -0,0 +1,57 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: {{ spec_hash }}
// scenarios: {{ scenario_count }}
#include "apps_gen.h"
#include <cstring>
namespace {
constexpr AppBindingDef kGeneratedAppBindings[] = {
{% for app in app_entries %} {"{{ app.id }}", {{ app.app_cpp }}},
{% endfor %}};
{% set la_configs = app_entries | selectattr('la_config') | list %}
{% if la_configs %}
constexpr LaDetectorAppConfigDef kGeneratedLaConfigs[] = {
{% for app in la_configs %} {"{{ app.id }}", true, {{ app.la_config.hold_ms }}U, "{{ app.la_config.unlock_event }}", {{ 'true' if app.la_config.require_listening else 'false' }}},
{% endfor %}};
{% endif %}
} // namespace
const AppBindingDef* generatedAppBindingById(const char* id) {
if (id == nullptr || id[0] == '\0') {
return nullptr;
}
for (const AppBindingDef& binding : kGeneratedAppBindings) {
if (binding.id != nullptr && strcmp(binding.id, id) == 0) {
return &binding;
}
}
return nullptr;
}
uint8_t generatedAppBindingCount() {
return static_cast<uint8_t>(sizeof(kGeneratedAppBindings) / sizeof(kGeneratedAppBindings[0]));
}
const char* generatedAppBindingIdAt(uint8_t index) {
if (index >= generatedAppBindingCount()) {
return nullptr;
}
return kGeneratedAppBindings[index].id;
}
const LaDetectorAppConfigDef* generatedLaDetectorConfigByBindingId(const char* id) {
if (id == nullptr || id[0] == '\0') {
return nullptr;
}
{% if la_configs %}
for (const LaDetectorAppConfigDef& cfg : kGeneratedLaConfigs) {
if (cfg.bindingId != nullptr && strcmp(cfg.bindingId, id) == 0) {
return &cfg;
}
}
{% endif %}
return nullptr;
}
@@ -0,0 +1,23 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: {{ spec_hash }}
// scenarios: {{ scenario_count }}
#pragma once
#include <Arduino.h>
#include "../core/scenario_def.h"
struct LaDetectorAppConfigDef {
const char* bindingId;
bool hasConfig;
uint32_t holdMs;
const char* unlockEvent;
bool requireListening;
};
const AppBindingDef* generatedAppBindingById(const char* id);
uint8_t generatedAppBindingCount();
const char* generatedAppBindingIdAt(uint8_t index);
const LaDetectorAppConfigDef* generatedLaDetectorConfigByBindingId(const char* id);
@@ -0,0 +1,78 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: {{ spec_hash }}
// scenarios: {{ scenario_count }}
#include "scenarios_gen.h"
#include <cstring>
namespace {
{% for scenario in scenarios %}
{% for step in scenario.steps %}
{% if step.actions %}
constexpr const char* {{ step.prefix }}Actions[] = {
{% for action in step.actions %} "{{ action }}",
{% endfor %}};
{% endif %}
{% if step.apps %}
constexpr const char* {{ step.prefix }}Apps[] = {
{% for app_id in step.apps %} "{{ app_id }}",
{% endfor %}};
{% endif %}
{% if step.transitions %}
constexpr TransitionDef {{ step.prefix }}Transitions[] = {
{% for tr in step.transitions %} {"{{ tr.id }}", {{ tr.trigger_cpp }}, {{ tr.event_cpp }}, "{{ tr.event_name }}", {{ tr.after_ms }}U, "{{ tr.target_step_id }}", {{ tr.priority }}U, {{ tr.debug_only }}},
{% endfor %}};
{% endif %}
{% endfor %}
constexpr StepDef kScenario{{ scenario.index }}Steps[] = {
{% for step in scenario.steps %} {"{{ step.id }}", {{ "{" }}{% if step.screen_scene_id == 'nullptr' %}nullptr{% else %}"{{ step.screen_scene_id }}"{% endif %}, {% if step.audio_pack_id == 'nullptr' %}nullptr{% else %}"{{ step.audio_pack_id }}"{% endif %}, {% if step.actions %}{{ step.prefix }}Actions{% else %}nullptr{% endif %}, {{ step.actions|length }}U, {% if step.apps %}{{ step.prefix }}Apps{% else %}nullptr{% endif %}, {{ step.apps|length }}U{{ "}" }}, {% if step.transitions %}{{ step.prefix }}Transitions{% else %}nullptr{% endif %}, {{ step.transitions|length }}U, {{ step.mp3_gate_open }}},
{% endfor %}};
constexpr ScenarioDef kScenario{{ scenario.index }} = {
"{{ scenario.id }}",
{{ scenario.version }}U,
kScenario{{ scenario.index }}Steps,
{{ scenario.steps|length }}U,
"{{ scenario.initial_step }}",
};
{% endfor %}
constexpr const ScenarioDef* kGeneratedScenarios[] = {
{% for scenario in scenarios %} &kScenario{{ scenario.index }},
{% endfor %}};
} // namespace
const ScenarioDef* generatedScenarioById(const char* id) {
if (id == nullptr || id[0] == '\0') {
return generatedScenarioDefault();
}
for (const ScenarioDef* scenario : kGeneratedScenarios) {
if (scenario != nullptr && scenario->id != nullptr && strcmp(scenario->id, id) == 0) {
return scenario;
}
}
return nullptr;
}
const ScenarioDef* generatedScenarioDefault() {
return (generatedScenarioCount() == 0U) ? nullptr : kGeneratedScenarios[0];
}
uint8_t generatedScenarioCount() {
return static_cast<uint8_t>(sizeof(kGeneratedScenarios) / sizeof(kGeneratedScenarios[0]));
}
const char* generatedScenarioIdAt(uint8_t index) {
if (index >= generatedScenarioCount()) {
return nullptr;
}
return kGeneratedScenarios[index]->id;
}
const char* generatedScenarioSpecHash() {
return "{{ spec_hash }}";
}
@@ -0,0 +1,16 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: {{ spec_hash }}
// scenarios: {{ scenario_count }}
#pragma once
#include <Arduino.h>
#include "../core/scenario_def.h"
const ScenarioDef* generatedScenarioById(const char* id);
const ScenarioDef* generatedScenarioDefault();
uint8_t generatedScenarioCount();
const char* generatedScenarioIdAt(uint8_t index);
const char* generatedScenarioSpecHash();
@@ -0,0 +1,95 @@
#pragma once
/**
* @file story_hal.h
* @brief Hardware Abstraction Layer for the story engine.
*
* These interfaces decouple the portable story logic from
* platform-specific APIs (Arduino, ESP-IDF, POSIX, etc.).
* Each platform provides its own implementation.
*/
#include <stddef.h>
#include <stdint.h>
namespace zacus_hal {
/// Filesystem abstraction — replaces LittleFS / SPIFFS / VFS.
class IStoryStorage {
public:
virtual ~IStoryStorage() = default;
/// Check if a file exists at the given path.
virtual bool exists(const char* path) = 0;
/// Read entire file into buffer. Returns bytes read, 0 on failure.
virtual size_t readFile(const char* path, uint8_t* buf, size_t bufSize) = 0;
/// Write buffer to file. Returns true on success.
virtual bool writeFile(const char* path, const uint8_t* data, size_t len) = 0;
/// List files in directory. Calls callback for each entry.
/// callback(name, sizeBytes) — return false to stop iteration.
virtual void listDir(const char* dirPath,
bool (*callback)(const char* name, size_t sizeBytes, void* ctx),
void* ctx) = 0;
/// Filesystem info.
virtual bool fsInfo(uint32_t* totalBytes, uint32_t* usedBytes) = 0;
};
/// Logging abstraction — replaces Serial.printf / ESP_LOGx.
class IStoryLogger {
public:
virtual ~IStoryLogger() = default;
virtual void info(const char* tag, const char* fmt, ...) = 0;
virtual void warn(const char* tag, const char* fmt, ...) = 0;
virtual void error(const char* tag, const char* fmt, ...) = 0;
virtual void debug(const char* tag, const char* fmt, ...) = 0;
};
/// Timer abstraction — replaces millis() / esp_timer_get_time().
class IStoryTimer {
public:
virtual ~IStoryTimer() = default;
/// Current time in milliseconds since boot.
virtual uint32_t millis() = 0;
/// Delay (non-blocking if possible on the platform).
virtual void delayMs(uint32_t ms) = 0;
};
/// Audio playback abstraction — replaces audio_manager.
class IStoryAudio {
public:
virtual ~IStoryAudio() = default;
/// Play an audio file (path on filesystem or URL).
virtual bool play(const char* source, uint8_t volume = 80, bool loop = false) = 0;
/// Stop current playback.
virtual void stop() = 0;
/// Check if audio is currently playing.
virtual bool isPlaying() = 0;
/// Set master volume (0-100).
virtual void setVolume(uint8_t volume) = 0;
};
/// Global HAL instance — set once at startup.
struct StoryHAL {
IStoryStorage* storage = nullptr;
IStoryLogger* logger = nullptr;
IStoryTimer* timer = nullptr;
IStoryAudio* audio = nullptr;
};
/// Set the global HAL. Must be called before any story engine use.
void setHAL(const StoryHAL& hal);
/// Get the global HAL.
const StoryHAL& getHAL();
} // namespace zacus_hal
@@ -0,0 +1,145 @@
/**
* @file hal_arduino.cpp
* @brief Arduino/PlatformIO implementation of the Story HAL.
*
* Compile this only when building with Arduino framework.
* For ESP-IDF, use hal_espidf.cpp instead.
*/
#ifdef ARDUINO
#include "zacus_story_portable/story_hal.h"
#include <Arduino.h>
#include <LittleFS.h>
#include <stdarg.h>
#include <stdio.h>
namespace zacus_hal {
// --- Arduino Storage (LittleFS) ---
class ArduinoStorage final : public IStoryStorage {
public:
bool exists(const char* path) override {
return LittleFS.exists(path);
}
size_t readFile(const char* path, uint8_t* buf, size_t bufSize) override {
File f = LittleFS.open(path, "r");
if (!f) return 0;
size_t read = f.read(buf, bufSize);
f.close();
return read;
}
bool writeFile(const char* path, const uint8_t* data, size_t len) override {
File f = LittleFS.open(path, "w");
if (!f) return false;
size_t written = f.write(data, len);
f.close();
return written == len;
}
void listDir(const char* dirPath,
bool (*callback)(const char* name, size_t sizeBytes, void* ctx),
void* ctx) override {
File root = LittleFS.open(dirPath);
if (!root || !root.isDirectory()) return;
File file = root.openNextFile();
while (file) {
if (!callback(file.name(), file.size(), ctx)) break;
file = root.openNextFile();
}
}
bool fsInfo(uint32_t* totalBytes, uint32_t* usedBytes) override {
if (totalBytes) *totalBytes = LittleFS.totalBytes();
if (usedBytes) *usedBytes = LittleFS.usedBytes();
return true;
}
};
// --- Arduino Logger (Serial) ---
class ArduinoLogger final : public IStoryLogger {
public:
void info(const char* tag, const char* fmt, ...) override {
va_list args;
va_start(args, fmt);
Serial.printf("[I][%s] ", tag);
vprintf(fmt, args);
Serial.println();
va_end(args);
}
void warn(const char* tag, const char* fmt, ...) override {
va_list args;
va_start(args, fmt);
Serial.printf("[W][%s] ", tag);
vprintf(fmt, args);
Serial.println();
va_end(args);
}
void error(const char* tag, const char* fmt, ...) override {
va_list args;
va_start(args, fmt);
Serial.printf("[E][%s] ", tag);
vprintf(fmt, args);
Serial.println();
va_end(args);
}
void debug(const char* tag, const char* fmt, ...) override {
va_list args;
va_start(args, fmt);
Serial.printf("[D][%s] ", tag);
vprintf(fmt, args);
Serial.println();
va_end(args);
}
};
// --- Arduino Timer (millis) ---
class ArduinoTimer final : public IStoryTimer {
public:
uint32_t millis() override {
return ::millis();
}
void delayMs(uint32_t ms) override {
::delay(ms);
}
};
// --- Arduino Audio (stub — real impl in audio_manager) ---
class ArduinoAudioStub final : public IStoryAudio {
public:
bool play(const char*, uint8_t, bool) override { return false; }
void stop() override {}
bool isPlaying() override { return false; }
void setVolume(uint8_t) override {}
};
// --- Singleton instances ---
static ArduinoStorage s_storage;
static ArduinoLogger s_logger;
static ArduinoTimer s_timer;
static ArduinoAudioStub s_audio;
/// Call this in setup() to initialize the Arduino HAL.
void initArduinoHAL() {
StoryHAL hal;
hal.storage = &s_storage;
hal.logger = &s_logger;
hal.timer = &s_timer;
hal.audio = &s_audio;
setHAL(hal);
}
} // namespace zacus_hal
#endif // ARDUINO
@@ -0,0 +1,15 @@
#include "zacus_story_portable/story_hal.h"
namespace zacus_hal {
static StoryHAL g_hal = {};
void setHAL(const StoryHAL& hal) {
g_hal = hal;
}
const StoryHAL& getHAL() {
return g_hal;
}
} // namespace zacus_hal
@@ -0,0 +1,112 @@
// npc_engine.h - Professor Zacus NPC decision engine.
// Lightweight state machine: trigger rules, mood system, phrase selection.
#pragma once
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
#define NPC_MAX_SCENES 12
#define NPC_MAX_HINT_LEVEL 3
#define NPC_PHRASE_MAX_LEN 200
#define NPC_STUCK_TIMEOUT_MS (3UL * 60UL * 1000UL)
#define NPC_FAST_THRESHOLD_PCT 50
#define NPC_SLOW_THRESHOLD_PCT 150
#define NPC_QR_DEBOUNCE_MS 30000
typedef enum {
NPC_MOOD_NEUTRAL = 0,
NPC_MOOD_IMPRESSED,
NPC_MOOD_WORRIED,
NPC_MOOD_AMUSED,
NPC_MOOD_COUNT
} npc_mood_t;
typedef enum {
NPC_TRIGGER_NONE = 0,
NPC_TRIGGER_HINT_REQUEST,
NPC_TRIGGER_STUCK_TIMER,
NPC_TRIGGER_QR_SCANNED,
NPC_TRIGGER_WRONG_ACTION,
NPC_TRIGGER_FAST_PROGRESS,
NPC_TRIGGER_SLOW_PROGRESS,
NPC_TRIGGER_SCENE_TRANSITION,
NPC_TRIGGER_GAME_START,
NPC_TRIGGER_GAME_END,
NPC_TRIGGER_COUNT
} npc_trigger_t;
typedef enum {
NPC_AUDIO_NONE = 0,
NPC_AUDIO_LIVE_TTS,
NPC_AUDIO_SD_CONTEXTUAL,
NPC_AUDIO_SD_GENERIC
} npc_audio_source_t;
typedef struct {
uint8_t current_scene;
uint8_t current_step;
uint32_t scene_start_ms;
uint32_t total_elapsed_ms;
uint8_t hints_given[NPC_MAX_SCENES];
uint8_t qr_scanned_count;
uint8_t failed_attempts;
bool phone_off_hook;
bool tower_reachable;
npc_mood_t mood;
uint32_t last_qr_scan_ms;
uint32_t expected_scene_duration_ms;
} npc_state_t;
typedef struct {
npc_trigger_t trigger;
npc_audio_source_t audio_source;
char phrase_text[NPC_PHRASE_MAX_LEN];
char sd_path[128];
npc_mood_t resulting_mood;
} npc_decision_t;
/// Initialize NPC state to defaults.
void npc_init(npc_state_t* state);
/// Reset NPC state for a new game session.
void npc_reset(npc_state_t* state);
/// Evaluate trigger rules and produce a decision.
/// Returns true if NPC wants to speak.
bool npc_evaluate(const npc_state_t* state, uint32_t now_ms, npc_decision_t* out);
/// Notify NPC of a scene change.
void npc_on_scene_change(npc_state_t* state, uint8_t new_scene,
uint32_t expected_duration_ms, uint32_t now_ms);
/// Notify NPC of a QR scan result.
void npc_on_qr_scan(npc_state_t* state, bool valid, uint32_t now_ms);
/// Notify NPC that player picked up / hung up phone.
void npc_on_phone_hook(npc_state_t* state, bool off_hook);
/// Notify NPC of hint request (phone picked up while stuck).
void npc_on_hint_request(npc_state_t* state, uint32_t now_ms);
/// Notify NPC of Tower TTS reachability change.
void npc_on_tower_status(npc_state_t* state, bool reachable);
/// Update mood based on progress ratio (elapsed_ms / expected_ms).
void npc_update_mood(npc_state_t* state, uint32_t now_ms);
/// Get the current hint level for a scene (0 = no hints given, max 3).
uint8_t npc_hint_level(const npc_state_t* state, uint8_t scene);
/// Build SD card fallback path for a given trigger + scene.
/// Writes to out_path, returns true if a valid path was built.
bool npc_build_sd_path(char* out_path, size_t capacity,
uint8_t scene, npc_trigger_t trigger,
npc_mood_t mood, uint8_t variant);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,68 @@
// qr_scanner.h - QR decode task with ZACUS protocol, debounce, anti-cheat.
// Wraps the existing ui::QrScanController with NPC-level logic.
#pragma once
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
#define QR_PREFIX "ZACUS:"
#define QR_PREFIX_LEN 6
#define QR_SCENE_ID_MAX 32
#define QR_EVENT_ID_MAX 32
#define QR_CHECKSUM_LEN 4
#define QR_DEBOUNCE_MS 30000
#define QR_HMAC_KEY "zacus-escape-2026"
#define QR_MAX_HISTORY 16
typedef enum {
QR_VALID = 0,
QR_INVALID_FORMAT,
QR_INVALID_CHECKSUM,
QR_WRONG_SCENE,
QR_DEBOUNCED,
QR_ALREADY_SCANNED
} qr_validation_t;
typedef struct {
char scene_id[QR_SCENE_ID_MAX];
char event_id[QR_EVENT_ID_MAX];
char checksum[QR_CHECKSUM_LEN + 1];
qr_validation_t status;
uint32_t scanned_at_ms;
} qr_decode_result_t;
/// Initialize QR scanner subsystem.
void qr_npc_init(void);
/// Parse a raw QR payload string into structured result.
/// Does NOT validate scene or checksum yet.
bool qr_npc_parse(const char* payload, qr_decode_result_t* out);
/// Validate a parsed QR result against current game state.
/// current_scene_id: the scene the player is currently in.
/// now_ms: current time for debounce check.
qr_validation_t qr_npc_validate(const qr_decode_result_t* decoded,
const char* current_scene_id,
uint32_t now_ms);
/// Compute HMAC-SHA256 checksum (truncated to 4 hex chars).
/// Input: "SCENE_ID:EVENT_ID", output: 4 hex chars in out_checksum.
void qr_npc_compute_checksum(const char* scene_id, const char* event_id,
char out_checksum[5]);
/// Record a scan in history (for anti-cheat / dedup).
void qr_npc_record_scan(const char* scene_id, const char* event_id, uint32_t now_ms);
/// Check if a specific QR was already scanned.
bool qr_npc_was_scanned(const char* scene_id, const char* event_id);
/// Get total valid scans count.
uint8_t qr_npc_scan_count(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,63 @@
// tts_client.h - HTTP client for Piper TTS on Tower:8001 with SD fallback.
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define TTS_TOWER_IP "192.168.0.120"
#define TTS_TOWER_PORT 8001
#define TTS_API_PATH "/api/tts"
#define TTS_VOICE "tom-medium"
#define TTS_TIMEOUT_MS 2000
#define TTS_HEALTH_INTERVAL_MS 30000
#define TTS_MAX_TEXT_LEN 200
#define TTS_WAV_BUF_SIZE (64 * 1024)
typedef enum {
TTS_RESULT_OK = 0,
TTS_RESULT_TIMEOUT,
TTS_RESULT_HTTP_ERROR,
TTS_RESULT_ALLOC_FAIL,
TTS_RESULT_TOWER_DOWN,
TTS_RESULT_TEXT_TOO_LONG
} tts_result_t;
typedef struct {
bool tower_reachable;
uint32_t last_health_check_ms;
uint32_t last_latency_ms;
uint32_t total_requests;
uint32_t total_failures;
} tts_stats_t;
/// Initialize TTS client (call once from setup).
void tts_init(void);
/// Check Tower TTS health (non-blocking, HEAD request).
/// Updates internal reachability state.
/// Returns true if Tower responded within TTS_TIMEOUT_MS.
bool tts_check_health(uint32_t now_ms);
/// Periodic health check — only pings if TTS_HEALTH_INTERVAL_MS elapsed.
void tts_health_tick(uint32_t now_ms);
/// Query last known Tower reachability.
bool tts_is_tower_reachable(void);
/// Request TTS synthesis. Blocking call (up to TTS_TIMEOUT_MS).
/// On success, writes WAV data to out_buf and sets *out_len.
/// Caller must allocate out_buf of at least TTS_WAV_BUF_SIZE bytes (use PSRAM).
tts_result_t tts_synthesize(const char* text, uint8_t* out_buf,
size_t buf_capacity, size_t* out_len);
/// Get TTS client statistics.
tts_stats_t tts_get_stats(void);
#ifdef __cplusplus
}
#endif
+198
View File
@@ -0,0 +1,198 @@
// npc_engine.cpp - Professor Zacus NPC decision engine implementation.
#include "npc/npc_engine.h"
#include <cstring>
#include <cstdio>
// Scene ID strings for SD path generation (must match zacus_v2.yaml step order).
static const char* const kSceneIds[] = {
"SCENE_U_SON_PROTO",
"SCENE_LA_DETECTOR",
"SCENE_WIN_ETAPE1",
"SCENE_WARNING",
"SCENE_LEFOU_DETECTOR",
"SCENE_WIN_ETAPE2",
"SCENE_QR_DETECTOR",
"SCENE_FINAL_WIN"
};
static const uint8_t kSceneCount = sizeof(kSceneIds) / sizeof(kSceneIds[0]);
static const char* const kTriggerDirs[] = {
[NPC_TRIGGER_NONE] = "generic",
[NPC_TRIGGER_HINT_REQUEST] = "indice",
[NPC_TRIGGER_STUCK_TIMER] = "indice",
[NPC_TRIGGER_QR_SCANNED] = "felicitations",
[NPC_TRIGGER_WRONG_ACTION] = "attention",
[NPC_TRIGGER_FAST_PROGRESS] = "fausse_piste",
[NPC_TRIGGER_SLOW_PROGRESS] = "adaptation",
[NPC_TRIGGER_SCENE_TRANSITION] = "transition",
[NPC_TRIGGER_GAME_START] = "ambiance",
[NPC_TRIGGER_GAME_END] = "ambiance",
};
static const char* const kMoodSuffixes[] = {
[NPC_MOOD_NEUTRAL] = "neutral",
[NPC_MOOD_IMPRESSED] = "impressed",
[NPC_MOOD_WORRIED] = "worried",
[NPC_MOOD_AMUSED] = "amused",
};
void npc_init(npc_state_t* state) {
if (state == NULL) return;
memset(state, 0, sizeof(*state));
state->mood = NPC_MOOD_NEUTRAL;
}
void npc_reset(npc_state_t* state) {
npc_init(state);
}
void npc_on_scene_change(npc_state_t* state, uint8_t new_scene,
uint32_t expected_duration_ms, uint32_t now_ms) {
if (state == NULL) return;
state->current_scene = new_scene;
state->scene_start_ms = now_ms;
state->expected_scene_duration_ms = expected_duration_ms;
state->failed_attempts = 0;
}
void npc_on_qr_scan(npc_state_t* state, bool valid, uint32_t now_ms) {
if (state == NULL) return;
if (valid) {
state->qr_scanned_count++;
} else {
state->failed_attempts++;
}
state->last_qr_scan_ms = now_ms;
}
void npc_on_phone_hook(npc_state_t* state, bool off_hook) {
if (state == NULL) return;
state->phone_off_hook = off_hook;
}
void npc_on_hint_request(npc_state_t* state, uint32_t now_ms) {
if (state == NULL) return;
uint8_t scene = state->current_scene;
if (scene < NPC_MAX_SCENES && state->hints_given[scene] < NPC_MAX_HINT_LEVEL) {
state->hints_given[scene]++;
}
(void)now_ms;
}
void npc_on_tower_status(npc_state_t* state, bool reachable) {
if (state == NULL) return;
state->tower_reachable = reachable;
}
void npc_update_mood(npc_state_t* state, uint32_t now_ms) {
if (state == NULL || state->expected_scene_duration_ms == 0) return;
uint32_t elapsed = now_ms - state->scene_start_ms;
uint32_t expected = state->expected_scene_duration_ms;
uint32_t pct = (elapsed * 100U) / expected;
if (state->failed_attempts >= 3) {
state->mood = NPC_MOOD_AMUSED;
} else if (pct < NPC_FAST_THRESHOLD_PCT) {
state->mood = NPC_MOOD_IMPRESSED;
} else if (pct > NPC_SLOW_THRESHOLD_PCT) {
state->mood = NPC_MOOD_WORRIED;
} else {
state->mood = NPC_MOOD_NEUTRAL;
}
}
uint8_t npc_hint_level(const npc_state_t* state, uint8_t scene) {
if (state == NULL || scene >= NPC_MAX_SCENES) return 0;
return state->hints_given[scene];
}
bool npc_build_sd_path(char* out_path, size_t capacity,
uint8_t scene, npc_trigger_t trigger,
npc_mood_t mood, uint8_t variant) {
if (out_path == NULL || capacity < 16) return false;
const char* scene_id = (scene < kSceneCount) ? kSceneIds[scene] : "npc";
const char* trigger_dir = (trigger < NPC_TRIGGER_COUNT)
? kTriggerDirs[trigger] : "generic";
const char* mood_str = (mood < NPC_MOOD_COUNT)
? kMoodSuffixes[mood] : "neutral";
// Per-scene triggers: /hotline_tts/{scene_id}/{trigger}_{mood}_{variant}.mp3
// Generic NPC: /hotline_tts/npc/{trigger}_{mood}_{variant}.mp3
bool is_scene_specific = (trigger != NPC_TRIGGER_GAME_START
&& trigger != NPC_TRIGGER_GAME_END
&& trigger != NPC_TRIGGER_NONE);
int written;
if (is_scene_specific && scene < kSceneCount) {
written = snprintf(out_path, capacity,
"/hotline_tts/%s/%s_%s_%u.mp3",
scene_id, trigger_dir, mood_str, (unsigned)variant);
} else {
written = snprintf(out_path, capacity,
"/hotline_tts/npc/%s_%s_%u.mp3",
trigger_dir, mood_str, (unsigned)variant);
}
return (written > 0 && (size_t)written < capacity);
}
bool npc_evaluate(const npc_state_t* state, uint32_t now_ms, npc_decision_t* out) {
if (state == NULL || out == NULL) return false;
memset(out, 0, sizeof(*out));
uint32_t scene_elapsed = now_ms - state->scene_start_ms;
uint32_t expected = state->expected_scene_duration_ms;
// Priority 1: Hint request (phone off hook while stuck)
if (state->phone_off_hook && scene_elapsed > NPC_STUCK_TIMEOUT_MS) {
uint8_t level = npc_hint_level(state, state->current_scene);
out->trigger = NPC_TRIGGER_HINT_REQUEST;
out->resulting_mood = state->mood;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_HINT_REQUEST,
state->mood, level);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 2: Stuck timer (proactive, no phone needed)
if (scene_elapsed > NPC_STUCK_TIMEOUT_MS
&& npc_hint_level(state, state->current_scene) == 0) {
out->trigger = NPC_TRIGGER_STUCK_TIMER;
out->resulting_mood = NPC_MOOD_WORRIED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_STUCK_TIMER,
NPC_MOOD_WORRIED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 3: Fast progress detection
if (expected > 0 && scene_elapsed > 0
&& (scene_elapsed * 100U / expected) < NPC_FAST_THRESHOLD_PCT) {
out->trigger = NPC_TRIGGER_FAST_PROGRESS;
out->resulting_mood = NPC_MOOD_IMPRESSED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_FAST_PROGRESS,
NPC_MOOD_IMPRESSED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 4: Slow progress detection
if (expected > 0 && (scene_elapsed * 100U / expected) > NPC_SLOW_THRESHOLD_PCT) {
out->trigger = NPC_TRIGGER_SLOW_PROGRESS;
out->resulting_mood = NPC_MOOD_WORRIED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_SLOW_PROGRESS,
NPC_MOOD_WORRIED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
return false;
}
+157
View File
@@ -0,0 +1,157 @@
// qr_scanner.cpp - ZACUS QR protocol parser, validator, anti-cheat.
#include "npc/qr_scanner.h"
#include <cstring>
#include <cstdio>
#include <mbedtls/md.h>
typedef struct {
char scene_id[QR_SCENE_ID_MAX];
char event_id[QR_EVENT_ID_MAX];
uint32_t at_ms;
} qr_history_entry_t;
static qr_history_entry_t s_history[QR_MAX_HISTORY];
static uint8_t s_history_count = 0;
static uint32_t s_last_scan_ms = 0;
static char s_last_payload[QR_SCENE_ID_MAX + QR_EVENT_ID_MAX + 8] = {0};
void qr_npc_init(void) {
memset(s_history, 0, sizeof(s_history));
s_history_count = 0;
s_last_scan_ms = 0;
s_last_payload[0] = '\0';
}
bool qr_npc_parse(const char* payload, qr_decode_result_t* out) {
if (payload == NULL || out == NULL) return false;
memset(out, 0, sizeof(*out));
// Expected: ZACUS:{scene_id}:{event_id}:{checksum}
if (strncmp(payload, QR_PREFIX, QR_PREFIX_LEN) != 0) {
out->status = QR_INVALID_FORMAT;
return false;
}
const char* p = payload + QR_PREFIX_LEN;
// Parse scene_id
const char* sep1 = strchr(p, ':');
if (sep1 == NULL) { out->status = QR_INVALID_FORMAT; return false; }
size_t scene_len = (size_t)(sep1 - p);
if (scene_len == 0 || scene_len >= QR_SCENE_ID_MAX) {
out->status = QR_INVALID_FORMAT; return false;
}
memcpy(out->scene_id, p, scene_len);
out->scene_id[scene_len] = '\0';
// Parse event_id
const char* p2 = sep1 + 1;
const char* sep2 = strchr(p2, ':');
if (sep2 == NULL) { out->status = QR_INVALID_FORMAT; return false; }
size_t event_len = (size_t)(sep2 - p2);
if (event_len == 0 || event_len >= QR_EVENT_ID_MAX) {
out->status = QR_INVALID_FORMAT; return false;
}
memcpy(out->event_id, p2, event_len);
out->event_id[event_len] = '\0';
// Parse checksum (4 hex chars)
const char* p3 = sep2 + 1;
size_t ck_len = strlen(p3);
if (ck_len != QR_CHECKSUM_LEN) {
out->status = QR_INVALID_FORMAT; return false;
}
memcpy(out->checksum, p3, QR_CHECKSUM_LEN);
out->checksum[QR_CHECKSUM_LEN] = '\0';
out->status = QR_VALID;
return true;
}
void qr_npc_compute_checksum(const char* scene_id, const char* event_id,
char out_checksum[5]) {
if (scene_id == NULL || event_id == NULL || out_checksum == NULL) return;
char input[QR_SCENE_ID_MAX + QR_EVENT_ID_MAX + 2];
snprintf(input, sizeof(input), "%s:%s", scene_id, event_id);
uint8_t hmac[32];
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_setup(&ctx, info, 1);
mbedtls_md_hmac_starts(&ctx, (const uint8_t*)QR_HMAC_KEY, strlen(QR_HMAC_KEY));
mbedtls_md_hmac_update(&ctx, (const uint8_t*)input, strlen(input));
mbedtls_md_hmac_finish(&ctx, hmac);
mbedtls_md_free(&ctx);
// Truncate to first 2 bytes = 4 hex chars
snprintf(out_checksum, 5, "%02X%02X", hmac[0], hmac[1]);
}
qr_validation_t qr_npc_validate(const qr_decode_result_t* decoded,
const char* current_scene_id,
uint32_t now_ms) {
if (decoded == NULL || decoded->status != QR_VALID) {
return QR_INVALID_FORMAT;
}
// Checksum verification
char expected[5];
qr_npc_compute_checksum(decoded->scene_id, decoded->event_id, expected);
if (strncmp(expected, decoded->checksum, QR_CHECKSUM_LEN) != 0) {
return QR_INVALID_CHECKSUM;
}
// Scene check: QR only valid for its designated scene
if (current_scene_id != NULL
&& strcmp(decoded->scene_id, current_scene_id) != 0) {
return QR_WRONG_SCENE;
}
// Already scanned check
if (qr_npc_was_scanned(decoded->scene_id, decoded->event_id)) {
return QR_ALREADY_SCANNED;
}
// Debounce: same raw payload within QR_DEBOUNCE_MS
char key[QR_SCENE_ID_MAX + QR_EVENT_ID_MAX + 2];
snprintf(key, sizeof(key), "%s:%s", decoded->scene_id, decoded->event_id);
if (strcmp(key, s_last_payload) == 0
&& (now_ms - s_last_scan_ms) < QR_DEBOUNCE_MS) {
return QR_DEBOUNCED;
}
return QR_VALID;
}
void qr_npc_record_scan(const char* scene_id, const char* event_id, uint32_t now_ms) {
if (scene_id == NULL || event_id == NULL) return;
// Update debounce state
snprintf(s_last_payload, sizeof(s_last_payload), "%s:%s", scene_id, event_id);
s_last_scan_ms = now_ms;
// Add to history (ring buffer)
if (s_history_count < QR_MAX_HISTORY) {
qr_history_entry_t* entry = &s_history[s_history_count++];
strncpy(entry->scene_id, scene_id, QR_SCENE_ID_MAX - 1);
strncpy(entry->event_id, event_id, QR_EVENT_ID_MAX - 1);
entry->at_ms = now_ms;
}
}
bool qr_npc_was_scanned(const char* scene_id, const char* event_id) {
if (scene_id == NULL || event_id == NULL) return false;
for (uint8_t i = 0; i < s_history_count; i++) {
if (strcmp(s_history[i].scene_id, scene_id) == 0
&& strcmp(s_history[i].event_id, event_id) == 0) {
return true;
}
}
return false;
}
uint8_t qr_npc_scan_count(void) {
return s_history_count;
}
+143
View File
@@ -0,0 +1,143 @@
// tts_client.cpp - Piper TTS HTTP client for ESP32.
#include "npc/tts_client.h"
#include <Arduino.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <cstring>
#include <cstdio>
static tts_stats_t s_stats = {};
static char s_url[128] = {0};
void tts_init(void) {
memset(&s_stats, 0, sizeof(s_stats));
snprintf(s_url, sizeof(s_url), "http://%s:%d%s",
TTS_TOWER_IP, TTS_TOWER_PORT, TTS_API_PATH);
Serial.printf("[TTS] init url=%s\n", s_url);
}
bool tts_check_health(uint32_t now_ms) {
if (WiFi.status() != WL_CONNECTED) {
s_stats.tower_reachable = false;
s_stats.last_health_check_ms = now_ms;
return false;
}
HTTPClient http;
http.setTimeout(TTS_TIMEOUT_MS);
char health_url[128];
snprintf(health_url, sizeof(health_url), "http://%s:%d/",
TTS_TOWER_IP, TTS_TOWER_PORT);
bool ok = false;
if (http.begin(health_url)) {
uint32_t t0 = millis();
int code = http.sendRequest("HEAD");
s_stats.last_latency_ms = millis() - t0;
ok = (code >= 200 && code < 400);
http.end();
}
s_stats.tower_reachable = ok;
s_stats.last_health_check_ms = now_ms;
Serial.printf("[TTS] health: %s (%lu ms)\n",
ok ? "OK" : "DOWN", (unsigned long)s_stats.last_latency_ms);
return ok;
}
void tts_health_tick(uint32_t now_ms) {
if (now_ms - s_stats.last_health_check_ms >= TTS_HEALTH_INTERVAL_MS) {
tts_check_health(now_ms);
}
}
bool tts_is_tower_reachable(void) {
return s_stats.tower_reachable;
}
tts_result_t tts_synthesize(const char* text, uint8_t* out_buf,
size_t buf_capacity, size_t* out_len) {
if (text == NULL || out_buf == NULL || out_len == NULL) {
return TTS_RESULT_ALLOC_FAIL;
}
*out_len = 0;
size_t text_len = strlen(text);
if (text_len > TTS_MAX_TEXT_LEN) {
return TTS_RESULT_TEXT_TOO_LONG;
}
if (!s_stats.tower_reachable) {
return TTS_RESULT_TOWER_DOWN;
}
s_stats.total_requests++;
HTTPClient http;
http.setTimeout(TTS_TIMEOUT_MS);
if (!http.begin(s_url)) {
s_stats.total_failures++;
return TTS_RESULT_HTTP_ERROR;
}
http.addHeader("Content-Type", "application/json");
// Build JSON body: {"text":"...","voice":"tom-medium","format":"wav"}
char body[TTS_MAX_TEXT_LEN + 64];
snprintf(body, sizeof(body),
"{\"text\":\"%s\",\"voice\":\"%s\",\"format\":\"wav\"}",
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] POST failed: %d (%lu ms)\n", code,
(unsigned long)s_stats.last_latency_ms);
http.end();
s_stats.total_failures++;
if (s_stats.last_latency_ms >= TTS_TIMEOUT_MS) {
return TTS_RESULT_TIMEOUT;
}
return TTS_RESULT_HTTP_ERROR;
}
int content_len = http.getSize();
if (content_len <= 0 || (size_t)content_len > buf_capacity) {
Serial.printf("[TTS] bad content_len: %d (cap %u)\n",
content_len, (unsigned)buf_capacity);
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] OK: %u bytes, %lu ms\n",
(unsigned)total_read, (unsigned long)s_stats.last_latency_ms);
return TTS_RESULT_OK;
}
tts_stats_t tts_get_stats(void) {
return s_stats;
}