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:
L'électron rare
2026-06-14 02:50:31 +02:00
parent 7893c32bf6
commit f895d1b864
5 changed files with 320 additions and 48 deletions
+36 -4
View File
@@ -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
View File
@@ -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
+60 -1
View File
@@ -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");
+126 -32
View File
@@ -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;
}
+4
View File
@@ -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