diff --git a/box3_voice/main/Kconfig.projbuild b/box3_voice/main/Kconfig.projbuild index 2963391..0c1ceaa 100644 --- a/box3_voice/main/Kconfig.projbuild +++ b/box3_voice/main/Kconfig.projbuild @@ -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= 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= 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" diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c index af63310..be75e11 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -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 diff --git a/box3_voice/main/scenario_server.c b/box3_voice/main/scenario_server.c index 3c5a3b3..9a4f8d0 100644 --- a/box3_voice/main/scenario_server.c +++ b/box3_voice/main/scenario_server.c @@ -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"); diff --git a/box3_voice/main/voice_ws_client.c b/box3_voice/main/voice_ws_client.c index aad0109..3e8a12e 100644 --- a/box3_voice/main/voice_ws_client.c +++ b/box3_voice/main/voice_ws_client.c @@ -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; } diff --git a/box3_voice/sdkconfig.defaults b/box3_voice/sdkconfig.defaults index b096919..e288b10 100644 --- a/box3_voice/sdkconfig.defaults +++ b/box3_voice/sdkconfig.defaults @@ -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