/* 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 "soc/mipi_dsi_bridge_struct.h" #include "desklock.h" /* 1 = L1 wifi driver diagnosis (SoftAP, no STA/gateway). 0 = normal app. */ #define WIFI_DIAG_MODE 0 /* 1 = deterministic load-emulation harness for the blue-flicker RCA: cycles * isolated load types (render-only ladder, light-render+network, heavy-render+ * network, heavy-render+playback) with a log marker per phase, so we can see * which load starves the DSI. Kept (gated off) for future bus-contention debugging. * 0 = normal app. */ #define FACE_LOADTEST 0 /* Dev mode: enabled at deploy time with `idf.py -DDESKLOCK_DEVMODE=ON build flash` * (see main/CMakeLists.txt), OFF by default in production. Currently gates only the * screenshot watcher — a task that watches the USB-serial-JTAG RX for a trigger * byte and dumps the current screen (see the device-screenshot skill / * firmware/tools/device_shot.py). Off by default so production spends no internal * RAM on it and nothing extra runs on the render path. */ #ifndef DESKLOCK_DEVMODE #define DESKLOCK_DEVMODE 0 #endif 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) { /* Only fall back to idle if we're still the speaker. After the butler filler * line, the gateway re-asserts "thinking" (FACE_EFFORT) while Tatlock works — * don't clobber that spinner when the filler audio finishes. */ if (face_get() == FACE_SPEAKING) { face_set(FACE_IDLE); } } #if FACE_LOADTEST /* Stream real mic audio upstream for `ms` — exercises the network path (I2S-in * DMA + esp-hosted SDIO TX, both touching PSRAM) with valid utterance framing so * the gateway keeps the WS open. */ static void loadtest_stream(int ms) { if (!gw_connected()) { vTaskDelay(pdMS_TO_TICKS(ms)); return; } gw_send_text("{\"type\":\"utterance_start\"}"); audio_capture_start(); vTaskDelay(pdMS_TO_TICKS(ms)); audio_capture_stop(); gw_send_text("{\"type\":\"utterance_end\"}"); } static void loadtest_task(void *arg) { (void)arg; vTaskDelay(pdMS_TO_TICKS(12000)); /* let boot + Wi-Fi + gateway settle */ const struct { face_state_t st; const char *name; } LADDER[] = { { FACE_IDLE, "IDLE(4)" }, { FACE_PENSIVE, "PENSIVE(7)" }, { FACE_SPEAKING, "SPEAKING(14)" }, { FACE_LISTENING, "LISTENING(16)" }, { FACE_RAGE, "RAGE(34)" }, { FACE_EFFORT, "EFFORT(40)" }, }; char tag[24]; static uint8_t netframe[1024] = {0}; for (;;) { ESP_LOGW("loadtest", "=== P1 render-only ladder (NO network) ==="); for (int i = 0; i < 6; i++) { ESP_LOGW("loadtest", "P1: %s", LADDER[i].name); face_set(LADDER[i].st); snprintf(tag, sizeof(tag), "P1 %s", LADDER[i].name); face_status(tag); /* phase label on screen */ vTaskDelay(pdMS_TO_TICKS(6000)); } ESP_LOGW("loadtest", "=== P2 LIGHT render (IDLE 4) + NETWORK stream — key test ==="); face_set(FACE_IDLE); face_status("P2 net + idle"); loadtest_stream(8000); vTaskDelay(pdMS_TO_TICKS(6000)); /* let any reply drain */ ESP_LOGW("loadtest", "=== P3 HEAVY render (EFFORT 40) + NETWORK stream ==="); face_set(FACE_EFFORT); face_status("P3 net + EFFORT"); loadtest_stream(8000); vTaskDelay(pdMS_TO_TICKS(6000)); ESP_LOGW("loadtest", "=== P4 HEAVY render (EFFORT 40) + PLAYBACK dma ==="); face_set(FACE_EFFORT); face_status("P4 play + EFFORT"); for (int i = 0; i < 3; i++) { audio_play_gong(); vTaskDelay(pdMS_TO_TICKS(2500)); } /* P5: the real-conversation worst case — heavy render + audio playback + * network stream ALL AT ONCE, like the SPEAKING state (TTS audio arriving * over WS while it plays and the rain runs). Render (P1) and each DMA source * alone (P3/P4) were clean at 48 MHz; this stacks them to reproduce the live * flicker. utterance framing keeps the WS accepting the frames. */ ESP_LOGW("loadtest", "=== P5 EFFORT + PLAYBACK + NETWORK (full-reply emulation) ==="); face_set(FACE_EFFORT); face_status("P5 play+net+render"); gw_send_text("{\"type\":\"utterance_start\"}"); for (int t = 0; t < 550; t++) { /* ~8s combined load */ if (!audio_is_playing()) { audio_play_gong(); /* I2S-out playback DMA */ face_set(FACE_EFFORT); /* playback-done flips to IDLE */ } if (gw_connected()) { gw_send_bin(netframe, sizeof(netframe)); /* SDIO/mempool DMA */ } vTaskDelay(pdMS_TO_TICKS(15)); } gw_send_text("{\"type\":\"utterance_end\"}"); vTaskDelay(pdMS_TO_TICKS(4000)); ESP_LOGW("loadtest", "=== cycle complete, repeating in 3s ==="); vTaskDelay(pdMS_TO_TICKS(3000)); } } #endif #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 #if DESKLOCK_DEVMODE #include "hal/usb_serial_jtag_ll.h" /* Watch the USB-serial-JTAG RX FIFO (LL reads, no driver install -> no conflict * with the secondary console's TX) for a trigger byte and dump one frame. */ static void screenshot_task(void *arg) { (void)arg; vTaskDelay(pdMS_TO_TICKS(6000)); /* let boot + first render settle */ ESP_LOGI(TAG, "screenshot: ready — send 's' on USB serial to capture a frame"); uint8_t rx[16]; for (;;) { if (usb_serial_jtag_ll_rxfifo_data_available()) { int n = usb_serial_jtag_ll_read_rxfifo(rx, sizeof(rx)); for (int i = 0; i < n; i++) { if (rx[i] == 's') { face_screenshot_dump(false); break; } /* current screen */ if (rx[i] == 'o') { face_screenshot_dump(true); break; } /* force overlay */ } } vTaskDelay(pdMS_TO_TICKS(150)); } } #endif void app_main(void) { ESP_LOGI(TAG, "DeskLock starting"); s_events = xQueueCreate(8, sizeof(app_event_t)); /* Same config bsp_display_start() uses (TRIPLE_PARTIAL, no rotation) but with a * 16 KB LVGL task stack instead of the 8 KB default. * * The blue flicker's real cause is a DSI framebuffer-read underrun on the PSRAM * bus (fixed primarily by the reduced DPI clock in the BSP). This 16 KB stack * pairs with LV_INV_BUF_SIZE=128 (top CMakeLists), a SECONDARY bandwidth * mitigation: 128 keeps the busy 40-stream rain as small partial redraws instead * of collapsing into full-screen redraws, which would pile large PSRAM write * bursts onto the DSI's read and re-provoke the underrun. The 128-entry inv * buffer enlarges the flush stack frame, and 8 KB overflowed it by ~1 KB. */ 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_PARTIAL, .touch_flags = { .swap_xy = 0, .mirror_x = 0, .mirror_y = 0 }, }; disp_cfg.lv_adapter_cfg.task_stack_size = 16 * 1024; bsp_display_start_with_config(&disp_cfg); /* Raise the DSI bridge FIFO "almost empty" threshold (hardware default 512) * so the bridge demands a DMA refill with more slack still in the FIFO. Under * load, esp-hosted's SDIO DMA bursts win the shared PSRAM bus and briefly stall * the DSI's continuous framebuffer read; a bigger refill margin lets the FIFO * ride out that stall instead of draining to the hardware underrun colour (the * blue flash). Symptom mitigation for the bus-arbitration starvation. */ MIPI_DSI_BRIDGE.raw_buf_almost_empty_thrd.dsi_raw_buf_almost_empty_thrd = 1024; 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"); #if FACE_LOADTEST xTaskCreate(loadtest_task, "loadtest", 4096, NULL, 4, NULL); #endif #if DESKLOCK_DEVMODE xTaskCreate(screenshot_task, "shot", 5120, NULL, 4, NULL); #endif }