feat(box3): QR + melody stimulus generator
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user