diff --git a/box3_voice/main/CMakeLists.txt b/box3_voice/main/CMakeLists.txt index b13cec6..317c840 100644 --- a/box3_voice/main/CMakeLists.txt +++ b/box3_voice/main/CMakeLists.txt @@ -1,9 +1,10 @@ idf_component_register( - SRCS "main.c" "voice_ws_client.c" "scenario_server.c" + SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" INCLUDE_DIRS "." PRIV_REQUIRES driver esp_event + esp_http_client esp_http_server esp_netif esp_wifi diff --git a/box3_voice/main/Kconfig.projbuild b/box3_voice/main/Kconfig.projbuild index f8d6376..2963391 100644 --- a/box3_voice/main/Kconfig.projbuild +++ b/box3_voice/main/Kconfig.projbuild @@ -19,6 +19,14 @@ menu "Zacus BOX-3 Voice Configuration" WebSocket endpoint for the mascarade voice bridge. The bridge routes voice commands to the hints engine. + config ZACUS_MASTER_URL + string "Zacus Master Base URL" + default "http://10.2.5.42" + help + Base URL of the Zacus master ESP32. The virtual PLIP annex + reports hook transitions to /voice/hook. Prefer a plain + IP (mDNS .local names need the mdns component). + config ZACUS_VOICE_TOKEN string "Voice Bridge Auth Token" default "" diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c index ca63431..ffea8de 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -26,6 +26,7 @@ #include "board_config.h" #include "voice_ws_client.h" #include "scenario_server.h" +#include "plip_virtual.h" #include "scenario_mesh.h" /* BSP header — provided by espressif/esp-box component */ @@ -319,6 +320,15 @@ static void button_task(void *arg) /* Detect falling edge (button press) */ if (last_state && !current) { + /* Virtual PLIP hook first: while ringing or off-hook the BOOT + * button is the hook switch (pickup/hangup), not the streaming + * toggle. */ + if (plip_virtual_button_press()) { + vTaskDelay(pdMS_TO_TICKS(300)); + last_state = current; + continue; + } + s_voice_streaming = !s_voice_streaming; ESP_LOGI(TAG, "BOOT button pressed — streaming %s", s_voice_streaming ? "ON" : "OFF"); @@ -383,6 +393,13 @@ void app_main(void) * httpd_start binds to all netifs — works as soon as the WiFi STA has an IP. */ if (scenario_server_start() != ESP_OK) { ESP_LOGW(TAG, "scenario_server_start failed — IR hot-load unavailable"); + } else { + /* Phone-less PLIP annex: same REST contract as PLIP_FIRMWARE + * (POST /ring /stop /play, GET /status), ring on the speaker, + * BOOT button as the virtual hook switch. */ + if (plip_virtual_init(scenario_server_handle(), s_spk_handle) != ESP_OK) { + ESP_LOGW(TAG, "plip_virtual_init failed — virtual phone unavailable"); + } } /* Start the ESP-NOW receiver so the master can relay scenarios to us even diff --git a/box3_voice/main/plip_virtual.c b/box3_voice/main/plip_virtual.c new file mode 100644 index 0000000..8a0e78d --- /dev/null +++ b/box3_voice/main/plip_virtual.c @@ -0,0 +1,275 @@ +// plip_virtual — implementation. See plip_virtual.h for the contract. +// +// Threading: the ring tone runs in its own task (synthesised sine bursts on +// the shared speaker I2S channel, French cadence 1.5 s on / 3.5 s off); hook +// reports run in a tiny worker so the button path never blocks on HTTP. + +#include "plip_virtual.h" + +#include +#include + +#include "cJSON.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +#include "board_config.h" + +#ifndef CONFIG_ZACUS_MASTER_URL +#define CONFIG_ZACUS_MASTER_URL "http://10.2.5.42" +#endif + +#define TAG "plip_virtual" + +#define RING_FREQ_HZ 440.0f +#define RING_ON_MS 1500 /* French cadence */ +#define RING_OFF_MS 3500 +#define RING_AMPLITUDE 14000.0f +#define HOOK_QUEUE_DEPTH 4 + +typedef enum { HOOK_ON = 0, HOOK_RINGING, HOOK_OFF } hook_state_t; + +typedef struct { + char state[8]; /* "off" | "on" */ + char reason[16]; /* "pickup" | "hangup" | "ring_timeout" */ +} hook_event_t; + +static i2s_chan_handle_t s_spk; +static volatile hook_state_t s_state = HOOK_ON; +static volatile bool s_ring_stop = false; +static TaskHandle_t s_ring_task = NULL; +static QueueHandle_t s_hook_queue = NULL; + +/* ---------- hook reporting (mirrors PLIP's zacus_hook_client) ---------- */ + +static void hook_report(const char *state, const char *reason) +{ + if (!s_hook_queue) return; + hook_event_t ev; + strlcpy(ev.state, state, sizeof(ev.state)); + strlcpy(ev.reason, reason, sizeof(ev.reason)); + if (xQueueSend(s_hook_queue, &ev, 0) != pdTRUE) { + ESP_LOGW(TAG, "hook queue full, dropping (%s/%s)", state, reason); + } +} + +static void hook_worker_task(void *arg) +{ + (void) arg; + hook_event_t ev; + char url[160]; + snprintf(url, sizeof(url), "%s/voice/hook", CONFIG_ZACUS_MASTER_URL); + + for (;;) { + if (xQueueReceive(s_hook_queue, &ev, portMAX_DELAY) != pdTRUE) continue; + + char body[96]; + snprintf(body, sizeof(body), "{\"state\":\"%s\",\"reason\":\"%s\"}", + ev.state, ev.reason); + + esp_http_client_config_t cfg = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = 3000, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (!client) continue; + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_post_field(client, body, strlen(body)); + esp_err_t err = esp_http_client_perform(client); + if (err == ESP_OK) { + ESP_LOGI(TAG, "hook POST %s -> %d (%s/%s)", url, + esp_http_client_get_status_code(client), + ev.state, ev.reason); + } else { + ESP_LOGW(TAG, "hook POST failed: %s (%s/%s)", + esp_err_to_name(err), ev.state, ev.reason); + } + esp_http_client_cleanup(client); + } +} + +/* ---------- ring tone ---------- */ + +static void ring_burst(int duration_ms) +{ + const int total = AUDIO_SAMPLE_RATE * duration_ms / 1000; + int16_t buffer[256]; + size_t written = 0; + int idx = 0; + while (idx < total && !s_ring_stop) { + int chunk = (total - idx < 256) ? (total - idx) : 256; + for (int i = 0; i < chunk; i++) { + float t = (float) (idx + i) / (float) AUDIO_SAMPLE_RATE; + buffer[i] = (int16_t) (RING_AMPLITUDE * + sinf(2.0f * (float) M_PI * RING_FREQ_HZ * t)); + } + i2s_channel_write(s_spk, buffer, chunk * sizeof(int16_t), &written, + pdMS_TO_TICKS(500)); + idx += chunk; + } +} + +static void ring_task(void *arg) +{ + int duration_ms = (int) (intptr_t) arg; + int elapsed = 0; + + ESP_LOGI(TAG, "ring start (%d ms)", duration_ms); + while (elapsed < duration_ms && !s_ring_stop) { + ring_burst(RING_ON_MS); + elapsed += RING_ON_MS; + if (elapsed >= duration_ms || s_ring_stop) break; + for (int w = 0; w < RING_OFF_MS && !s_ring_stop; w += 100) { + vTaskDelay(pdMS_TO_TICKS(100)); + } + elapsed += RING_OFF_MS; + } + + if (s_state == HOOK_RINGING) { + s_state = HOOK_ON; /* nobody picked up */ + if (!s_ring_stop) hook_report("on", "ring_timeout"); + } + ESP_LOGI(TAG, "ring end (state=%d)", (int) s_state); + s_ring_task = NULL; + vTaskDelete(NULL); +} + +static void ring_stop(void) +{ + s_ring_stop = true; + /* The task observes the flag within one 100 ms slice and self-deletes. */ + for (int i = 0; i < 20 && s_ring_task; i++) vTaskDelay(pdMS_TO_TICKS(50)); + s_ring_stop = false; +} + +/* ---------- REST handlers ---------- */ + +static esp_err_t send_json(httpd_req_t *req, const char *status, const char *body) +{ + httpd_resp_set_status(req, status); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_sendstr(req, body); +} + +static esp_err_t handle_ring_post(httpd_req_t *req) +{ + int duration_ms = 4000; + if (req->content_len > 0 && req->content_len < 128) { + char body[128]; + int got = httpd_req_recv(req, body, req->content_len); + if (got > 0) { + body[got] = '\0'; + cJSON *root = cJSON_Parse(body); + const cJSON *d = root ? cJSON_GetObjectItem(root, "duration_ms") : NULL; + if (cJSON_IsNumber(d) && d->valueint > 0 && d->valueint <= 60000) { + duration_ms = d->valueint; + } + cJSON_Delete(root); + } + } + + if (s_state == HOOK_OFF) { + return send_json(req, "409 Conflict", "{\"error\":\"off-hook\"}"); + } + if (s_ring_task) ring_stop(); + + s_state = HOOK_RINGING; + if (xTaskCreate(ring_task, "plip_ring", 4096, + (void *) (intptr_t) duration_ms, 4, &s_ring_task) != pdPASS) { + s_state = HOOK_ON; + return send_json(req, "500 Internal Server Error", + "{\"error\":\"ring task failed\"}"); + } + char resp[64]; + snprintf(resp, sizeof(resp), "{\"ok\":true,\"duration_ms\":%d}", duration_ms); + return send_json(req, "200 OK", resp); +} + +static esp_err_t handle_stop_post(httpd_req_t *req) +{ + if (s_ring_task) ring_stop(); + if (s_state == HOOK_RINGING) s_state = HOOK_ON; + return send_json(req, "200 OK", "{\"ok\":true}"); +} + +static esp_err_t handle_play_post(httpd_req_t *req) +{ + /* Audio on the BOX-3 flows through the voice-bridge WS TTS path; a file + * player duplicating that would lie about capabilities. */ + return send_json(req, "501 Not Implemented", + "{\"error\":\"use the voice bridge TTS path\"}"); +} + +static esp_err_t handle_status_get(httpd_req_t *req) +{ + char resp[96]; + snprintf(resp, sizeof(resp), + "{\"off_hook\":%s,\"ringing\":%s,\"playing\":false}", + s_state == HOOK_OFF ? "true" : "false", + s_state == HOOK_RINGING ? "true" : "false"); + return send_json(req, "200 OK", resp); +} + +/* ---------- public API ---------- */ + +bool plip_virtual_button_press(void) +{ + switch (s_state) { + case HOOK_RINGING: + ring_stop(); + s_state = HOOK_OFF; + ESP_LOGI(TAG, "virtual pickup"); + hook_report("off", "pickup"); + return true; + case HOOK_OFF: + s_state = HOOK_ON; + ESP_LOGI(TAG, "virtual hangup"); + hook_report("on", "hangup"); + return true; + default: + return false; /* on-hook, not ringing: not ours */ + } +} + +esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk) +{ + if (!server || !spk) return ESP_ERR_INVALID_ARG; + s_spk = spk; + + s_hook_queue = xQueueCreate(HOOK_QUEUE_DEPTH, sizeof(hook_event_t)); + if (!s_hook_queue) return ESP_ERR_NO_MEM; + if (xTaskCreate(hook_worker_task, "plip_hook", 4096, NULL, 3, NULL) != pdPASS) { + vQueueDelete(s_hook_queue); + s_hook_queue = NULL; + return ESP_FAIL; + } + + static const httpd_uri_t uri_ring = { + .uri = "/ring", .method = HTTP_POST, + .handler = handle_ring_post, .user_ctx = NULL, + }; + static const httpd_uri_t uri_stop = { + .uri = "/stop", .method = HTTP_POST, + .handler = handle_stop_post, .user_ctx = NULL, + }; + static const httpd_uri_t uri_play = { + .uri = "/play", .method = HTTP_POST, + .handler = handle_play_post, .user_ctx = NULL, + }; + static const httpd_uri_t uri_status = { + .uri = "/status", .method = HTTP_GET, + .handler = handle_status_get, .user_ctx = NULL, + }; + httpd_register_uri_handler(server, &uri_ring); + httpd_register_uri_handler(server, &uri_stop); + httpd_register_uri_handler(server, &uri_play); + httpd_register_uri_handler(server, &uri_status); + + ESP_LOGI(TAG, "virtual PLIP up (POST /ring /stop /play, GET /status; " + "BOOT button = hook switch; master=%s)", CONFIG_ZACUS_MASTER_URL); + return ESP_OK; +} diff --git a/box3_voice/main/plip_virtual.h b/box3_voice/main/plip_virtual.h new file mode 100644 index 0000000..72d57ed --- /dev/null +++ b/box3_voice/main/plip_virtual.h @@ -0,0 +1,39 @@ +// plip_virtual — phone-less PLIP annex running on the ESP32-S3-BOX-3. +// +// Implements the PLIP REST contract (PLIP_FIRMWARE/src/network_task.cpp) on +// the box's existing HTTP server: +// POST /ring { "duration_ms": 4000 } → ring cadence on the BOX-3 speaker +// POST /stop → stop ringing +// POST /play → 501 (audio path is the WS TTS) +// GET /status → { off_hook, ringing, playing } +// +// The BOOT button is the virtual hook switch: a press during the ring picks +// up, a press while off-hook hangs up. Each transition is reported to the +// Zacus master (POST CONFIG_ZACUS_MASTER_URL/voice/hook {state, reason}) +// exactly like the real PLIP's zacus_hook_client — the master cannot tell +// the two annexes apart. + +#pragma once + +#include +#include "driver/i2s_std.h" +#include "esp_err.h" +#include "esp_http_server.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Register the REST handlers on `server` and keep `spk` for the ring tone. +// Call once after scenario_server_start(); the speaker channel must be +// enabled (speaker_init() in main.c). +esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk); + +// Forward a BOOT-button press. Returns true when the press was consumed as +// a hook transition (pickup/hangup) — the caller should then skip its own +// handling (voice-streaming toggle). +bool plip_virtual_button_press(void); + +#ifdef __cplusplus +} +#endif diff --git a/box3_voice/main/scenario_server.c b/box3_voice/main/scenario_server.c index 36a4241..3c5a3b3 100644 --- a/box3_voice/main/scenario_server.c +++ b/box3_voice/main/scenario_server.c @@ -218,6 +218,10 @@ static esp_err_t handle_scenario_post(httpd_req_t *req) { // ---------- public init ---------- +httpd_handle_t scenario_server_handle(void) { + return s_server; +} + esp_err_t scenario_server_start(void) { if (s_server) { ESP_LOGW(TAG, "scenario_server already running"); diff --git a/box3_voice/main/scenario_server.h b/box3_voice/main/scenario_server.h index 50b9cef..f47a109 100644 --- a/box3_voice/main/scenario_server.h +++ b/box3_voice/main/scenario_server.h @@ -5,6 +5,7 @@ #include #include "esp_err.h" +#include "esp_http_server.h" #ifdef __cplusplus extern "C" { @@ -12,6 +13,10 @@ extern "C" { esp_err_t scenario_server_start(void); +// Handle of the running server (NULL before scenario_server_start succeeds). +// Lets other modules (plip_virtual) register their URIs on the same port 80. +httpd_handle_t scenario_server_handle(void); + // Shared internal apply path (the spec's `_scenario_apply`): validate a // Runtime 3 IR blob, atomically write it to SPIFFS, and schedule the hot-reload // reboot. Used both by the HTTP POST /game/scenario handler and by the ESP-NOW