fix(voice): secure WS auth/TLS and harden tasks
Voice security findings from a code audit, re-applied against the current feat/idf-migration code (build-verified with idf.py build, NOT yet hardware-tested): - Open WiFi no longer silent: WPA2 required by default; open path gated behind new ZACUS_ALLOW_OPEN_WIFI (default n) with a loud warning; empty password without opt-in refuses to bring up the network stack instead of joining open. - Voice WS auth mandatory: empty ZACUS_VOICE_TOKEN refuses to connect (mic never streamed unauthenticated). wss:// with cert pinning (cert_pem or CA bundle); ws:// warns plaintext; default URL wss. - Scenario HTTP server authed: POST /game/scenario checks X-Zacus- Token (header or query) -> 401; wildcard CORS removed; restart single-shot (409 if already pending). - Concurrency: speaker I2S writes mutex-guarded; mic-read error path backs off; WS error-recovery moved off the event task; oversized/ fragmented inbound frames dropped with a cap; handshake timeout closes+restarts; removed the manual reconnect racing the built-in. - I2S MONO packing left to hardware verification, guarded by a frame-size tripwire that logs+skips a mismatch. idf.py build: clean, 0 warnings.
This commit is contained in:
@@ -10,14 +10,32 @@ menu "Zacus BOX-3 Voice Configuration"
|
||||
string "WiFi Password"
|
||||
default ""
|
||||
help
|
||||
WiFi password. Leave empty for open networks.
|
||||
WiFi password (WPA2-PSK). Required unless ZACUS_ALLOW_OPEN_WIFI
|
||||
is enabled. Leaving this empty without opting in to open WiFi
|
||||
will abort WiFi init at boot to avoid silently joining an
|
||||
unencrypted network.
|
||||
|
||||
config ZACUS_ALLOW_OPEN_WIFI
|
||||
bool "Allow joining open (unencrypted) WiFi networks"
|
||||
default n
|
||||
help
|
||||
Conscious opt-in: when enabled AND the WiFi password is empty,
|
||||
the device is allowed to join an OPEN (no encryption) network.
|
||||
This is INSECURE — voice traffic and the scenario HTTP server
|
||||
become reachable on a network anyone can join. Keep this off
|
||||
(default) for field deployments; a loud warning is logged when
|
||||
the open path is actually used.
|
||||
|
||||
config ZACUS_VOICE_BRIDGE_URL
|
||||
string "Voice Bridge WebSocket URL"
|
||||
default "ws://192.168.0.119:8200/voice/ws"
|
||||
default "wss://192.168.0.119:8200/voice/ws"
|
||||
help
|
||||
WebSocket endpoint for the mascarade voice bridge.
|
||||
The bridge routes voice commands to the hints engine.
|
||||
RECOMMENDED: use wss:// (TLS). The firmware will pin the
|
||||
server certificate via the CA bundle (or ZACUS_VOICE_CERT_PEM
|
||||
if set) when the URL starts with wss://. Plain ws:// still
|
||||
works but streams mic audio in clear text and logs a warning.
|
||||
|
||||
config ZACUS_MASTER_URL
|
||||
string "Zacus Master Base URL"
|
||||
@@ -31,8 +49,22 @@ menu "Zacus BOX-3 Voice Configuration"
|
||||
string "Voice Bridge Auth Token"
|
||||
default ""
|
||||
help
|
||||
Authentication token for the voice bridge.
|
||||
If set, appended as ?token=<value> query parameter.
|
||||
Authentication token for the voice bridge. MANDATORY: if left
|
||||
empty the device refuses to connect and will not stream mic
|
||||
audio (it is never sent unauthenticated). Appended as
|
||||
?token=<value> query parameter on the WebSocket URL. Also
|
||||
required by the scenario HTTP server (X-Zacus-Token).
|
||||
|
||||
config ZACUS_VOICE_CERT_PEM
|
||||
string "Voice Bridge pinned server certificate (PEM)"
|
||||
default ""
|
||||
help
|
||||
Optional PEM-encoded server (or CA) certificate used to verify
|
||||
the voice bridge when connecting over wss://. When empty, the
|
||||
firmware falls back to the bundled CA certificate store
|
||||
(esp_crt_bundle). Use this to pin a self-signed bridge cert.
|
||||
Embed as a single string with literal "\n" escapes, or leave
|
||||
empty to use the CA bundle.
|
||||
|
||||
config ZACUS_WAKE_WORD
|
||||
string "Wake Word"
|
||||
|
||||
+94
-11
@@ -12,6 +12,7 @@
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_err.h"
|
||||
@@ -40,9 +41,29 @@ static const char *TAG = "zacus-voice";
|
||||
|
||||
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 SemaphoreHandle_t s_spk_mutex = NULL; /* Guards speaker I2S writes */
|
||||
static volatile bool s_wifi_connected = false;
|
||||
static volatile bool s_voice_streaming = false;
|
||||
|
||||
/* Serialize speaker I2S writes shared by the boot test tone and the TTS
|
||||
* WebSocket-event callback so concurrent i2s_channel_write() calls cannot
|
||||
* interleave. Falls back to an unguarded write if the mutex is missing. */
|
||||
static esp_err_t speaker_write_locked(const void *data, size_t len, TickType_t timeout)
|
||||
{
|
||||
if (!s_spk_handle || len == 0) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (s_spk_mutex) {
|
||||
xSemaphoreTake(s_spk_mutex, portMAX_DELAY);
|
||||
}
|
||||
size_t bytes_written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle, data, len, &bytes_written, timeout);
|
||||
if (s_spk_mutex) {
|
||||
xSemaphoreGive(s_spk_mutex);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* --------------- Forward declarations --------------- */
|
||||
|
||||
static esp_err_t wifi_init_sta(void);
|
||||
@@ -92,9 +113,25 @@ static esp_err_t wifi_init_sta(void)
|
||||
},
|
||||
};
|
||||
|
||||
/* Allow open networks if no password configured */
|
||||
/*
|
||||
* Security: do NOT silently join open WiFi. By default WPA2-PSK is
|
||||
* required. The open-network path is a conscious opt-in gated behind
|
||||
* CONFIG_ZACUS_ALLOW_OPEN_WIFI, and even then it logs a loud warning.
|
||||
*/
|
||||
if (strlen(CONFIG_ZACUS_WIFI_PASSWORD) == 0) {
|
||||
#if CONFIG_ZACUS_ALLOW_OPEN_WIFI
|
||||
ESP_LOGW(TAG, "**********************************************************");
|
||||
ESP_LOGW(TAG, "* INSECURE: joining OPEN WiFi '%s' (no password set).", CONFIG_ZACUS_WIFI_SSID);
|
||||
ESP_LOGW(TAG, "* Voice traffic and the scenario server are exposed.");
|
||||
ESP_LOGW(TAG, "* Disable ZACUS_ALLOW_OPEN_WIFI for field deployment.");
|
||||
ESP_LOGW(TAG, "**********************************************************");
|
||||
wifi_config.sta.threshold.authmode = WIFI_AUTH_OPEN;
|
||||
#else
|
||||
ESP_LOGE(TAG, "WiFi password is empty and ZACUS_ALLOW_OPEN_WIFI is off — "
|
||||
"refusing to join an open network. Set a WPA2 password "
|
||||
"(menuconfig) or explicitly enable ZACUS_ALLOW_OPEN_WIFI.");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
#endif
|
||||
}
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
@@ -115,7 +152,6 @@ void audio_play_tone(float frequency, int duration_ms)
|
||||
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)
|
||||
@@ -124,8 +160,7 @@ void audio_play_tone(float frequency, int duration_ms)
|
||||
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);
|
||||
speaker_write_locked(buffer, chunk * sizeof(int16_t), portMAX_DELAY);
|
||||
sample_idx += chunk;
|
||||
}
|
||||
}
|
||||
@@ -146,7 +181,6 @@ static void audio_test_tone(void)
|
||||
const float amplitude = 16000.0f;
|
||||
|
||||
int16_t buffer[256];
|
||||
size_t bytes_written = 0;
|
||||
int sample_idx = 0;
|
||||
|
||||
while (sample_idx < total_samples) {
|
||||
@@ -155,7 +189,7 @@ static void audio_test_tone(void)
|
||||
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);
|
||||
speaker_write_locked(buffer, chunk * sizeof(int16_t), portMAX_DELAY);
|
||||
sample_idx += chunk;
|
||||
}
|
||||
|
||||
@@ -175,9 +209,10 @@ static void tts_audio_callback(const uint8_t *data, size_t len)
|
||||
return;
|
||||
}
|
||||
|
||||
size_t bytes_written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle, data, len,
|
||||
&bytes_written, pdMS_TO_TICKS(200));
|
||||
/* Bounded timeout so a stuck/contended speaker channel cannot block the
|
||||
* WebSocket event task indefinitely; the write is mutex-guarded so it
|
||||
* cannot interleave with the boot test tone or stimulus melody. */
|
||||
esp_err_t ret = speaker_write_locked(data, len, pdMS_TO_TICKS(200));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "TTS write to speaker failed: %s", esp_err_to_name(ret));
|
||||
}
|
||||
@@ -187,6 +222,16 @@ static void tts_audio_callback(const uint8_t *data, size_t len)
|
||||
|
||||
static esp_err_t speaker_init(void)
|
||||
{
|
||||
/* Mutex guarding all speaker I2S writes (boot tone vs TTS callback vs
|
||||
* stimulus melody) */
|
||||
if (!s_spk_mutex) {
|
||||
s_spk_mutex = xSemaphoreCreateMutex();
|
||||
if (!s_spk_mutex) {
|
||||
ESP_LOGE(TAG, "Failed to create speaker mutex");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enable power amplifier */
|
||||
gpio_set_direction(BOX3_PA_ENABLE, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(BOX3_PA_ENABLE, 1);
|
||||
@@ -250,13 +295,35 @@ static void mic_monitor_task(void *arg)
|
||||
int16_t buffer[AUDIO_FRAME_SAMPLES];
|
||||
size_t bytes_read = 0;
|
||||
int rms_log_counter = 0;
|
||||
unsigned err_count = 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));
|
||||
err_count++;
|
||||
ESP_LOGW(TAG, "Mic read error (#%u): %s", err_count, esp_err_to_name(ret));
|
||||
/* Back off so a persistent fault doesn't tight-spin this task */
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
continue;
|
||||
}
|
||||
err_count = 0;
|
||||
|
||||
/*
|
||||
* I2S MONO packing guard: a full frame must read back exactly
|
||||
* AUDIO_FRAME_SAMPLES * 2 bytes. A short/oversized read means the
|
||||
* slot bit-width vs MEMS data width is misconfigured (audio would be
|
||||
* garbled). Skip the frame and make it visible rather than forwarding
|
||||
* corrupt PCM. NOTE: the root I2S slot config still needs on-hardware
|
||||
* verification — this is only a runtime tripwire.
|
||||
*/
|
||||
if (bytes_read != AUDIO_FRAME_SAMPLES * sizeof(int16_t)) {
|
||||
ESP_LOGW(TAG, "Mic frame size mismatch: got %u bytes, expected %u "
|
||||
"(check I2S slot/data width on hardware) — skipping",
|
||||
(unsigned)bytes_read,
|
||||
(unsigned)(AUDIO_FRAME_SAMPLES * sizeof(int16_t)));
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -409,7 +476,20 @@ void app_main(void)
|
||||
|
||||
/* Connect to WiFi */
|
||||
ESP_LOGI(TAG, "Connecting to WiFi...");
|
||||
wifi_init_sta();
|
||||
esp_err_t wifi_ret = wifi_init_sta();
|
||||
if (wifi_ret != ESP_OK) {
|
||||
/* Most likely the secure-by-default guard refused an open network.
|
||||
* wifi_init_sta() returns before esp_wifi_start(), so neither the
|
||||
* voice bridge, the scenario HTTP server, nor the ESP-NOW receiver
|
||||
* can come up. Skip the whole network stack; the device still boots
|
||||
* so the failure is visible (audio + logs) rather than crashing.
|
||||
* Fix the WiFi config (WPA2 password or ZACUS_ALLOW_OPEN_WIFI) and
|
||||
* reboot. */
|
||||
ESP_LOGE(TAG, "WiFi init failed: %s — voice bridge, scenario server "
|
||||
"and ESP-NOW relay disabled. Fix WiFi config and reboot.",
|
||||
esp_err_to_name(wifi_ret));
|
||||
goto network_disabled;
|
||||
}
|
||||
|
||||
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
|
||||
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
|
||||
@@ -451,6 +531,9 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "ESP-NOW scenario receiver active");
|
||||
}
|
||||
|
||||
network_disabled:
|
||||
(void)0; /* landing pad when WiFi init refused (secure-by-default guard) */
|
||||
|
||||
/* TODO: Initialize ESP-SR WakeNet for wake-word detection
|
||||
* - Load WakeNet9 model ("hi esp" or custom)
|
||||
* - Feed audio frames from mic to WakeNet
|
||||
|
||||
@@ -32,16 +32,58 @@
|
||||
|
||||
static httpd_handle_t s_server = NULL;
|
||||
static bool s_spiffs_mounted = false;
|
||||
static volatile bool s_restart_scheduled = false; // single-shot reboot guard
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
static esp_err_t send_json(httpd_req_t *req, const char *status_line, const char *body) {
|
||||
httpd_resp_set_status(req, status_line);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
// No wildcard CORS. The scenario hot-load endpoint is only called by the
|
||||
// device/LAN tooling (curl / desktop hub), not from a browser on another
|
||||
// origin, so cross-origin access is intentionally NOT granted.
|
||||
return httpd_resp_sendstr(req, body);
|
||||
}
|
||||
|
||||
// Token compare against CONFIG_ZACUS_VOICE_TOKEN. Returns true when the
|
||||
// request carries a matching token via the X-Zacus-Token header or a ?token=
|
||||
// query parameter. Refuses when no token is configured.
|
||||
static bool scenario_auth_ok(httpd_req_t *req) {
|
||||
const char *expected = CONFIG_ZACUS_VOICE_TOKEN;
|
||||
if (expected == NULL || expected[0] == '\0') {
|
||||
ESP_LOGE(TAG, "scenario auth: CONFIG_ZACUS_VOICE_TOKEN unset — refusing");
|
||||
return false;
|
||||
}
|
||||
size_t exp_len = strlen(expected);
|
||||
char provided[128] = {0};
|
||||
|
||||
// 1) Header: X-Zacus-Token
|
||||
if (httpd_req_get_hdr_value_str(req, "X-Zacus-Token",
|
||||
provided, sizeof(provided)) == ESP_OK) {
|
||||
if (strlen(provided) == exp_len && strcmp(provided, expected) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Query parameter: ?token=...
|
||||
size_t qlen = httpd_req_get_url_query_len(req) + 1;
|
||||
if (qlen > 1 && qlen < 256) {
|
||||
char *query = (char *) malloc(qlen);
|
||||
if (query) {
|
||||
char tok[128] = {0};
|
||||
bool ok = false;
|
||||
if (httpd_req_get_url_query_str(req, query, qlen) == ESP_OK &&
|
||||
httpd_query_key_value(query, "token", tok, sizeof(tok)) == ESP_OK) {
|
||||
ok = (strlen(tok) == exp_len && strcmp(tok, expected) == 0);
|
||||
}
|
||||
free(query);
|
||||
if (ok) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static esp_err_t send_error(httpd_req_t *req, const char *status_line, const char *message) {
|
||||
char buf[192];
|
||||
snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : "");
|
||||
@@ -74,6 +116,13 @@ static void deferred_restart_task(void *arg) {
|
||||
}
|
||||
|
||||
static void schedule_restart(void) {
|
||||
// Single-shot: ignore repeated triggers so a burst of POSTs cannot spawn
|
||||
// multiple restart tasks (and so the device reboots exactly once).
|
||||
if (s_restart_scheduled) {
|
||||
ESP_LOGW(TAG, "restart already scheduled — ignoring repeat trigger");
|
||||
return;
|
||||
}
|
||||
s_restart_scheduled = true;
|
||||
xTaskCreate(deferred_restart_task, "scenario_restart",
|
||||
4096, NULL, tskIDLE_PRIORITY + 1, NULL);
|
||||
}
|
||||
@@ -177,6 +226,16 @@ esp_err_t scenario_apply_buffer(const char *data, size_t len) {
|
||||
}
|
||||
|
||||
static esp_err_t handle_scenario_post(httpd_req_t *req) {
|
||||
// Auth first: refuse unauthenticated scenario overwrite (which would
|
||||
// otherwise let anyone on the network replace the IR and reboot the box).
|
||||
if (!scenario_auth_ok(req)) {
|
||||
ESP_LOGW(TAG, "POST /game/scenario: missing/invalid auth token — 401");
|
||||
return send_error(req, "401 Unauthorized", "missing or invalid token");
|
||||
}
|
||||
// Reject once a reboot is already pending (avoids racing writes).
|
||||
if (s_restart_scheduled) {
|
||||
return send_error(req, "409 Conflict", "restart already pending");
|
||||
}
|
||||
if (req->content_len <= 0 || req->content_len > MAX_SCENARIO_BYTES) {
|
||||
ESP_LOGW(TAG, "POST /game/scenario: bad body length %d", (int) req->content_len);
|
||||
return send_error(req, "413 Payload Too Large", "body must be 1..65536 bytes");
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_websocket_client.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
#include "voice_ws_client.h"
|
||||
@@ -49,6 +50,16 @@ static void (*s_audio_cb)(const uint8_t *data, size_t len) = NULL;
|
||||
#define RECONNECT_DELAY_MS 5000
|
||||
#define HANDSHAKE_TIMEOUT_MS 5000
|
||||
|
||||
/* Max accepted binary (TTS audio) frame size. The bridge streams PCM16 in
|
||||
* small chunks; anything larger is treated as hostile/buggy and dropped so a
|
||||
* misbehaving bridge cannot flood the speaker path. */
|
||||
#define MAX_BINARY_FRAME_BYTES 8192
|
||||
|
||||
/* Recovery request from the WS event task to the recovery task: set when a
|
||||
* bridge "error" message arrives, so the heavy work (delay / state reset)
|
||||
* happens off the event task. */
|
||||
#define EVT_RECOVER BIT3
|
||||
|
||||
/* --------------- State helpers --------------- */
|
||||
|
||||
static void set_state(voice_state_t new_state)
|
||||
@@ -179,10 +190,10 @@ static void handle_json_message(const char *data, int len)
|
||||
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);
|
||||
/* Defer recovery to the recovery task — never block the WS event
|
||||
* task here (a vTaskDelay would freeze all WS processing). */
|
||||
if (s_events) {
|
||||
xEventGroupSetBits(s_events, EVT_RECOVER);
|
||||
}
|
||||
|
||||
} else if (strcmp(msg_type, "hint") == 0) {
|
||||
@@ -226,6 +237,23 @@ static void ws_event_handler(void *arg, esp_event_base_t event_base,
|
||||
break;
|
||||
|
||||
case WEBSOCKET_EVENT_DATA:
|
||||
/*
|
||||
* Fragmentation guard: esp_websocket_client may deliver a frame in
|
||||
* several callbacks. payload_offset > 0 means this is a continuation
|
||||
* chunk and payload_len is the *total* frame size. We only safely
|
||||
* handle complete, single-delivery frames here; partial deliveries
|
||||
* are dropped (and logged) rather than mis-parsed as whole messages.
|
||||
* Full reassembly is intentionally out of scope (would be invasive).
|
||||
*/
|
||||
if (data->payload_offset > 0 ||
|
||||
(data->payload_len > 0 && data->data_len != data->payload_len)) {
|
||||
ESP_LOGW(TAG, "Dropping fragmented WS frame "
|
||||
"(off=%d len=%d total=%d op=0x%02x)",
|
||||
data->payload_offset, data->data_len,
|
||||
data->payload_len, data->op_code);
|
||||
break;
|
||||
}
|
||||
|
||||
if (data->op_code == 0x01) {
|
||||
/* Text frame — JSON control message */
|
||||
if (data->data_ptr && data->data_len > 0) {
|
||||
@@ -234,6 +262,11 @@ static void ws_event_handler(void *arg, esp_event_base_t event_base,
|
||||
} else if (data->op_code == 0x02) {
|
||||
/* Binary frame — TTS audio data (PCM16 or OPUS) */
|
||||
/* TODO(opus): OPUS-decode here before forwarding */
|
||||
if (data->data_len > MAX_BINARY_FRAME_BYTES) {
|
||||
ESP_LOGW(TAG, "Oversized binary frame (%d > %d bytes) — dropped",
|
||||
data->data_len, MAX_BINARY_FRAME_BYTES);
|
||||
break;
|
||||
}
|
||||
if (s_audio_cb && data->data_ptr && data->data_len > 0) {
|
||||
s_audio_cb((const uint8_t *)data->data_ptr, data->data_len);
|
||||
}
|
||||
@@ -250,25 +283,42 @@ static void ws_event_handler(void *arg, esp_event_base_t event_base,
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------- Reconnect task --------------- */
|
||||
/* --------------- Recovery / health monitor task --------------- */
|
||||
|
||||
static void reconnect_task(void *arg)
|
||||
/*
|
||||
* Single recovery mechanism. Transport-level reconnects are handled
|
||||
* exclusively by the esp_websocket_client built-in auto-reconnect
|
||||
* (reconnect_timeout_ms) — we do NOT start the client manually here, so there
|
||||
* is no double-reconnect race. This task only:
|
||||
* - reacts to EVT_RECOVER (a bridge "error" message): reset state to READY
|
||||
* off the WS event task;
|
||||
* - logs a loud "bridge persistently down" message after a prolonged
|
||||
* disconnect so the field operator can see it (LCD optional / future).
|
||||
*/
|
||||
static void recovery_task(void *arg)
|
||||
{
|
||||
ESP_LOGI(TAG, "Reconnect monitor started");
|
||||
ESP_LOGI(TAG, "Recovery 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));
|
||||
EventBits_t bits = xEventGroupWaitBits(
|
||||
s_events, EVT_RECOVER | EVT_DISCONNECTED,
|
||||
pdTRUE /* clear on exit */, pdFALSE /* any bit */,
|
||||
pdMS_TO_TICKS(RECONNECT_DELAY_MS * 2));
|
||||
|
||||
/* 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);
|
||||
if (bits & EVT_RECOVER) {
|
||||
/* Deferred recovery from a bridge error message. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
if (s_ws_client && esp_websocket_client_is_connected(s_ws_client)) {
|
||||
set_state(VOICE_STATE_READY);
|
||||
ESP_LOGI(TAG, "Recovered to READY after bridge error");
|
||||
}
|
||||
}
|
||||
|
||||
/* If still disconnected after the wait window, surface it. The
|
||||
* built-in auto-reconnect keeps retrying underneath. */
|
||||
if (s_ws_client && !esp_websocket_client_is_connected(s_ws_client)) {
|
||||
ESP_LOGW(TAG, "Voice bridge persistently down — auto-reconnect retrying");
|
||||
/* TODO(lcd): show a "bridge down" indicator on the ILI9341. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,21 +344,31 @@ esp_err_t voice_ws_init(void)
|
||||
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);
|
||||
/*
|
||||
* Auth token is MANDATORY. We never stream mic audio unauthenticated:
|
||||
* if no token is configured, refuse to connect.
|
||||
*/
|
||||
const char *token = CONFIG_ZACUS_VOICE_TOKEN;
|
||||
if (token == NULL || strlen(token) == 0) {
|
||||
ESP_LOGE(TAG, "CONFIG_ZACUS_VOICE_TOKEN is empty — refusing to connect "
|
||||
"(mic audio is never streamed unauthenticated). Set the "
|
||||
"voice bridge auth token in menuconfig.");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
#else
|
||||
snprintf(url, sizeof(url), "%s", base_url);
|
||||
#endif
|
||||
|
||||
ESP_LOGI(TAG, "Voice bridge URL: %s", url);
|
||||
/* Build URL with mandatory token query param */
|
||||
char url[256];
|
||||
snprintf(url, sizeof(url), "%s?token=%s", base_url, token);
|
||||
ESP_LOGI(TAG, "Voice bridge base URL: %s", base_url);
|
||||
|
||||
/* Detect transport: wss:// = TLS with cert pinning, ws:// = plaintext */
|
||||
bool is_tls = (strncmp(base_url, "wss://", 6) == 0);
|
||||
if (!is_tls) {
|
||||
ESP_LOGW(TAG, "Voice bridge uses PLAINTEXT ws:// — mic audio and token "
|
||||
"are sent in clear. Use wss:// for field deployment.");
|
||||
}
|
||||
|
||||
/* Configure WebSocket client */
|
||||
esp_websocket_client_config_t ws_cfg = {
|
||||
@@ -319,6 +379,25 @@ esp_err_t voice_ws_init(void)
|
||||
.ping_interval_sec = 15,
|
||||
};
|
||||
|
||||
/*
|
||||
* TLS server verification for wss://. Pin an explicit PEM certificate
|
||||
* when ZACUS_VOICE_CERT_PEM is provided, otherwise fall back to the
|
||||
* bundled CA store (esp_crt_bundle). The bridge must terminate TLS for
|
||||
* this to work end-to-end (host-side, out of firmware scope).
|
||||
*/
|
||||
if (is_tls) {
|
||||
#ifdef CONFIG_ZACUS_VOICE_CERT_PEM
|
||||
if (strlen(CONFIG_ZACUS_VOICE_CERT_PEM) > 0) {
|
||||
ws_cfg.cert_pem = CONFIG_ZACUS_VOICE_CERT_PEM;
|
||||
ESP_LOGI(TAG, "wss:// — pinning configured server certificate");
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
ws_cfg.crt_bundle_attach = esp_crt_bundle_attach;
|
||||
ESP_LOGI(TAG, "wss:// — verifying server via CA certificate bundle");
|
||||
}
|
||||
}
|
||||
|
||||
s_ws_client = esp_websocket_client_init(&ws_cfg);
|
||||
if (!s_ws_client) {
|
||||
ESP_LOGE(TAG, "Failed to init WebSocket client");
|
||||
@@ -333,8 +412,9 @@ esp_err_t voice_ws_init(void)
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Start reconnect monitor task */
|
||||
xTaskCreate(reconnect_task, "ws_reconnect", 3072, NULL, 3, NULL);
|
||||
/* Start recovery / health monitor task (transport reconnect is handled
|
||||
* by the client's built-in auto-reconnect, not here). */
|
||||
xTaskCreate(recovery_task, "ws_recovery", 3072, NULL, 3, NULL);
|
||||
|
||||
set_state(VOICE_STATE_IDLE);
|
||||
ESP_LOGI(TAG, "Voice WS client initialized");
|
||||
@@ -363,8 +443,22 @@ esp_err_t voice_ws_connect(void)
|
||||
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 */
|
||||
ESP_LOGW(TAG, "Handshake timeout — bridge connected but sent no "
|
||||
"hello_ack; closing and restarting for a clean retry");
|
||||
/*
|
||||
* The socket is connected at the transport level but the bridge never
|
||||
* completed the application handshake. A user-requested close() does
|
||||
* NOT trigger the built-in auto-reconnect, so we explicitly close then
|
||||
* start() again to re-establish cleanly and re-send hello. This is a
|
||||
* single deterministic re-arm — there is no second/background
|
||||
* reconnect mechanism that could race it.
|
||||
*/
|
||||
esp_websocket_client_close(s_ws_client, pdMS_TO_TICKS(1000));
|
||||
esp_err_t restart = esp_websocket_client_start(s_ws_client);
|
||||
if (restart != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Restart after handshake timeout failed: %s",
|
||||
esp_err_to_name(restart));
|
||||
}
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
|
||||
# Enable HTTPS / TLS for voice bridge
|
||||
CONFIG_ESP_TLS_USING_MBEDTLS=y
|
||||
CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
|
||||
# CA certificate bundle — used for wss:// server verification
|
||||
# (esp_crt_bundle_attach) when no explicit ZACUS_VOICE_CERT_PEM is pinned.
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y
|
||||
|
||||
# Heap: place large buffers in PSRAM
|
||||
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096
|
||||
|
||||
Reference in New Issue
Block a user