feat(box3): annexe PLIP virtuelle (sans téléphone) #5
@@ -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
|
||||
|
||||
@@ -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 <url>/voice/hook. Prefer a plain
|
||||
IP (mDNS .local names need the mdns component).
|
||||
|
||||
config ZACUS_VOICE_TOKEN
|
||||
string "Voice Bridge Auth Token"
|
||||
default ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#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
|
||||
@@ -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");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <stddef.h>
|
||||
|
||||
#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
|
||||
|
||||
Reference in New Issue
Block a user