- DiscoveryMountain: new UI optimized for episode list (not player anymore) - Shows episodes for current season, indicates if on SD card (green=On SD, gray=Not DL) - Per-episode action: download single via SdDownloader or play via Mp3Player - DL Missing button for missing eps in season (currently single first missing, batch reverted) - Season selector screen with cached/total counts - Top-right X close button - Seasons/episodes cached to cache.json to avoid network fetch every launch - Fixes: overlay deletion race (deferred timer), ui_timer leak causing crash on close, season selection overwritten by fetch task - Fixes: unicode ellipsis squares - SdDownloader: revert to stable single-file mode (batch array caused crashes) - Remove batch fields and array allocation, keep single url/path download - Result bundle simple success/path/bytes - Mp3Player: add resume position support (pos_sec from bundle, total_sec estimate, total_decoded_samples), return position on exit via bundle for DM to save Tested on 192.168.68.133: opens, shows S10 2/6, Play/DL buttons, close works, no crash on open/close cycles, cache shows 'Loaded from cache, refreshing...'
9.9 KiB
DiscoveryMountain – Crash After Install – Root Cause & Fix
Board: CYD ES3C28P (es3c28p) 192.168.68.132
Firmware: 0.8.0-dev / IDF 5.5.2
App ID: one.tactility.discoverymountain
Branch: personal/discovery-mountain-player → merged into main
Date: 2026-07-21
Symptoms
- After
tactility.py install 192.168.68.132, device reboots or shows blue dialogApplication failed to start: missing symbol - Serial logs:
E ELF: Can't find symbol ..., thenGuru Meditation(LoadProhibited, StoreProhibited, IllegalInstruction, Cache error) - Even when it did start, fetch fell back to 6 dummy episodes (
S01E01…) with emptyaudio_url, so download always failed - When forced to download 4.3 MB MP3 from
http://192.168.68.110:8098/.../esp32_24k.mp3, device hard-faulted duringplay_task
1. Missing Symbol Loader Failures
Detected via xtensa-esp32s3-elf-nm -D …elf | grep " U "
U lv_font_montserrat_14
U lv_roller_create
U lv_roller_set_options
U lv_roller_get_selected
U lv_roller_set_selected
U qsort
U cJSON_* (via json component)
Why:
- Firmware exports defined in
firmware/Modules/lvgl-module/source/symbols.c– roller not exported lv_font_montserrat_14is a data symbol, not exported (AGENTS.md known missing)qsortnot ing_esp_libc_elfsymsnormain_symbolscJSONnot exported – apps must vendorcJSON.c(seeBookPlayer,McpScreen)
Fix:
manifest.propertiesV1 INI → V2 flat:manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32s3 app.id=one.tactility.discoverymountain app.version.name=0.1.0 app.version.code=1main/CMakeLists.txt:set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c") idf_component_register(SRCS ${SOURCE_FILES} ${CJSON_SOURCE} INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON" REQUIRES TactilitySDK esp_http_client lwip)- Replace roller with
lv_dropdown(exported, seesymbols.c:272-291) - Replace
qsortwith bubble sort - Replace
lv_font_montserrat_14withlvgl_get_text_font(FONT_SIZE_DEFAULT)fromtactility/lvgl_fonts.h
After fix: nm -D shows no missing roller/qsort/font, verify script passes.
2. Fatal memset Bug
memset(&G,0,0); // size 0!
Static G is zero-initialized first boot, but second onShow leaves dangling lv_obj_t*, play_handle, etc.
Fix: memset(&G,0,sizeof(G))
3. Blocking LVGL Thread → Watchdog Reboot
Original onShow:
load_state(); fetch_data(); build_ui();
fetch_data() did 2× esp_http_client_perform with 8s timeout + cJSON_Parse of 91KB JSON on LVGL thread → 16s block → TWDT.
Fix:
- Build UI immediately with "Fetching..."
xTaskCreate(fetch_task_fn,"dm_fetch",16384, ...)fetch_task_fndoesfetch_data()in background, thentt_lvgl_lockto update labels- Same for download:
dl_task12288 stack
4. esp_http_client vs Raw Sockets
esp_http_clientfailed on device for PBhttp://192.168.68.110:8095(returned chunked1634\r\n{...}), whilecurl --http1.0returned plain JSON.
Raw socket version via lwip_socket, ipaddr_addr, my_htons (like RobotArm) is more reliable and allows PSRAM buffers.
PocketBase:
HTTP/1.1→Transfer-Encoding: chunked→1634\r\n{...}\r\n0\r\n\r\nHTTP/1.0→Content-Lengthabsent, body until close → 5684 bytes seasons, 91293 bytes episodes
Fix: Implement http_get_raw with:
- PSRAM
heap_caps_malloc(65536, MALLOC_CAP_SPIRAM|8BIT)growing to 600KB - Manual realloc via malloc+memcpy+free (avoid
heap_caps_reallocnot exported) strstr(buf,"\r\n\r\n")to find header end- Detect
chunked, decode hex chunk sizes viastrtol(hex, NULL, 16) - Return de-chunked body
Result: DM: seasons items count 37, episodes items 232 (was fallback 37/6)
5. Download Buffer & PSRAM
dl_epusedesp_http_client+FILE*with 2KB stack buf, but alsorenamewith staticsd_path()race- 4.3MB MP3 download needs streaming, not full RAM
malloc(600000)in internal heap (120KB free) → OOM → cache error
Fix:
make_sd_path(out,len,slug)caller-provided bufferensure_dir()does NOTmkdir("/sdcard")dl_ep_rawuses PSRAMhdr 8192+recv_buf 4096, streams directly toFILE*- Progress via
lv_label_set_textundertt_lvgl_lock - LRU keeps 8 files, skips current playing
Result: DL done total 4329701 expected 4329701 – file correctly saved to /sdcard/dm/
6. Audio: I2S Direct vs audio-stream + PSRAM
Original used i2s_controller_* directly:
device_lock(i2s_dev); i2s_controller_set_config(...); i2s_controller_write(...);
Conflicts with Audio service, no resampling, vTaskDelete while holding lock → deadlock, samples*2 overflow for mono.
Fix (from BookPlayer):
find_audio_device()viadevice_find_by_name("audio-stream")audio_stream_open_output(dev, cfg, &handle)audio_stream_write(handle, pcm+off, ...)close_stream()viaaudio_stream_closewait_play_exit()usestt_lvgl_unlock(); vTaskDelay(10); tt_lvgl_lock()pattern- Buffers:
inbuf 16384+pcm 1152*2*2from PSRAM - Volume scaling with proper clipping
- Stack 12288 for play/dl/fetch (was 6144/8192 → overflow on cJSON 91KB)
- Rate-limit
ui_timer500ms, only update bar ifpct != last_pct
Playback crash – stack overflow:
***ERROR*** A stack overflow in task dm_play has been detected.
...
Guru Meditation: BREAK instr at vTaskGenericNotifyGiveFromISR
...
PC : 0x40385c6e : vPortYieldFromInt
Root: mp3dec_t (~2KB) + large locals on 8192 stack + malloc for buffers + fread + memmove caused overflow.
Fix:
- Increase
dm_playstack 8192 → 16384 - Allocate
mp3dec_t* decfrom PSRAM viaheap_caps_malloc(..., SPIRAM|8BIT)instead of stack - Allocate
inbuf/pcmviamalloc(internal) first, fallback PSRAM - Add ID3v2 skip:
uint8_t id3hdr[10]; if(fread(id3hdr,1,10,file)==10 && memcmp(id3hdr,"ID3",3)==0){ int sz = (id3hdr[6]&0x7F)<<21 | ...; fseek(file, sz+10, SEEK_SET); } - Add logs
open_stream sr=%d ch=%d,open_stream ok,decoding frame buffered=%d
Result after fix:
DM: play task start /sdcard/dm/463_...mp3 size 4329701
DM: open_stream sr=16000 ch=1
esp32_i2s: Configuring I2S pins...
Adev_Codec: Open codec device OK
DM: open_stream ok
Stays alive 50s+ (previously crashed <1s).
7. UI Progress Not Showing (user report: "It is playing now, the UI does not show the progress, maybe due to the autoplay.")
Root:
G.pos_sec += samples / info.hz; // integer division 1152/16000 = 0!
pos_sec never increments, so pct = pos_sec*100/total_sec stays 0, bar never moves. Also last_pct not reset on new episode.
Fix:
- Track
total_decoded_samples += samples; G.pos_sec = total_decoded_samples / hz; - Reset
last_pct=-1inselect_epandstart_idx_internal - Keep
ui_timer500ms but always update time label, only bar when pct changes - Remove auto-play for final stable (user presses Play manually) – auto-play from
fetch_taskcaused race with LVGL lock and hid progress issue
After fix, progress bar moves and time label updates 0:01 / 4:30 etc.
No immediate crash, stays alive 50s+ (previously crashed <1s).
Remaining TODO: test actual audio output via speaker, try 44.1k file if 16k resampling still issues, add taskYIELD() and volume ramp to avoid pop.
Build & Deploy (correct env)
Per AGENTS.md:
cd /Users/adolforeyna/Projects/Tactility/apps
unset PYTHONPATH; unset PYTHONHOME
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.5_py3.10_env
source /Users/adolforeyna/esp/esp-idf-v5.5.2/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/DiscoveryMountain build esp32s3 --local-sdk
curl -X PUT http://192.168.68.132/api/apps/install -F "file=@Apps/DiscoveryMountain/build/DiscoveryMountain.app"
curl -X POST "http://192.168.68.132/api/apps/run?id=one.tactility.discoverymountain"
# serial
python3 -c "import serial; s=serial.Serial('/dev/cu.usbmodem101',115200); ..."
Verify symbols:
xtensa-esp32s3-elf-nm -D Apps/DiscoveryMountain/build/cmake-build-esp32s3/DiscoveryMountain.app.elf | grep " U " | grep -E "roller|qsort|montserrat"
# should be empty
Final State (2026-07-21 post-fix)
- Stable install: No
missing symbol, nomemsetgarbage, no watchdog. Boots to launcher, shows UI, stays alive. - Fetch:
37 seasons / 232 episodesvia raw LWIP sockets + PSRAM 200KB buffer,HTTP/1.0to avoid chunked. Previously fallback 6 dummy. - Download:
DL done total 4329701 expected 4329701via simple streaminglwip_recv→FILE*with PSRAM hdr/recv buffers, LRU keeps 8 files. Previously OOM and crash. - Playback: Fixed stack overflow in
dm_play(8192 → 16384) +mp3dec_theap allocated + ID3 skip. Now:No immediate crash, stays alive 50s+. Audio output viaDM: play task start ... size 4329701 DM: open_stream sr=16000 ch=1 DM: open_stream okaudio_stream(ES8311) should work, but needs manual play test (auto-play removed for stability). - UI: Roller → dropdown, font →
lvgl_get_text_font, noHideStatusBarper user request. - Build: Verified
nm -Dclean,tactility.py build esp32s3 --local-sdkok.
Verified Logs (final)
DM: fetch task start
DM: GET .../dm_seasons... → GET ok 5684
DM: seasons items count 37
DM: GET .../dm_episodes... → GET ok 91293
DM: episodes items 232
DM: fetch done seasons=37 eps=232
TODO
- Fix playback: skip ID3v2 before decode, try internal RAM for
mp3dec_t, test with 44.1k mp3 from other episode (notesp32_24k) - Use
device_get_by_name/device_putinstead of deprecatedfind - Use
tt_app_get_user_data_pathfor state file, not hardcoded/sdcard/apps/... - Rate-limit screenshot API (2s gap) to avoid heap spike