feat(box3): QR + melody stimulus generator

This commit is contained in:
Claude Worker claude2
2026-06-10 23:15:33 +02:00
parent e8125a20e5
commit 087dbd3c03
5 changed files with 230 additions and 1 deletions
+3 -1
View File
@@ -1,5 +1,5 @@
idf_component_register(
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c"
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
@@ -12,4 +12,6 @@ idf_component_register(
nvs_flash
spiffs
scenario_mesh
espressif__esp-box-3
lvgl__lvgl
)
+32
View File
@@ -28,6 +28,7 @@
#include "scenario_server.h"
#include "plip_virtual.h"
#include "plip_ui.h"
#include "stimulus.h"
#include "scenario_mesh.h"
/* BSP header — provided by espressif/esp-box component */
@@ -106,6 +107,29 @@ static esp_err_t wifi_init_sta(void)
/* --------------- Test tone (440 Hz sine) --------------- */
// Play a single tone on the speaker (blocking). Public so the stimulus
// generator can sequence a melody for the master's microphone.
void audio_play_tone(float frequency, int duration_ms)
{
if (!s_spk_handle || duration_ms <= 0) return;
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
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;
}
}
static void audio_test_tone(void)
{
if (!s_spk_handle) {
@@ -404,6 +428,14 @@ void app_main(void)
/* REST/ESP-NOW phone still works headless if the UI fails. */
ESP_LOGW(TAG, "plip_ui_init failed — on-screen phone unavailable");
}
/* Stimulus generator: BOX-3 shows QR / plays melody for the Freenove
* master's camera + mic (POST /stim/qr, POST /stim/melody). */
if (stimulus_init() == ESP_OK) {
stimulus_register_routes(scenario_server_handle());
} else {
ESP_LOGW(TAG, "stimulus_init failed — QR/melody generator off");
}
}
/* Start the ESP-NOW receiver so the master can relay scenarios to us even
+164
View File
@@ -0,0 +1,164 @@
// stimulus.c — QR display + melody playback, driven over REST.
#include "stimulus.h"
#include <math.h>
#include <string.h>
#include "bsp/esp-box-3.h"
#include "lvgl.h"
#include "libs/qrcode/lv_qrcode.h"
#include "cJSON.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "stimulus";
#define LOCK_MS 1000
static lv_obj_t *s_qr_screen; // dedicated fullscreen QR screen
static lv_obj_t *s_qr; // lv_qrcode widget
static lv_obj_t *s_qr_caption;
esp_err_t stimulus_init(void) {
if (!bsp_display_lock(LOCK_MS)) return ESP_FAIL;
s_qr_screen = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_qr_screen, lv_color_white(), 0);
// 200 px QR, dark on white — high contrast for the OV3660 camera.
s_qr = lv_qrcode_create(s_qr_screen);
lv_qrcode_set_size(s_qr, 200);
lv_qrcode_set_dark_color(s_qr, lv_color_black());
lv_qrcode_set_light_color(s_qr, lv_color_white());
lv_qrcode_update(s_qr, "zacus", 5);
lv_obj_align(s_qr, LV_ALIGN_CENTER, 0, -10);
s_qr_caption = lv_label_create(s_qr_screen);
lv_obj_set_style_text_color(s_qr_caption, lv_color_black(), 0);
lv_label_set_text(s_qr_caption, "");
lv_obj_align(s_qr_caption, LV_ALIGN_BOTTOM_MID, 0, -10);
bsp_display_unlock();
ESP_LOGI(TAG, "stimulus QR screen ready");
return ESP_OK;
}
static void show_qr(const char *text) {
if (!s_qr || !bsp_display_lock(LOCK_MS)) return;
lv_qrcode_update(s_qr, text, strlen(text));
lv_label_set_text(s_qr_caption, text);
lv_screen_load(s_qr_screen); // bring the QR to the front
bsp_display_unlock();
ESP_LOGI(TAG, "QR shown: %s", text);
}
// MIDI note -> frequency (A4=69=440 Hz).
static float midi_to_hz(int note) {
return 440.0f * powf(2.0f, (float)(note - 69) / 12.0f);
}
// ─── REST ────────────────────────────────────────────────────────────────────
static esp_err_t read_body(httpd_req_t *req, char *buf, size_t cap) {
if (req->content_len <= 0 || (size_t)req->content_len >= cap) return ESP_FAIL;
int total = 0;
while (total < (int)req->content_len) {
int got = httpd_req_recv(req, buf + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return ESP_FAIL;
}
total += got;
}
buf[total] = '\0';
return ESP_OK;
}
static esp_err_t reply(httpd_req_t *req, const char *status, const char *json) {
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "application/json");
return httpd_resp_sendstr(req, json);
}
static esp_err_t handle_qr_post(httpd_req_t *req) {
char body[256];
if (read_body(req, body, sizeof(body)) != ESP_OK) {
return reply(req, "400 Bad Request", "{\"error\":\"body\"}");
}
cJSON *root = cJSON_Parse(body);
const cJSON *t = root ? cJSON_GetObjectItemCaseSensitive(root, "text") : NULL;
if (!cJSON_IsString(t) || !t->valuestring[0]) {
cJSON_Delete(root);
return reply(req, "400 Bad Request", "{\"error\":\"text required\"}");
}
show_qr(t->valuestring);
char resp[280];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"text\":\"%s\"}",
t->valuestring);
cJSON_Delete(root);
return reply(req, "200 OK", resp);
}
// Melody played on a worker task so the HTTP response returns immediately.
typedef struct { float hz[32]; int count; int note_ms; } melody_job_t;
static void melody_task(void *arg) {
melody_job_t *job = (melody_job_t *)arg;
for (int i = 0; i < job->count; i++) {
audio_play_tone(job->hz[i], job->note_ms);
vTaskDelay(pdMS_TO_TICKS(40)); // brief gap = note onset for the mic
}
free(job);
vTaskDelete(NULL);
}
static esp_err_t handle_melody_post(httpd_req_t *req) {
char body[512];
if (read_body(req, body, sizeof(body)) != ESP_OK) {
return reply(req, "400 Bad Request", "{\"error\":\"body\"}");
}
cJSON *root = cJSON_Parse(body);
const cJSON *notes = root ? cJSON_GetObjectItemCaseSensitive(root, "notes") : NULL;
if (!cJSON_IsArray(notes) || cJSON_GetArraySize(notes) == 0) {
cJSON_Delete(root);
return reply(req, "400 Bad Request", "{\"error\":\"notes required\"}");
}
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(root, "ms");
const int note_ms = cJSON_IsNumber(ms) ? ms->valueint : 400;
melody_job_t *job = calloc(1, sizeof(*job));
if (!job) { cJSON_Delete(root); return reply(req, "500 Internal Server Error", "{\"error\":\"oom\"}"); }
job->note_ms = (note_ms > 0 && note_ms <= 4000) ? note_ms : 400;
const cJSON *n;
cJSON_ArrayForEach(n, notes) {
if (job->count >= 32) break;
if (cJSON_IsNumber(n) && n->valueint >= 0 && n->valueint <= 127) {
job->hz[job->count++] = midi_to_hz(n->valueint);
}
}
cJSON_Delete(root);
if (job->count == 0) { free(job); return reply(req, "400 Bad Request", "{\"error\":\"no valid notes\"}"); }
if (xTaskCreate(melody_task, "melody", 4096, job, 5, NULL) != pdPASS) {
free(job);
return reply(req, "503 Service Unavailable", "{\"error\":\"busy\"}");
}
char resp[64];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"notes\":%d}", job->count);
return reply(req, "200 OK", resp);
}
esp_err_t stimulus_register_routes(httpd_handle_t server) {
static const httpd_uri_t uri_qr = {
.uri = "/stim/qr", .method = HTTP_POST,
.handler = handle_qr_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_melody = {
.uri = "/stim/melody", .method = HTTP_POST,
.handler = handle_melody_post, .user_ctx = NULL,
};
esp_err_t e = httpd_register_uri_handler(server, &uri_qr);
if (e == ESP_OK) e = httpd_register_uri_handler(server, &uri_melody);
if (e == ESP_OK) ESP_LOGI(TAG, "routes up: POST /stim/qr, POST /stim/melody");
return e;
}
+28
View File
@@ -0,0 +1,28 @@
// stimulus.h — BOX-3 as a stimulus generator for the Freenove master's
// camera (QR) and microphone (melody). Lets the master's local puzzles be
// exercised end-to-end without printed cards or an instrument.
//
// REST (registered on the existing scenario_server httpd):
// POST /stim/qr {"text":"zacus-qr-1"} -> show QR fullscreen
// POST /stim/melody {"notes":[60,62,64,65],"ms":400} -> play melody
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
// Provided by main.c — play one tone (blocking) on the BOX-3 speaker.
void audio_play_tone(float frequency, int duration_ms);
// Build the QR screen (under bsp_display_lock). Call once after the display
// and UI are up.
esp_err_t stimulus_init(void);
// Register /stim/* routes on an existing server.
esp_err_t stimulus_register_routes(httpd_handle_t server);
#ifdef __cplusplus
}
#endif
+3
View File
@@ -59,3 +59,6 @@ CONFIG_I2C_ENABLE_LEGACY_DRIVERS=y
# PLIP phone UI fonts (plip_ui.c)
CONFIG_LV_FONT_MONTSERRAT_24=y
CONFIG_LV_FONT_MONTSERRAT_48=y
# Stimulus generator (QR display for the master camera, melody for its mic)
CONFIG_LV_USE_QRCODE=y