Files
desklock/firmware/main/desklock_main.c
T
jpmschweitzerandClaude Fable 5 6b3b9ce0ed
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
Fix load-dependent DSI flicker: TRIPLE_PARTIAL -> TRIPLE_FULL tear-avoid
Root cause (investigation): the BSP default tear-avoid mode is
TRIPLE_PARTIAL. On ESP-IDF 5.5 the MIPI-DSI driver has no
on_frame_buf_complete callback (added in IDF 6.0 -> the boot warning
"buffer-switch...may not function on MIPI DSI"), so the adapter falls
back to on_refresh_done, which fires every refresh (~60Hz) as a fake
vsync. In PARTIAL mode that release path is NOT submit-gated, so when
one LVGL frame takes >1 refresh to render it over-releases the buffer
that is still being scanned out -> LVGL draws into the live front
buffer -> tearing/flicker.

Why it only started with the wake word, and only under load: at idle
(sparse rain) a frame renders in <16.6ms so exactly one submit per
refresh -> harmless. The always-on WakeNet added constant CPU/PSRAM
load that pushed the heavy-rain frames (listening=16, thinking=40
streams) past one refresh -> triggered the over-release. User
correctly identified it as a resource starve exposing the latent bug.

Fix: switch to TRIPLE_FULL, which IS submit-gated even without the
callback (one release per actual submit). Same 3 framebuffers, zero
memory cost, app-side one-liner via bsp_display_start_with_config so
the managed component is untouched. Trade-off: full-screen redraw per
frame; if the rain gets choppy under load, cheaper rain rendering
(canvas / half-rate tick) is the follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:31:25 +02:00

176 lines
5.7 KiB
C

/* DeskLock — living-room face and voice for the Tatlock butler.
*
* 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/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "bsp/esp-bsp.h"
#include "desklock.h"
/* 1 = L1 wifi driver diagnosis (SoftAP, no STA/gateway). 0 = normal app. */
#define WIFI_DIAG_MODE 0
static const char *TAG = "desklock";
/* All utterance framing (blocking WS sends, chime, face) runs on app_task, never
* on the LVGL touch callback nor the AFE detect/fetch thread. The wake word, VAD,
* and touch just POST an event; app_task is the single serializer of start/end,
* which removes both the fetch-thread stall and the double-start race. */
typedef enum { EV_TOUCH, EV_WAKE, EV_SPEECH_END } app_event_t;
static QueueHandle_t s_events;
static void post_event(app_event_t ev)
{
if (s_events != NULL) {
xQueueSend(s_events, &ev, 0);
}
}
/* --- these three are the fast, non-blocking hooks called from other tasks --- */
void app_on_touch(void) { post_event(EV_TOUCH); } /* LVGL touch callback */
void app_on_wake(void) { post_event(EV_WAKE); } /* AFE detect_task: wake word */
void app_on_speech_end(void) { post_event(EV_SPEECH_END); } /* AFE detect_task: VAD silence */
/* --- the actual work, all on app_task --- */
static void start_utterance(void)
{
if (!gw_connected() || audio_is_playing() || audio_capture_active()
|| face_get() == FACE_EFFORT) {
return;
}
face_activity();
audio_play_chime(); /* audible "I heard you" */
gw_send_text("{\"type\":\"utterance_start\"}"); /* must precede any audio frames */
audio_capture_start(); /* now detect_task forwards audio */
face_set(FACE_LISTENING);
ESP_LOGI(TAG, "listening…");
}
static void end_utterance(void)
{
if (!audio_capture_active()) {
return;
}
audio_capture_stop();
gw_send_text("{\"type\":\"utterance_end\"}");
face_set(FACE_PENSIVE);
ESP_LOGI(TAG, "utterance sent");
}
/* WS link dropped: abandon any half-utterance so the next wake starts fresh. */
void app_on_disconnect(void)
{
audio_capture_stop();
}
static void app_task(void *arg)
{
(void)arg;
app_event_t ev;
for (;;) {
if (xQueueReceive(s_events, &ev, portMAX_DELAY) != pdTRUE) {
continue;
}
if (ev == EV_TOUCH) {
vTaskDelay(pdMS_TO_TICKS(60)); /* debounce */
xQueueReset(s_events); /* coalesce a bouncy tap burst */
face_activity();
if (!gw_connected()) {
audio_capture_stop();
} else if (!audio_capture_active()) {
start_utterance(); /* tap = manual wake */
} else {
end_utterance(); /* tap = manual end */
}
} else if (ev == EV_WAKE) {
start_utterance();
} else if (ev == EV_SPEECH_END) {
end_utterance();
}
}
}
void app_on_playback_done(void)
{
face_set(FACE_IDLE);
}
#if SDIO_TX_SELFTEST
/* Temporary autonomous SDIO-TX stress test: stream mic audio upstream for 40s
* right after connecting (no user tap needed) to prove the #167 alignment fix.
* Remove once verified. */
static void sdio_tx_selftest_task(void *arg)
{
(void)arg;
vTaskDelay(pdMS_TO_TICKS(4000));
ESP_LOGW("selftest", "SDIO TX STRESS START: streaming mic audio 40s");
audio_capture_start();
for (int i = 0; i < 40; i++) {
vTaskDelay(pdMS_TO_TICKS(1000));
ESP_LOGW("selftest", "SDIO TX STRESS: alive %ds (no wedge)", i + 1);
}
audio_capture_stop();
ESP_LOGW("selftest", "SDIO TX STRESS SURVIVED 40s — #167 FIX CONFIRMED");
vTaskDelete(NULL);
}
void sdio_tx_selftest_kick(void)
{
static bool started;
if (!started) {
started = true;
xTaskCreate(sdio_tx_selftest_task, "sdiotest", 4096, NULL, 4, NULL);
}
}
#endif
void app_main(void)
{
ESP_LOGI(TAG, "DeskLock starting");
s_events = xQueueCreate(8, sizeof(app_event_t));
/* TRIPLE_FULL, not the BSP default TRIPLE_PARTIAL: on IDF 5.5 the DSI driver
* lacks the on_frame_buf_complete callback, so PARTIAL mode falls back to a
* per-refresh "fake vsync" that over-releases the scanout buffer when a frame
* takes >1 refresh to draw (heavy rain + always-on WakeNet) -> tearing/flicker.
* TRIPLE_FULL is submit-gated even without that callback. Same 3 framebuffers. */
bsp_display_cfg_t disp_cfg = {
.lv_adapter_cfg = ESP_LV_ADAPTER_DEFAULT_CONFIG(),
.rotation = ESP_LV_ADAPTER_ROTATE_0,
.tear_avoid_mode = ESP_LV_ADAPTER_TEAR_AVOID_MODE_TRIPLE_FULL,
.touch_flags = { .swap_xy = 0, .mirror_x = 0, .mirror_y = 0 },
};
bsp_display_start_with_config(&disp_cfg);
bsp_display_backlight_on();
face_init();
audio_init();
if (esp_reset_reason() == ESP_RST_POWERON) {
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
net_start();
c6_ota_start(); /* one-shot C6 radio firmware update — remove once proven */
#endif
ESP_LOGI(TAG, "DeskLock up");
}