From 5c6a6eba36dd1d01a647daba1b41fefaa5787bab Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 15 Jul 2026 09:04:57 +0200 Subject: [PATCH] Fix touch crash + make rain a reachability signal + calm the alarm churn TOUCH CRASH (the blue screen): app_on_touch ran a blocking WebSocket send (up to 5s) directly in the LVGL touch callback, stalling the MIPI-DSI flush into a garbage/blue frame + task-watchdog reboot on every tap. Now touch_cb only gives a semaphore; a dedicated app_task does the blocking sends, audio, and face changes off the render thread. WS send timeout cut 5s to 1.5s as belt-and-braces. RAIN = REACHABILITY (user request): rain now falls only while the gateway WebSocket is live (gated on gw_connected in rain_tick). It drains gracefully on disconnect, resumes on reconnect, a genuine glanceable reachable signal. Idle density bumped 2 to 4 so connected-idle reads distinctly from disconnected-black. CALM THE WEDGE CHURN (user request): transient wifi/WS drops no longer slam to the x_x error face or a CONNECTING banner. Boot goes straight to the calm idle face (dry until connected). Only a sustained 30s+ outage escalates to x_x (clock_cb); the ~15s wedge-recovery just shows a brief rain pause. Two fixes from adversarial concurrency review before flashing: - persistent single capture task (was xTaskCreate per utterance; a rapid re-tap or WS-stop-vs-app-start race could put two readers on one mic/I2S handle and corrupt the codec) - reset s_talking on disconnect (app_on_disconnect) so the first tap after a mid-utterance drop starts fresh, not the stop branch Co-Authored-By: Claude Fable 5 --- firmware/main/audio.c | 22 +++++++++++------ firmware/main/desklock.h | 1 + firmware/main/desklock_main.c | 46 ++++++++++++++++++++++++++++++++--- firmware/main/face.c | 31 ++++++++++++++++++++--- firmware/main/gw_client.c | 9 ++++--- firmware/main/net.c | 2 +- 6 files changed, 93 insertions(+), 18 deletions(-) diff --git a/firmware/main/audio.c b/firmware/main/audio.c index 200e16a..8f3bbfc 100644 --- a/firmware/main/audio.c +++ b/firmware/main/audio.c @@ -35,6 +35,7 @@ static volatile bool s_capturing; typedef enum { JOB_GONG, JOB_REPLY } job_t; static QueueHandle_t s_jobs; +static TaskHandle_t s_capture_task; /* --- gong: see docs/architecture.md "Sound signature" --- */ @@ -126,17 +127,23 @@ static void audio_task(void *arg) } } +/* One persistent capture task, created once. It parks on a notification until + * capture is requested, then streams mic -> gateway while s_capturing holds. + * This makes capture single-instance: no create/delete restart race, so a rapid + * re-tap (or a WS-disconnect stop racing an app_task start) can never put two + * readers on the one mic/I2S handle. */ static void capture_task(void *arg) { (void)arg; uint8_t *chunk = heap_caps_malloc(CAPTURE_CHUNK, MALLOC_CAP_DEFAULT); - while (s_capturing) { - if (esp_codec_dev_read(s_mic, chunk, CAPTURE_CHUNK) == ESP_CODEC_DEV_OK) { - gw_send_bin(chunk, CAPTURE_CHUNK); + for (;;) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + while (s_capturing) { + if (esp_codec_dev_read(s_mic, chunk, CAPTURE_CHUNK) == ESP_CODEC_DEV_OK) { + gw_send_bin(chunk, CAPTURE_CHUNK); + } } } - free(chunk); - vTaskDelete(NULL); } void audio_init(void) @@ -159,6 +166,7 @@ void audio_init(void) s_reply = heap_caps_malloc(REPLY_MAX, MALLOC_CAP_SPIRAM); s_jobs = xQueueCreate(4, sizeof(job_t)); xTaskCreate(audio_task, "audio", 4096, NULL, 5, NULL); + xTaskCreate(capture_task, "capture", 4096, NULL, 6, &s_capture_task); ESP_LOGI(TAG, "audio up (spk=%d mic=%d)", s_spk != NULL, s_mic != NULL); } @@ -170,11 +178,11 @@ void audio_play_gong(void) void audio_capture_start(void) { - if (s_mic == NULL || s_capturing) { + if (s_mic == NULL || s_capturing || s_capture_task == NULL) { return; } s_capturing = true; - xTaskCreate(capture_task, "capture", 4096, NULL, 6, NULL); + xTaskNotifyGive(s_capture_task); /* wake the persistent task's inner loop */ } void audio_capture_stop(void) diff --git a/firmware/main/desklock.h b/firmware/main/desklock.h index 076ca1a..794e648 100644 --- a/firmware/main/desklock.h +++ b/firmware/main/desklock.h @@ -50,4 +50,5 @@ void c6_ota_start(void); /* desklock_main.c */ void app_on_touch(void); +void app_on_disconnect(void); void app_on_playback_done(void); diff --git a/firmware/main/desklock_main.c b/firmware/main/desklock_main.c index 750f5bf..0af43ec 100644 --- a/firmware/main/desklock_main.c +++ b/firmware/main/desklock_main.c @@ -2,10 +2,18 @@ * * Boot: display + face -> audio (gong) -> Wi-Fi (C6/ESP-Hosted) -> gateway WS. * Interaction (phase 2): touch-to-talk — tap to speak, tap again to finish. + * + * Touch is DECOUPLED from the LVGL thread: the touch callback only signals a + * semaphore; all the real work (blocking WebSocket sends, audio, face changes) + * runs in app_task. A blocking WS send on the LVGL render thread stalls the + * MIPI-DSI flush -> blue/garbage frame + task-watchdog reboot. */ #include "esp_log.h" #include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" #include "bsp/esp-bsp.h" #include "desklock.h" @@ -15,13 +23,31 @@ static const char *TAG = "desklock"; +static SemaphoreHandle_t s_touch_sem; static bool s_talking; +/* Called from the LVGL touch callback — MUST be fast and non-blocking. */ void app_on_touch(void) +{ + if (s_touch_sem != NULL) { + xSemaphoreGive(s_touch_sem); + } +} + +/* Called from the WS event task when the link drops: forget any half-utterance + * so the next tap starts a fresh one instead of hitting the "stop" branch. */ +void app_on_disconnect(void) +{ + s_talking = false; +} + +/* Runs in app_task (normal context): blocking sends here don't stall rendering. */ +static void handle_touch(void) { face_activity(); if (!gw_connected()) { + s_talking = false; /* link gone; nothing to talk to */ return; } if (!s_talking) { @@ -42,17 +68,29 @@ void app_on_touch(void) } } +static void app_task(void *arg) +{ + (void)arg; + for (;;) { + if (xSemaphoreTake(s_touch_sem, portMAX_DELAY) == pdTRUE) { + vTaskDelay(pdMS_TO_TICKS(60)); /* debounce */ + xSemaphoreTake(s_touch_sem, 0); /* coalesce repeats during debounce */ + handle_touch(); + } + } +} + void app_on_playback_done(void) { -#if !WIFI_DIAG_MODE - face_set(FACE_IDLE); /* in diag mode the status line must stay visible */ -#endif + face_set(FACE_IDLE); } void app_main(void) { ESP_LOGI(TAG, "DeskLock starting"); + s_touch_sem = xSemaphoreCreateBinary(); + bsp_display_start(); bsp_display_backlight_on(); face_init(); @@ -62,6 +100,8 @@ void app_main(void) audio_play_gong(); /* cold boot only — recovery reboots stay silent */ } + xTaskCreate(app_task, "app", 4096, NULL, 5, NULL); + #if WIFI_DIAG_MODE wifi_diag_start(); #else diff --git a/firmware/main/face.c b/firmware/main/face.c index d9e7b66..7b16b42 100644 --- a/firmware/main/face.c +++ b/firmware/main/face.c @@ -39,7 +39,7 @@ typedef struct { static const face_def_t DEFS[] = { [FACE_BOOT] = { "- -", "\\_/", false, false, false, 6, 3 }, - [FACE_IDLE] = { "- -", "\\_/", true, false, false, 2, 2 }, + [FACE_IDLE] = { "- -", "\\_/", true, false, false, 4, 2 }, [FACE_LISTENING] = { "O O", "o", true, false, false, 16, 4 }, [FACE_PENSIVE] = { "\xC2\xB7 \xC2\xB7", "~", false, true, false, 7, 3 }, [FACE_EFFORT] = { "> <", "~", false, false, false, 40, 9 }, @@ -153,7 +153,9 @@ static void stream_hide(stream_t *s) static void rain_tick(void) { const face_def_t *d = &DEFS[F.state]; - int target = (F.power == 2) ? 0 : d->rain_streams; + /* Rain is the reachability signal: it only falls when the gateway is live. + * A disconnect drains it gracefully (streams finish falling, none respawn). */ + int target = (F.power == 2 || !gw_connected()) ? 0 : d->rain_streams; int alive = 0; for (int i = 0; i < RAIN_MAX; i++) { stream_t *s = &F.streams[i]; @@ -308,6 +310,24 @@ static void tick_cb(lv_timer_t *timer) static void clock_cb(lv_timer_t *timer) { (void)timer; + + /* Sustained-outage escalation: a brief wedge-recovery (~15 s) just drains + * the rain and keeps the calm face; only a real >30 s outage shows x_x. */ + static uint32_t disc_since; + if (!gw_connected()) { + if (disc_since == 0) { + disc_since = now_ms(); + } + if (now_ms() - disc_since > 30000 && F.state == FACE_IDLE) { + face_set(FACE_ERROR); + } + } else { + disc_since = 0; + if (F.state == FACE_ERROR) { + face_set(FACE_IDLE); + } + } + if (F.state != FACE_IDLE) { lv_obj_add_flag(F.clock_lbl, LV_OBJ_FLAG_HIDDEN); return; @@ -373,8 +393,9 @@ void face_init(void) F.status = lv_label_create(scr); lv_obj_set_style_text_font(F.status, &lv_font_montserrat_28, 0); lv_obj_set_style_text_color(F.status, lv_color_hex(0x2F7A4B), 0); - lv_label_set_text(F.status, "CONNECTING"); + lv_label_set_text(F.status, ""); lv_obj_align(F.status, LV_ALIGN_CENTER, 0, 270); + lv_obj_add_flag(F.status, LV_OBJ_FLAG_HIDDEN); /* only shown in diag mode */ F.elapsed = lv_label_create(scr); lv_obj_set_style_text_font(F.elapsed, &lv_font_montserrat_28, 0); @@ -405,7 +426,9 @@ void face_init(void) lv_obj_add_flag(touch, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(touch, touch_cb, LV_EVENT_CLICKED, NULL); - F.state = FACE_BOOT; + /* Boot straight into the calm idle face (dry — rain gate holds it off until + * the gateway connects). No "CONNECTING" banner churn on every recovery. */ + F.state = FACE_IDLE; F.last_activity = now_ms(); F.state_since = now_ms(); F.blink_in_ms = 3000; diff --git a/firmware/main/gw_client.c b/firmware/main/gw_client.c index b157f2f..5a4b986 100644 --- a/firmware/main/gw_client.c +++ b/firmware/main/gw_client.c @@ -80,7 +80,10 @@ static void ws_event(void *arg, esp_event_base_t base, int32_t id, void *data) } s_connected = false; audio_capture_stop(); - face_set(FACE_ERROR); + app_on_disconnect(); + /* Stay calm: the rain draining away signals "unreachable". A sustained + * outage escalates to the x_x error face in face.c (clock_cb). */ + face_set(FACE_IDLE); break; case WEBSOCKET_EVENT_DATA: if (ev->op_code == 0x01) { /* text */ @@ -120,13 +123,13 @@ bool gw_connected(void) void gw_send_text(const char *json) { if (s_connected) { - esp_websocket_client_send_text(s_ws, json, strlen(json), pdMS_TO_TICKS(5000)); + esp_websocket_client_send_text(s_ws, json, strlen(json), pdMS_TO_TICKS(1500)); } } void gw_send_bin(const uint8_t *data, size_t len) { if (s_connected) { - esp_websocket_client_send_bin(s_ws, (const char *)data, len, pdMS_TO_TICKS(5000)); + esp_websocket_client_send_bin(s_ws, (const char *)data, len, pdMS_TO_TICKS(1500)); } } diff --git a/firmware/main/net.c b/firmware/main/net.c index bbf58ad..f5f5c73 100644 --- a/firmware/main/net.c +++ b/firmware/main/net.c @@ -76,7 +76,7 @@ static void wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data) } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { wifi_event_sta_disconnected_t *dc = (wifi_event_sta_disconnected_t *)data; ESP_LOGW(TAG, "wifi disconnected (reason %d), retrying", dc ? dc->reason : -1); - face_set(FACE_ERROR); + /* rain drains via the gw_connected() gate; face stays calm (see face.c) */ vTaskDelay(pdMS_TO_TICKS(3000)); esp_wifi_connect(); } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {