31 Commits

Author SHA1 Message Date
Adolfo 7ef8e272fc feat(mp3,voice): fix scroll anim audio glitch, add seek/volume/history UI
- Mp3Player:
  * Disable LV_LABEL_LONG_SCROLL_CIRCULAR during playback (flag ENABLE_TITLE_SCROLL_ANIM_WHEN_IDLE) to avoid screen invalidation starving audio_stream
  * Single-line controls [-10s][Play/Pause][Stop][+10s] icon-only, volume slider at bottom, no card/toolbar, time label MM:SS / MM:SS updating every sec
  * Seek ±10s with proportional byte calc and correct sample count (was byte vs sample bug)
  * Safe history persistence: heap buffer (not stack), recursive mkdir, play_history.txt and volume.txt in user data
  * History only for >=10min audios, updated on pause/stop/hide, auto-resume latest
  * Full-screen history screen with Back button, open via top-right list icon, playing file highlighted CHECKED, tap to resume long or restart short

- VoiceRecorder:
  * Fix crash from lv_label_set_long_mode on icon child in list buttons (force DOT only on last child label)
  * Force DOT on status and memo list to avoid scroll anim during audio
  * Throttle UI updates (1Hz rec, %/sec change play)

Tested on 192.168.68.107, no crash, heap free ~15KB, screenshot verified
2026-07-30 15:00:34 -04:00
Adolfo eb364cb34f Scale Immich photos to cover the display 2026-07-29 22:00:18 -04:00
Adolfo eaccebc95d feat(DM+SdDDL): sequential full-season download queue, fix close crash, return true
- DM: add dl_missing_* fields to AppCtx for sequential queue
- DM player: count_missing_in_season, find_next_missing, download_episode, download_missing_season_start/next
- DM main onResult: auto-continue queue when dl_missing_active, updates status DL X/Y, finishes with done ok/fail
- SdDownloader: file exists returns true (Already on SD) with total=file size, not false
- SdDownloader: set_result_and_close minimal bundle (only success bool) to avoid internal heap OOM crash on close
- SdDownloader: Cancel button becomes Close after done, calls tt_app_stop() directly (manual close per request, auto-close unreliable)
- Fixes crash on close observed in demo mode (file exists) and after download done

Tested on 192.168.68.133: SdDownloader shows Already on SD + Close, DM shows S10 5/6 after sequential, no crash on open/close cycles
2026-07-25 22:49:28 -04:00
Adolfo 3778cb3399 feat(DM): library UI with episode list, SD status, cache, close button, stable single-file DL
- 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...'
2026-07-25 22:06:43 -04:00
Adolfo 6a1fe5dda6 feat(ImmichElias): add person selector like Bible books, 4 persons Elias/Grace/Alicia/Adolfo, picker UI with toolbar, player bottom-only, random pagination, cache, fullscreen 2026-07-25 16:29:24 -04:00
Adolfo d06f8ddbb5 fix(ImmichElias): black screen fix, 320x240 request for PSRAM stability (480x320 OOM), fullscreen widget 480x320 bg, bottom-only UI, tap toggle 2026-07-25 16:14:34 -04:00
Adolfo a2bb8e5132 feat(ImmichElias): 2x render 480x320 fullscreen bg, display detection, bottom-only UI, PSRAM 2026-07-25 16:11:29 -04:00
Adolfo 9ee0599bec fix(ImmichElias): black screen fix, 240x180 request for PSRAM memory safety, fullscreen bg widget 480x320, bottom-only UI, tap toggle, cache random pagination 2026-07-25 16:09:45 -04:00
Adolfo c3689e82cc fix(ImmichElias): black screen fix, clear hidden flag, fullscreen 480x320 bg, bottom-only UI tap toggle, filter VIDEO, random pagination 100, cache 2026-07-25 16:05:59 -04:00
Adolfo 4c71c77bf6 fix(ImmichElias): show >50 photos via random pagination, filter IMAGE only, increase to 100, JSON 128KB, slideshow + cache 2026-07-25 16:00:18 -04:00
Adolfo 0595e149d4 fix(ImmichElias): bottom-only UI like Bible, fullscreen 480x320 bg, tap toggles chrome, PSRAM, random, cache with offline fallback, filter IMAGE only, proxy fallback 320x240, slideshow 60s, crash fixes for touch 2026-07-25 15:53:37 -04:00
Adolfo 995297314d fix(ImmichElias): bottom-only UI like Bible, fullscreen bg, tap to toggle chrome, dynamic 480x320, cache, random, slideshow 60s, crash fixes 2026-07-25 15:47:08 -04:00
Adolfo 349034b8ba fix(ImmichElias): fullscreen background 480x320, BookPlayer UI, no crop when hidden, PSRAM for images, random, cache offline, slideshow 60s, crash fixes 2026-07-25 15:39:08 -04:00
Adolfo 3085bac5f1 fix(ImmichElias): merge dynamic sizing 480x320 + crash fix, BookPlayer UI, random, cache offline, slideshow 60s 2026-07-25 15:24:29 -04:00
Hermes Reyna Bot b03cb2b244 fix(ImmichElias): PNG via resize proxy 8106 for real photos, fixes WebP crash, device 133 yellow case
- Proxy already fixed on FamReynaServer 192.168.68.110:8106 returns PNG 93KB 320x240 blur-fill no-stretch (JPEG 16KB also) - old 8105 zombie busy
- TL identified crash cause: original app fetched WebP thumbnail 15KB which LVGL cannot decode (LV_USE_TJPGD false, LODEPNG true -> PNG only), only metadata shown
- Photo in user image: ID 7f5fbc96-4d06-447d-a90f-d2257b4d0b59, File BCB7D69F-0C61-4A2C-82BA-70DADD7E, 1280x720, 4/50, 50/10739, 10739 total Elias photos
- New main.c 219+: RESIZE_PROXY_URL 8106, /data/immich_cur.png + fallbacks /tmp/ /sdcard/, save via fopen, lv_img_set_src file path
- Build 30K OK but device 192.168.68.133 offline (was 61ms, now Destination Host Unreachable) - needs test after wake, crashes at opening likely due to /data fs driver S: vs A: or fopen before exists - pushes for review
- Model: opencode/muse-spark-1.1 via api.meta.ai same key, tmux visible engineer window 2:engineer on iMac .124, persistent session opencode handles cache better per user
- Branch feature/immich-elias-client
2026-07-25 13:40:50 -04:00
Hermes Reyna Bot 3a2e6f3f11 feat(ImmichElias): implement Elias photo browser client
- Define IMMICH_BASE_URL as http://192.168.68.110:2283 and API key, person IDs
- Photo struct with id, thumbnail_url, width, height, fileCreatedAt
- Global photos array, count, current index
- HTTP helpers using esp_http_client with x-api-key header
- fetch_person_assets() POSTs to /api/search/metadata with personIds, parses cJSON {assets:{items:[...]}}
- download_thumbnail() GETs /api/assets/{id}/thumbnail?size=thumbnail (12KB WebP) with fallback note for preview=238KB JPEG for RAM safety
- UI: toolbar, title "Elias - 10739 photos", count label, img placeholder, prev/next 60x40 bottom, info label with asset id/date/size
- FreeRTOS tasks for fetch + thumb to avoid blocking LVGL
- Proper aspect no stretch, uses lvgl_get_text_font(SMALL/DEFAULT/LARGE)
- Builds clean for esp32s3, 0 missing symbols verified via nm -D
- CMake: fix TACTILITY_SDK_PATH and cJSON inclusion via json component source
- Tested Immich API locally: metadata search returns 10739-capable, thumbnail endpoints working

Co-Authored-By: internal-model
2026-07-25 12:35:54 -04:00
Adolfo 9def43b4ed Merge personal/main into main - preserve GameBoy fixes and RobotArm mDNS
Main / Build (AudioTest) (push) Has been cancelled
Main / Build (BibleVerse) (push) Has been cancelled
Main / Build (BookPlayer) (push) Has been cancelled
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (EspNowBridge) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GameBoy) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (McpScreen) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (Mp3Player) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (PocketDungeon) (push) Has been cancelled
Main / Build (ReynaBot) (push) Has been cancelled
Main / Build (RobotArm) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Build (VoiceRecorder) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
Main / PublishApps (push) Has been cancelled
- Keep GameBoy emulator with canvas fixes from personal/main (4ff787f etc)
- Keep RobotArm mDNS auto-connect (6d9001f) over local cute version
- Keep BibleVerse stack overflow fix (4a15309)
- Keep manifest V2 conversions (c258aeb)
2026-07-21 17:43:00 -04:00
Adolfo c258aebca1 chore(manifest): convert all local apps to V2 format for SDK 0.8.0-dev
- AudioTest, BookPlayer, McpScreen, Mp3Player, PocketDungeon, ReynaBot, RobotArm, VoiceRecorder
- V1 [manifest] version=0.1 -> V2 manifest.version=0.2
- sdk 0.7.0-dev -> 0.8.0-dev
- flags handling: app.flags=HideStatusBar
- Fixes 'Invalid manifest format: manifest.version not found' error during build
2026-07-21 17:42:30 -04:00
Adolfo 4a153099f9 fix(bibleverse): fix gui stack overflow after firmware update
Gui task stack is 4096 with 2800 free min. After recent driver migrations,
app overflowed:

- verse_buf[2048] on stack in show_current_verse
- bin_path[320]+idx_path[320] in load_book
- temp[512] in parse_books_json

Caused 'stack overflow in task gui' + LoadProhibited in sdmmc/stat path.

Fix:
- MAX_PATH 320 -> 128 (paths <80 chars)
- temp 512 -> 256
- verse_buf stack -> heap malloc/free
- ref buffers reduced

Also bump manifest to V2 (0.2) required by SDK 0.8.0-dev and versionCode 2.

Tested on 192.168.68.132 (ES3C28P): loads 66 books, displays verse, no reboot.
2026-07-21 14:16:20 -04:00
Adolfo aebee50d25 Merge origin/main into main - integrate EspNowBridge + upstream SDK 0.8.0-dev updates
- Merged CI workflow to include all local apps + EspNowBridge
- HelloWorld manifest now V2 format from upstream (sdk 0.8.0-dev)
- Kept local SfxEngine enhancements (polyphonic gate, auto-normalization)
- Brings main up to date with origin/main 25b966a
2026-07-21 11:16:16 -04:00
Adolfo 6d9001fcd7 feat(RobotArm): mDNS auto-connect, connecting screen, scroller fix
Main / Build (BookPlayer) (push) Has been cancelled
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
- Pure mDNS discovery via tt_mdns_browse('_robotarm','_tcp') + resolve 'robotarm.local'
  Fallback to 192.168.68.148/.103 for old firmware, no LAN-wide scan
- Connecting screen first: shows 'Finding robotarm...' + spinner + retry button,
  establishes connection then shows main UI (auto-connect on IP change)
- Fix overall scroller lost: root not scrollable, inner content flex-column
  scrollable vertically, all nested cards/grid/hdr/brow/seqbar clear_flag
  SCROLLABLE + scrollbar OFF - no double scrollbars
- Keep 2x tall sliders 22x72 3 cols, cute pastel cards, Home/Read/Open/Close
  56x18, shoulder max 70, open=0 close=180
- Sequencer bottom not fixed but inside scrollable content, colored blocks
  22x22 no info, white 3px border highlight current, larger buttons 38x32
  Play 56x32, smooth move_all_joints
- Fix missing symbol on 0.8.0-dev old firmware: remove esp_log (ESP_LOGI),
  lwip_recvfrom/sendto, keep 50U (was 52U) matching ReynaBot working set
- MicroPython arm: mdns_server.py now advertises _robotarm._tcp.local PTR/SRV/TXT
  for tt_mdns_browse, A robotarm.local, re-announce 60s
2026-07-20 14:04:18 -04:00
Adolfo 653ad8902f fix(GameBoy): FPS but no game - cache invalidation bug
Root cause: LVGL9 canvas = lv_image backed by draw_buf with image cache.
Mutating raw uint16_t* buffer without lv_image_cache_drop + lv_draw_buf_invalidate_cache
leaves stale texture (gray/black). Only label (FPS) updated via lv_label_set_text_fmt,
so symptom = FPS counter works, game invisible.

Fix:
- fb_native typed uint16_t* raw RGB565, not lv_color16_t bitfield (endian bug)
- keep lv_draw_buf_t* from lv_canvas_get_draw_buf()
- each frame after gb_run_frame(): invalidate_cache + cache_drop + invalidate
- initial buffer fill check pattern removed, added lines_drawn debug in FPS label
- forward-decl lv_image_cache_drop (not in public SDK headers but firmware exports it)
- requires firmware feature/gameboy-canvas-full-api:
  lv_draw_buf_invalidate_cache, lv_image_cache_drop, lv_canvas_get_draw_buf,
  lv_image_set_scale/pivot, display resolution

SDK 0.8.0-dev built with IDF 5.5.2 - 0 undefined beyond firmware exports.
Builds to 50K GameBoy.app

Also preserves mDNS browse API from parent branch (Mdns.h/cpp, tt_mdns.h/cpp)
which sits on top of 60764979 'kidsOS-XXXX' mdns init.
2026-07-20 12:36:11 -04:00
Adolfo e4d1d35da4 feat(GameBoy): restore integer scaling using full canvas API
- use lv_display_get_*_resolution to compute available area
- apply lv_image_set_scale 2x/3x + pivot center when display large enough
- removes previous comment hack 'avoid non-exported LVGL transform symbols'
- now requires firmware feature/gameboy-canvas-full-api (symbols exported)

SDK: 0.8.0-dev built with IDF 5.5.2, 0 missing symbols
2026-07-20 00:21:01 -04:00
Shadowtrance 25b966a340 Add EspNowBridge + updates for Audio System (#36) 2026-07-19 21:52:39 +02:00
Hermes Reyna Bot 6eb032cb53 Restore GameBoy canvas path and FPS marker 2026-07-17 18:11:05 -04:00
Hermes Reyna Bot 07749d86a1 Add GameBoy emulator prototype 2026-07-17 11:05:33 -04:00
Ken Van Hoeylandt 8721a653e7 Tool 4.1.0 2026-07-03 23:17:09 +02:00
Ken Van Hoeylandt 694b68de1a Fixes for app publishing (#35) 2026-07-03 22:30:14 +02:00
Ken Van Hoeylandt 71bf2631f4 Implement CDN uploading (#34) 2026-07-03 21:41:18 +02:00
Ken Van Hoeylandt 4ab2377970 Manifest format update (#33) 2026-07-03 00:04:29 +02:00
Ken Van Hoeylandt 8a0f9ef4e4 SDK 0.7.0 & bump app versions (#32) 2026-07-02 19:31:34 +02:00
82 changed files with 13615 additions and 565 deletions
+21
View File
@@ -0,0 +1,21 @@
name: Publish Apps
inputs:
sdk_version:
description: The SDK version that determines the path on the CDN
required: true
runs:
using: 'composite'
steps:
- name: 'Download cdn-files'
uses: actions/download-artifact@v4
with:
name: 'cdn-files'
path: cdn_files
- name: 'Install boto3'
shell: bash
run: pip install boto3
- name: 'Upload files'
shell: bash
run: python Buildscripts/CDN/upload-app-files.py cdn_files ${{ inputs.sdk_version }} ${{ env.CDN_ID }} ${{ env.CDN_TOKEN_NAME }} ${{ env.CDN_TOKEN_VALUE }}
+9 -1
View File
@@ -1,5 +1,10 @@
name: Release Apps name: Release Apps
outputs:
sdk_version:
description: 'Common SDK version shared by all bundled apps'
value: ${{ steps.release.outputs.sdk_version }}
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
@@ -11,8 +16,11 @@ runs:
run: rsync -av downloaded_apps/*/*.app cdn_files/ run: rsync -av downloaded_apps/*/*.app cdn_files/
shell: bash shell: bash
- name: 'Create CDN release files' - name: 'Create CDN release files'
run: python release.py cdn_files/ id: release
shell: bash shell: bash
run: |
python release.py cdn_files/
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
- name: 'Upload Artifact' - name: 'Upload Artifact'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
+22 -1
View File
@@ -12,10 +12,12 @@ jobs:
Build: Build:
strategy: strategy:
matrix: matrix:
app_name: [Brainfuck, Breakout, BookPlayer, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven] app_name: [AudioTest, BibleVerse, BookPlayer, Brainfuck, Breakout, Calculator, Diceware, EpubReader, EspNowBridge, GPIO, GameBoy, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, McpScreen, MediaKeys, Mp3Player, MystifyDemo, PocketDungeon, ReynaBot, RobotArm, SerialConsole, Snake, TamaTac, TodoList, TwoEleven, VoiceRecorder]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Build" - name: "Build"
uses: ./.github/actions/build-app uses: ./.github/actions/build-app
with: with:
@@ -23,7 +25,26 @@ jobs:
Bundle: Bundle:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Build] needs: [Build]
outputs:
sdk_version: ${{ steps.release.outputs.sdk_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: "Build" - name: "Build"
id: release
uses: ./.github/actions/release-apps uses: ./.github/actions/release-apps
PublishApps:
runs-on: ubuntu-latest
needs: [Bundle]
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Publish Apps"
env:
CDN_ID: ${{ secrets.CDN_ID }}
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
uses: ./.github/actions/publish-apps
with:
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3
sdk=0.7.0-dev app.id=one.tactility.audiotest
platforms=esp32s3 app.version.name=0.1.0
[app] app.version.code=1
id=one.tactility.audiotest app.name=Audio Test
versionName=0.1.0
versionCode=1
name=Audio Test
+15 -14
View File
@@ -33,7 +33,7 @@ LV_FONT_DECLARE(georgia_regular_24)
#define MAX_BIBLE_BOOKS 66 #define MAX_BIBLE_BOOKS 66
#define MAX_BOOK_NAME 40 #define MAX_BOOK_NAME 40
#define MAX_VERSE_TEXT 2048 #define MAX_VERSE_TEXT 2048
#define MAX_PATH 320 #define MAX_PATH 128
typedef struct { typedef struct {
uint32_t offset; uint32_t offset;
@@ -239,7 +239,7 @@ static bool parse_books_json(AppCtx* ctx, const char* json_str) {
const char* obj_end = strchr(obj_start, '}'); const char* obj_end = strchr(obj_start, '}');
if (!obj_end) break; if (!obj_end) break;
// parse fields within obj_start..obj_end // parse fields within obj_start..obj_end
char temp[512]; char temp[256];
size_t len = (size_t)(obj_end - obj_start + 1); size_t len = (size_t)(obj_end - obj_start + 1);
if (len >= sizeof(temp)) len = sizeof(temp)-1; if (len >= sizeof(temp)) len = sizeof(temp)-1;
memcpy(temp, obj_start, len); memcpy(temp, obj_start, len);
@@ -672,27 +672,27 @@ static void show_current_verse(AppCtx* ctx) {
const char* text_ptr = "(empty)"; const char* text_ptr = "(empty)";
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off); if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
char verse_buf[MAX_VERSE_TEXT]; // Fix stack overflow: verse_buf was 2048 bytes on stack, causing gui task (4096) overflow
strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1); // Use heap allocation
verse_buf[sizeof(verse_buf)-1]='\0'; char* verse_buf = (char*)malloc(MAX_VERSE_TEXT);
if (!verse_buf) {
ESP_LOGE(TAG, "Failed to malloc verse_buf");
return;
}
strncpy(verse_buf, text_ptr, MAX_VERSE_TEXT-1);
verse_buf[MAX_VERSE_TEXT-1]='\0';
char ref_buf[96]; char ref_buf[64];
snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum); snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
// Editorial reference style like your image: "PSALMS 23:1" with tracking char ref_pretty[80];
char ref_pretty[108]; char bname_upper[40];
// Use uppercase for book? Keep title for now but uppercase style for editorial match
// We'll format as "— PSALMS 23:1 —" feel? For dark mode keep minimal like ref image
// Reference image shows lines flanking citation - we do with letterspacing + dim
// For now: "PSALMS 23:1" style - uppercase via runtime? We'll uppercase book name
char bname_upper[48];
strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1); strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
bname_upper[sizeof(bname_upper)-1]='\0'; bname_upper[sizeof(bname_upper)-1]='\0';
for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p); for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p);
snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum); snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum);
if (ctx->lbl_verse) { if (ctx->lbl_verse) {
// start faded for transition
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0); lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
lv_label_set_text(ctx->lbl_verse, verse_buf); lv_label_set_text(ctx->lbl_verse, verse_buf);
apply_verse_scaling(ctx, verse_buf); apply_verse_scaling(ctx, verse_buf);
@@ -741,6 +741,7 @@ static void show_current_verse(AppCtx* ctx) {
if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED); if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED); else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
} }
free(verse_buf);
save_progress(ctx); save_progress(ctx);
verse_fade_in(ctx); verse_fade_in(ctx);
} }
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3,esp32p4
sdk=0.8.0-dev app.id=one.tactility.bibleverse
platforms=esp32s3,esp32p4 app.version.name=0.1.0
[app] app.version.code=2
id=one.tactility.bibleverse app.name=Bible Verse
versionName=0.1.0 app.description=Single verse at a time, advances each minute. Tap to show controls.
versionCode=1
name=Bible Verse
description=Single verse at a time, advances each minute. Tap to show controls.
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3
sdk=0.8.0-dev app.id=one.tactility.bookplayer
platforms=esp32s3 app.version.name=1.0.0
[app] app.version.code=1
id=one.tactility.bookplayer app.name=Book Player
versionName=1.0.0
versionCode=1
name=Book Player
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.brainfuck
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.5.0
[app] app.version.code=5
id=one.tactility.brainfuck app.name=Brainfuck interpreter
versionName=0.2.0 app.description=Brainfuck esoteric language interpreter
versionCode=2
name=Brainfuck interpreter
description=Brainfuck esoteric language interpreter
-1
View File
@@ -124,7 +124,6 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
if (!sfxEngine) { if (!sfxEngine) {
sfxEngine = new SfxEngine(); sfxEngine = new SfxEngine();
sfxEngine->start(); sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
sfxEngine->setEnabled(soundEnabled); sfxEngine->setEnabled(soundEnabled);
} }
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.breakout
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.5.0
[app] app.version.code=5
id=one.tactility.breakout app.name=Breakout
versionName=0.2.0 app.description=Classic brick-breaking arcade game
versionCode=2
name=Breakout
description=Classic brick-breaking arcade game
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.calculator
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.calculator app.name=Calculator
versionName=0.3.0
versionCode=3
name=Calculator
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.diceware
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.diceware app.name=Diceware
versionName=0.3.0
versionCode=3
name=Diceware
+12
View File
@@ -0,0 +1,12 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(DiscoveryMountain)
tactility_project(DiscoveryMountain)
+263
View File
@@ -0,0 +1,263 @@
# 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 dialog `Application failed to start: missing symbol`
- Serial logs: `E ELF: Can't find symbol ...`, then `Guru Meditation` (LoadProhibited, StoreProhibited, IllegalInstruction, Cache error)
- Even when it did start, fetch fell back to 6 dummy episodes (`S01E01`…) with empty `audio_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 during `play_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_14` is a data symbol, not exported (AGENTS.md known missing)
- `qsort` not in `g_esp_libc_elfsyms` nor `main_symbols`
- `cJSON` not exported apps must vendor `cJSON.c` (see `BookPlayer`, `McpScreen`)
**Fix:**
- `manifest.properties` V1 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=1
```
- `main/CMakeLists.txt`:
```cmake
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, see `symbols.c:272-291`)
- Replace `qsort` with bubble sort
- Replace `lv_font_montserrat_14` with `lvgl_get_text_font(FONT_SIZE_DEFAULT)` from `tactility/lvgl_fonts.h`
After fix: `nm -D` shows no missing roller/qsort/font, verify script passes.
---
## 2. Fatal `memset` Bug
```c
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`:
```c
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_fn` does `fetch_data()` in background, then `tt_lvgl_lock` to update labels
- Same for download: `dl_task` 12288 stack
---
## 4. `esp_http_client` vs Raw Sockets
- `esp_http_client` failed on device for PB `http://192.168.68.110:8095` (returned chunked `1634\r\n{...}`), while `curl --http1.0` returned 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\n`
- `HTTP/1.0` → `Content-Length` absent, 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_realloc` not exported)
- `strstr(buf,"\r\n\r\n")` to find header end
- Detect `chunked`, decode hex chunk sizes via `strtol(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_ep` used `esp_http_client` + `FILE*` with 2KB stack buf, but also `rename` with static `sd_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 buffer
- `ensure_dir()` does NOT `mkdir("/sdcard")`
- `dl_ep_raw` uses PSRAM `hdr 8192` + `recv_buf 4096`, streams directly to `FILE*`
- Progress via `lv_label_set_text` under `tt_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:
```c
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()` via `device_find_by_name("audio-stream")`
- `audio_stream_open_output(dev, cfg, &handle)`
- `audio_stream_write(handle, pcm+off, ...)`
- `close_stream()` via `audio_stream_close`
- `wait_play_exit()` uses `tt_lvgl_unlock(); vTaskDelay(10); tt_lvgl_lock()` pattern
- Buffers: `inbuf 16384` + `pcm 1152*2*2` from PSRAM
- Volume scaling with proper clipping
- Stack 12288 for play/dl/fetch (was 6144/8192 → overflow on cJSON 91KB)
- Rate-limit `ui_timer` 500ms, only update bar if `pct != 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_play` stack 8192 → 16384
- Allocate `mp3dec_t* dec` from PSRAM via `heap_caps_malloc(..., SPIRAM|8BIT)` instead of stack
- Allocate `inbuf` / `pcm` via `malloc` (internal) first, fallback PSRAM
- Add ID3v2 skip:
```c
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:
```c
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=-1` in `select_ep` and `start_idx_internal`
- Keep `ui_timer` 500ms but always update time label, only bar when pct changes
- Remove auto-play for final stable (user presses Play manually) auto-play from `fetch_task` caused 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`:
```bash
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:
```bash
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`, no `memset` garbage, no watchdog. Boots to launcher, shows UI, stays alive.
- **Fetch:** `37 seasons / 232 episodes` via raw LWIP sockets + PSRAM 200KB buffer, `HTTP/1.0` to avoid chunked. Previously fallback 6 dummy.
- **Download:** `DL done total 4329701 expected 4329701` via simple streaming `lwip_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_t` heap allocated + ID3 skip. Now:
```
DM: play task start ... size 4329701
DM: open_stream sr=16000 ch=1
DM: open_stream ok
```
No immediate crash, stays alive 50s+. Audio output via `audio_stream` (ES8311) should work, but needs manual play test (auto-play removed for stability).
- **UI:** Roller → dropdown, font → `lvgl_get_text_font`, no `HideStatusBar` per user request.
- **Build:** Verified `nm -D` clean, `tactility.py build esp32s3 --local-sdk` ok.
## 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 (not `esp32_24k`)
- [ ] Use `device_get_by_name` / `device_put` instead of deprecated `find`
- [ ] Use `tt_app_get_user_data_path` for state file, not hardcoded `/sdcard/apps/...`
- [ ] Rate-limit screenshot API (2s gap) to avoid heap spike
+259
View File
@@ -0,0 +1,259 @@
# Discovery Mountain Download & Playback Crash Report
**Date:** 2026-07-25
**Device:** 192.168.68.133 (es3c35p, /dev/cu.usbmodem1101)
**App ID:** `one.tactility.discoverymountain` (DM) + `one.tactility.sddownloader` (SDDL) + `one.tactility.mp3player` (MP3)
**Firmware:** 0.8.0-dev / IDF 5.5.2 / Tactility SDK 0.8.0-dev
---
## Summary What is failing: DM or Downloader?
**Both, same root cause: low internal heap (37 KB) + unsafe task handling.**
- **DM fetch:** 1st launch OK (37/232), 2nd launch crashes with StoreProhibited.
- **Downloader (SDDL):** S01E01 dummy (empty URL) causes gui stack overflow, S01E04 34 MB file causes gui overflow + phy_wakeup crash.
---
## 1. Original Download Crash (from your logs)
**Log:** `2026-07-24/cu.usbmodem101_21-42-13_s002.log`
```
[21:43:39] DM: DL start 396_Wherever_You_Go_S07E01 url ... 36 MB
[21:43:42] dl header done cl=36147383, wrote initial 1143, streaming
[21:43:57] W DM: dl timeout 1/12 total=21303/36147383
[21:43:51] wifi:bcn_timeout,ap_probe_send_start
[21:44:05] Guru Meditation Error: Core 0 panic'ed (StoreProhibited) PC 0x4038fc5d EXCVADDR 0x05
Backtrace: 0x4038fc5a 0x400398b5 0x4038bd9d 0x4038d7c9 0x421fdab6
[21:44:05] WebServerService: GET /api/sysinfo
```
- Download stalls at 21 KB / 36 MB, 12×15 s timeouts = 3 min blocked in `lwip_recv`
- `onHide` did `vTaskDelay(100)` then `vTaskDelete(dl_handle)` while still blocked in recv → fd/socket/PSRAM leak → next WebServer request crashes.
- Also `sdmmc_read_sectors: not enough mem 0x101` seen in many logs DMA pool 32 KB exhausted.
**Fix applied:**
- Timeout 15 s → 5 s, max 12 → 5 (25 s max stall)
- `onHide` graceful wait 2 s fetch / 3 s DL, only force delete after, no leak
- `recv_buf`/`hdr` 8192 → 4096 PSRAM, `fflush` every 8 chunks, `vTaskDelay(10)` to let SDMMC recover
- `sin_len = sizeof(sa)`, proper `my_htons` masking
---
## 2. S01E01 Empty URL + GUI Stack Overflow
**Log:** `cu.usbmodem1101_20-33-06_s008.log`
```
[20:33:14] DM: connect fail 192.168.68.110:8095 → seasons fallback, episodes fallback → done 37/6
[20:33:17] DM: Starting external SdDownloader for S01E01 -> /sdcard/dm/S01E01.mp3
[20:33:17] SDDL: Should autostart has_url=1 has_path=1 url= path=/sdcard/dm/S01E01.mp3
[20:33:17] xTaskCreate download_task
***ERROR*** A stack overflow in task gui has been detected.
Backtrace: 0x40385b29:0x3fcdb9d0 ...
```
- When PB fetch fails (connect fail), DM falls back to 6 dummy episodes `S01E01``S01E06` with **empty `audio_url`**.
- User presses S01E01 → DM launches SdDownloader with empty URL → `download_one_file` checks `strlen(url)<8``Invalid URL` → calls `update_ui_status` + `set_result_and_close(false)` which did `vTaskDelay(1500)` + `tt_app_stop()` **from download_task** → deadlock: onHide waits for download_task, download_task waits in delay, gui task overflows.
- Also `S01E01.mp3` path exists? No, so it tries download.
**Fix:**
- `try_external_downloader` now checks `strlen(audio_url)<8` → shows "No URL fetch failed?" and returns false, no launch
- `btn_dl_season_cb` filters `strlen>=8`
- `SdDownloader` `set_result_and_close` no longer calls `tt_app_stop()` from download_task (just sets result, leaves app to be closed via Cancel button) → no deadlock
- `SdDownloader` download_task stack 16384 → 8192 to save heap
**User note:** "That episode should be on sd card already" real S01E01 is `463_Discovering_Discovery_Mountain_S01E01.mp3` (4.3 MB), not dummy `S01E01.mp3`. Real file exists, dummy does not.
---
## 3. S01E04 34 MB File, GUI Stack Overflow on DL Start
**Log:** `cu.usbmodem1101_20-26-11_s006.log`
```
[20:28:43] DM: Starting external SdDownloader for 460_The_Lookout...S01E04 -> /sdcard/dm/460_...mp3
[20:28:43] SDDL: Should autostart url=http://.../460_...mp3 path=/sdcard/dm/460_...mp3
[20:28:43] xTaskCreate download_task res=1 handle=0x3fcc3f08
***ERROR*** A stack overflow in task gui
```
- S01E04 is 34 MB (`file_size 34460594`). Download task created, but gui task overflows immediately, before any DL log.
- Root: `lru_check` had `FE files[200]` → 200*304=60 KB on stack of download_task (12 KB stack) → overflow. Also `sort_eps` had `EpInfo t` 500 bytes on stack inside double loop.
- `ensure_parent_dir` had 512+512 stack, `req[512]`, etc.
**Fix:**
- `lru_check`: `files` allocated from PSRAM via `heap_caps_malloc(200*sizeof(FE))`
- `sort_eps`: temp `EpInfo t` allocated from PSRAM
- `ensure_parent_dir`: rewritten without `strtok_r` (not exported) and without large stack, using manual parsing and `strncat`
- Download task stack 16K → 8K, fetch task stays 12K, heap check before socket.
---
## 4. DM Second Launch StoreProhibited 0x8208775e
**Log:** `..._17-37-00_s015.log` (latest after clean build)
```
[20:54:50] fetch done seasons=37 eps=232
[20:55:39] onShow heap before fetch: internal=50455 psram=4567656
[20:55:39] xTaskCreate fetch_task res=1 handle=0x3fcc5110
[20:55:39] fetch task start → seasons 723 ok
[20:55:39] GET ...page=1 → 9875 ok → got 50
[20:55:39] Guru StoreProhibited PC 0x4238a8c0 EXCVADDR 0x8208775e
Backtrace: 0x4238a8bd(app) 0x420874f3(lv_timer.c:107) 0x42069754(esp_lvgl_port.c:242) 0x421fdaba(port.c:139)
```
- 1st fetch after boot OK, 2nd fetch (app reopened) crashes 11 ms after first page GET, before header_end.
- Backtrace shows crash in **LVGL timer task**, not fetch task: `lv_timer.c:107``ui_timer`
- `ui_timer` (200 ms) was doing heavy work: `save_current_ep_pos()` + `save_state()` (cJSON create, print, fopen, fwrite) every 5 sec when playing. Called from GUI task with limited stack → StoreProhibited when trying to write to flash 0x82... (string literal in flash?).
- Also `G` global is 240 KB (400*492) BSS in DRAM, plus `saved_pos` 40 KB = 280 KB, leaving free heap 50 KB → after first fetch cJSON 10 KB nodes, heap fragments to 3-7 KB → `MemoryChecker: Internal memory low: 3607/7279/7435`
**Fix:**
- `ui_timer`: early return if `G.fetching`, remove periodic `save_state()` (saving is done explicitly in `select_ep`, `go_next`, `onHide`, `onResult`)
- `AppCtx.MAX_EPS` 400→250, `title` 128→64, `audio_url` 256→128, `MP3_BUF` 16384→4096 → size 240 KB → ~100 KB
- `fetch_task` stack 16384→12288, `onShow` heap log, `xTaskCreate` result log
- Pagination 100/page → 50/page (now 25/page) with 300-400 ms delay between pages to let heap recover, `fields=season_num` and `fields=title,slug,season_num,episode_num,audio_url` to cut JSON 91 KB → 42 KB → 9 KB/page
---
## 5. DL Season Button IntegerDivideByZero in Bundle
**Log:** `cu.usbmodem1101_16-57-59_s014.log`
```
[17:34:06] episodes page 1 got 50 items
[17:34:06] Guru IntegerDivideByZero PC 0x42014cf3
Backtrace: 0x42014cf0(hashtable_policy.h:534) 0x42014dac(unordered_map.h:988) 0x42014345(tt_bundle.cpp:46 putInt32) 0x4237a4d3(app)
```
- Pressing **DL Season** during fetch → `G.ep_cnt` only 50 (partial), `G.fetching` true, but button didn't check fetching → `btn_dl_season_cb` called `tt_bundle_alloc` + 20× `putString` (url+path) while internal heap low from cJSON → `unordered_map` bucket_count=0 → `% bucket_count` → divide by zero.
**Fix:**
- `btn_seasons_cb` and `btn_dl_season_cb` now check `if(G.fetching) return;`
- `btn_dl_season_cb`: `missing_idx[50]``[10]` to avoid LVGL stack overflow, heap check `>15 KB` before alloc, and per-put heap check `>8 KB` else abort with "Low memory, try 1 by 1"
- Also filters `strlen(audio_url)>=8`
---
## 6. Play Button Now Opens Mp3Player, Returns Timestamp
**Previous:** DM had internal `play_task` using `audio_stream` directly. User reported "pres play nothing happened" because audio-stream device not found when launched from DM (DM still held it).
**Fix in `player.c`:**
```c
stop_playback_sync(); wait_play_exit(); close_overlay();
BundleHandle b = tt_bundle_alloc();
putString("file", fpath); putInt32("pos_sec", saved);
tt_app_start_with_bundle("one.tactility.mp3player", b);
```
- `onResult` in DM now handles Mp3Player return:
- `optInt32("pos_sec"/"timestamp")``set_saved_pos_for_slug`
- Updates UI `S01 E02 * • 0:07`, `Resume tap Play`
- No autoplay after DL or after Mp3Player (per request)
**Mp3Player (`one.tactility.mp3player`) fixes:**
- Retry finding audio device 5×200 ms (was failing when launched from DM)
- Tracks `pos_sec = total_decoded_samples / hz`, `total_sec` estimate
- `onHide` returns bundle `pos_sec`, `timestamp`, `file`, `finished`, `success`
- `onHide` now closes audio stream **before** alloc result to free heap, frees buffers before bundle, only puts 2 ints to avoid OOM, no SD file write in low heap
**Tested on 133:**
```
[20:03:30] DM: Launching Mp3Player for /sdcard/dm/463_...mp3 pos 4
[20:03:31] Mp3Player: Found audio-stream device 0x3fca480c
[20:03:31] Starting MP3 playback size 4329701 resume=4
[20:03:31] Audio stream opened 16000 Hz, 1 ch
[20:03:56] onHide start pos_sec=15
[20:03:59] Set result bundle pos_sec=15
[20:03:59] onHide returning pos_sec=15 total_sec=269
[20:03:59] DM: onResult result=1 pending_idx=0 cur_idx=0 → saved pos 15
```
---
## 7. SdDownloader Now Batch + Headless (per your latest spec)
**Manifest:** `one.tactility.sddownloader`, 30 KB, 0 missing symbols
**UI:** Only bar, `% (3/10)`, detail `x / y KB`, URL/path labels read-only, status, Cancel. No input fields.
**Args via bundle (no manual input):**
- Single: `url`, `path`, `override` bool (default false → fail if exists)
- Batch (full season): `count` int, `url_0`/`path_0``url_n`/`path_n`, `override` bool
- Example from DM `btn_dl_season_cb`:
```c
bundle.putInt32("count", 7);
putString("url_0", "http://.../S02E01.mp3"); putString("path_0", "/sdcard/dm/...");
...
tt_app_start_with_bundle("one.tactility.sddownloader", b);
```
**Returns bool:**
```c
bundle.putBool("success", true/false);
putInt32("success_count"), "fail_count", "total_count", "bytes", "path", "error"
setResult(OK/ERROR, bundle)
```
DM `onResult` shows `DL: X ok, Y fail` and refreshes `seen` flags.
**Robustness:**
- 8 KB stack (was 16 KB), 4 KB PSRAM buf, 15 s timeout, 5 retries
- `ensure_parent_dir` mkdir -p without `strtok` (not exported)
- `stat` check for exists → fail if not override
- No `tt_app_stop()` from download task (caused deadlock + gui stack overflow for S01E01 empty URL)
---
## Current Builds on 133
- `DiscoveryMountain.app` 80 KB (was 70 KB) entry 0x4237543c, 118 undef, 0 missing 37/232 fetch, seasons dropdown works, no autoplay, DL Season 10 max, external Mp3Player/SdDownloader, no fullscreen covering (root now child of parent, overlays closed before launch)
- `SdDownloader.app` 30 KB 74 undef, 0 missing batch 10, file-exists check
- `Mp3Player.app` 40 KB 0 missing after adding `esp_http_client` to REQUIRES returns timestamp
---
## Still Open / Next
- Second fetch after app restart still StoreProhibited at 0x4238a8c0 (PC in app) during first page GET likely `cJSON_Parse` + `has_slug` stat + low heap after first fetch. Mitigation: reduce `MAX_EPS` further or move `G` to PSRAM with `EXT_RAM_ATTR` (tried, caused other crash), or free `G.eps` between fetches.
- S01E01 dummy fallback (empty URL) now shows "No URL fetch failed?" instead of crash, but ideally should never appear if fetch succeeds. Ensure PB at `192.168.68.110:8095` is reachable (seen `connect fail` when WiFi bcn_timeout).
- Mp3Player close takes 2.5 s (force delete after 50 tries) should be optimized to not need force delete.
---
## Reproduction Steps for S01E04 Crash (you reported)
1. Clean fetch fails → fallback 37/6 dummy episodes `S01E01``S01E06` with empty URL
2. Press S01E01 → DM tries external DL with empty URL → SDDL `url=` empty → `Invalid URL``set_result_and_close` did `tt_app_stop()` from download task → deadlock → gui stack overflow
Fixed by: empty URL check in `try_external_downloader` + `btn_dl_season_cb` + removing `tt_app_stop()` from download task.
For real S01E04 (34 MB, `460_...`), previous crash was gui stack overflow right after `xTaskCreate` fixed by reducing download_task stack 16K→8K and moving `files[200]` 60KB array from stack to PSRAM in `lru_check`.
---
## Files Changed
- `Apps/DiscoveryMountain/main/Source/app_context.c` added `pending_dl_idx`, `root`
- `app_context.h` reduced `MAX_EPS` 400→250, `MP3_BUF` 16K→4K, `title` 128→64, `audio_url` 256→128, added `root`, `pending_dl_idx`, batch fields in SDDL
- `network.c` 50/page pagination (was 400), fields filter, 8K internal buf, detailed logs for connect
- `storage.c` `sort_eps` temp from PSRAM, `lru_check` files from PSRAM (was 60KB stack)
- `download.c` stubbed to only overlays (internal DL removed)
- `player.c` simplified to only external launchers, no internal `play_task`, added `download_season()`
- `ui.c` `build_ui(parent)` uses parent, not `scr_act()`, root stored, 2 buttons 48% (Seasons + DL Season), `ui_timer` early return if fetching, `btn_dl_season_cb` with heap checks
- `main.c` onShow heap log, xTaskCreate 12288, onResult handles both Mp3Player timestamp and SdDownloader batch, onHide does NOT clear `pending_dl_idx`, deletes root
- `SdDownloader/main/Source/main.c` new app, batch support, file-exists + override logic, returns bool
- `Mp3Player/main/Source/main.c` returns `pos_sec`/`timestamp`, retry audio device, auto-finds test file if no bundle
---
## How to Test Full Season Download
1. Open DM → wait `fetch done 37/232`
2. Tap `Seasons` → S01 → tap `DL Season`
3. Should show `Starting DL 6 eps S01` → launches SDDL → bar shows `3/6: 45%` etc.
4. On finish, DM shows `DL: 6 ok, 0 fail` and refreshes `*` markers
5. Tap episode with `*` → Play → launches Mp3Player → plays from saved pos → close → DM shows `Resume • 0:12`
@@ -0,0 +1,8 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
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
)
@@ -0,0 +1,3 @@
#include "app_context.h"
AppCtx G = {0};
@@ -0,0 +1,106 @@
#pragma once
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <lvgl.h>
#include <tactility/lvgl_fonts.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#ifdef __cplusplus
extern "C" {
#endif
#define TAG "DM"
#define PB "http://192.168.68.110:8095"
#define DM_DIR "/sdcard/dm"
#define STATE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/state.json"
#define CACHE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/cache.json"
#define MAX_SEASONS 40
#define MAX_EPS 250
#define MP3_BUF 4096
typedef struct { int num; } SeasonInfo;
typedef struct {
char title[64];
char slug[96];
int season;
int ep_num;
char audio_url[128];
bool seen;
} EpInfo;
typedef struct { char slug[96]; int pos; } SavedPos;
typedef struct {
SeasonInfo seasons[MAX_SEASONS];
int season_cnt;
EpInfo eps[MAX_EPS];
int ep_cnt;
int cur_season;
int cur_ep_idx;
struct Device* stream_dev;
AudioStreamHandle stream_handle;
bool is_playing;
bool is_paused;
bool need_stop;
TaskHandle_t play_handle;
TaskHandle_t fetch_handle;
TaskHandle_t dl_handle;
int pos_sec;
int total_sec;
int volume;
int last_pct;
lv_obj_t *root;
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
lv_obj_t *lbl_season;
lv_obj_t *ep_list_cont;
lv_obj_t *btn_seasons;
lv_obj_t *btn_dl_missing;
lv_obj_t *overlay, *dropdown;
lv_obj_t *season_list_cont;
lv_timer_t *ui_timer_handle;
// Download UI
lv_obj_t *dl_overlay;
lv_obj_t *dl_bar;
lv_obj_t *dl_lbl_pct;
lv_obj_t *dl_lbl_detail;
lv_obj_t *dl_lbl_title;
lv_obj_t *dl_lbl_sub;
char cur_title[128];
char last_slug[96];
bool fetching;
bool downloading;
volatile int dl_total;
int dl_expected;
int dl_last_ui_update;
volatile bool dl_cancel_req;
volatile bool fetch_cancel_req;
char dl_ep_title[128];
char dl_slug[96];
int dl_season;
int dl_ep_num;
int dl_last_pct;
// Per-episode resume
SavedPos saved_pos[MAX_EPS];
int saved_pos_cnt;
int last_save_tick;
// External downloader integration (SdDownloader)
int pending_dl_idx;
// Full season download queue (sequential single-file to avoid batch crash)
bool dl_missing_active;
int dl_missing_season;
int dl_missing_total;
int dl_missing_done;
int dl_missing_failed;
} AppCtx;
extern AppCtx G;
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,49 @@
#include "download.h"
#include "storage.h"
#include <tt_lvgl.h>
#include <string.h>
#include "esp_log.h"
// Simplified: internal download removed, we use external SdDownloader app
// Keep overlay functions as stubs for compatibility, but they do minimal
void hide_dl_overlay(){
if(G.dl_overlay){
lv_obj_delete(G.dl_overlay);
G.dl_overlay=NULL;
G.dl_bar=NULL;
G.dl_lbl_pct=NULL;
G.dl_lbl_detail=NULL;
G.dl_lbl_title=NULL;
G.dl_lbl_sub=NULL;
}
G.downloading=false;
}
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){
// No longer used - external downloader has its own UI
// Keep as stub that shows simple status
(void)ep_title; (void)slug; (void)season; (void)ep_num;
G.downloading=true;
}
void update_dl_overlay_ui(){
// No internal download progress anymore
}
bool dl_ep_raw(EpInfo* ep){
(void)ep;
return false;
}
bool dl_ep(EpInfo* ep){
(void)ep;
return false;
}
void dl_task(void* arg){
(void)arg;
G.downloading=false;
G.dl_handle=NULL;
vTaskDelete(NULL);
}
@@ -0,0 +1,24 @@
#pragma once
#include "app_context.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Raw download to file with progress tracking
bool dl_ep_raw(EpInfo* ep);
bool dl_ep(EpInfo* ep);
// Download task
void dl_task(void* arg);
// Download overlay UI
void hide_dl_overlay(void);
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num);
void update_dl_overlay_ui(void);
#ifdef __cplusplus
}
#endif
+433
View File
@@ -0,0 +1,433 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_bundle.h>
#include "app_context.h"
#include "storage.h"
#include "network.h"
#include "download.h"
#include "player.h"
#include "ui.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
static void fetch_task_fn(void* arg){
(void)arg;
G.fetching=true;
G.fetch_cancel_req=false;
ESP_LOGI(TAG,"fetch task start");
// Try load cached seasons/episodes instant offline support
bool cache_loaded = load_cache();
if(cache_loaded){
ESP_LOGI(TAG,"Cache loaded: %d seasons, %d eps", G.season_cnt, G.ep_cnt);
// Restore cur idx from last_slug
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
if(G.cur_ep_idx<0){
int s1=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; }
if(s1==-1 && G.ep_cnt>0){
s1=0;
G.cur_season=G.eps[0].season;
}
G.cur_ep_idx=s1;
}
// Show cached data immediately
G.fetching=false;
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Loaded from cache, refreshing...");
tt_lvgl_unlock();
G.fetching=true;
vTaskDelay(pdMS_TO_TICKS(200));
} else {
ESP_LOGI(TAG,"No cache found, will fetch from network");
}
// Network fetch updates if possible, otherwise keeps cache
bool fetch_ok = fetch_data();
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"fetch task canceled, exiting");
G.fetching=false;
G.fetch_handle=NULL;
vTaskDelete(NULL);
return;
}
if(fetch_ok){
ESP_LOGI(TAG,"Network fetch ok: %d seasons, %d eps saving cache", G.season_cnt, G.ep_cnt);
save_cache();
} else {
if(cache_loaded){
ESP_LOGW(TAG,"Network fetch failed, restoring cache");
// fetch_data overwrote with fallback, restore cache
load_cache();
} else {
ESP_LOGW(TAG,"Network fetch failed and no cache using fallback");
}
}
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d cache=%d fetch_ok=%d",G.season_cnt,G.ep_cnt,cache_loaded,fetch_ok);
vTaskDelay(pdMS_TO_TICKS(300));
// Resolve current episode index preserve user season selection if cache was already shown
if(cache_loaded){
// User may have changed season while network fetch was in progress keep their selection if it still exists
int cur_s = G.cur_season;
bool season_exists=false;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==cur_s) { season_exists=true; break; }
if(!season_exists){
// Fallback to last_slug or first season
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; season_exists=true; break; }
}
}
if(season_exists){
// Keep cur_season, find best idx within it
int best=-1;
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==cur_s && strcmp(G.eps[i].slug,G.last_slug)==0){ best=i; break; }
}
if(best==-1){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==cur_s){ best=i; break; }
}
if(best!=-1) G.cur_ep_idx=best;
} else {
// Season no longer exists, fallback to first
if(G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
}
} else {
// First launch use last_slug to restore position
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
if(G.cur_ep_idx<0){
int s1=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; }
if(s1==-1 && G.ep_cnt>0){
s1=0;
G.cur_season=G.eps[0].season;
}
G.cur_ep_idx=s1;
}
}
G.fetching=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.fetch_cancel_req){
tt_lvgl_unlock();
G.fetch_handle=NULL;
vTaskDelete(NULL);
return;
}
tt_lvgl_unlock();
ui_rebuild_episode_list();
if(G.ep_cnt>0 && G.cur_ep_idx>=0){
EpInfo* ep=&G.eps[G.cur_ep_idx];
int saved = get_saved_pos_for_slug(ep->slug);
if(saved==0) saved = G.pos_sec;
if(saved>0) G.pos_sec=saved;
}
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
if(G.ep_cnt>0){
if(fetch_ok) lv_label_set_text(G.lbl_status, "Select episode to play / download");
else if(cache_loaded) lv_label_set_text(G.lbl_status, "Offline, using cached data");
else lv_label_set_text(G.lbl_status,"No network limited data");
} else {
lv_label_set_text(G.lbl_status,"No data check network");
}
}
tt_lvgl_unlock();
G.fetch_handle=NULL;
save_state();
vTaskDelete(NULL);
}
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
(void)app; (void)data; (void)parent;
memset(&G,0,sizeof(G));
G.pos_sec=0;
G.total_sec=0;
G.cur_season=1;
G.cur_ep_idx=-1;
G.pending_dl_idx=-1;
G.volume=80;
G.last_pct=-1;
G.dl_last_pct=-1;
G.dl_expected=-1;
G.fetch_cancel_req=false;
G.dl_cancel_req=false;
ensure_dir();
load_state();
build_ui(parent);
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
ESP_LOGI(TAG, "onShow heap before fetch task: internal=%d psram=%d", free_internal, free_psram);
BaseType_t res = xTaskCreate(fetch_task_fn,"dm_fetch",12288,NULL,5,&G.fetch_handle);
ESP_LOGI(TAG, "xTaskCreate fetch_task res=%d handle=%p", res, G.fetch_handle);
if (res != pdPASS) {
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Failed to create fetch task");
tt_lvgl_unlock();
}
}
static void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData){
(void)app; (void)data; (void)launchId;
ESP_LOGI(TAG, "onResult: result=%d pending_idx=%d cur_idx=%d", result, G.pending_dl_idx, G.cur_ep_idx);
// Check if result contains timestamp from Mp3Player
if (resultData) {
int32_t pos = 0;
bool has_pos = false;
if (tt_bundle_opt_int32(resultData, "pos_sec", &pos) ||
tt_bundle_opt_int32(resultData, "timestamp", &pos) ||
tt_bundle_opt_int32(resultData, "position", &pos)) {
has_pos = true;
}
char file_path[512]={0};
bool has_file = tt_bundle_opt_string(resultData, "file", file_path, sizeof(file_path)) ||
tt_bundle_opt_string(resultData, "path", file_path, sizeof(file_path));
if (has_pos) {
ESP_LOGI(TAG, "Mp3Player returned pos %d sec for file %s", pos, has_file?file_path:"unknown");
int target_idx = -1;
if (has_file && strlen(file_path)>0) {
for(int i=0;i<G.ep_cnt;i++){
char ep_path[300];
make_sd_path(ep_path, sizeof(ep_path), G.eps[i].slug);
if (strcmp(ep_path, file_path)==0) { target_idx = i; break; }
}
}
if (target_idx<0 && G.pending_dl_idx>=0 && G.pending_dl_idx < G.ep_cnt) target_idx = G.pending_dl_idx;
if (target_idx<0 && G.cur_ep_idx>=0) target_idx = G.cur_ep_idx;
if (target_idx>=0) {
bool finished = false;
tt_bundle_opt_bool(resultData, "finished", &finished);
int32_t total_sec = 0;
tt_bundle_opt_int32(resultData, "total_sec", &total_sec);
if (finished || (total_sec>0 && pos >= total_sec-10)) {
set_saved_pos_for_slug(G.eps[target_idx].slug, 0);
G.pos_sec = 0;
ESP_LOGI(TAG, "Episode %s finished, clearing pos", G.eps[target_idx].slug);
} else {
set_saved_pos_for_slug(G.eps[target_idx].slug, pos);
G.pos_sec = pos;
ESP_LOGI(TAG, "Saved pos %d for %s", pos, G.eps[target_idx].slug);
}
save_state();
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
if (pos>5) lv_label_set_text(G.lbl_status,"Progress saved tap episode to resume");
else lv_label_set_text(G.lbl_status,"Select episode to play / download");
}
tt_lvgl_unlock();
}
G.pending_dl_idx = -1;
return;
}
}
// SdDownloader batch result handling
if (G.pending_dl_idx <0 || G.pending_dl_idx >= G.ep_cnt) {
if (resultData) {
int32_t succ=0, fail=0, total=0;
if (tt_bundle_opt_int32(resultData, "success_count", &succ) ||
tt_bundle_opt_int32(resultData, "total_count", &total)) {
tt_bundle_opt_int32(resultData, "fail_count", &fail);
ESP_LOGI(TAG, "Batch DL result: success %ld fail %ld total %ld", (long)succ, (long)fail, (long)total);
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
save_state();
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
char msg[64];
snprintf(msg,sizeof(msg),"DL: %ld ok, %ld fail", (long)succ, (long)fail);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
G.pending_dl_idx = -1;
return;
}
}
G.pending_dl_idx = -1;
// Still refresh list in case files appeared
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
ui_rebuild_episode_list();
return;
}
// Single download result
bool success = false;
if (resultData) {
tt_bundle_opt_bool(resultData, "success", &success);
}
if (result == APP_RESULT_OK) {
if (resultData) {
bool b=false;
if (tt_bundle_opt_bool(resultData, "success", &b)) success = b;
else success = true;
} else {
success = true;
}
}
int idx = G.pending_dl_idx;
G.pending_dl_idx = -1;
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
save_state();
ui_rebuild_episode_list();
// Handle sequential full-season download queue (safe single-file mode)
if(G.dl_missing_active){
if(success) G.dl_missing_done++;
else G.dl_missing_failed++;
int next = find_next_missing_in_season(G.dl_missing_season);
if(next!=-1){
ESP_LOGI(TAG, "Batch queue continuing: %d/%d done, next idx %d", G.dl_missing_done, G.dl_missing_total, next);
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL %d/%d S%02d...", G.dl_missing_done+1, G.dl_missing_total, G.dl_missing_season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
// Small delay to let UI update before launching next downloader
vTaskDelay(pdMS_TO_TICKS(300));
if(download_episode(next)){
// started next
return;
} else {
// failed to start next, abort queue
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL stopped %d ok %d fail", G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return;
}
} else {
// No more missing queue finished
ESP_LOGI(TAG, "Batch queue finished: %d ok %d fail", G.dl_missing_done, G.dl_missing_failed);
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL S%02d done %d ok %d fail", G.dl_missing_season, G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
if(success && idx>=0) ESP_LOGI(TAG, "Single DL success idx %d", idx);
else ESP_LOGW(TAG, "External DL failed for idx %d", idx);
return;
}
}
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
if (success) lv_label_set_text(G.lbl_status, "DL done tap to play");
else lv_label_set_text(G.lbl_status, "DL failed tap to retry");
}
tt_lvgl_unlock();
if(success && idx>=0){
ESP_LOGI(TAG, "Single DL success idx %d", idx);
} else {
ESP_LOGW(TAG, "External DL failed for idx %d", idx);
}
}
static void onHide(AppHandle app, void* data){
(void)app; (void)data;
save_current_ep_pos();
G.fetch_cancel_req=true;
G.dl_cancel_req=true;
G.dl_missing_active=false;
if(G.fetch_handle){
ESP_LOGI(TAG,"onHide: waiting fetch to exit");
tt_lvgl_lock(portMAX_DELAY);
for(int i=0;i<20 && G.fetch_handle!=NULL; i++){
tt_lvgl_unlock();
vTaskDelay(pdMS_TO_TICKS(100));
tt_lvgl_lock(portMAX_DELAY);
}
if(G.fetch_handle){
ESP_LOGW(TAG,"onHide: force deleting fetch task (leak risk)");
vTaskDelete(G.fetch_handle);
G.fetch_handle=NULL;
}
G.fetching=false;
tt_lvgl_unlock();
}
if(G.dl_handle){
ESP_LOGI(TAG,"onHide: waiting dl to exit");
tt_lvgl_lock(portMAX_DELAY);
for(int i=0;i<30 && G.dl_handle!=NULL; i++){
tt_lvgl_unlock();
vTaskDelay(pdMS_TO_TICKS(100));
tt_lvgl_lock(portMAX_DELAY);
}
if(G.dl_handle){
ESP_LOGW(TAG,"onHide: force deleting dl task after timeout");
vTaskDelete(G.dl_handle);
G.dl_handle=NULL;
G.downloading=false;
if(G.dl_overlay) hide_dl_overlay();
}
tt_lvgl_unlock();
}
tt_lvgl_lock(portMAX_DELAY);
wait_play_exit();
ui_deinit();
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
if(G.root){
lv_obj_delete(G.root);
G.root = NULL;
G.lbl_title = NULL;
G.lbl_meta = NULL;
G.lbl_season = NULL;
G.ep_list_cont = NULL;
G.lbl_time = NULL;
G.lbl_status = NULL;
G.btn_play = NULL;
G.btn_seasons = NULL;
G.btn_dl_missing = NULL;
G.season_list_cont = NULL;
G.bar = NULL;
}
tt_lvgl_unlock();
save_state();
}
int main(int argc, char* argv[]){
(void)argc; (void)argv;
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide, .onResult=onResult });
return 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,330 @@
#include "network.h"
#include "storage.h"
#include "cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include <unistd.h>
uint16_t my_htons(uint16_t v){
// Proper byte swap with masking to avoid overflow
return (uint16_t)(((v & 0x00FF) << 8) | ((v & 0xFF00) >> 8));
}
bool resolve_host(const char* host, uint32_t* out_ip){
if(!host || !out_ip) return false;
uint32_t ip = ipaddr_addr(host);
if(ip!=0 && ip!=0xFFFFFFFF){
*out_ip=ip;
return true;
}
ESP_LOGE(TAG,"resolve fail (DNS not supported) %s",host);
return false;
}
void normalize_audio_url(const char* in, char* out, size_t out_len){
if(!in || !out) return;
if(strncmp(in,"http://",7)==0){
strncpy(out,in,out_len-1);
out[out_len-1]=0;
return;
}
if(in[0]=='/'){
snprintf(out,out_len,"%s%s",PB,in);
return;
}
if(strstr(in,"://")==NULL){
if(in[0]=='/') snprintf(out,out_len,"%s%s",PB,in);
else snprintf(out,out_len,"%s/%s",PB,in);
return;
}
strncpy(out,in,out_len-1);
out[out_len-1]=0;
}
int http_get_raw(const char* url, char** out_body){
if(!url || strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"http_get url not http %s",url); return -1; }
const char* p = url+7;
const char* slash = strchr(p,'/');
if(!slash){ ESP_LOGE(TAG,"no slash %s",url); return -1; }
char hostport[128]={0};
size_t hlen=slash-p;
if(hlen>=sizeof(hostport)) return -1;
memcpy(hostport,p,hlen);
hostport[hlen]=0;
const char* path=slash;
char host[96]={0};
int port=80;
char* colon=strchr(hostport,':');
if(colon){ *colon=0; strncpy(host,hostport,sizeof(host)-1); port=atoi(colon+1); if(port<=0) port=80; } else { strncpy(host,hostport,sizeof(host)-1); }
ESP_LOGI(TAG,"GET %s host=%s port=%d",url,host,port);
uint32_t ip=0;
ESP_LOGI(TAG,"Resolving host %s",host);
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; }
ESP_LOGI(TAG,"Resolved %s -> ip 0x%08x", host, ip);
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
ESP_LOGI(TAG,"Socket fd=%d", fd);
if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; }
struct sockaddr_in sa;
ESP_LOGI(TAG,"Memset sa");
memset(&sa,0,sizeof(sa));
ESP_LOGI(TAG,"Set sa len/family/port/addr");
sa.sin_len = sizeof(sa);
sa.sin_family=AF_INET;
sa.sin_port=my_htons(port);
sa.sin_addr.s_addr=ip;
ESP_LOGI(TAG,"SetSockOpt RCV");
struct timeval tv={5,0};
int opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"SetSockOpt RCV res=%d", opt_res);
ESP_LOGI(TAG,"SetSockOpt SND");
opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"SetSockOpt SND res=%d", opt_res);
ESP_LOGI(TAG,"Connecting to %s:%d",host,port);
int conn_res = lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa));
ESP_LOGI(TAG,"Connect res=%d", conn_res);
if(conn_res<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
ESP_LOGI(TAG,"Connected, sending request");
char req[512];
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
if(lwip_send(fd,req,reqlen,0)<0){ ESP_LOGE(TAG,"send fail"); close(fd); return -1; }
ESP_LOGI(TAG,"Request sent, waiting for response");
int capacity=8192; // further reduced to 8K internal to avoid PSRAM StoreProhibited crash (seen 0x8208775e)
char* buf=malloc(capacity);
if(!buf){
buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!buf){ ESP_LOGE(TAG,"buf 8k fail"); close(fd); return -1; }
}
int total=0;
char* header_end=NULL;
int body_start=0;
int content_len=-1;
bool is_chunked=false;
while(1){
// Graceful cancel support to avoid force vTaskDelete leaking PSRAM/SD resources
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"http_get_raw canceled by user");
heap_caps_free(buf);
close(fd);
return -1;
}
if(total>=capacity-1){
int newcap=capacity*2;
if(newcap>600000){ ESP_LOGE(TAG,"too large %d",newcap); heap_caps_free(buf); close(fd); return -1; }
char* nb=heap_caps_malloc(newcap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!nb){ heap_caps_free(buf); close(fd); return -1; }
memcpy(nb,buf,total);
heap_caps_free(buf);
buf=nb;
capacity=newcap;
}
int r=lwip_recv(fd,buf+total,capacity-total-1,0);
if(r<=0){
if(r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT)){
ESP_LOGW(TAG,"http_get_raw timeout total=%d",total);
// On timeout, if fetching canceled, abort
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"http_get_raw timeout + canceled");
heap_caps_free(buf);
close(fd);
return -1;
}
// Continue loop LWIP will retry until EOF
// But if we already have header and content_len satisfied, break
if(header_end && content_len>=0 && !is_chunked && total-body_start >= content_len) break;
// If no header yet and timeout, treat as failure to avoid 15s hang
if(!header_end && total==0){
ESP_LOGE(TAG,"http_get_raw timeout before header");
heap_caps_free(buf);
close(fd);
return -1;
}
// Otherwise continue to try again
continue;
}
break;
}
total+=r;
buf[total]=0;
if(!header_end){
header_end=strstr(buf,"\r\n\r\n");
if(header_end){
body_start=(header_end-buf)+4;
if(strstr(buf,"chunked")) is_chunked=true;
char* cl=strstr(buf,"Content-Length:");
if(!cl) cl=strstr(buf,"content-length:");
if(cl){ cl=strchr(cl,':'); if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); } }
ESP_LOGI(TAG,"header_end chunked=%d cl=%d total=%d",is_chunked,content_len,total);
if(content_len>=0 && !is_chunked){
if(total-body_start >= content_len) break;
}
}
} else {
if(content_len>=0 && !is_chunked){
if(total-body_start >= content_len) break;
}
}
}
close(fd);
if(!header_end){ ESP_LOGE(TAG,"no header end total=%d",total); heap_caps_free(buf); return -1; }
if(strncmp(buf,"HTTP/1.1 200",12)!=0 && strncmp(buf,"HTTP/1.0 200",12)!=0){ ESP_LOGE(TAG,"not 200: %.60s",buf); heap_caps_free(buf); return -1; }
int raw_len=total-body_start;
char* raw=buf+body_start;
char* final_body=NULL;
int final_len=0;
if(is_chunked){
final_body=heap_caps_malloc(200000, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!final_body){ heap_caps_free(buf); return -1; }
int pos=0, out=0;
while(pos<raw_len){
char* crlf=strstr(raw+pos,"\r\n");
if(!crlf) break;
int off=crlf-(raw+pos);
if(off<=0 || off>16){ pos+=off+2; continue; }
char hex[16]={0};
memcpy(hex,raw+pos,off);
char* semi=strchr(hex,';');
if(semi) *semi=0;
long sz=strtol(hex,NULL,16);
if(sz==0) break;
pos+=off+2;
if(pos+sz>raw_len) sz=raw_len-pos;
if(out+sz>=200000) break;
memcpy(final_body+out,raw+pos,sz);
out+=sz;
pos+=sz;
if(pos+1<raw_len && raw[pos]=='\r' && raw[pos+1]=='\n') pos+=2;
}
final_body[out]=0;
final_len=out;
} else {
int bl=raw_len;
if(content_len>=0 && bl>content_len) bl=content_len;
final_body=heap_caps_malloc(bl+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!final_body){ heap_caps_free(buf); return -1; }
memcpy(final_body,raw,bl);
final_body[bl]=0;
final_len=bl;
}
heap_caps_free(buf);
*out_body=final_body;
ESP_LOGI(TAG,"GET ok %d",final_len);
return final_len;
}
bool fetch_data(){
char* js=NULL;
char url[320];
bool seasons_ok=false;
bool episodes_ok=false;
ESP_LOGI(TAG,"fetch seasons");
// Reduce payload via fields filter drastically lowers cJSON malloc pressure (internal heap low warning)
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num&fields=season_num",PB);
int len=http_get_raw(url,&js);
if(len>0 && js){
cJSON* root=cJSON_Parse(js);
free(js); js=NULL;
if(root){
cJSON* items=cJSON_GetObjectItem(root,"items");
if(cJSON_IsArray(items)){
G.season_cnt=0;
int n=cJSON_GetArraySize(items);
for(int i=0;i<n&&G.season_cnt<MAX_SEASONS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
if(sn&&cJSON_IsNumber(sn)){ G.seasons[G.season_cnt].num=sn->valueint; G.season_cnt++; }
}
sort_seasons();
if(G.season_cnt>0) seasons_ok=true;
}
cJSON_Delete(root);
}
} else {
ESP_LOGW(TAG,"seasons fetch failed");
}
if(G.season_cnt==0){
ESP_LOGW(TAG,"seasons fallback");
for(int i=1;i<=37;i++) G.seasons[i-1].num=i;
G.season_cnt=37;
}
ESP_LOGI(TAG,"fetch episodes paginated to reduce internal heap (prev low 7KB)");
G.ep_cnt=0;
for(int page=1; page<=10; page++){
if(G.fetch_cancel_req) break;
if(G.ep_cnt>=MAX_EPS) break;
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=25&page=%d&sort=season_num,episode_num&fields=title,slug,season_num,episode_num,audio_url",PB,page);
len=http_get_raw(url,&js);
if(len<=0 || !js){
ESP_LOGW(TAG,"episodes page %d fetch failed",page);
if(js){ free(js); js=NULL; }
break;
}
cJSON* root=cJSON_Parse(js);
free(js); js=NULL;
if(!root){
ESP_LOGW(TAG,"episodes page %d JSON parse fail",page);
break;
}
cJSON* items=cJSON_GetObjectItem(root,"items");
if(!cJSON_IsArray(items)){
cJSON_Delete(root);
break;
}
int n=cJSON_GetArraySize(items);
if(n==0){
cJSON_Delete(root);
break;
}
ESP_LOGI(TAG,"episodes page %d got %d items (total so far %d)",page,n,G.ep_cnt);
for(int i=0;i<n&&G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* t=cJSON_GetObjectItem(it,"title");
cJSON* slug=cJSON_GetObjectItem(it,"slug");
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
if(!sn||!en) continue;
EpInfo* e=&G.eps[G.ep_cnt];
memset(e,0,sizeof(*e));
if(t&&cJSON_IsString(t)) strncpy(e->title,t->valuestring,sizeof(e->title)-1);
if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1);
else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1);
e->season=sn->valueint;
e->ep_num=en->valueint;
if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1);
e->seen=has_slug(e->slug);
G.ep_cnt++;
}
episodes_ok=true;
cJSON_Delete(root);
// Yield between pages to let MemoryChecker recover
vTaskDelay(pdMS_TO_TICKS(400));
if(n<25) break; // last page
}
// Final sort after all pages
if(G.ep_cnt>0) sort_eps();
if(G.ep_cnt==0){
ESP_LOGW(TAG,"episodes fallback");
for(int i=0;i<6;i++){
EpInfo* e=&G.eps[i];
snprintf(e->slug,sizeof(e->slug),"S01E%02d",i+1);
snprintf(e->title,sizeof(e->title),"Episode %d",i+1);
e->season=1; e->ep_num=i+1;
}
G.ep_cnt=6;
}
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
return (seasons_ok && episodes_ok);
}
@@ -0,0 +1,23 @@
#pragma once
#include "app_context.h"
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
uint16_t my_htons(uint16_t v);
bool resolve_host(const char* host, uint32_t* out_ip);
void normalize_audio_url(const char* in, char* out, size_t out_len);
// Robust HTTP GET with PSRAM buffer, returns length and allocates *out_body (must be free'd via free/heap_caps_free)
int http_get_raw(const char* url, char** out_body);
// Fetch seasons and episodes from PB returns true if network fetch succeeded with real data
bool fetch_data(void);
#ifdef __cplusplus
}
#endif
+292
View File
@@ -0,0 +1,292 @@
#include "player.h"
#include "storage.h"
#include "ui.h"
#include "download.h"
#include <tt_lvgl.h>
#include <tt_app.h>
#include <tt_bundle.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
void wait_play_exit(void) {
// No internal playback anymore, nothing to wait
}
void stop_playback_sync(void) {
// No internal playback, nothing to stop
G.is_playing = false;
G.is_paused = false;
}
static bool try_external_downloader(int idx) {
if(idx<0||idx>=G.ep_cnt) return false;
EpInfo* ep=&G.eps[idx];
if (strlen(ep->audio_url) < 8) {
ESP_LOGW(TAG, "Skipping DL for %s: empty URL", ep->slug);
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "No URL fetch failed?");
tt_lvgl_unlock();
return false;
}
char fpath[300];
make_sd_path(fpath, sizeof(fpath), ep->slug);
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
tt_lvgl_unlock();
BundleHandle b = tt_bundle_alloc();
if (!b) {
ESP_LOGW(TAG, "Failed to alloc bundle for external DL");
return false;
}
tt_bundle_put_string(b, "url", ep->audio_url);
tt_bundle_put_string(b, "path", fpath);
tt_bundle_put_bool(b, "override", false);
ESP_LOGI(TAG, "Starting external SdDownloader for %s -> %s", ep->slug, fpath);
G.pending_dl_idx = idx;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Starting downloader...");
tt_lvgl_unlock();
tt_app_start_with_bundle("one.tactility.sddownloader", b);
return true;
}
int count_missing_in_season(int season){
int cnt=0;
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season==season && !G.eps[i].seen && strlen(G.eps[i].audio_url)>=8) cnt++;
}
return cnt;
}
int find_next_missing_in_season(int season){
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season==season && !G.eps[i].seen && strlen(G.eps[i].audio_url)>=8) return i;
}
return -1;
}
bool download_episode(int idx){
return try_external_downloader(idx);
}
bool download_missing_next(void){
if(!G.dl_missing_active) return false;
int next = find_next_missing_in_season(G.dl_missing_season);
if(next==-1){
// No more missing done
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL S%02d done %d ok %d fail", G.dl_missing_season, G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return false;
}
// Update UI for next file
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL %d/%d S%02d...", G.dl_missing_done+1, G.dl_missing_total, G.dl_missing_season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return try_external_downloader(next);
}
bool download_missing_season_start(int season){
int missing = count_missing_in_season(season);
if(missing==0){
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Season already cached");
tt_lvgl_unlock();
G.dl_missing_active=false;
return true;
}
ESP_LOGI(TAG, "Starting sequential DL for S%02d, %d missing", season, missing);
G.dl_missing_active=true;
G.dl_missing_season=season;
G.dl_missing_total=missing;
G.dl_missing_done=0;
G.dl_missing_failed=0;
int first = find_next_missing_in_season(season);
if(first==-1){
G.dl_missing_active=false;
return true;
}
// Update status
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL 1/%d S%02d...", missing, season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return try_external_downloader(first);
}
static bool try_external_downloader_batch(int season) {
// Sequential queue safe single-file mode
return download_missing_season_start(season);
}
void start_idx_internal(int idx){
if(idx<0||idx>=G.ep_cnt) return;
EpInfo* ep=&G.eps[idx];
char fpath[300];
make_sd_path(fpath, sizeof(fpath), ep->slug);
if(!has_slug(ep->slug)){
ESP_LOGW(TAG, "start_idx_internal called but file missing %s", ep->slug);
start_idx(idx);
return;
}
int saved = 0;
if(G.cur_ep_idx!=idx){
saved = get_saved_pos_for_slug(ep->slug);
if(saved>5){
ESP_LOGI(TAG,"Restoring saved pos %d for %s", saved, ep->slug);
}
} else {
saved = G.pos_sec;
}
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
tt_lvgl_unlock();
BundleHandle b = tt_bundle_alloc();
if(b){
tt_bundle_put_string(b, "file", fpath);
tt_bundle_put_string(b, "path", fpath);
tt_bundle_put_int32(b, "pos_sec", saved);
tt_bundle_put_int32(b, "position", saved);
tt_bundle_put_int32(b, "volume", G.volume);
ESP_LOGI(TAG, "Launching Mp3Player for %s pos %d", fpath, saved);
G.pending_dl_idx = idx;
G.cur_ep_idx=idx; G.cur_season=ep->season;
strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
strncpy(G.last_slug,ep->slug,sizeof(G.last_slug)-1);
save_state();
tt_app_start_with_bundle("one.tactility.mp3player", b);
return;
}
ESP_LOGW(TAG, "Failed to alloc bundle for Mp3Player");
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Failed to launch player");
tt_lvgl_unlock();
}
void start_idx(int idx){
if(idx<0||idx>=G.ep_cnt) return;
EpInfo* ep=&G.eps[idx];
if(!has_slug(ep->slug)){
// File not on SD, download via external downloader
if (try_external_downloader(idx)) {
return;
}
// Fallback: show message, cannot download internally anymore
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Install SdDownloader");
tt_lvgl_unlock();
return;
}
start_idx_internal(idx);
}
void select_ep(int idx){
if(idx<0||idx>=G.ep_cnt) return;
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt && G.cur_ep_idx!=idx){
save_current_ep_pos();
}
G.cur_ep_idx=idx;
EpInfo* ep=&G.eps[idx];
G.cur_season=ep->season;
strncpy(G.last_slug, ep->slug, sizeof(G.last_slug)-1);
int saved = get_saved_pos_for_slug(ep->slug);
G.pos_sec=saved;
G.last_pct=-1;
G.total_sec=0;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
if(G.lbl_meta){
char b[64];
if(saved>5){
char tbuf[16];
fmt_time(tbuf,saved);
snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf);
} else {
snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":"");
}
lv_label_set_text(G.lbl_meta,b);
}
if(G.lbl_status){
if(saved>5) lv_label_set_text(G.lbl_status,"Resume tap Play");
else lv_label_set_text(G.lbl_status,ep->seen?"On SD tap Play":"Tap Play to download");
}
if(G.bar){
lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
}
if(G.lbl_time){
if(saved>5){
char a[16];
fmt_time(a,saved);
char buf[40];
snprintf(buf,sizeof(buf),"%s / --:--",a);
lv_label_set_text(G.lbl_time,buf);
} else {
lv_label_set_text(G.lbl_time,"0:00 / --:--");
}
}
tt_lvgl_unlock();
save_state();
}
void go_next(){
if(G.ep_cnt==0) return;
save_current_ep_pos();
save_state();
int n=G.cur_ep_idx+1;
if(n>=G.ep_cnt) n=0;
select_ep(n);
// Do not auto-play next, per user request: no autoplay
}
void go_prev(){
if(G.ep_cnt==0) return;
if(G.pos_sec>5){
if(G.cur_ep_idx>=0){
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, 0);
save_state();
}
G.pos_sec=0;
int cur=G.cur_ep_idx;
if(cur<0) cur=0;
select_ep(cur);
return;
}
save_current_ep_pos();
save_state();
int p=G.cur_ep_idx-1;
if(p<0) p=G.ep_cnt-1;
select_ep(p);
}
// Called from UI for season batch download
bool download_season(int season) {
return try_external_downloader_batch(season);
}
@@ -0,0 +1,30 @@
#pragma once
#include "app_context.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Simplified: no internal audio, only external apps
void wait_play_exit(void);
void stop_playback_sync(void);
void start_idx_internal(int idx);
void start_idx(int idx);
void select_ep(int idx);
void go_next(void);
void go_prev(void);
bool download_season(int season);
// New single-file download API
bool download_episode(int idx);
bool download_missing_season_start(int season);
bool download_missing_next(void); // continue queue, returns false if done
int count_missing_in_season(int season);
int find_next_missing_in_season(int season);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,321 @@
#include "storage.h"
#include "cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include "esp_heap_caps.h"
void make_sd_path(char* out, size_t out_len, const char* slug){
if(!out || !slug) return;
snprintf(out, out_len, "%s/%s.mp3", DM_DIR, slug);
}
void ensure_dir(){
mkdir("/sdcard/apps",0777);
mkdir("/sdcard/apps/one.tactility.discoverymountain",0777);
mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777);
mkdir(DM_DIR,0777);
}
bool has_slug(const char* slug){
char p[300];
make_sd_path(p, sizeof(p), slug);
struct stat st;
return stat(p,&st)==0;
}
void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
void sort_seasons(){
for(int i=0;i<G.season_cnt;i++){
for(int j=i+1;j<G.season_cnt;j++){
if(G.seasons[j].num < G.seasons[i].num){
SeasonInfo t=G.seasons[i]; G.seasons[i]=G.seasons[j]; G.seasons[j]=t;
}
}
}
}
void sort_eps(){
// Use PSRAM for temp to avoid stack overflow (EpInfo ~300 bytes, was on stack)
EpInfo *t = heap_caps_malloc(sizeof(EpInfo), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!t) return;
for(int i=0;i<G.ep_cnt;i++){
for(int j=i+1;j<G.ep_cnt;j++){
EpInfo *a=&G.eps[i], *b=&G.eps[j];
bool should_swap=false;
if(a->season != b->season) should_swap = (b->season < a->season);
else should_swap = (b->ep_num < a->ep_num);
if(should_swap){
*t=*a; *a=*b; *b=*t;
}
}
}
heap_caps_free(t);
}
// ── Per-episode position helpers ──
int find_saved_pos_idx(const char* slug){
if(!slug) return -1;
for(int i=0;i<G.saved_pos_cnt;i++){
if(strcmp(G.saved_pos[i].slug, slug)==0) return i;
}
return -1;
}
int get_saved_pos_for_slug(const char* slug){
int idx=find_saved_pos_idx(slug);
if(idx>=0) return G.saved_pos[idx].pos;
return 0;
}
void set_saved_pos_for_slug(const char* slug, int pos){
if(!slug || strlen(slug)==0) return;
if(pos<0) pos=0;
if(G.total_sec>0 && pos>0 && pos >= G.total_sec-10){
pos=0;
}
int idx=find_saved_pos_idx(slug);
if(idx>=0){
G.saved_pos[idx].pos=pos;
return;
}
if(G.saved_pos_cnt < MAX_EPS){
strncpy(G.saved_pos[G.saved_pos_cnt].slug, slug, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
G.saved_pos[G.saved_pos_cnt].slug[sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1]=0;
G.saved_pos[G.saved_pos_cnt].pos=pos;
G.saved_pos_cnt++;
}
}
void save_current_ep_pos(){
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt){
const char* slug=G.eps[G.cur_ep_idx].slug;
set_saved_pos_for_slug(slug, G.pos_sec);
} else if(strlen(G.last_slug)>0){
set_saved_pos_for_slug(G.last_slug, G.pos_sec);
}
}
void save_state(){
if(G.cur_ep_idx>=0 && G.ep_cnt>0){
if(G.pos_sec>=0){
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, G.pos_sec);
}
}
cJSON* r=cJSON_CreateObject();
if(G.ep_cnt>0 && G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
else if(strlen(G.last_slug)>0) cJSON_AddStringToObject(r,"last_slug",G.last_slug);
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
if(G.saved_pos_cnt>0){
cJSON* pos_obj=cJSON_CreateObject();
for(int i=0;i<G.saved_pos_cnt;i++){
if(G.saved_pos[i].slug[0]==0) continue;
if(G.saved_pos[i].pos>0){
cJSON_AddNumberToObject(pos_obj, G.saved_pos[i].slug, G.saved_pos[i].pos);
}
}
cJSON_AddItemToObject(r,"positions",pos_obj);
}
char* s=cJSON_PrintUnformatted(r);
if(s){
FILE* f=fopen(STATE_PATH,"w");
if(f){ fwrite(s,1,strlen(s),f); fclose(f);}
free(s);
}
cJSON_Delete(r);
}
void load_state(){
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
G.saved_pos_cnt=0;
G.last_save_tick=0;
memset(G.last_slug,0,sizeof(G.last_slug));
memset(G.saved_pos,0,sizeof(G.saved_pos));
FILE* f=fopen(STATE_PATH,"r");
if(!f) return;
fseek(f,0,SEEK_END);
long sz=ftell(f);
fseek(f,0,SEEK_SET);
if(sz<=0||sz>16384){ fclose(f); return;}
char* buf=malloc(sz+1);
if(!buf){ fclose(f); return; }
size_t got=fread(buf,1,sz,f);
buf[got]=0;
fclose(f);
cJSON* r=cJSON_Parse(buf);
free(buf);
if(!r) return;
cJSON* ls=cJSON_GetObjectItem(r,"last_slug");
cJSON* lsn=cJSON_GetObjectItem(r,"last_season");
cJSON* ps=cJSON_GetObjectItem(r,"pos_sec");
if(ps&&cJSON_IsNumber(ps)) G.pos_sec=ps->valueint;
if(lsn&&cJSON_IsNumber(lsn)) G.cur_season=lsn->valueint;
if(ls&&cJSON_IsString(ls)){
strncpy(G.last_slug, ls->valuestring, sizeof(G.last_slug)-1);
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,ls->valuestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
cJSON* poss=cJSON_GetObjectItem(r,"positions");
if(poss && cJSON_IsObject(poss)){
cJSON* child=NULL;
cJSON_ArrayForEach(child, poss){
if(cJSON_IsNumber(child) && child->string && G.saved_pos_cnt < MAX_EPS){
strncpy(G.saved_pos[G.saved_pos_cnt].slug, child->string, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
G.saved_pos[G.saved_pos_cnt].pos=child->valueint;
G.saved_pos_cnt++;
}
}
if(strlen(G.last_slug)>0){
int sp=get_saved_pos_for_slug(G.last_slug);
if(sp>0) G.pos_sec=sp;
}
}
cJSON_Delete(r);
}
bool save_cache(void){
if(G.ep_cnt==0 || G.season_cnt==0) return false;
cJSON* root=cJSON_CreateObject();
if(!root) return false;
cJSON* seasons_arr=cJSON_CreateArray();
if(!seasons_arr){ cJSON_Delete(root); return false; }
for(int i=0;i<G.season_cnt && i<MAX_SEASONS;i++){
cJSON_AddItemToArray(seasons_arr, cJSON_CreateNumber(G.seasons[i].num));
}
cJSON_AddItemToObject(root,"seasons",seasons_arr);
cJSON* eps_arr=cJSON_CreateArray();
if(!eps_arr){ cJSON_Delete(root); return false; }
for(int i=0;i<G.ep_cnt && i<MAX_EPS;i++){
EpInfo* e=&G.eps[i];
if(e->slug[0]==0) continue;
cJSON* obj=cJSON_CreateObject();
if(!obj) continue;
cJSON_AddStringToObject(obj,"title", e->title);
cJSON_AddStringToObject(obj,"slug", e->slug);
cJSON_AddNumberToObject(obj,"season_num", e->season);
cJSON_AddNumberToObject(obj,"episode_num", e->ep_num);
cJSON_AddStringToObject(obj,"audio_url", e->audio_url);
cJSON_AddItemToArray(eps_arr, obj);
}
cJSON_AddItemToObject(root,"episodes",eps_arr);
char* s=cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if(!s) return false;
FILE* f=fopen(CACHE_PATH,"w");
bool ok=false;
if(f){
size_t len=strlen(s);
size_t wrote=fwrite(s,1,len,f);
fclose(f);
ok = (wrote==len);
}
free(s);
return ok;
}
bool load_cache(void){
FILE* f=fopen(CACHE_PATH,"r");
if(!f) return false;
fseek(f,0,SEEK_END);
long sz=ftell(f);
fseek(f,0,SEEK_SET);
if(sz<=0 || sz>200000){ fclose(f); return false; }
// Use PSRAM for large json to avoid internal heap pressure
char* buf=heap_caps_malloc(sz+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!buf){
buf=malloc(sz+1);
if(!buf){ fclose(f); return false; }
}
size_t got=fread(buf,1,sz,f);
buf[got]=0;
fclose(f);
cJSON* root=cJSON_Parse(buf);
heap_caps_free(buf);
if(!root) return false;
cJSON* seasons_arr=cJSON_GetObjectItem(root,"seasons");
cJSON* eps_arr=cJSON_GetObjectItem(root,"episodes");
if(!cJSON_IsArray(seasons_arr) || !cJSON_IsArray(eps_arr)){
cJSON_Delete(root);
return false;
}
int scnt=cJSON_GetArraySize(seasons_arr);
G.season_cnt=0;
for(int i=0;i<scnt && G.season_cnt<MAX_SEASONS;i++){
cJSON* it=cJSON_GetArrayItem(seasons_arr,i);
if(cJSON_IsNumber(it)){
G.seasons[G.season_cnt].num=it->valueint;
G.season_cnt++;
}
}
int ecnt=cJSON_GetArraySize(eps_arr);
G.ep_cnt=0;
for(int i=0;i<ecnt && G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(eps_arr,i);
if(!cJSON_IsObject(it)) continue;
cJSON* t=cJSON_GetObjectItem(it,"title");
cJSON* slug=cJSON_GetObjectItem(it,"slug");
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
if(!sn || !en) continue;
EpInfo* e=&G.eps[G.ep_cnt];
memset(e,0,sizeof(*e));
if(t && cJSON_IsString(t)) strncpy(e->title, t->valuestring, sizeof(e->title)-1);
if(slug && cJSON_IsString(slug)) strncpy(e->slug, slug->valuestring, sizeof(e->slug)-1);
else if(t && cJSON_IsString(t)) strncpy(e->slug, t->valuestring, sizeof(e->slug)-1);
e->season=sn->valueint;
e->ep_num=en->valueint;
if(au && cJSON_IsString(au)) strncpy(e->audio_url, au->valuestring, sizeof(e->audio_url)-1);
e->seen=has_slug(e->slug);
G.ep_cnt++;
}
cJSON_Delete(root);
if(G.season_cnt>0) sort_seasons();
if(G.ep_cnt>0) sort_eps();
// Refresh seen in case SD changed
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
return (G.season_cnt>0 && G.ep_cnt>0);
}
void lru_check(){
typedef struct{ char path[300]; time_t mt; } FE;
// Allocate from PSRAM to avoid stack overflow (200*~304=60KB on stack would overflow 12KB task)
FE *files = heap_caps_malloc(200 * sizeof(FE), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!files) return;
int fc=0;
DIR* d=opendir(DM_DIR);
if(!d){ heap_caps_free(files); return; }
struct dirent* de;
char cur_path[300]={0};
if(G.cur_ep_idx>=0 && G.cur_ep_idx<G.ep_cnt){
make_sd_path(cur_path, sizeof(cur_path), G.eps[G.cur_ep_idx].slug);
}
while((de=readdir(d))!=NULL && fc<200){
if(de->d_name[0]=='.') continue;
size_t l=strlen(de->d_name);
if(l<5) continue;
if(strcmp(de->d_name+l-4,".mp3")!=0) continue;
char full[300];
snprintf(full,sizeof(full),"%s/%.200s",DM_DIR,de->d_name);
struct stat st;
if(stat(full,&st)!=0) continue;
if(cur_path[0] && strcmp(full,cur_path)==0) continue;
strncpy(files[fc].path,full,sizeof(files[fc].path)-1);
files[fc].mt=st.st_mtime;
fc++;
}
closedir(d);
if(fc<12){ heap_caps_free(files); return; }
for(int i=0;i<fc;i++){
for(int j=i+1;j<fc;j++){
if(files[j].mt<files[i].mt){ FE t=files[i]; files[i]=files[j]; files[j]=t; }
}
}
int todel=fc-8;
for(int i=0;i<todel;i++) unlink(files[i].path);
heap_caps_free(files);
}
@@ -0,0 +1,42 @@
#pragma once
#include "app_context.h"
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Path helpers
void make_sd_path(char* out, size_t out_len, const char* slug);
void ensure_dir(void);
bool has_slug(const char* slug);
// LRU cache cleanup
void lru_check(void);
// Sorting
void sort_seasons(void);
void sort_eps(void);
// Time formatting
void fmt_time(char* o, int s);
// Per-episode resume helpers
int find_saved_pos_idx(const char* slug);
int get_saved_pos_for_slug(const char* slug);
void set_saved_pos_for_slug(const char* slug, int pos);
void save_current_ep_pos(void);
// Persistence
void save_state(void);
void load_state(void);
// Seasons / episodes cache (PB data, rarely changes)
bool save_cache(void);
bool load_cache(void);
#ifdef __cplusplus
}
#endif
+554
View File
@@ -0,0 +1,554 @@
#include "ui.h"
#include "storage.h"
#include "player.h"
#include "download.h"
#include <tt_lvgl.h>
#include <tt_app.h>
#include <tt_bundle.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include "esp_log.h"
#include "esp_heap_caps.h"
void close_overlay(){
if(G.overlay){
lv_obj_delete(G.overlay);
G.overlay=NULL;
G.dropdown=NULL;
G.season_list_cont=NULL;
}
}
static void overlay_close_timer_cb(lv_timer_t* t){
close_overlay();
lv_timer_delete(t);
}
static void close_overlay_safe(void){
// Defer deletion to avoid deleting ancestor of current event target (season buttons inside overlay)
lv_timer_create(overlay_close_timer_cb, 20, NULL);
}
// Forward decls
static void ep_action_cb(lv_event_t* e);
static void season_select_cb(lv_event_t* e);
static void rebuild_episode_list_locked(void);
static void clear_container(lv_obj_t* cont){
if(!cont) return;
uint32_t child_cnt = lv_obj_get_child_cnt(cont);
while(child_cnt>0){
lv_obj_t* child = lv_obj_get_child(cont, 0);
if(child) lv_obj_delete(child);
else break;
child_cnt = lv_obj_get_child_cnt(cont);
}
}
static void btn_close_cb(lv_event_t* e){
(void)e;
if(G.overlay){
close_overlay();
return;
}
tt_app_stop();
}
static void get_season_stats(int season, int* total, int* cached){
int t=0,c=0;
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season==season){
t++;
if(G.eps[i].seen) c++;
}
}
if(total) *total=t;
if(cached) *cached=c;
}
void ui_update_status_locked(const char* msg){
if(G.lbl_status && msg) lv_label_set_text(G.lbl_status, msg);
}
void ui_update_header_locked(void){
if(!G.lbl_title && !G.lbl_season) return;
if(G.fetching){
if(G.lbl_season) lv_label_set_text(G.lbl_season, "Fetching...");
return;
}
if(G.season_cnt==0){
if(G.lbl_season) lv_label_set_text(G.lbl_season, "No seasons");
return;
}
int total=0,cached=0;
get_season_stats(G.cur_season,&total,&cached);
char buf[64];
if(total>0){
snprintf(buf,sizeof(buf),"S%02d • %d/%d on SD • %d eps", G.cur_season, cached, total, total);
}else{
snprintf(buf,sizeof(buf),"S%02d • %d eps", G.cur_season, total);
}
if(G.lbl_season) lv_label_set_text(G.lbl_season, buf);
if(G.lbl_title){
// Keep app name
// optionally update title to count
lv_label_set_text(G.lbl_title, "Discovery Mountain");
}
}
static void rebuild_episode_list_locked(void){
if(!G.ep_list_cont) return;
clear_container(G.ep_list_cont);
if(G.fetching){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"Fetching episodes...");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
lv_obj_align(lbl,LV_ALIGN_TOP_MID,0,20);
return;
}
if(G.ep_cnt==0){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"No episodes");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
return;
}
// Count episodes for cur season
int count=0;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season) count++;
if(count==0){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"No episodes in season");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
// Also show all if fallback?
// list first few from all
return;
}
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season!=G.cur_season) continue;
EpInfo* ep=&G.eps[i];
// Row container optimized for low internal heap (4 objs per ep)
lv_obj_t* row=lv_obj_create(G.ep_list_cont);
lv_obj_set_size(row, LV_PCT(100), 50);
lv_obj_set_style_bg_color(row, ep->seen ? lv_color_hex(0x1E2A22) : lv_color_hex(0x222222),0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER,0);
lv_obj_set_style_radius(row,8,0);
lv_obj_set_style_pad_all(row,6,0);
lv_obj_set_style_border_width(row,1,0);
lv_obj_set_style_border_color(row, ep->seen ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_gap(row,6,0);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(row, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
// Left single label with title (truncated) we embed meta as second line via \n to save objects
lv_obj_t* ltitle=lv_label_create(row);
lv_obj_set_flex_grow(ltitle,1);
// Truncate title to ~26 chars for display
char short_title[30];
strncpy(short_title, ep->title, sizeof(short_title)-1);
short_title[sizeof(short_title)-1]=0;
if(strlen(ep->title) > 26){
short_title[23]='.';
short_title[24]='.';
short_title[25]='.';
short_title[26]=0;
}
char line1[64];
snprintf(line1,sizeof(line1),"E%02d: %s", ep->ep_num, short_title);
// Second line status
char line2[32];
int saved = get_saved_pos_for_slug(ep->slug);
if(ep->seen){
if(saved>5){
char tb[16]; fmt_time(tb,saved);
snprintf(line2,sizeof(line2),"On SD %s", tb);
}else{
snprintf(line2,sizeof(line2),"On SD");
}
}else{
snprintf(line2,sizeof(line2),"Not DL");
}
char tbuf[96];
snprintf(tbuf,sizeof(tbuf),"%s\n%s", line1, line2);
lv_label_set_text(ltitle, tbuf);
lv_obj_set_width(ltitle, LV_PCT(100));
lv_label_set_long_mode(ltitle, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_font(ltitle, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(ltitle, ep->seen ? lv_color_hex(0xE0E0E0) : lv_color_hex(0xCCCCCC),0);
// Right side: button
lv_obj_t* btn=lv_btn_create(row);
lv_obj_set_size(btn, 52, 34);
if(ep->seen){
lv_obj_set_style_bg_color(btn, lv_color_hex(0x2E7D32),0);
}else{
lv_obj_set_style_bg_color(btn, lv_color_hex(0x1E88E5),0);
}
lv_obj_set_style_radius(btn,6,0);
lv_obj_t* bl=lv_label_create(btn);
lv_label_set_text(bl, ep->seen ? LV_SYMBOL_PLAY : LV_SYMBOL_DOWNLOAD);
lv_obj_center(bl);
lv_obj_add_event_cb(btn, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
}
// Scroll to top after rebuild
lv_obj_scroll_to_y(G.ep_list_cont, 0, LV_ANIM_OFF);
}
void ui_rebuild_episode_list(void){
tt_lvgl_lock(portMAX_DELAY);
if(G.ep_list_cont) rebuild_episode_list_locked();
ui_update_header_locked();
tt_lvgl_unlock();
}
static void ep_action_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
// Retrieve idx from user data
int idx = (int)(intptr_t)lv_event_get_user_data(e);
// Fallback: try event target's user data via current target?
// lv_event_get_user_data should already give us the idx
if(idx<0 || idx>=G.ep_cnt) return;
if(G.downloading || G.fetching) return;
EpInfo* ep=&G.eps[idx];
ESP_LOGI(TAG,"ep_action idx %d S%02dE%02d seen %d", idx, ep->season, ep->ep_num, ep->seen);
if(ep->season != G.cur_season){
// Shouldn't happen but update cur season to match
G.cur_season = ep->season;
}
// Use start_idx which handles both DL and Play
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
if(ep->seen) lv_label_set_text(G.lbl_status,"Starting player...");
else lv_label_set_text(G.lbl_status,"Starting downloader...");
}
tt_lvgl_unlock();
start_idx(idx);
}
static void season_select_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
int season_num = (int)(intptr_t)lv_event_get_user_data(e);
if(season_num<=0) return;
ESP_LOGI(TAG,"Season selected S%02d", season_num);
// Defer overlay deletion deleting ancestor during event is unsafe
close_overlay_safe();
G.cur_season = season_num;
// Save state immediately
tt_lvgl_lock(portMAX_DELAY);
ui_update_header_locked();
rebuild_episode_list_locked();
if(G.lbl_status){
char buf[40];
snprintf(buf,sizeof(buf),"Switched to S%02d", season_num);
lv_label_set_text(G.lbl_status, buf);
}
tt_lvgl_unlock();
save_state();
// Jump to first unseen or first in season
int first=-1, first_unseen=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==season_num){
if(first==-1) first=i;
if(!G.eps[i].seen && first_unseen==-1) first_unseen=i;
}
int tgt = first_unseen!=-1?first_unseen:first;
if(tgt!=-1){
// Optional: select ep updates title but we already rebuilt list; keep cur_ep_idx
G.cur_ep_idx = tgt;
strncpy(G.last_slug, G.eps[tgt].slug, sizeof(G.last_slug)-1);
save_state();
}
}
void dropdown_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return;
lv_obj_t* dd = (lv_obj_t*)lv_event_get_target(e);
int sel=lv_dropdown_get_selected(dd);
if(sel<0||sel>=G.season_cnt) return;
int sn=G.seasons[sel].num;
close_overlay_safe();
int first=-1, first_unseen=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==sn){
if(first==-1) first=i;
if(!G.eps[i].seen && first_unseen==-1) first_unseen=i;
}
int tgt=first_unseen!=-1?first_unseen:first;
if(tgt!=-1){
G.cur_season=sn;
G.cur_ep_idx=tgt;
strncpy(G.last_slug, G.eps[tgt].slug, sizeof(G.last_slug)-1);
tt_lvgl_lock(portMAX_DELAY);
ui_update_header_locked();
rebuild_episode_list_locked();
tt_lvgl_unlock();
save_state();
}
}
static void close_btn_cb(lv_event_t* e){
(void)e;
close_overlay_safe();
}
void btn_seasons_cb(lv_event_t* e){
(void)e;
if(G.downloading) return;
if(G.fetching) return;
if(G.overlay){ close_overlay(); return; }
tt_lvgl_lock(portMAX_DELAY);
// Create overlay centered modal parented to root if possible to avoid lingering on app background
lv_obj_t* ovl_parent = G.root ? G.root : lv_scr_act();
G.overlay=lv_obj_create(ovl_parent);
lv_obj_set_size(G.overlay, 240, 280);
lv_obj_center(G.overlay);
lv_obj_set_style_bg_color(G.overlay,lv_color_hex(0x222222),0);
lv_obj_set_style_radius(G.overlay,12,0);
lv_obj_set_style_border_width(G.overlay,1,0);
lv_obj_set_style_border_color(G.overlay, lv_color_hex(0x444444),0);
lv_obj_set_style_pad_all(G.overlay,8,0);
lv_obj_set_flex_flow(G.overlay, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(G.overlay, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(G.overlay,6,0);
lv_obj_t* title=lv_label_create(G.overlay);
lv_label_set_text(title,"Select Season");
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_DEFAULT),0);
// Scrollable list container
G.season_list_cont=lv_obj_create(G.overlay);
lv_obj_set_size(G.season_list_cont, LV_PCT(100), 200);
lv_obj_set_flex_grow(G.season_list_cont,1);
lv_obj_set_style_bg_color(G.season_list_cont, lv_color_hex(0x1A1A1A),0);
lv_obj_set_style_radius(G.season_list_cont,8,0);
lv_obj_set_style_pad_all(G.season_list_cont,4,0);
lv_obj_set_flex_flow(G.season_list_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_gap(G.season_list_cont,4,0);
// Ensure scrollable
lv_obj_add_flag(G.season_list_cont, LV_OBJ_FLAG_SCROLLABLE);
for(int i=0;i<G.season_cnt;i++){
int sn=G.seasons[i].num;
int total,cached;
get_season_stats(sn,&total,&cached);
lv_obj_t* btn=lv_btn_create(G.season_list_cont);
lv_obj_set_size(btn, LV_PCT(100), 38);
lv_obj_set_style_bg_color(btn, sn==G.cur_season ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0);
lv_obj_set_style_radius(btn,6,0);
lv_obj_set_style_pad_all(btn,6,0);
lv_obj_add_event_cb(btn, season_select_cb, LV_EVENT_CLICKED, (void*)(intptr_t)sn);
char blabel[40];
if(total>0){
if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d %d/%d " LV_SYMBOL_OK, sn, cached, total);
else snprintf(blabel,sizeof(blabel),"S%02d %d/%d", sn, cached, total);
}else{
if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d " LV_SYMBOL_OK, sn);
else snprintf(blabel,sizeof(blabel),"S%02d", sn);
}
lv_obj_t* l1=lv_label_create(btn);
lv_label_set_text(l1, blabel);
lv_obj_set_style_text_font(l1, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(l1);
}
lv_obj_t* bc=lv_btn_create(G.overlay);
lv_obj_set_size(bc, LV_PCT(60), 36);
lv_obj_set_style_bg_color(bc, lv_color_hex(0x444444),0);
lv_obj_t* lc=lv_label_create(bc);
lv_label_set_text(lc,"Close");
lv_obj_center(lc);
lv_obj_add_event_cb(bc,close_btn_cb,LV_EVENT_CLICKED,NULL);
tt_lvgl_unlock();
}
void btn_dl_season_cb(lv_event_t* e){
(void)e;
if(G.downloading) return;
if(G.fetching) return;
if(G.ep_cnt==0) return;
int season = G.cur_season;
ESP_LOGI(TAG, "DL Missing S%02d pressed", season);
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
if(G.lbl_status){
char buf[40];
snprintf(buf,sizeof(buf),"Checking S%02d missing...", season);
lv_label_set_text(G.lbl_status, buf);
}
tt_lvgl_unlock();
if (!download_season(season)) {
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL Season failed no missing?");
tt_lvgl_unlock();
}
}
void build_ui(lv_obj_t* parent){
tt_lvgl_lock(portMAX_DELAY);
lv_obj_t* root = lv_obj_create(parent);
G.root = root;
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
lv_obj_set_style_bg_color(root,lv_color_hex(0x111111),0);
lv_obj_set_style_pad_all(root,6,0);
lv_obj_set_style_pad_gap(root,6,0);
lv_obj_set_style_border_width(root,0,0);
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
// Header container
lv_obj_t* header=lv_obj_create(root);
lv_obj_set_size(header, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(header,0,0);
lv_obj_set_style_pad_all(header,2,0);
lv_obj_set_style_pad_gap(header,6,0);
lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(header, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* header_left=lv_obj_create(header);
lv_obj_set_size(header_left, LV_PCT(80), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(header_left, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(header_left,0,0);
lv_obj_set_style_pad_all(header_left,0,0);
lv_obj_set_flex_flow(header_left, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_gap(header_left,2,0);
lv_obj_remove_flag(header_left, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_grow(header_left,1);
G.lbl_title=lv_label_create(header_left);
lv_label_set_text(G.lbl_title,"Discovery Mountain");
lv_obj_set_style_text_font(G.lbl_title, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
lv_obj_set_style_text_color(G.lbl_title, lv_color_hex(0xFFFFFF),0);
G.lbl_season=lv_label_create(header_left);
lv_label_set_text(G.lbl_season,"Loading seasons...");
lv_obj_set_style_text_font(G.lbl_season, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(G.lbl_season,lv_color_hex(0xAAAAAA),0);
// Close button top right
lv_obj_t* btn_close=lv_btn_create(header);
lv_obj_set_size(btn_close, 36, 32);
lv_obj_set_style_bg_color(btn_close, lv_color_hex(0x333333),0);
lv_obj_set_style_radius(btn_close,6,0);
lv_obj_t* l_close=lv_label_create(btn_close);
lv_label_set_text(l_close, LV_SYMBOL_CLOSE);
lv_obj_set_style_text_font(l_close, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(l_close);
lv_obj_add_event_cb(btn_close, btn_close_cb, LV_EVENT_CLICKED, NULL);
// Action row: Seasons + DL Missing
lv_obj_t* row=lv_obj_create(root);
lv_obj_set_size(row,LV_PCT(100),44);
lv_obj_set_flex_flow(row,LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_bg_opa(row,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(row,0,0);
lv_obj_set_style_pad_all(row,0,0);
lv_obj_set_style_pad_gap(row,8,0);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* bs=lv_btn_create(row);
lv_obj_set_size(bs,LV_PCT(48),36);
G.btn_seasons=bs;
lv_obj_set_style_bg_color(bs, lv_color_hex(0x333333),0);
lv_obj_set_style_radius(bs,6,0);
lv_obj_t* ls=lv_label_create(bs);
lv_label_set_text(ls,"Seasons");
lv_obj_set_style_text_font(ls, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(ls);
lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* bd=lv_btn_create(row);
lv_obj_set_size(bd,LV_PCT(48),36);
G.btn_dl_missing=bd;
lv_obj_set_style_bg_color(bd, lv_color_hex(0x1E88E5), 0);
lv_obj_set_style_radius(bd,6,0);
lv_obj_t* ld=lv_label_create(bd);
lv_label_set_text(ld,"DL Missing");
lv_obj_set_style_text_font(ld, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(ld);
lv_obj_add_event_cb(bd,btn_dl_season_cb,LV_EVENT_CLICKED,NULL);
// Episode list container fills remaining space
G.ep_list_cont=lv_obj_create(root);
lv_obj_set_size(G.ep_list_cont, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_grow(G.ep_list_cont, 1);
lv_obj_set_style_bg_color(G.ep_list_cont, lv_color_hex(0x0E0E0E),0);
lv_obj_set_style_bg_opa(G.ep_list_cont, LV_OPA_COVER,0);
lv_obj_set_style_radius(G.ep_list_cont,8,0);
lv_obj_set_style_pad_all(G.ep_list_cont,4,0);
lv_obj_set_style_pad_gap(G.ep_list_cont,4,0);
lv_obj_set_flex_flow(G.ep_list_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(G.ep_list_cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
// Ensure scrollable
lv_obj_add_flag(G.ep_list_cont, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(G.ep_list_cont, LV_SCROLLBAR_MODE_AUTO);
lv_obj_t* hint=lv_label_create(G.ep_list_cont);
lv_label_set_text(hint,"Loading episodes...");
lv_obj_set_style_text_color(hint, lv_color_hex(0x888888),0);
lv_obj_set_style_text_font(hint, lvgl_get_text_font(FONT_SIZE_SMALL),0);
// Status bar at bottom
G.lbl_status=lv_label_create(root);
lv_label_set_text(G.lbl_status,"Fetching data...");
lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0);
lv_obj_set_style_text_font(G.lbl_status, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_width(G.lbl_status, LV_PCT(100));
lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_DOT);
// Legacy placeholders nulled
G.lbl_meta=NULL;
G.bar=NULL;
G.lbl_time=NULL;
G.btn_play=NULL;
G.dl_overlay=NULL;
G.ui_timer_handle=NULL;
G.ui_timer_handle = lv_timer_create(ui_timer,300,NULL);
tt_lvgl_unlock();
}
void ui_deinit(void){
if(G.ui_timer_handle){
lv_timer_delete(G.ui_timer_handle);
G.ui_timer_handle=NULL;
}
}
void ui_timer(lv_timer_t* t){
(void)t;
// If app is closing (root null) skip
if(!G.root) return;
if(G.fetching){
return;
}
if(G.downloading && G.dl_overlay){
update_dl_overlay_ui();
}
}
void btn_play_cb(lv_event_t* e){ (void)e; /* legacy, not used */ }
void btn_prev_cb(lv_event_t* e){ (void)e; /* legacy */ }
void btn_next_cb(lv_event_t* e){ (void)e; /* legacy */ }
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "app_context.h"
#ifdef __cplusplus
extern "C" {
#endif
void build_ui(lv_obj_t* parent);
void ui_timer(lv_timer_t* t);
void btn_play_cb(lv_event_t* e);
void btn_prev_cb(lv_event_t* e);
void btn_next_cb(lv_event_t* e);
void btn_seasons_cb(lv_event_t* e);
void btn_dl_season_cb(lv_event_t* e);
void close_overlay(void);
void dropdown_cb(lv_event_t* e);
// New library UI helpers
void ui_rebuild_episode_list(void);
void ui_update_header_locked(void);
void ui_update_status_locked(const char* msg);
void ui_deinit(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,7 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.discoverymountain
app.name=Discovery Mountain
app.version.name=0.1.0
app.version.code=1
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3,esp32p4
sdk=0.7.0-dev app.id=one.tactility.epubreader
platforms=esp32s3,esp32p4 app.version.name=0.4.0
[app] app.version.code=4
id=one.tactility.epubreader app.name=Epub Reader
versionName=0.1.0 app.description=Epub and text file reader. Requires PSRAM!
versionCode=1
name=Epub Reader
description=Epub and text file reader. Requires PSRAM!
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(EspNowBridge)
tactility_project(EspNowBridge)
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
file(GLOB_RECURSE SOURCE_FILES
Source/*.c*
)
idf_component_register(
SRCS ${SOURCE_FILES}
# Library headers must be included directly,
# because all regular dependencies get stripped by elf_loader's cmake script
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
REQUIRES TactilitySDK bootloader_support esp_app_format
)
@@ -0,0 +1,739 @@
#include "EspNowBridge.h"
#include <tactility/device.h>
#include <tactility/drivers/wifi.h>
#include <tactility/wifi_auto_scan.h>
#include <tactility/firmware/firmware.h>
#include <tt_app.h>
#include <tt_app_fileselection.h>
#include <tt_bundle.h>
#include <tt_lock.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <esp_app_desc.h>
#include <esp_app_format.h>
#include <esp_system.h>
#include <tactility/log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <algorithm>
#include <cinttypes>
#include <cstdio>
#include <cstring>
static constexpr auto* TAG = "EspNowBridge";
static constexpr size_t CHUNK_SIZE = 1500;
static constexpr uint32_t TRANSPORT_WAIT_TIMEOUT_MS = 5000;
static constexpr uint32_t UPDATE_TASK_STACK_SIZE = 8192;
AutoScanPauseGuard::AutoScanPauseGuard() { wifi_auto_scan_set_paused(true); }
AutoScanPauseGuard::~AutoScanPauseGuard() { wifi_auto_scan_set_paused(false); }
// Binary partition table format (gen_esp32part.py STRUCT_FORMAT '<2sBBLL16sL'): a flat array of
// 32-byte little-endian records starting at flash offset PARTITION_TABLE_OFFSET, terminated by
// an all-0xFF entry or an MD5-checksum record (magic 0xEBEB). Not exposed as a C header by
// ESP-IDF (only the Python generator knows the format) - this is a hand-ported minimal reader,
// just enough to locate the app partition inside a merged/factory bin.
static constexpr size_t PARTITION_TABLE_OFFSET = 0x8000;
static constexpr size_t PARTITION_TABLE_MAX_ENTRIES = 128; // covers the largest partition table IDF supports (0x1000 / 32)
static constexpr uint16_t PARTITION_ENTRY_MAGIC = 0x50AA; // little-endian bytes 0xAA, 0x50
static constexpr uint16_t PARTITION_MD5_MAGIC = 0xEBEB;
static constexpr uint8_t PARTITION_TYPE_APP = 0x00;
static constexpr uint8_t PARTITION_SUBTYPE_FACTORY = 0x00;
static constexpr uint8_t PARTITION_SUBTYPE_OTA_0 = 0x10;
struct __attribute__((packed)) PartitionEntry {
uint16_t magic;
uint8_t type;
uint8_t subtype;
uint32_t offset;
uint32_t size;
char name[16];
uint32_t flags;
};
static_assert(sizeof(PartitionEntry) == 32, "partition table entry must be 32 bytes");
/**
* Scans the partition table embedded in a merged/factory bin (at PARTITION_TABLE_OFFSET) for
* the app partition to flash: prefers "factory" if present, otherwise the first OTA slot
* (ota_0) - matches what a real M5Stack ESP-Hosted factory image contains.
* @return true if an app partition was found, with appOffset/appSize set to its location
* within the file (these are the same as the absolute flash offsets the merged bin preserves).
*/
static bool findAppPartitionInMergedBin(FILE* file, size_t& appOffset, size_t& appSize) {
if (fseek(file, static_cast<long>(PARTITION_TABLE_OFFSET), SEEK_SET) != 0) {
return false;
}
bool foundFactory = false;
bool foundOta0 = false;
size_t factoryOffset = 0, factorySize = 0;
size_t ota0Offset = 0, ota0Size = 0;
for (size_t i = 0; i < PARTITION_TABLE_MAX_ENTRIES; i++) {
PartitionEntry entry;
if (fread(&entry, 1, sizeof(entry), file) != sizeof(entry)) {
break;
}
if (entry.magic == PARTITION_MD5_MAGIC) {
break;
}
if (entry.magic != PARTITION_ENTRY_MAGIC) {
break;
}
if (entry.type == PARTITION_TYPE_APP) {
if (entry.subtype == PARTITION_SUBTYPE_FACTORY) {
foundFactory = true;
factoryOffset = entry.offset;
factorySize = entry.size;
} else if (entry.subtype == PARTITION_SUBTYPE_OTA_0 && !foundOta0) {
foundOta0 = true;
ota0Offset = entry.offset;
ota0Size = entry.size;
}
}
}
if (foundFactory) {
appOffset = factoryOffset;
appSize = factorySize;
return true;
}
if (foundOta0) {
appOffset = ota0Offset;
appSize = ota0Size;
return true;
}
return false;
}
/**
* Validates the app image at the given file offset and extracts its version string. The actual
* transfer size used for the OTA loop is just the real remaining file size from appOffset (see
* performUpdate) - hand-computing the image's "logical" size from segment headers + checksum/
* hash padding drifts a bit short of the real length, so we just use the file size instead.
*/
static bool parseImageHeader(FILE* file, size_t appOffset, char* versionOut, size_t versionOutLen, std::string* errorOut = nullptr) {
esp_image_header_t imageHeader;
if (fseek(file, static_cast<long>(appOffset), SEEK_SET) != 0 ||
fread(&imageHeader, 1, sizeof(imageHeader), file) != sizeof(imageHeader)) {
if (errorOut != nullptr) {
*errorOut = "Failed to read image header";
}
return false;
}
if (imageHeader.magic != ESP_IMAGE_HEADER_MAGIC) {
if (errorOut != nullptr) {
*errorOut = "Selected file is not a valid firmware image (bad magic)";
}
return false;
}
// Fail fast on a wrong-chip image (e.g. an ESP32 or S3 binary picked by mistake) before
// streaming the whole file over the paced, slow bridge link - esp_hosted_slave_ota_end()
// would eventually catch this too, but only after the entire transfer already completed.
if (imageHeader.chip_id != ESP_CHIP_ID_ESP32C6) {
if (errorOut != nullptr) {
char buf[96];
snprintf(buf, sizeof(buf), "Wrong chip: image targets chip id %u, expected ESP32-C6",
(unsigned)imageHeader.chip_id);
*errorOut = buf;
}
return false;
}
esp_image_segment_header_t segmentHeader;
size_t firstSegmentOffset = appOffset + sizeof(imageHeader);
if (fseek(file, static_cast<long>(firstSegmentOffset), SEEK_SET) != 0 ||
fread(&segmentHeader, 1, sizeof(segmentHeader), file) != sizeof(segmentHeader)) {
if (errorOut != nullptr) {
*errorOut = "Failed to read first segment header";
}
return false;
}
esp_app_desc_t appDesc;
size_t appDescOffset = appOffset + sizeof(imageHeader) + sizeof(segmentHeader);
if (fseek(file, static_cast<long>(appDescOffset), SEEK_SET) == 0 && fread(&appDesc, 1, sizeof(appDesc), file) == sizeof(appDesc)) {
strncpy(versionOut, appDesc.version, versionOutLen - 1);
versionOut[versionOutLen - 1] = '\0';
} else {
strncpy(versionOut, "unknown", versionOutLen - 1);
versionOut[versionOutLen - 1] = '\0';
}
return true;
}
static bool getCurrentVersionString(const FirmwareOps* ops, void* ctx, char* versionOut, size_t versionOutLen) {
FirmwareInfo info = {};
if (ops == nullptr || ops->get_info(ctx, &info) != ERROR_NONE) {
return false;
}
if (info.name[0] != '\0') {
snprintf(versionOut, versionOutLen, "%u.%u.%u (%s)",
(unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch, info.name);
} else {
snprintf(versionOut, versionOutLen, "%u.%u.%u",
(unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch);
}
return true;
}
/** Only slave firmware >= v2.6.0 implements esp_hosted_slave_ota_activate() - older slaves
* reject/lack the RPC entirely. Matches upstream's host_performs_slave_ota example. */
static bool activateSupported(uint32_t major, uint32_t minor) {
return (major > 2) || (major == 2 && minor > 5);
}
std::atomic<EspNowBridge*> EspNowBridge::liveInstance_{nullptr};
void EspNowBridge::onCreate(AppHandle app) {
appHandle_ = app;
taskDoneSemaphore_ = xSemaphoreCreateBinary();
liveInstance_ = this;
}
void EspNowBridge::onDestroy(AppHandle /*app*/) {
// Clear liveInstance_ first so any task still running bails out at its next liveInstance_
// check instead of continuing to touch this instance's members.
liveInstance_ = nullptr;
// Wait for any outstanding background task (OTA update, transport-wait) to actually finish -
// the app framework frees this instance shortly after onDestroy() returns, so a task that
// outlives it would dereference freed memory.
while (outstandingTasks_.load() > 0) {
if (taskDoneSemaphore_ != nullptr) {
xSemaphoreTake(taskDoneSemaphore_, pdMS_TO_TICKS(1000));
}
}
if (taskDoneSemaphore_ != nullptr) {
vSemaphoreDelete(taskDoneSemaphore_);
taskDoneSemaphore_ = nullptr;
}
}
void EspNowBridge::refreshCurrentVersion() {
char versionStr[32];
if (getCurrentVersionString(firmwareOps_, firmwareCtx_, versionStr, sizeof(versionStr))) {
lv_label_set_text_fmt(currentVersionLabel_, "Co-processor firmware: %s", versionStr);
} else {
lv_label_set_text(currentVersionLabel_, "Co-processor firmware: unknown (link not up)");
}
}
bool EspNowBridge::isWifiRadioOn() {
if (wifiDevice_ == nullptr) {
return false;
}
WifiRadioState radioState = WIFI_RADIO_STATE_OFF;
if (wifi_get_radio_state(wifiDevice_, &radioState) != ERROR_NONE) {
return false;
}
// ON with any station state (disconnected/pending/connected) is fine - the ESP-NOW bridge
// just needs the radio + esp_hosted transport up, not a completed AP connection.
return radioState == WIFI_RADIO_STATE_ON;
}
void EspNowBridge::refreshWifiPrompt() {
if (isWifiRadioOn()) {
lv_obj_add_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN);
setUpdateButtonsDisabled(false);
} else {
lv_obj_clear_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN);
setUpdateButtonsDisabled(true);
}
}
void EspNowBridge::setUpdateButtonsDisabled(bool disabled) {
if (disabled) {
lv_obj_add_state(updateButton_, LV_STATE_DISABLED);
lv_obj_add_state(updateBundledButton_, LV_STATE_DISABLED);
} else {
lv_obj_clear_state(updateButton_, LV_STATE_DISABLED);
lv_obj_clear_state(updateBundledButton_, LV_STATE_DISABLED);
}
}
void EspNowBridge::setStatus(const std::string& text) {
lv_label_set_text(statusLabel_, text.c_str());
}
void EspNowBridge::setProgress(int percent) {
lv_bar_set_value(progressBar_, percent, LV_ANIM_OFF);
}
namespace {
struct UiDispatchPayload {
EspNowBridge* instance;
void (*work)(EspNowBridge&, void*);
void* context;
void (*freeContext)(void*);
};
}
void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)) {
auto* payload = new UiDispatchPayload{this, work, context, freeContext};
// lv_async_call() itself is an LVGL operation and must be lock-guarded when called from a
// non-LVGL task (see tt_lvgl_lock()'s doc comment) - the OTA worker task calls dispatchToUi()
// repeatedly during the transfer, and without this lock most of those calls were silently
// racing LVGL's own task and getting lost (only the very last status update, right before
// esp_restart(), happened to land - everything else stayed stuck at "Waiting for
// co-processor link...").
bool locked = tt_lvgl_lock(TT_LVGL_DEFAULT_LOCK_TIME);
if (!locked) {
// Without the lock, lv_async_call() itself would be touching LVGL's internal timer list
// unguarded - and if it happened to still enqueue successfully, the callback below would
// later fire against `payload` after we've already freed it here. Drop the update instead.
if (freeContext != nullptr) {
freeContext(context);
}
delete payload;
return;
}
lv_result_t result = lv_async_call([](void* userData) {
auto* payload = static_cast<UiDispatchPayload*>(userData);
if (EspNowBridge::liveInstance_.load() == payload->instance && payload->instance->isShown_.load()) {
payload->work(*payload->instance, payload->context);
}
if (payload->freeContext != nullptr) {
payload->freeContext(payload->context);
}
delete payload;
}, payload);
tt_lvgl_unlock();
if (result != LV_RESULT_OK) {
if (freeContext != nullptr) {
freeContext(context);
}
delete payload;
}
}
namespace {
void workSetStatus(EspNowBridge& app, void* context) {
app.setStatus(*static_cast<std::string*>(context));
}
void freeString(void* context) { delete static_cast<std::string*>(context); }
void workSetProgress(EspNowBridge& app, void* context) {
app.setProgress(*static_cast<int*>(context));
}
void freeInt(void* context) { delete static_cast<int*>(context); }
} // namespace
void EspNowBridge::performUpdate(const std::string& filePath) {
dispatchToUi([](EspNowBridge& app, void*) {
app.setUpdateButtonsDisabled(true);
app.setProgress(0);
app.setStatus("Waiting for co-processor link...");
}, nullptr, nullptr);
if (firmwareOps_ == nullptr) {
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("This WiFi device has no updatable co-processor");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
if (!firmwareOps_->wait_ready(firmwareCtx_, TRANSPORT_WAIT_TIMEOUT_MS)) {
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Co-processor link not available - update cancelled");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
FILE* file = fopen(filePath.c_str(), "rb");
if (file == nullptr) {
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Failed to open selected file");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
fseek(file, 0, SEEK_END);
long fileSizeSigned = ftell(file);
if (fileSizeSigned <= 0) {
fclose(file);
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Failed to determine file size");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
size_t fileSize = static_cast<size_t>(fileSizeSigned);
// Support both a plain app image (starting with the app image header at offset 0) and a
// merged/factory bin (e.g. M5Stack's official ESP-Hosted factory image) - detected by whether
// a valid partition table is found at PARTITION_TABLE_OFFSET.
size_t appOffset = 0;
size_t partitionSize = 0;
bool isMergedBin = findAppPartitionInMergedBin(file, appOffset, partitionSize);
if (isMergedBin && appOffset >= fileSize) {
fclose(file);
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Merged bin's app partition is outside the file - selected file looks truncated");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
char newVersion[32];
std::string parseError;
if (!parseImageHeader(file, appOffset, newVersion, sizeof(newVersion), &parseError)) {
fclose(file);
dispatchToUi(workSetStatus, new std::string(parseError), freeString);
dispatchToUi([](EspNowBridge& app, void*) {
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
// Merged bins pad the app partition to its declared size; a plain app image is exactly as
// long as the app itself. Transfer whichever is smaller.
size_t remainingInFile = fileSize - appOffset;
size_t firmwareSize = isMergedBin ? std::min(partitionSize, remainingInFile) : remainingInFile;
std::string versionStr(newVersion);
{
char buf[64];
snprintf(buf, sizeof(buf), "Pushing firmware %s...", versionStr.c_str());
dispatchToUi(workSetStatus, new std::string(buf), freeString);
}
// Held on the app instance (not a local variable) so it outlives this function - see
// heldAutoScanPauseGuard_'s declaration for why. Released when the host actually restarts
// (moot, since esp_restart() doesn't return) or if the update fails early below.
heldAutoScanPauseGuard_.emplace();
FirmwareUpdateRequest updateRequest = {};
updateRequest.image_size = firmwareSize;
FirmwareUpdateHandle* handle = nullptr;
if (firmwareOps_->begin(firmwareCtx_, &updateRequest, &handle) != ERROR_NONE) {
fclose(file);
heldAutoScanPauseGuard_.reset();
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Failed to start OTA on co-processor");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
if (fseek(file, static_cast<long>(appOffset), SEEK_SET) != 0) {
fclose(file);
firmwareOps_->abort(handle);
heldAutoScanPauseGuard_.reset();
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Failed to seek to firmware start");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
uint8_t chunk[CHUNK_SIZE];
size_t sent = 0;
bool writeFailed = false;
int lastReportedPercent = -1;
while (sent < firmwareSize) {
size_t toRead = (firmwareSize - sent > CHUNK_SIZE) ? CHUNK_SIZE : (firmwareSize - sent);
size_t actuallyRead = fread(chunk, 1, toRead, file);
if (actuallyRead != toRead) {
LOG_E(TAG, "Failed to read file at offset %zu", sent);
writeFailed = true;
break;
}
if (firmwareOps_->write(handle, chunk, actuallyRead) != ERROR_NONE) {
LOG_E(TAG, "firmwareOps_->write() failed at offset %zu", sent);
writeFailed = true;
break;
}
// Pace the transfer - esp_hosted's SDIO driver only retries a write twice with no
// backoff before giving up and restarting the host. Back-to-back chunk writes with zero
// gap were observed to saturate the bus enough to trigger a genuine SDIO timeout
// mid-transfer, not just around the post-activate reboot.
vTaskDelay(pdMS_TO_TICKS(5));
sent += actuallyRead;
// Only touch LVGL every couple of percent, not every 1500-byte chunk - frequent
// display-bus activity during the transfer was implicated in SDIO transport crashes
// under sustained OTA write load.
int percent = (int)((sent * 100) / firmwareSize);
if (percent != lastReportedPercent) {
dispatchToUi(workSetProgress, new int(percent), freeInt);
lastReportedPercent = percent;
}
}
fclose(file);
if (writeFailed) {
firmwareOps_->abort(handle);
heldAutoScanPauseGuard_.reset();
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Update failed while transferring firmware");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
if (firmwareOps_->finish(handle) != ERROR_NONE) {
heldAutoScanPauseGuard_.reset();
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Failed to finalize OTA on co-processor");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
// Check the *currently running* (pre-update) slave version - the new image isn't running
// yet - and skip straight to the required host restart for older slaves.
FirmwareInfo runningInfo = {};
bool canActivate = firmwareOps_->get_info(firmwareCtx_, &runningInfo) == ERROR_NONE
&& activateSupported(runningInfo.fw_major, runningInfo.fw_minor);
if (canActivate) {
if (firmwareOps_->activate(firmwareCtx_) != ERROR_NONE) {
heldAutoScanPauseGuard_.reset();
dispatchToUi([](EspNowBridge& app, void*) {
app.setStatus("Failed to activate new firmware - co-processor still running old firmware");
app.setUpdateButtonsDisabled(false);
}, nullptr, nullptr);
return;
}
}
// heldAutoScanPauseGuard_ is deliberately left held (never explicitly released) - the host
// restarts itself immediately below, and there's no safe window to resume normal WiFi
// activity before that.
{
char buf[80];
if (canActivate) {
snprintf(buf, sizeof(buf), "Firmware %s activated - restarting...", versionStr.c_str());
} else {
snprintf(buf, sizeof(buf), "Firmware %s pushed - restarting to apply...", versionStr.c_str());
}
dispatchToUi(workSetStatus, new std::string(buf), freeString);
}
// Give the status message above a moment to actually be seen before the restart cuts the
// display, then restart.
vTaskDelay(pdMS_TO_TICKS(1500));
esp_restart();
}
void EspNowBridge::updateTaskEntry(void* arg) {
auto* self = static_cast<EspNowBridge*>(arg);
self->performUpdate(self->pendingUpdateFilePath_);
self->updateTask_ = nullptr;
if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) {
xSemaphoreGive(self->taskDoneSemaphore_);
}
vTaskDelete(nullptr);
}
void EspNowBridge::startUpdateTask(const std::string& filePath) {
if (updateTask_ != nullptr) {
return;
}
pendingUpdateFilePath_ = filePath;
outstandingTasks_.fetch_add(1);
if (xTaskCreate(updateTaskEntry, "espnow_bridge_ota", UPDATE_TASK_STACK_SIZE / sizeof(StackType_t), this, tskIDLE_PRIORITY + 1, &updateTask_) != pdPASS) {
outstandingTasks_.fetch_sub(1);
}
}
void EspNowBridge::onUpdateButtonClicked(lv_event_t* /*event*/) {
auto* self = liveInstance_.load();
if (self == nullptr || !self->isWifiRadioOn()) {
return;
}
self->pickFileLaunchId_ = tt_app_fileselection_start_for_existing_file();
}
// Name of the slave bridge firmware bundled in this app's assets/ folder
// lets users flash the known-good bridge firmware without needing to source/copy a
// .bin onto the SD card themselves. The SD-card picker (onUpdateButtonClicked above) stays
// available too, for factory-image downgrades or custom builds.
static constexpr auto* BUNDLED_FIRMWARE_ASSET_NAME = "espnow_bridge_slave_c6.bin";
void EspNowBridge::onUpdateBundledButtonClicked(lv_event_t* /*event*/) {
auto* self = liveInstance_.load();
if (self == nullptr || !self->isWifiRadioOn()) {
return;
}
char assetPath[256] = {};
size_t assetPathSize = sizeof(assetPath);
tt_app_get_assets_child_path(self->appHandle_, BUNDLED_FIRMWARE_ASSET_NAME, assetPath, &assetPathSize);
if (assetPath[0] == '\0') {
LOG_E(TAG, "Failed to resolve bundled firmware asset path");
return;
}
self->startUpdateTask(assetPath);
}
void EspNowBridge::onEnableWifiButtonClicked(lv_event_t* /*event*/) {
auto* self = liveInstance_.load();
if (self == nullptr || self->wifiDevice_ == nullptr) {
return;
}
device_start(self->wifiDevice_);
// start_device() allocates a fresh driver context (Platforms/platform-esp32's
// esp32_wifi.cpp), which wipes any event callback registered before the device was started -
// re-register now that it's actually running. Also refresh once directly rather than relying
// solely on the next WifiEvent, so the "WiFi on" prompt updates immediately even though the
// co-processor firmware version below isn't available yet.
wifi_add_event_callback(self->wifiDevice_, self, onWifiEvent);
self->refreshWifiPrompt();
self->refreshCurrentVersion();
// The co-processor RPC transport isn't up the instant device_start() returns - it comes up
// asynchronously (~1-2s later) - so firmwareOps_->get_info() above reliably fails right after
// enabling WiFi. Nothing else reliably re-triggers a version refresh once the transport
// actually comes up (WifiEvent only covers radio/station state, not transport readiness), so
// wait for it explicitly on a background task and refresh once it's ready.
if (self->firmwareOps_ != nullptr) {
self->outstandingTasks_.fetch_add(1);
if (xTaskCreate(waitForTransportTaskEntry, "espnow_bridge_wait", 4096 / sizeof(StackType_t), self, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) {
self->outstandingTasks_.fetch_sub(1);
}
}
}
void EspNowBridge::waitForTransportTaskEntry(void* arg) {
auto* self = static_cast<EspNowBridge*>(arg);
constexpr uint32_t WAIT_TIMEOUT_MS = 10000;
// liveInstance_ must be checked before touching any member of self - if onDestroy() already
// ran, `self` may be freed, and dereferencing self->firmwareOps_ first would be a
// use-after-free even just to read the pointer.
if (liveInstance_.load() == self && self->firmwareOps_ != nullptr
&& self->firmwareOps_->wait_ready(self->firmwareCtx_, WAIT_TIMEOUT_MS)
&& liveInstance_.load() == self) {
self->dispatchToUi([](EspNowBridge& app, void*) {
app.refreshCurrentVersion();
}, nullptr, nullptr);
}
if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) {
xSemaphoreGive(self->taskDoneSemaphore_);
}
vTaskDelete(nullptr);
}
void EspNowBridge::onWifiEvent(Device* /*device*/, void* callbackContext, WifiEvent /*event*/) {
auto* self = static_cast<EspNowBridge*>(callbackContext);
if (liveInstance_.load() != self) {
return;
}
self->dispatchToUi([](EspNowBridge& app, void*) {
app.refreshWifiPrompt();
app.refreshCurrentVersion();
}, nullptr, nullptr);
}
void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) {
isShown_ = true;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(wrapper, 8, LV_STATE_DEFAULT);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
currentVersionLabel_ = lv_label_create(wrapper);
lv_obj_set_style_pad_bottom(currentVersionLabel_, 12, LV_STATE_DEFAULT);
enableWifiButton_ = lv_button_create(wrapper);
lv_obj_add_event_cb(enableWifiButton_, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr);
auto* enableWifiButtonLabel = lv_label_create(enableWifiButton_);
lv_label_set_text(enableWifiButtonLabel, "Enable WiFi (required for co-processor link)");
lv_obj_set_style_pad_bottom(enableWifiButton_, 12, LV_STATE_DEFAULT);
updateBundledButton_ = lv_button_create(wrapper);
lv_obj_add_event_cb(updateBundledButton_, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr);
auto* updateBundledButtonLabel = lv_label_create(updateBundledButton_);
lv_label_set_text(updateBundledButtonLabel, "Update to bundled firmware");
lv_obj_set_style_pad_bottom(updateBundledButton_, 12, LV_STATE_DEFAULT);
updateButton_ = lv_button_create(wrapper);
lv_obj_add_event_cb(updateButton_, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr);
auto* updateButtonLabel = lv_label_create(updateButton_);
lv_label_set_text(updateButtonLabel, "Update from SD card...");
lv_obj_set_style_pad_bottom(updateButton_, 12, LV_STATE_DEFAULT);
progressBar_ = lv_bar_create(wrapper);
lv_obj_set_size(progressBar_, LV_PCT(100), LV_PCT(6));
lv_bar_set_range(progressBar_, 0, 100);
lv_bar_set_value(progressBar_, 0, LV_ANIM_OFF);
statusLabel_ = lv_label_create(wrapper);
lv_label_set_text(statusLabel_, "Ready");
wifiDevice_ = wifi_find_first_registered_device();
if (wifiDevice_ != nullptr) {
wifi_add_event_callback(wifiDevice_, this, onWifiEvent);
if (wifi_get_firmware_ops(wifiDevice_, &firmwareOps_, &firmwareCtx_) != ERROR_NONE) {
firmwareOps_ = nullptr;
firmwareCtx_ = nullptr;
}
}
refreshCurrentVersion();
refreshWifiPrompt();
// If an SD-card file was picked before this onShow() ran (FileSelection tears down and
// rebuilds this app's whole widget tree), perform the update now that widgets are valid
// again. The bundled-firmware button doesn't go through this path - it calls
// startUpdateTask() directly since there's no separate app launch/result round trip involved.
if (!pendingUpdateFilePath_.empty()) {
std::string path = std::move(pendingUpdateFilePath_);
pendingUpdateFilePath_.clear();
startUpdateTask(path);
}
}
void EspNowBridge::onHide(AppHandle /*app*/) {
isShown_ = false;
if (wifiDevice_ != nullptr) {
wifi_remove_event_callback(wifiDevice_, onWifiEvent);
wifiDevice_ = nullptr;
}
}
void EspNowBridge::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, AppResult result, BundleHandle resultData) {
if (launchId != pickFileLaunchId_) {
return;
}
pickFileLaunchId_ = 0;
if (result == APP_RESULT_OK && resultData != nullptr) {
char pathBuf[256] = {};
if (tt_app_fileselection_get_result_path(resultData, pathBuf, sizeof(pathBuf))) {
pendingUpdateFilePath_ = pathBuf;
}
}
}
@@ -0,0 +1,108 @@
#pragma once
#include <TactilityCpp/App.h>
#include <atomic>
#include <optional>
#include <string>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lvgl.h>
#include <tactility/drivers/wifi.h>
/** RAII guard: pauses WifiService's background auto-connect scan for the guard's lifetime. See
* tactility/wifi_auto_scan.h - belt-and-suspenders measure, not sufficient on its own (see the
* REBOOT comment in EspNowBridge.cpp). */
class AutoScanPauseGuard {
public:
AutoScanPauseGuard();
~AutoScanPauseGuard();
AutoScanPauseGuard(const AutoScanPauseGuard&) = delete;
AutoScanPauseGuard& operator=(const AutoScanPauseGuard&) = delete;
};
class EspNowBridge final : public App {
public:
EspNowBridge() = default;
EspNowBridge(const EspNowBridge&) = delete;
EspNowBridge& operator=(const EspNowBridge&) = delete;
void onCreate(AppHandle app) override;
void onDestroy(AppHandle app) override;
void onShow(AppHandle app, lv_obj_t* parent) override;
void onHide(AppHandle app) override;
void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
// Public so the free-function dispatchToUi() work callbacks in EspNowBridge.cpp (which run
// outside any member-function's lexical scope, unlike the inline lambdas in performUpdate())
// can call them.
void setStatus(const std::string& text);
void setProgress(int percent);
private:
AppHandle appHandle_ = nullptr;
AppLaunchId pickFileLaunchId_ = 0;
std::string pendingUpdateFilePath_;
Device* wifiDevice_ = nullptr;
// Resolved once in onShow() via wifi_get_firmware_ops() - null on a WiFi device with no
// updatable co-processor (e.g. a native, non-hosted chip). All OTA/version-query calls go
// through this generic interface, not any esp_hosted-specific API directly.
const FirmwareOps* firmwareOps_ = nullptr;
void* firmwareCtx_ = nullptr;
// Set once in onShow(), false once onHide() tears the widget tree down - checked (via
// dispatchToUi(), below) before touching any lv_obj_t*, since the OTA worker task and the
// WiFi-event callback can both outlive a hide/app-switch.
std::atomic<bool> isShown_{false};
// Only one EspNowBridge instance is ever live at a time (app loader owns a single instance
// per running app), so a single static "is this instance still current" pointer, guarded by
// an atomic, substitutes for the internal app's shared_ptr-based lifetime guard - the OTA
// worker task and dispatchToUi()'s lv_async_call closures check liveInstance_ == this before
// touching any member, instead of holding a shared_ptr to keep `this` alive.
static std::atomic<EspNowBridge*> liveInstance_;
TaskHandle_t updateTask_ = nullptr;
// Number of background tasks (updateTaskEntry, waitForTransportTaskEntry) currently running
// against this instance's members. onDestroy() must wait for this to hit 0 before returning -
// the app framework frees this instance shortly after onDestroy() returns (see Loader.cpp),
// so any task still touching `this` past that point is a use-after-free.
std::atomic<int> outstandingTasks_{0};
SemaphoreHandle_t taskDoneSemaphore_ = nullptr;
// Outlives performUpdate() deliberately, so auto-scan stays paused across the async gap
// between performUpdate() returning and the automatic restart - see performUpdate().
std::optional<AutoScanPauseGuard> heldAutoScanPauseGuard_;
lv_obj_t* currentVersionLabel_ = nullptr;
lv_obj_t* statusLabel_ = nullptr;
lv_obj_t* progressBar_ = nullptr;
lv_obj_t* updateButton_ = nullptr;
lv_obj_t* updateBundledButton_ = nullptr;
lv_obj_t* enableWifiButton_ = nullptr;
void refreshCurrentVersion();
bool isWifiRadioOn();
void refreshWifiPrompt();
/** Enables/disables both update-trigger buttons together - only one performUpdate() can run
* at a time (see updateTask_), regardless of which button started it. */
void setUpdateButtonsDisabled(bool disabled);
/** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance_ is
* still this instance (checked at dispatch time and again right before running, on the LVGL
* task) and isShown_ is true (this app's widget tree exists). */
void dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*));
void performUpdate(const std::string& filePath);
void startUpdateTask(const std::string& filePath);
static void updateTaskEntry(void* arg);
static void onUpdateButtonClicked(lv_event_t* event);
static void onUpdateBundledButtonClicked(lv_event_t* event);
static void onEnableWifiButtonClicked(lv_event_t* event);
static void onWifiEvent(Device* device, void* callbackContext, WifiEvent event);
static void waitForTransportTaskEntry(void* arg);
};
+11
View File
@@ -0,0 +1,11 @@
#include "EspNowBridge.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<EspNowBridge>();
return 0;
}
}
+8
View File
@@ -0,0 +1,8 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32p4
app.id=one.tactility.espnowbridge
app.version.name=0.1.0
app.version.code=1
app.name=ESP-NOW Bridge
app.description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support.
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.gpio
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.7.0
[app] app.version.code=7
id=one.tactility.gpio app.name=GPIO
versionName=0.4.0
versionCode=4
name=GPIO
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(GameBoy)
tactility_project(GameBoy)
+96
View File
@@ -0,0 +1,96 @@
# GameBoy Emulator (Tactility Prototype, No Audio)
DMG Game Boy emulator for Tactility side-loaded apps, using **Peanut-GB** (MIT) as CPU/LCD core.
## Status
- Prototype v0.1.0-dev compiling draft focused on architecture, not yet production-hardened.
- **No audio** (ENABLE_SOUND 0). Audio path stubbed for future MiniGB APU or i2s-driven implementation.
- Supports MBC1/MBC2/MBC3/MBC5 via Peanut-GB.
- ROM loader from SD card.
- Save RAM persistence via `.sav` file next to ROM.
- LVGL framebuffer: native 160x144 RGB565, integer-scaled if display large enough, centered on black background.
- Input: on-screen D-pad + A/B + Start/Select + hardware keyboard arrows + LVGL key events (z= A, x= B).
- Timer-driven at ~16ms (~60Hz) calling `gb_run_frame()`.
## ROM Location
- Scanned directory: `/sdcard/roms/gb/` for `*.gb`, `*.gbc`, `*.bin` (up to 64 entries).
- Default quick-load: `/sdcard/roms/gb/default.gb` if present on app show, autoloads and jumps directly to emulation.
- Place your legally dumped ROMs there; no ROMs are bundled.
## Save RAM Path Design (stubbed + implemented minimal)
- Save file = ROM path with extension replaced by `.sav` (e.g. `/sdcard/roms/gb/tetris.gb` -> `/sdcard/roms/gb/tetris.sav`).
- Loaded on ROM load, saved on:
- switching back to menu
- app hide
- error recovery path
- Size queried via `gb_get_save_size_s()` / `gb_get_save_size()`.
- Future improvement: also mirror to app user-data dir (`tt_app_get_user_data_child_path`) if SD is read-only.
## Memory Considerations (ESP32-S3 / PSRAM)
- Large buffers allocated via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)` with fallback to internal.
- ROM buffer: up to 2MB (MBC5 max-ish) PSRAM preferred.
- Cart RAM: variable, often 8KB-32KB, PSRAM.
- Framebuffer: 160*144*2 = 46,080 bytes (~45KB) native RGB565. PSRAM preferred. No double buffering needed (line callback writes directly).
- No huge heap allocations.
- Emulator context `struct gb_s` is static inside AppCtx (~few KB).
## Controls / Input Mapping
| GB | On-screen | Keyboard | Remarks |
|----|-----------|----------|---------|
| D-pad | 4 arrow buttons | LV_KEY_ arrows | press/release tracked |
| A | A button (right cluster) | z | |
| B | B button (right cluster) | x | |
| Start | Sta | Enter / Space | |
| Select | Sel | Esc / Backspace | |
| Touch | Quadrants not yet separated buttons cover |
Future: touch quadrants mapping via `pointToQuadrant` like GameKitInput.
## LCD Rendering
- Peanut-GB calls `lcd_draw_line(gb, pixels[160], line)` per scanline.
- `pixels` low 2 bits = shade 0-3.
- Mapped to olive/gray palette RGB565 (editable).
- Canvas buffer is the framebuffer itself.
- Scale: if display resolution >= 320x432 => 2x, >=480x576 => 3x via LVGL transform scale (keeps native buffer).
## No-Audio Limitation
- `ENABLE_SOUND 0` audio callbacks not compiled.
- To add audio:
1. Vendor MiniGB APU (`minigb_apu`) or similar.
2. Implement `audio_read` / `audio_write` forwarding to APU.
3. Define ENABLE_SOUND 1, include APU, create audio task similar to BookPlayer (i2s_controller).
4. Feed APU samples in timer / separate task.
## Build
Same as other Tactility apps:
```
. $IDF_PATH/export.sh
export TACTILITY_SDK_PATH=...
python3 tactility.py Apps/GameBoy build esp32s3 --local-sdk
```
## Licensing
- App code: GPLv3 (same as Tactility Apps).
- Peanut-GB vendored lib: MIT (Copyright (c) 2018-2023 Mahyar Koshkouei). License preserved in `Libraries/PeanutGB/peanut_gb.h`.
## Next Steps
- [ ] Improve input: add touch quadrant → D-pad, repeat timers for held buttons.
- [ ] Add pause/resume UI, FPS display.
- [ ] Add palette selector (auto_assign_palette logic from peanut_sdl).
- [ ] Add file picker dialog (`tt_app_selectiondialog_start`) improvement + recursive folder browsing.
- [ ] Audio: vendoring `minigb_apu` and creating I2S task.
- [ ] Save state beyond cart RAM (full emu snapshot).
- [ ] RTC persistence for MBC3 RTC games.
- [ ] Error dialog via `tt_app_alertdialog_start`.
- [ ] Validate with Cppcheck / clang-format and ESP-IDF build.
+7
View File
@@ -0,0 +1,7 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS Source ../../../Libraries/PeanutGB
REQUIRES TactilitySDK
)
+679
View File
@@ -0,0 +1,679 @@
/**
* @file main.c
* @brief GameBoy DMG Emulator for Tactility (Peanut-GB prototype, no audio)
*
* MIT licensed Peanut-GB core vendored in Libraries/PeanutGB/peanut_gb.h
* See app README for notes.
*/
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h>
#include <tt_lvgl_keyboard.h>
#include <lvgl.h>
/* lv_image_cache_drop is not in public LVGL headers but exported by Tactility firmware */
void lv_image_cache_drop(const void * src);
#include <esp_log.h>
/* Board firmware 0.8.0-dev/IDF 5.3.2 does not export esp_log for side-loaded ELFs. */
#undef ESP_LOGI
#undef ESP_LOGW
#undef ESP_LOGE
#define ESP_LOGI(tag, fmt, ...) do { (void)(tag); } while (0)
#define ESP_LOGW(tag, fmt, ...) do { (void)(tag); } while (0)
#define ESP_LOGE(tag, fmt, ...) do { (void)(tag); } while (0)
#include <esp_heap_caps.h>
#include <esp_timer.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>
#define ENABLE_SOUND 0
#define ENABLE_LCD 1
#include "peanut_gb.h"
#define TAG "GameBoy"
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb"
#define ROMS_DIR "/data/roms/gb"
#define MAX_ROMS 64
#define MAX_PATH 512
#define MAX_ROM_SIZE (2 * 1024 * 1024)
#define FRAME_W 160
#define FRAME_H 144
#define TICK_MS 16
/* RGB565 direct no bitfield endian ambiguity */
static const uint16_t GB_PALETTE[4] = {
0xFFFF, // white
0x8C51, // light gray ~ 0b10001 100010 10001 but pre-tuned
0x4A49, // dark gray
0x0000 // black
};
typedef enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
typedef struct {
char filename[MAX_PATH];
char fullpath[MAX_PATH];
} RomEntry;
typedef struct {
struct gb_s gb;
uint8_t* rom_data;
size_t rom_size;
uint8_t* cart_ram;
size_t cart_ram_size;
char rom_path[MAX_PATH];
char rom_title[32];
uint8_t joypad_state;
uint16_t* fb_native; /* RGB565 tightly packed 160*144, raw u16 avoids lv_color16_t bitfield */
lv_draw_buf_t* fb_draw_buf; /* draw_buf that canvas src points to owned by us so we can invalidate cache */
lv_obj_t* canvas;
lv_obj_t* root_wrapper;
lv_obj_t* browser_wrapper;
lv_obj_t* emu_wrapper;
lv_obj_t* toolbar;
lv_obj_t* status_label;
lv_obj_t* rom_list;
lv_obj_t* controls_cont;
lv_timer_t* emu_timer;
RomEntry roms[MAX_ROMS];
int rom_count;
int selected_rom_idx;
lv_obj_t* btn_up;
lv_obj_t* btn_down;
lv_obj_t* btn_left;
lv_obj_t* btn_right;
lv_obj_t* btn_a;
lv_obj_t* btn_b;
lv_obj_t* btn_start;
lv_obj_t* btn_select;
AppHandle app_handle;
AppMode mode;
bool emu_running;
bool framebuffer_allocated;
bool rom_loaded;
uint32_t fps_frames;
int64_t fps_last_us;
uint32_t lines_drawn; /* debug: should be 144 per frame */
} AppCtx;
typedef struct {
AppCtx* ctx;
uint8_t joypad_bit;
} BtnUserData;
/* PSRAM helpers */
static void* alloc_psram(size_t size) {
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
if (!p) p = malloc(size);
return p;
}
/* Peanut-GB callbacks */
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->rom_data) return 0xFF;
if (addr < ctx->rom_size) return ctx->rom_data[addr];
return 0xFF;
}
static uint8_t gb_cart_ram_read(struct gb_s* gb, const uint_fast32_t addr) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->cart_ram) return 0xFF;
if (addr < ctx->cart_ram_size) return ctx->cart_ram[addr];
return 0xFF;
}
static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const uint8_t val) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->cart_ram) return;
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
}
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) {
const char* err_str = "UNKNOWN";
switch (err) {
case GB_INVALID_OPCODE: err_str = "INVALID OPCODE"; break;
case GB_INVALID_READ: err_str = "INVALID READ"; break;
case GB_INVALID_WRITE: err_str = "INVALID WRITE"; break;
default: break;
}
ESP_LOGE(TAG, "GB error %s (%d) @ %04X", err_str, err, addr);
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (ctx && ctx->cart_ram_size) {
char sp[MAX_PATH];
strncpy(sp, ctx->rom_path, MAX_PATH-1); sp[MAX_PATH-1]='\0';
char* dot=strrchr(sp,'.'); char* sl=strrchr(sp,'/');
if (dot && (!sl || dot>sl)) snprintf(dot, MAX_PATH-(dot-sp), ".sav"); else strncat(sp,".sav",MAX_PATH-strlen(sp)-1);
FILE* f=fopen(sp,"wb"); if(f){fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);}
}
}
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->fb_native) return;
if (line >= FRAME_H) return;
uint16_t* dst = &ctx->fb_native[line * FRAME_W];
for (int x=0;x<FRAME_W;x++) {
uint8_t shade = pixels[x] & 0x03;
dst[x] = GB_PALETTE[shade];
}
ctx->lines_drawn++;
}
/* Save path */
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
if (!rom_path || !out) return;
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
char* dot=strrchr(out,'.'); char* sl=strrchr(out,'/');
if (dot && (!sl || dot>sl)) { snprintf(dot, out_len-(dot-out), ".sav"); }
else { strncat(out,".sav",out_len-strlen(out)-1); }
}
static void load_cart_ram(AppCtx* ctx) {
if (!ctx || !ctx->rom_path[0]) return;
char save_path[MAX_PATH];
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
size_t save_sz=0;
if (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
if (save_sz==0) { ESP_LOGI(TAG,"No save RAM"); return; }
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
ctx->cart_ram_size=save_sz;
ctx->cart_ram=alloc_psram(save_sz);
if (!ctx->cart_ram){ ESP_LOGE(TAG,"cart RAM alloc fail %zu",save_sz); ctx->cart_ram_size=0; return; }
memset(ctx->cart_ram,0,save_sz);
FILE* f=fopen(save_path,"rb");
if (!f){ ESP_LOGI(TAG,"No save %s",save_path); return; }
size_t r=fread(ctx->cart_ram,1,save_sz,f); fclose(f);
ESP_LOGI(TAG,"Loaded save %s %zu/%zu",save_path,r,save_sz);
}
static void save_cart_ram(AppCtx* ctx) {
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
if (!ctx->rom_path[0]) return;
char save_path[MAX_PATH];
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
FILE* f=fopen(save_path,"wb");
if (!f){ ESP_LOGE(TAG,"Save open fail %s",save_path); return; }
size_t w=fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
ESP_LOGI(TAG,"Saved RAM %s %zu",save_path,w);
}
/* ROM loading */
static bool load_rom_file(AppCtx* ctx, const char* path) {
if (!ctx || !path) return false;
ESP_LOGI(TAG,"Loading ROM %s",path);
FILE* f=fopen(path,"rb");
if (!f){ ESP_LOGE(TAG,"Open fail %s",path); return false; }
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
if (sz<=0 || sz>MAX_ROM_SIZE){ ESP_LOGE(TAG,"Bad size %ld %s",sz,path); fclose(f); return false; }
uint8_t* buf=alloc_psram((size_t)sz);
if (!buf){ ESP_LOGE(TAG,"Alloc fail %ld",sz); fclose(f); return false; }
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
if (read!=(size_t)sz){ ESP_LOGE(TAG,"Short read %zu vs %ld",read,sz); heap_caps_free(buf); return false; }
if (ctx->rom_data) { if (ctx->cart_ram) save_cart_ram(ctx); heap_caps_free(ctx->rom_data); }
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
ctx->rom_data=buf; ctx->rom_size=(size_t)sz;
strncpy(ctx->rom_path,path,sizeof(ctx->rom_path)-1); ctx->rom_path[sizeof(ctx->rom_path)-1]='\0';
memset(&ctx->gb,0,sizeof(ctx->gb));
ctx->joypad_state=0xFF;
enum gb_init_error_e err=gb_init(&ctx->gb, gb_rom_read, gb_cart_ram_read, gb_cart_ram_write, gb_error_handler, ctx);
if (err!=GB_INIT_NO_ERROR){ ESP_LOGE(TAG,"gb_init %d",err); heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
gb_init_lcd(&ctx->gb, lcd_draw_line);
load_cart_ram(ctx);
gb_reset(&ctx->gb);
char title[32]={0}; gb_get_rom_name(&ctx->gb, title); strncpy(ctx->rom_title,title,sizeof(ctx->rom_title)-1);
ESP_LOGI(TAG,"ROM OK title='%s' size=%zu save=%zu",ctx->rom_title,ctx->rom_size,ctx->cart_ram_size);
ctx->rom_loaded=true;
return true;
}
static void scan_rom_dir(AppCtx* ctx) {
ctx->rom_count=0;
DIR* dir=opendir(ROMS_DIR);
if (!dir){ ESP_LOGW(TAG,"ROMS dir missing %s",ROMS_DIR); return; }
struct dirent* ent;
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
if (ent->d_name[0]=='.') continue;
size_t len=strlen(ent->d_name);
if (len<3) continue;
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
if (!is_gb) continue;
RomEntry* e=&ctx->roms[ctx->rom_count++];
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
snprintf(e->fullpath, sizeof(e->fullpath), "%s/%s", ROMS_DIR, ent->d_name);
}
closedir(dir);
ESP_LOGI(TAG,"Found %d ROMs",ctx->rom_count);
}
/* Emu timer THIS IS WHERE "FPS but no image" WAS: missing cache drop */
static void emu_timer_cb(lv_timer_t* timer) {
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
ctx->lines_drawn = 0;
ctx->gb.direct.joypad = ctx->joypad_state;
gb_run_frame(&ctx->gb);
/* Critical fix: raw buffer mutated, LVGL image cache is stale.
Without this, canvas shows whatever was first uploaded (gray/black) and FPS label keeps updating,
giving "FPS but no game". */
if (ctx->fb_draw_buf) {
/* Invalidate both D-Cache (PSRAM) and LVGL image cache */
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
lv_image_cache_drop(ctx->fb_draw_buf);
/* Also invalidate area via the canvas src buf if different object */
if (ctx->canvas) {
lv_draw_buf_t* c_db = lv_canvas_get_draw_buf(ctx->canvas);
if (c_db && c_db != ctx->fb_draw_buf) {
lv_draw_buf_invalidate_cache(c_db, NULL);
lv_image_cache_drop(c_db);
}
}
}
lv_obj_invalidate(ctx->canvas);
ctx->fps_frames++;
int64_t now = esp_timer_get_time();
if (ctx->fps_last_us == 0) ctx->fps_last_us = now;
int64_t elapsed = now - ctx->fps_last_us;
if (elapsed >= 1000000) {
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
if (ctx->status_label) {
lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
}
printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
ctx->fps_frames = 0;
ctx->fps_last_us = now;
}
}
/* Input helpers */
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
if (!ctx) return;
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
else ctx->joypad_state |= bit;
}
static void input_down_cb(lv_event_t* e){
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
if (!ud) return;
set_joypad_bit(ud->ctx, ud->joypad_bit, true);
}
static void input_up_cb(lv_event_t* e){
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
if (!ud) return;
set_joypad_bit(ud->ctx, ud->joypad_bit, false);
}
static void key_press_cb(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
uint32_t key=lv_event_get_key(e);
switch(key){
case LV_KEY_UP: set_joypad_bit(ctx, JOYPAD_UP, true); break;
case LV_KEY_DOWN: set_joypad_bit(ctx, JOYPAD_DOWN, true); break;
case LV_KEY_LEFT: set_joypad_bit(ctx, JOYPAD_LEFT, true); break;
case LV_KEY_RIGHT: set_joypad_bit(ctx, JOYPAD_RIGHT, true); break;
case LV_KEY_ENTER: set_joypad_bit(ctx, JOYPAD_START, true); break;
case LV_KEY_ESC: set_joypad_bit(ctx, JOYPAD_SELECT, true); break;
default: {
if (key== (uint32_t)'z' || key== (uint32_t)'Z') set_joypad_bit(ctx, JOYPAD_A, true);
else if (key== (uint32_t)'x' || key== (uint32_t)'X') set_joypad_bit(ctx, JOYPAD_B, true);
break;
}
}
}
/* Forward declarations for UI switching */
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent);
static void switch_to_browser(AppCtx* ctx);
static void switch_to_emu(AppCtx* ctx);
/* ROM selection events */
static void rom_button_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
lv_obj_t* btn=lv_event_get_target_obj(e);
int* pIdx=(int*)lv_obj_get_user_data(btn);
if (!pIdx) return;
ctx->selected_rom_idx=*pIdx;
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
if (load_rom_file(ctx, path)){
switch_to_emu(ctx);
} else {
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
}
}
static void default_rom_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
switch_to_emu(ctx);
} else {
if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found in /data/roms/gb/");
}
}
static void back_to_menu_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
switch_to_browser(ctx);
}
/* UI builders */
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_clean(parent);
ctx->rom_list=NULL;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 4, 0);
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
lv_obj_t* info=lv_label_create(parent);
lv_label_set_text_fmt(info, "GameBoy (no audio) - Peanut-GB\nPlace ROMs in %s\nDefault: %s\nFound %d ROMs", ROMS_DIR, DEFAULT_ROM_PATH, ctx->rom_count);
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info, LV_PCT(100));
lv_obj_set_style_text_color(info, lv_color_white(), 0);
ctx->status_label=lv_label_create(parent);
lv_label_set_text(ctx->status_label, "Select a ROM to start");
lv_obj_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
lv_obj_t* list=lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
ctx->rom_list=list;
if (ctx->rom_count==0){
lv_obj_t* lbl=lv_label_create(parent);
lv_label_set_text(lbl, "No ROMs found in /data/roms/gb\nPut .gb files there.");
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
} else {
for (int i=0;i<ctx->rom_count;i++){
lv_obj_t* btn=lv_list_add_btn(list, LV_SYMBOL_FILE, ctx->roms[i].filename);
int* idx=(int*)malloc(sizeof(int)); *idx=i;
lv_obj_set_user_data(btn, idx);
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
}
}
lv_obj_t* default_btn=lv_btn_create(parent);
lv_obj_set_width(default_btn, LV_PCT(100));
lv_obj_t* dlbl=lv_label_create(default_btn);
lv_label_set_text(dlbl, "Try default.gb");
lv_obj_center(dlbl);
lv_obj_add_event_cb(default_btn, default_rom_event, LV_EVENT_CLICKED, ctx);
}
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_clean(parent);
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 0, 0);
lv_obj_set_style_pad_row(parent, 0, 0);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* info_bar=lv_obj_create(parent);
lv_obj_set_width(info_bar, LV_PCT(100));
lv_obj_set_height(info_bar, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(info_bar, 2, 0);
lv_obj_set_style_border_width(info_bar, 0, 0);
lv_obj_set_flex_flow(info_bar, LV_FLEX_FLOW_ROW);
lv_obj_set_style_bg_color(info_bar, lv_color_hex(0x222222), 0);
lv_obj_t* title_lbl=lv_label_create(info_bar);
ctx->status_label = title_lbl;
lv_label_set_text_fmt(title_lbl, "GB: %s FPS:--", ctx->rom_title[0]?ctx->rom_title:"GameBoy");
lv_obj_set_style_text_color(title_lbl, lv_color_white(), 0);
lv_obj_t* spacer=lv_obj_create(info_bar);
lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(spacer,0,0);
lv_obj_set_flex_grow(spacer,1);
lv_obj_t* back_btn=lv_btn_create(info_bar);
lv_obj_set_size(back_btn, 60, 28);
lv_obj_t* bl=lv_label_create(back_btn); lv_label_set_text(bl,"Menu"); lv_obj_center(bl);
lv_obj_add_event_cb(back_btn, back_to_menu_event, LV_EVENT_CLICKED, ctx);
lv_obj_t* canvas_cont=lv_obj_create(parent);
lv_obj_set_width(canvas_cont, LV_PCT(100));
lv_obj_set_flex_grow(canvas_cont,1);
lv_obj_set_style_bg_color(canvas_cont, lv_color_black(),0);
lv_obj_set_style_border_width(canvas_cont,0,0);
lv_obj_set_style_pad_all(canvas_cont,2,0);
lv_obj_set_flex_flow(canvas_cont, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
if (!ctx->fb_native){
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t);
ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes);
if (ctx->fb_native){
ctx->framebuffer_allocated=true;
memset(ctx->fb_native,0,fb_bytes);
/* Initial grey fill so we can visually confirm buffer ownership even before first gb_run_frame */
for(int i=0;i<FRAME_W*FRAME_H;i++) ctx->fb_native[i]=0x4208;
}
}
if (ctx->fb_native){
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
int avail_h = disp_h - 160;
int avail_w = disp_w - 8;
int scale = 1;
if (avail_w >= FRAME_W * 3 && avail_h >= FRAME_H * 3) scale = 3;
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
ctx->canvas = lv_canvas_create(canvas_cont);
/* lv_canvas_set_buffer creates internal static_buf header from our raw ptr */
lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565);
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
if (ctx->fb_draw_buf && ctx->fb_draw_buf->data) {
/* Force fresh cache state */
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
lv_image_cache_drop(ctx->fb_draw_buf);
}
if (scale > 1) {
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
}
lv_obj_set_style_border_width(ctx->canvas, 1, 0);
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x444444), 0);
lv_obj_center(ctx->canvas);
} else {
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0);
}
lv_obj_t* ctrl=lv_obj_create(parent);
ctx->controls_cont=ctrl;
lv_obj_set_width(ctrl, LV_PCT(100));
lv_obj_set_height(ctrl, 110);
lv_obj_set_style_pad_all(ctrl,2,0);
lv_obj_set_style_bg_color(ctrl, lv_color_hex(0x111111),0);
lv_obj_set_style_border_width(ctrl,0,0);
lv_obj_set_flex_flow(ctrl, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctrl, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
static BtnUserData btn_ud[8];
static bool ud_init=false;
if (!ud_init){ memset(btn_ud,0,sizeof(btn_ud)); ud_init=true; }
lv_obj_t* dpad=lv_obj_create(ctrl);
lv_obj_set_size(dpad,96,96);
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(dpad,0,0);
lv_obj_set_style_pad_all(dpad,0,0);
lv_obj_t* up=lv_btn_create(dpad); lv_obj_set_size(up,32,32); lv_obj_set_pos(up,32,0);
lv_obj_t* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
lv_obj_t* down=lv_btn_create(dpad); lv_obj_set_size(down,32,32); lv_obj_set_pos(down,32,64);
lv_obj_t* lbl; lbl=lv_label_create(up); lv_label_set_text(lbl, LV_SYMBOL_UP); lv_obj_center(lbl);
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); lv_obj_center(lbl);
lbl=lv_label_create(left); lv_label_set_text(lbl, LV_SYMBOL_LEFT); lv_obj_center(lbl);
lbl=lv_label_create(right); lv_label_set_text(lbl, LV_SYMBOL_RIGHT); lv_obj_center(lbl);
ctx->btn_up=up; ctx->btn_down=down; ctx->btn_left=left; ctx->btn_right=right;
btn_ud[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]);
btn_ud[2].ctx=ctx; btn_ud[2].joypad_bit=JOYPAD_LEFT; lv_obj_add_event_cb(left, input_down_cb, LV_EVENT_PRESSED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_RELEASED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[2]);
btn_ud[3].ctx=ctx; btn_ud[3].joypad_bit=JOYPAD_RIGHT;lv_obj_add_event_cb(right, input_down_cb, LV_EVENT_PRESSED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_RELEASED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[3]);
lv_obj_t* center_col=lv_obj_create(ctrl);
lv_obj_set_size(center_col,64,96);
lv_obj_set_style_bg_opa(center_col,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(center_col,0,0);
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(center_col,4,0);
lv_obj_t* sel_btn=lv_btn_create(center_col); lv_obj_set_size(sel_btn,60,28); lbl=lv_label_create(sel_btn); lv_label_set_text(lbl,"Sel"); lv_obj_center(lbl);
lv_obj_t* sta_btn=lv_btn_create(center_col); lv_obj_set_size(sta_btn,60,28); lbl=lv_label_create(sta_btn); lv_label_set_text(lbl,"Sta"); lv_obj_center(lbl);
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
btn_ud[4].ctx=ctx; btn_ud[4].joypad_bit=JOYPAD_SELECT; lv_obj_add_event_cb(sel_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[4]);
btn_ud[5].ctx=ctx; btn_ud[5].joypad_bit=JOYPAD_START; lv_obj_add_event_cb(sta_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[5]);
lv_obj_t* ab=lv_obj_create(ctrl);
lv_obj_set_size(ab,96,96);
lv_obj_set_style_bg_opa(ab,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(ab,0,0);
lv_obj_set_style_pad_all(ab,0,0);
lv_obj_t* b_btn=lv_btn_create(ab); lv_obj_set_size(b_btn,40,40); lv_obj_set_pos(b_btn,0,24); lv_obj_set_style_radius(b_btn,20,0);
lv_obj_t* a_btn=lv_btn_create(ab); lv_obj_set_size(a_btn,40,40); lv_obj_set_pos(a_btn,48,8); lv_obj_set_style_radius(a_btn,20,0);
lbl=lv_label_create(b_btn); lv_label_set_text(lbl,"B"); lv_obj_center(lbl);
lbl=lv_label_create(a_btn); lv_label_set_text(lbl,"A"); lv_obj_center(lbl);
ctx->btn_a=a_btn; ctx->btn_b=b_btn;
btn_ud[6].ctx=ctx; btn_ud[6].joypad_bit=JOYPAD_B; lv_obj_add_event_cb(b_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[6]);
btn_ud[7].ctx=ctx; btn_ud[7].joypad_bit=JOYPAD_A; lv_obj_add_event_cb(a_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[7]);
lv_obj_add_event_cb(parent, key_press_cb, LV_EVENT_KEY, ctx);
if (tt_lvgl_hardware_keyboard_is_available()){
lv_group_t* g=lv_group_get_default();
if (g){ lv_group_add_obj(g, parent); lv_group_focus_obj(parent); lv_group_set_editing(g, true); }
}
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->fps_frames = 0;
ctx->fps_last_us = esp_timer_get_time();
ctx->lines_drawn = 0;
ctx->emu_timer=lv_timer_create(emu_timer_cb, TICK_MS, ctx);
ctx->emu_running=true;
ctx->mode=APP_MODE_EMU;
}
static void switch_to_browser(AppCtx* ctx){
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->emu_running=false;
save_cart_ram(ctx);
ctx->mode=APP_MODE_BROWSER;
if (!ctx->browser_wrapper || !lv_obj_is_valid(ctx->browser_wrapper)) return;
if (ctx->emu_wrapper) lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
scan_rom_dir(ctx);
build_browser_ui(ctx, ctx->browser_wrapper);
}
static void switch_to_emu(AppCtx* ctx){
if (!ctx->rom_loaded) return;
if (ctx->browser_wrapper) lv_obj_add_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
if (!ctx->emu_wrapper) return;
lv_obj_remove_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
build_emu_ui(ctx, ctx->emu_wrapper);
}
/* Lifecycle */
static void* create_data(void){
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
return ctx;
}
static void destroy_data(void* data){
AppCtx* ctx=(AppCtx*)data;
if (!ctx) return;
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
if (ctx->fb_native) heap_caps_free(ctx->fb_native);
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
free(ctx);
}
static void on_create(AppHandle app, void* data){
AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app;
}
static void on_destroy(AppHandle app, void* data){ (void)app; (void)data; }
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
ctx->app_handle=app;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent,0,0);
lv_obj_set_style_pad_row(parent,0,0);
lv_obj_set_style_bg_color(parent, lv_color_black(),0);
ctx->toolbar=tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_set_style_bg_color(ctx->toolbar, lv_color_hex(0x111111),0);
ctx->root_wrapper=lv_obj_create(parent);
lv_obj_set_width(ctx->root_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->root_wrapper,1);
lv_obj_set_style_pad_all(ctx->root_wrapper,0,0);
lv_obj_set_style_border_width(ctx->root_wrapper,0,0);
lv_obj_set_style_bg_color(ctx->root_wrapper, lv_color_black(),0);
lv_obj_set_flex_flow(ctx->root_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(ctx->root_wrapper, LV_OBJ_FLAG_SCROLLABLE);
ctx->browser_wrapper=lv_obj_create(ctx->root_wrapper);
lv_obj_set_width(ctx->browser_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->browser_wrapper,1);
lv_obj_set_style_pad_all(ctx->browser_wrapper,0,0);
lv_obj_set_style_border_width(ctx->browser_wrapper,0,0);
ctx->emu_wrapper=lv_obj_create(ctx->root_wrapper);
lv_obj_set_width(ctx->emu_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->emu_wrapper,1);
lv_obj_set_style_pad_all(ctx->emu_wrapper,0,0);
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
scan_rom_dir(ctx);
struct stat st;
bool default_exists=(stat(DEFAULT_ROM_PATH,&st)==0);
if (default_exists){
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx);
return;
}
}
build_browser_ui(ctx, ctx->browser_wrapper);
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
ctx->mode=APP_MODE_BROWSER;
}
static void on_hide(AppHandle app, void* data){
(void)app;
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->emu_running=false;
if (ctx->rom_loaded) save_cart_ram(ctx);
ctx->canvas=NULL; ctx->fb_draw_buf=NULL; ctx->toolbar=NULL; ctx->root_wrapper=NULL; ctx->browser_wrapper=NULL; ctx->emu_wrapper=NULL;
ctx->status_label=NULL; ctx->rom_list=NULL; ctx->controls_cont=NULL;
ctx->btn_up=ctx->btn_down=ctx->btn_left=ctx->btn_right=NULL;
ctx->btn_a=ctx->btn_b=ctx->btn_start=ctx->btn_select=NULL;
ctx->app_handle=NULL;
}
int main(int argc, char* argv[]){
(void)argc; (void)argv;
tt_app_register((AppRegistration){
.createData=create_data,
.destroyData=destroy_data,
.onCreate=on_create,
.onDestroy=on_destroy,
.onShow=on_show,
.onHide=on_hide
});
return 0;
}
+11
View File
@@ -0,0 +1,11 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.gameboy
versionName=0.1.0-dev
versionCode=1
name=GameBoy
description=DMG Game Boy emulator (no audio prototype, Peanut-GB)
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.graphicsdemo
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.graphicsdemo app.name=Graphics Demo
versionName=0.3.0
versionCode=3
name=Graphics Demo
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.helloworld
platforms=esp32s3 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.helloworld app.name=Hello World
versionName=0.3.0
versionCode=3
name=Hello World
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(ImmichElias)
tactility_project(ImmichElias)
+9
View File
@@ -0,0 +1,9 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
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
)
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-variable)
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.imichelias
app.version.name=0.1.0
app.version.code=1
app.name=Immich Elias
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3,esp32p4
sdk=0.7.0-dev app.id=one.tactility.m5unittest
platforms=esp32s3,esp32p4 app.version.name=0.4.0
[app] app.version.code=4
id=one.tactility.m5unittest app.name=M5 Unit Test
versionName=0.1.0
versionCode=1
name=M5 Unit Test
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.magic8ball
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.5.0
[app] app.version.code=5
id=one.tactility.magic8ball app.name=Magic 8-Ball
versionName=0.2.0
versionCode=2
name=Magic 8-Ball
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3
sdk=0.7.0-dev app.id=one.tactility.mcpscreen
platforms=esp32s3 app.version.name=0.1.0
[app] app.version.code=1
id=one.tactility.mcpscreen app.name=MCP Screen
versionName=0.1.0
versionCode=1
name=MCP Screen
+46 -14
View File
@@ -211,12 +211,29 @@ void MediaKeys::startHid() {
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
} }
void MediaKeys::teardownBt() {
// Remove callback FIRST - stops any in-flight BT events from firing against
// our (possibly already freed) UI widget pointers after this returns.
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
// Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() /
// ble_gatts_start() which corrupts NimBLE heap while the host task is still
// running. HID device is a persistent kernel device; hid_device_start() cleans
// up stale context on next use. Explicit stop is handled by handleSwitchToggle.
// Restore the radio/device to the state we found them in.
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
if (_btDevice && _deviceWasStarted) device_stop(_btDevice);
_btDevice = nullptr;
_hidDevice = nullptr;
_radioWasOff = false;
_deviceWasStarted = false;
}
void MediaKeys::handleSwitchToggle(bool enabled) { void MediaKeys::handleSwitchToggle(bool enabled) {
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF"); LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
_isEnabled = enabled; _isEnabled = enabled;
if (enabled) { if (enabled) {
_btDevice = bluetooth_find_first_ready_device(); _btDevice = device_find_first_by_type(&BLUETOOTH_TYPE);
if (!_btDevice) { if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found"); LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false; _isEnabled = false;
@@ -224,6 +241,19 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
return; return;
} }
// Device may not be started yet (BT disabled in DTS by default to save memory).
if (!device_is_ready(_btDevice)) {
LOG_I(TAG, "BT device not started, starting now");
if (device_start(_btDevice) != ERROR_NONE) {
LOG_E(TAG, "Failed to start BT device");
_btDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
_deviceWasStarted = true;
}
bluetooth_set_device_name(_btDevice, "Tactility Media Keys"); bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
// Register callback before enabling radio so we don't miss the state-change event. // Register callback before enabling radio so we don't miss the state-change event.
@@ -247,12 +277,10 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
} else { } else {
_radioEnabling = false; _radioEnabling = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
// Explicit user toggle-off: stop HID cleanly (safe here since we're on the
// LVGL task and the user intentionally disabled, so no race with app teardown).
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice); if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback); teardownBt();
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_radioWasOff = false;
_btDevice = nullptr;
_hidDevice = nullptr;
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
} }
} }
@@ -321,19 +349,23 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
} }
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
// Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app).
struct Device* btDev = device_find_first_by_type(&BLUETOOTH_TYPE);
if (btDev && device_is_ready(btDev)) {
enum BtRadioState radioState;
if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) {
lv_obj_add_state(_switchWidget, LV_STATE_CHECKED);
handleSwitchToggle(true);
}
}
} }
void MediaKeys::onHide(AppHandle /*appHandle*/) { void MediaKeys::onHide(AppHandle /*appHandle*/) {
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_btDevice = nullptr;
_hidDevice = nullptr;
_isEnabled = false;
_radioEnabling = false; _radioEnabling = false;
_radioWasOff = false; _isEnabled = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
teardownBt();
if (_keyHighlightTimer) { if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer); lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr; _keyHighlightTimer = nullptr;
+5 -2
View File
@@ -2,6 +2,7 @@
#include <TactilityCpp/App.h> #include <TactilityCpp/App.h>
#include <lvgl.h> #include <lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h> #include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h> #include <tactility/drivers/bluetooth_hid_device.h>
#include <tt_app.h> #include <tt_app.h>
@@ -24,8 +25,9 @@ class MediaKeys final : public App {
// State - accessed from both LVGL thread and BT callback thread // State - accessed from both LVGL thread and BT callback thread
std::atomic<bool> _isEnabled {false}; std::atomic<bool> _isEnabled {false};
std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON
std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off) std::atomic<bool> _radioWasOff {false}; // true if we turned the radio on (restore on exit)
std::atomic<bool> _deviceWasStarted{false}; // true if we called device_start (restore on exit)
// Static event callbacks // Static event callbacks
static void onSwitchToggled(lv_event_t* e); static void onSwitchToggled(lv_event_t* e);
@@ -36,6 +38,7 @@ class MediaKeys final : public App {
static void sendKeyTask(void* param); static void sendKeyTask(void* param);
// Instance methods called by static callbacks // Instance methods called by static callbacks
void teardownBt(); // remove callback + stop HID + restore radio/device state
void handleSwitchToggle(bool enabled); void handleSwitchToggle(bool enabled);
void handleButtonPress(uint32_t buttonId); void handleButtonPress(uint32_t buttonId);
void startHid(); // called once radio is confirmed ON void startHid(); // called once radio is confirmed ON
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3,esp32p4
sdk=0.7.0-dev app.id=one.tactility.mediakeys
platforms=esp32s3,esp32p4 app.version.name=0.4.0
[app] app.version.code=4
id=one.tactility.mediakeys app.name=Media Keys
versionName=0.1.0 app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
versionCode=1
name=Media Keys
description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
+2 -1
View File
@@ -2,5 +2,6 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register( idf_component_register(
SRCS ${SOURCE_FILES} SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK REQUIRES TactilitySDK esp_http_client
) )
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-deprecated-declarations -Wno-unused-but-set-variable -Wno-format-truncation)
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3
sdk=0.8.0-dev app.id=one.tactility.mp3player
platforms=esp32s3 app.version.name=1.0.0
[app] app.version.code=1
id=one.tactility.mp3player app.name=MP3 Player
versionName=1.0.0
versionCode=1
name=MP3 Player
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.mystifydemo
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.mystifydemo app.name=Mystify Demo
versionName=0.3.0
versionCode=3
name=Mystify Demo
+9 -12
View File
@@ -1,12 +1,9 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3,esp32p4
sdk=0.8.0-dev app.id=one.tactility.pocketdungeon
platforms=esp32s3,esp32p4 app.version.name=0.1.2
[app] app.version.code=3
id=one.tactility.pocketdungeon app.name=Pocket Dungeon
versionName=0.1.2 app.description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
versionCode=3 app.flags=HideStatusBar
name=Pocket Dungeon
description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
flags=HideStatusBar
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3
sdk=0.7.0-dev app.id=one.tactility.reynabot
platforms=esp32s3 app.version.name=1.0.0
[app] app.version.code=1
id=one.tactility.reynabot app.name=ReynaBot
versionName=1.0.0
versionCode=1
name=ReynaBot
+173 -79
View File
@@ -1,6 +1,7 @@
#include <tt_app.h> #include <tt_app.h>
#include <tt_lvgl_toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tactility/lvgl_fonts.h> #include <tactility/lvgl_fonts.h>
#include <tt_mdns.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
@@ -8,10 +9,8 @@
#include <unistd.h> #include <unistd.h>
#include <lwip/sockets.h> #include <lwip/sockets.h>
#include <lwip/inet.h> #include <lwip/inet.h>
#include "esp_log.h"
#define TAG "RobotArm" #define TAG "RobotArm"
#define ARM_HOST "192.168.68.103"
#define ARM_PORT 80 #define ARM_PORT 80
#define ARM_PATH "/api/mcp" #define ARM_PATH "/api/mcp"
#define NUM_JOINTS 6 #define NUM_JOINTS 6
@@ -41,68 +40,68 @@ static int seq_len = 0, seq_idx = -1;
static bool seq_playing = false; static bool seq_playing = false;
static int seq_play_pos = 0; static int seq_play_pos = 0;
static char arm_host[64] = "";
static bool arm_connected = false;
typedef struct { typedef struct {
AppHandle app; AppHandle app;
lv_obj_t* root;
lv_obj_t* content; // scrollable column container
lv_obj_t* status; lv_obj_t* status;
lv_obj_t* sliders[6]; lv_obj_t* sliders[6];
lv_obj_t* vals[6]; lv_obj_t* vals[6];
lv_obj_t* seq_label; lv_obj_t* seq_label;
lv_obj_t* play_label; lv_obj_t* play_label;
lv_obj_t* blocks[SEQ_MAX]; lv_obj_t* blocks[SEQ_MAX];
lv_obj_t* connect_box;
lv_obj_t* main_box;
lv_obj_t* connect_label;
lv_obj_t* connect_spinner;
int pend[6]; int pend[6];
bool has[6]; bool has[6];
int ticks[6]; int ticks[6];
lv_timer_t* poll; lv_timer_t* poll;
lv_timer_t* seq_timer; lv_timer_t* seq_timer;
lv_timer_t* connect_timer;
int connect_attempts;
} Ctx; } Ctx;
static Ctx* g = NULL; static Ctx* g = NULL;
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); } static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){ static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
uint32_t ip=ipaddr_addr(host);
if(ip==0 || ip==0xFFFFFFFF) return -2;
int fd=lwip_socket(AF_INET,SOCK_STREAM,0); int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
if(fd<0) return -1; if(fd<0) return -1;
struct sockaddr_in s; memset(&s,0,sizeof(s)); struct sockaddr_in s; memset(&s,0,sizeof(s));
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ipaddr_addr(host); s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ip;
struct timeval tv={5,0}; struct timeval tv={2,0};
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv)); lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv)); lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;} if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
char hdr[256]; char hdr[256]; int bl=strlen(body);
int bl=strlen(body);
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl); int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;} if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;} if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
int tot=0; int tot=0; while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
out[tot]='\0'; close(fd); out[tot]='\0'; close(fd);
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);} char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
return tot>0?0:-4; return tot>0?0:-4;
} }
static bool jget(const char* js,const char* key,int* out){ static bool jget(const char* js,const char* key,int* out){
const char* p=strstr(js,key); const char* p=strstr(js,key); if(!p) return false;
if(!p) return false; p+=strlen(key); while(*p && *p!=':'){p++; if(!*p) return false;} p++;
p+=strlen(key);
while(*p && *p!=':'){
p++;
if(!*p) return false;
}
p++;
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++; while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
int sign=1; int sign=1; if(*p=='-'){sign=-1;p++;}
if(*p=='-'){sign=-1;p++;} float v=0,frac=0.1f; bool dot=false,got=false;
float v=0,frac=0.1f;
bool dot=false,got=false;
while(*p){ while(*p){
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}} if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
else if(*p=='.'&&!dot) dot=true; else if(*p=='.'&&!dot) dot=true; else break; p++;
else break;
p++;
} }
if(!got) return false; if(!got) return false;
*out=(int)(v*sign+0.5f); *out=(int)(v*sign+0.5f); return true;
return true;
} }
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){ static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
int v; bool ok=true; int v; bool ok=true;
@@ -117,30 +116,24 @@ static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* g
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){ static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}"; const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
char resp[2048]; memset(resp,0,sizeof(resp)); char resp[2048]; memset(resp,0,sizeof(resp));
if(http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false; if(http_post(arm_host,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
return parse_state(resp,b,s,e,p,ro,gr); return parse_state(resp,b,s,e,p,ro,gr);
} }
static bool rpc_move(const char* j,int a){ static bool rpc_move(const char* j,int a){
char body[300]; snprintf(body,sizeof(body), char body[300]; snprintf(body,sizeof(body),
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",j,a);
j,a); char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
char r[1024]; memset(r,0,sizeof(r));
return http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
} }
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){ static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
char body[420]; snprintf(body,sizeof(body), char body[420]; snprintf(body,sizeof(body),
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}", "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
b,s,e,p,ro,gr,dur); b,s,e,p,ro,gr,dur);
char r[1024]; memset(r,0,sizeof(r)); char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
int rc=http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r));
ESP_LOGI(TAG,"move_all %d %d %d rc=%d",b,s,e,rc);
return rc==0;
} }
static bool rpc_home(void){ static bool rpc_home(void){
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}"; const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
char r[512]; memset(r,0,sizeof(r)); return http_post(ARM_HOST,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0; char r[512]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
} }
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); } static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
static void apply_ui(void){ static void apply_ui(void){
if(!g) return; if(!g) return;
@@ -150,7 +143,7 @@ static void apply_ui(void){
} }
} }
static void refresh_arm(void){ static void refresh_arm(void){
set_status("Reading .103..."); set_status("Reading...");
int b,s,e,p,ro,gr; int b,s,e,p,ro,gr;
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){ if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
joints[0].value=b; joints[1].value=s; joints[2].value=e; joints[0].value=b; joints[1].value=s; joints[2].value=e;
@@ -158,21 +151,21 @@ static void refresh_arm(void){
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}} for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
apply_ui(); apply_ui();
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf); char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
} else set_status("No .103"); } else set_status("No arm");
} }
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); } static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); } static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
static void poll_cb(lv_timer_t* t){ static void poll_cb(lv_timer_t* t){
(void)t; if(!g) return; (void)t; if(!g || !arm_connected) return;
for(int i=0;i<NUM_JOINTS;i++){ for(int i=0;i<NUM_JOINTS;i++){
if(!g->has[i]) continue; if(!g->has[i]) continue;
g->ticks[i]++; if(g->ticks[i]<3) continue; g->ticks[i]++; if(g->ticks[i]<3) continue;
g->has[i]=false; g->ticks[i]=0; g->has[i]=false; g->ticks[i]=0;
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue; int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
joints[i].last_sent=tgt; joints[i].value=tgt; joints[i].last_sent=tgt; joints[i].value=tgt;
char buf[20]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf); char buf[24]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
bool ok=rpc_move(joints[i].name,tgt); bool ok=rpc_move(joints[i].name,tgt);
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"ok":"fail"); set_status(buf); snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"o":"x"); set_status(buf);
break; break;
} }
} }
@@ -185,8 +178,6 @@ static void slider_cb(lv_event_t* e){
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0; g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
} }
} }
/* ── sequencer ── */
static void update_seq_ui(void){ static void update_seq_ui(void){
if(!g) return; if(!g) return;
if(g->seq_label){ if(g->seq_label){
@@ -216,26 +207,21 @@ static void load_frame(int fidx){
apply_ui(); seq_idx=fidx; update_seq_ui(); apply_ui(); seq_idx=fidx; update_seq_ui();
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b); char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
} }
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Seq full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); } static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
static void seq_rem_cb(void){ static void seq_rem_cb(void){
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;} if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
int rem=seq_idx; int rem=seq_idx; for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;} seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
if(seq_idx>=seq_len) seq_idx=seq_len-1; if(seq_idx>=seq_len) seq_idx=seq_len-1; load_frame(seq_idx);
load_frame(seq_idx);
} }
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); } static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); } static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
static void seq_timer_cb(lv_timer_t* t){ static void seq_timer_cb(lv_timer_t* t){
(void)t; if(!seq_playing||seq_len==0) return; (void)t; if(!seq_playing||seq_len==0) return;
int f=seq_play_pos%seq_len; int f=seq_play_pos%seq_len; int* v=seq[f].v;
int* v=seq[f].v;
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){ if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
set_status("Seq fail"); seq_playing=false; set_status("Seq fail"); seq_playing=false;
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play"); if(g&&g->play_label) lv_label_set_text(g->play_label,"Play"); return;
return;
} }
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}} for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
apply_ui(); seq_idx=f; update_seq_ui(); apply_ui(); seq_idx=f; update_seq_ui();
@@ -253,42 +239,155 @@ static void seq_rem_ev(lv_event_t* e){(void)e; seq_rem_cb();}
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();} static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();} static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);} static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");} static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
static void read_cb(lv_event_t* e){(void)e; refresh_arm();} static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");} static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");} static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
static bool try_host(const char* host){
char resp[1024]; memset(resp,0,sizeof(resp));
if(http_post(host,ARM_PORT,ARM_PATH,"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}",resp,sizeof(resp))==0){
if(strstr(resp,"base")){
strncpy(arm_host,host,sizeof(arm_host)-1);
return true;
}
}
return false;
}
static void show_main_ui(void){
if(!g) return;
arm_connected=true;
if(g->connect_box) lv_obj_add_flag(g->connect_box, LV_OBJ_FLAG_HIDDEN);
if(g->main_box) lv_obj_clear_flag(g->main_box, LV_OBJ_FLAG_HIDDEN);
if(g->connect_timer){ lv_timer_delete(g->connect_timer); g->connect_timer=NULL; }
if(!g->poll) g->poll=lv_timer_create(poll_cb,100,NULL);
if(!g->seq_timer) g->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
lv_timer_create(init_cb,500,NULL);
char buf[48]; snprintf(buf,sizeof(buf),"Conn %s",arm_host); set_status(buf);
}
static void connect_timer_cb(lv_timer_t* t){
(void)t; if(!g || arm_connected) return;
g->connect_attempts++;
char lbl[64];
if(g->connect_attempts==1) snprintf(lbl,sizeof(lbl),"mDNS browsing _robotarm._tcp...");
else snprintf(lbl,sizeof(lbl),"Searching (%d)...",g->connect_attempts);
if(g->connect_label) lv_label_set_text(g->connect_label,lbl);
// ── 1) mDNS browse for _robotarm._tcp (preferred) ──
TtMdnsBrowseResult res; memset(&res,0,sizeof(res));
if(tt_mdns_browse("_robotarm","_tcp",2500,10,&res)){
for(int i=0;i<res.count;i++){
if(res.services[i].primaryAddress[0]){
if(try_host(res.services[i].primaryAddress)){
show_main_ui(); return;
}
}
}
}
// ── 2) mDNS resolve robotarm.local hostname ──
char ipbuf[64]={0};
if(tt_mdns_resolve_hostname("robotarm.local",2000,ipbuf) || tt_mdns_resolve_hostname("robotarm",2000,ipbuf)){
if(try_host(ipbuf)){ show_main_ui(); return; }
}
// ── 3) fallback hardcoded (old behavior) ──
const char* fallbacks[]={"192.168.68.148","192.168.68.103","192.168.68.102"};
int idx = (g->connect_attempts-1) % (int)(sizeof(fallbacks)/sizeof(fallbacks[0]));
if(try_host(fallbacks[idx])){ show_main_ui(); return; }
if(g->connect_attempts>15){
if(g->connect_label) lv_label_set_text(g->connect_label,"No arm found.\nMake sure robotarm\nis powered & on WiFi.\nTap Retry.");
}
}
static void retry_cb(lv_event_t* e){(void)e; if(!g) return; g->connect_attempts=0; if(g->connect_label) lv_label_set_text(g->connect_label,"Retrying mDNS...");}
static void onShow(AppHandle app,void* data,lv_obj_t* parent){ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
(void)data; (void)data;
Ctx* c=(Ctx*)calloc(1,sizeof(Ctx)); if(!c) return; Ctx* c=calloc(1,sizeof(Ctx)); if(!c) return;
c->app=app; g=c; c->app=app; g=c;
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;} for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
arm_host[0]='\0'; arm_connected=false; c->connect_attempts=0;
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0); lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
// Root fills screen below toolbar - NOT scrollable itself, content will be
lv_obj_t* root=lv_obj_create(parent); lv_obj_t* root=lv_obj_create(parent);
c->root=root;
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100)); lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,34,0); lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,36,0);
lv_obj_set_style_border_width(root,0,0); lv_obj_set_style_border_width(root,0,0);
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0); lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0); lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
lv_obj_set_scroll_dir(root, LV_DIR_VER); lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* hdr=lv_obj_create(root); lv_obj_set_size(hdr,LV_PCT(100),16); // ── connecting screen (initial visible) ──
c->connect_box=lv_obj_create(root);
lv_obj_set_size(c->connect_box,LV_PCT(100),LV_PCT(100));
lv_obj_set_pos(c->connect_box,0,0);
lv_obj_set_style_border_width(c->connect_box,0,0);
lv_obj_set_style_bg_opa(c->connect_box,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(c->connect_box,4,0);
lv_obj_clear_flag(c->connect_box, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* tl=lv_label_create(c->connect_box);
lv_label_set_text(tl,"Finding robotarm...");
lv_obj_set_style_text_font(tl,lvgl_get_text_font(FONT_SIZE_LARGE),0);
lv_obj_set_style_text_color(tl,lv_color_hex(0x3D2B5A),0);
lv_obj_set_pos(tl,4,4);
c->connect_label=lv_label_create(c->connect_box);
lv_label_set_text(c->connect_label,"mDNS: _robotarm._tcp / robotarm.local\nFallback: 192.168.68.148\n\nSearching...");
lv_obj_set_style_text_font(c->connect_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(c->connect_label,lv_color_hex(0x6A5A7A),0);
lv_obj_set_pos(c->connect_label,4,28); lv_obj_set_width(c->connect_label,260);
c->connect_spinner=lv_spinner_create(c->connect_box);
lv_obj_set_size(c->connect_spinner,40,40);
lv_obj_set_pos(c->connect_spinner,120,110);
lv_obj_t* rb=lv_btn_create(c->connect_box);
lv_obj_set_size(rb,80,28); lv_obj_set_pos(rb,80,170);
lv_obj_set_style_bg_color(rb,lv_color_hex(0xC5F5C5),0); lv_obj_set_style_radius(rb,8,0);
lv_obj_t* rbl=lv_label_create(rb); lv_label_set_text(rbl,"Retry");
lv_obj_set_style_text_font(rbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(rbl,lv_color_hex(0x2A2A5A),0); lv_obj_center(rbl);
lv_obj_add_event_cb(rb,retry_cb,LV_EVENT_CLICKED,NULL);
// ── main scrollable content (hidden until connected) ──
// content is the ONLY scrollable container - FIX overall app scroller lost
c->content=lv_obj_create(root);
lv_obj_set_size(c->content,LV_PCT(100),LV_PCT(100));
lv_obj_set_pos(c->content,0,0);
lv_obj_set_style_border_width(c->content,0,0);
lv_obj_set_style_bg_opa(c->content,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(c->content,0,0);
lv_obj_set_flex_flow(c->content, LV_FLEX_FLOW_COLUMN);
lv_obj_add_flag(c->content, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(c->content, LV_SCROLLBAR_MODE_AUTO);
c->main_box=lv_obj_create(c->content);
lv_obj_set_size(c->main_box,LV_PCT(100),LV_SIZE_CONTENT);
lv_obj_set_style_border_width(c->main_box,0,0);
lv_obj_set_style_bg_opa(c->main_box,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(c->main_box,0,0);
lv_obj_clear_flag(c->main_box, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(c->main_box, LV_OBJ_FLAG_HIDDEN);
lv_obj_t* hdr=lv_obj_create(c->main_box); lv_obj_set_size(hdr,LV_PCT(100),16);
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(hdr,0,0); lv_obj_set_pos(hdr,0,0); lv_obj_set_style_pad_all(hdr,0,0);
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3"); lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(title,2,0); lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0);
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn .103..."); lv_obj_set_pos(title,2,0);
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn...");
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0); lv_obj_set_pos(c->status,90,0); lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0);
lv_obj_set_pos(c->status,90,0);
lv_obj_t* brow=lv_obj_create(root); lv_obj_set_size(brow,LV_PCT(100),20); lv_obj_t* brow=lv_obj_create(c->main_box); lv_obj_set_size(brow,LV_PCT(100),20);
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(brow,0,0); lv_obj_set_pos(brow,0,16); lv_obj_set_style_pad_all(brow,0,0);
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={ struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},}; {"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
@@ -302,12 +401,12 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL); lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
} }
/* ── large nice vertical sliders: 3 cols, 2x height 22x72 per request ── */ lv_obj_t* grid=lv_obj_create(c->main_box);
lv_obj_t* grid=lv_obj_create(root);
lv_obj_set_size(grid,LV_PCT(100),196); lv_obj_set_size(grid,LV_PCT(100),196);
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(grid,2,0); lv_obj_set_pos(grid,0,36); lv_obj_set_style_pad_all(grid,2,0);
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_OFF);
for(int i=0;i<NUM_JOINTS;i++){ for(int i=0;i<NUM_JOINTS;i++){
int col=i%3, row=i/3; int col=i%3, row=i/3;
@@ -320,6 +419,7 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
lv_obj_set_style_border_width(card,0,0); lv_obj_set_style_border_width(card,0,0);
lv_obj_set_style_pad_all(card,3,0); lv_obj_set_style_pad_all(card,3,0);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_OFF);
lv_obj_t* name=lv_label_create(card); lv_obj_t* name=lv_label_create(card);
lv_label_set_text(name,joints[i].cute); lv_label_set_text(name,joints[i].cute);
@@ -357,27 +457,22 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i); lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
} }
/* ── sequencer at bottom, NOT fixed — scrollable with content ── lv_obj_t* seqbar=lv_obj_create(c->main_box);
visual: colored blocks, no info, current highlighted, larger buttons */
lv_obj_t* seqbar=lv_obj_create(root);
lv_obj_set_size(seqbar,316,96); lv_obj_set_size(seqbar,316,96);
lv_obj_set_pos(seqbar,2,232);
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0); lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0); lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
lv_obj_set_style_radius(seqbar,12,0); lv_obj_set_style_radius(seqbar,12,0);
lv_obj_set_style_border_width(seqbar,1,0); lv_obj_set_style_border_width(seqbar,1,0);
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0); lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
lv_obj_set_style_pad_all(seqbar,4,0); lv_obj_set_style_pad_all(seqbar,4,0);
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq"); lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0); lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty"); c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0); lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={ struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0}, {"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},}; {"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
@@ -394,7 +489,6 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l); lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL); lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
} }
uint32_t blk_cols[16]={ uint32_t blk_cols[16]={
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF, 0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF 0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
@@ -412,9 +506,8 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
} }
update_seq_ui(); update_seq_ui();
c->poll=lv_timer_create(poll_cb,100,NULL); c->connect_timer=lv_timer_create(connect_timer_cb, 1000, NULL);
c->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL); connect_timer_cb(NULL);
lv_timer_create(init_cb,600,NULL);
} }
static void onHide(AppHandle app,void* data){ static void onHide(AppHandle app,void* data){
@@ -422,8 +515,9 @@ static void onHide(AppHandle app,void* data){
if(g){ if(g){
if(g->poll) lv_timer_delete(g->poll); if(g->poll) lv_timer_delete(g->poll);
if(g->seq_timer) lv_timer_delete(g->seq_timer); if(g->seq_timer) lv_timer_delete(g->seq_timer);
if(g->connect_timer) lv_timer_delete(g->connect_timer);
free(g); g=NULL; free(g); g=NULL;
} }
seq_playing=false; seq_playing=false; arm_connected=false;
} }
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;} int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
+12
View File
@@ -0,0 +1,12 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(SdDownloader)
tactility_project(SdDownloader)
+6
View File
@@ -0,0 +1,6 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS Source
REQUIRES TactilitySDK esp_http_client
)
+460
View File
@@ -0,0 +1,460 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_bundle.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_http_client.h"
#include "esp_heap_caps.h"
#define TAG "SDDL"
#define DEFAULT_URL "http://192.168.68.110:8098/463_Discovering_Discovery_Mountain_S01E01/discovery_mountain_s01e01_esp32_24k.mp3"
#define DEFAULT_PATH "/sdcard/download/test2.mp3"
typedef struct {
lv_obj_t *bar;
lv_obj_t *lbl_url;
lv_obj_t *lbl_path;
lv_obj_t *lbl_pct;
lv_obj_t *lbl_detail;
lv_obj_t *lbl_status;
lv_obj_t *btn_cancel;
char url[512];
char path[512];
bool override_existing;
AppHandle app_handle;
TaskHandle_t dl_handle;
volatile bool downloading;
volatile bool cancel_req;
volatile int total;
volatile int expected;
int last_pct;
bool result_success;
} AppCtx;
static AppCtx G;
static void ensure_parent_dir(const char *filepath) {
if (!filepath) return;
char path_copy[512];
strncpy(path_copy, filepath, sizeof(path_copy)-1);
path_copy[sizeof(path_copy)-1]=0;
char *last_slash = strrchr(path_copy, '/');
if (!last_slash) return;
*last_slash = 0;
if (strlen(path_copy)==0) return;
char cur[512] = "";
if (path_copy[0]=='/') strcpy(cur, "/");
char *start = path_copy[0]=='/' ? path_copy+1 : path_copy;
int len = strlen(start);
int i = 0;
while (i <= len) {
int j = i;
while (j < len && start[j] != '/') j++;
if (j > i) {
size_t cur_len = strlen(cur);
if (cur_len > 0 && cur[cur_len-1] != '/') {
if (cur_len + 1 < sizeof(cur)) {
cur[cur_len] = '/';
cur[cur_len+1] = 0;
}
}
char saved = start[j];
start[j] = 0;
strncat(cur, start+i, sizeof(cur)-strlen(cur)-1);
start[j] = saved;
mkdir(cur, 0777);
}
i = j+1;
}
}
static void update_ui_status(const char *txt) {
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, txt);
tt_lvgl_unlock();
}
static void set_result_and_close(bool success) {
G.result_success = success;
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
BundleHandle result_bundle = NULL;
if (free_internal > 8000) {
result_bundle = tt_bundle_alloc();
}
if (result_bundle) {
tt_bundle_put_bool(result_bundle, "success", success);
// Keep bundle minimal to avoid low heap crash (previous put_string path 512B caused OOM)
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, result_bundle);
} else {
// Low heap return OK without bundle (DM will still refresh via has_slug)
if (free_internal <= 8000) {
ESP_LOGW(TAG, "Low heap %d, returning result without bundle", free_internal);
}
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, NULL);
}
// Change Cancel button to Close once done (manual close, auto-close unreliable)
tt_lvgl_lock(portMAX_DELAY);
if (G.btn_cancel) {
lv_obj_t* lbl = lv_obj_get_child(G.btn_cancel, 0);
if (lbl) lv_label_set_text(lbl, "Close");
}
tt_lvgl_unlock();
}
static bool download_one_file(const char *url, const char *path) {
ESP_LOGI(TAG, "DL one file url=%s path=%s override=%d", url, path, G.override_existing);
struct stat st;
if (stat(path, &st)==0) {
if (!G.override_existing) {
ESP_LOGW(TAG, "File exists and override=false: %s (returning true as cached)", path);
G.total = st.st_size;
update_ui_status("Already on SD");
return true;
} else {
ESP_LOGI(TAG, "File exists but override=true, overwriting %s", path);
unlink(path);
}
}
ensure_parent_dir(path);
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
ESP_LOGI(TAG, "Heap before DL: internal=%d", free_internal);
if (free_internal < 10000) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
if (strlen(url) < 8 || strncmp(url, "http://", 7)!=0) {
update_ui_status("Invalid URL");
return false;
}
char tmp_path[520];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
unlink(tmp_path);
FILE *f = fopen(tmp_path, "wb");
if (!f) {
ESP_LOGE(TAG, "fopen fail %s errno=%d", tmp_path, errno);
update_ui_status("Failed to open file");
return false;
}
esp_http_client_config_t cfg = {
.url = url,
.timeout_ms = 15000,
.buffer_size = 4096,
.buffer_size_tx = 1024,
.keep_alive_enable = false,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) {
fclose(f);
unlink(tmp_path);
update_ui_status("HTTP init failed");
return false;
}
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
update_ui_status("HTTP open failed");
return false;
}
int content_len = esp_http_client_fetch_headers(client);
int status = esp_http_client_get_status_code(client);
ESP_LOGI(TAG, "HTTP status %d, content_len %d", status, content_len);
if (status != 200) {
char msg[64];
snprintf(msg, sizeof(msg), "HTTP %d", status);
update_ui_status(msg);
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
return false;
}
G.expected = content_len;
uint8_t *buf = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!buf) {
buf = malloc(4096);
if (!buf) {
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
update_ui_status("No memory");
return false;
}
}
int total = 0;
int timeouts = 0;
bool ok = true;
while (1) {
if (G.cancel_req) { ok = false; break; }
if (content_len >=0 && total >= content_len) break;
int r = esp_http_client_read(client, (char*)buf, 4096);
if (r < 0) {
timeouts++;
if (timeouts >= 5) { ok = false; break; }
vTaskDelay(pdMS_TO_TICKS(200));
continue;
}
if (r == 0) break;
timeouts = 0;
if (fwrite(buf, 1, r, f) != (size_t)r) { ok = false; break; }
total += r;
G.total = total;
int pct = 0;
if (content_len > 0) {
pct = (int)((int64_t)total * 100 / content_len);
if (pct<0) pct=0;
if (pct>100) pct=100;
}
if (pct != G.last_pct) {
G.last_pct = pct;
tt_lvgl_lock(portMAX_DELAY);
if (G.bar) lv_bar_set_value(G.bar, pct, LV_ANIM_OFF);
if (G.lbl_detail) {
char d[64];
if (content_len>0) snprintf(d,sizeof(d),"%d / %d KB", total/1024, content_len/1024);
else snprintf(d,sizeof(d),"%d KB", total/1024);
lv_label_set_text(G.lbl_detail, d);
}
if (G.lbl_pct) {
char b[32];
if (content_len>0) snprintf(b,sizeof(b),"%d%%",pct);
else snprintf(b,sizeof(b),"%d KB", total/1024);
lv_label_set_text(G.lbl_pct, b);
}
tt_lvgl_unlock();
}
if ((total / 4096) % 8 == 0) {
fflush(f);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
heap_caps_free(buf);
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
if (G.cancel_req) { unlink(tmp_path); update_ui_status("Canceled"); return false; }
if (!ok || total < 512) { unlink(tmp_path); update_ui_status("Failed error"); return false; }
if (content_len >=0 && total < content_len * 95 / 100) { unlink(tmp_path); update_ui_status("Failed incomplete"); return false; }
if (rename(tmp_path, path) != 0) { unlink(tmp_path); update_ui_status("Rename failed"); return false; }
return true;
}
static void download_task(void *arg) {
(void)arg;
G.downloading = true;
G.cancel_req = false;
G.last_pct = -1;
char url[512];
char path[512];
strncpy(url, G.url, sizeof(url)-1);
strncpy(path, G.path, sizeof(path)-1);
url[sizeof(url)-1]=0;
path[sizeof(path)-1]=0;
bool ok = download_one_file(url, path);
if (ok) {
char msg[64];
snprintf(msg, sizeof(msg), "Done! %d KB", G.total/1024);
update_ui_status(msg);
}
G.downloading = false;
G.dl_handle = NULL;
set_result_and_close(ok);
vTaskDelete(NULL);
}
static void btn_cancel_cb(lv_event_t *e) {
(void)e;
if (!G.downloading) {
tt_app_stop();
return;
}
G.cancel_req = true;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Canceling...");
tt_lvgl_unlock();
}
static void build_ui(void) {
tt_lvgl_lock(portMAX_DELAY);
lv_obj_t *root = lv_obj_create(lv_scr_act());
lv_obj_set_size(root, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(root, 8, 0);
lv_obj_set_style_bg_color(root, lv_color_hex(0x111111), 0);
lv_obj_set_style_border_width(root, 0, 0);
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(root, 6, 0);
lv_obj_t *title = lv_label_create(root);
lv_label_set_text(title, "Downloading...");
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
G.lbl_url = lv_label_create(root);
lv_label_set_text(G.lbl_url, G.url);
lv_obj_set_width(G.lbl_url, LV_PCT(100));
lv_label_set_long_mode(G.lbl_url, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(G.lbl_url, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_url, lv_color_hex(0x888888), 0);
G.lbl_path = lv_label_create(root);
lv_label_set_text(G.lbl_path, G.path);
lv_obj_set_width(G.lbl_path, LV_PCT(100));
lv_label_set_long_mode(G.lbl_path, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(G.lbl_path, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_path, lv_color_hex(0xAAAAAA), 0);
G.bar = lv_bar_create(root);
lv_obj_set_size(G.bar, LV_PCT(100), 14);
lv_bar_set_range(G.bar, 0, 100);
lv_bar_set_value(G.bar, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(G.bar, lv_color_hex(0x333333), LV_PART_MAIN);
lv_obj_set_style_bg_color(G.bar, lv_color_hex(0x2E7D32), LV_PART_INDICATOR);
lv_obj_t *row = lv_obj_create(root);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
G.lbl_pct = lv_label_create(row);
lv_label_set_text(G.lbl_pct, "0%");
lv_obj_set_style_text_font(G.lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
G.lbl_detail = lv_label_create(row);
lv_label_set_text(G.lbl_detail, "-");
lv_obj_set_style_text_font(G.lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_detail, lv_color_hex(0x888888), 0);
G.lbl_status = lv_label_create(root);
lv_label_set_text(G.lbl_status, "Starting...");
lv_obj_set_width(G.lbl_status, LV_PCT(100));
lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_color(G.lbl_status, lv_color_hex(0xCCCCCC), 0);
G.btn_cancel = lv_button_create(root);
lv_obj_set_size(G.btn_cancel, 100, 40);
lv_obj_set_style_bg_color(G.btn_cancel, lv_color_hex(0x444444), 0);
lv_obj_t *lbl = lv_label_create(G.btn_cancel);
lv_label_set_text(lbl, "Cancel");
lv_obj_center(lbl);
lv_obj_add_event_cb(G.btn_cancel, btn_cancel_cb, LV_EVENT_CLICKED, NULL);
tt_lvgl_unlock();
}
static void onShow(AppHandle app, void* data, lv_obj_t* parent) {
(void)data; (void)parent;
memset(&G, 0, sizeof(G));
G.app_handle = app;
G.last_pct = -1;
BundleHandle bundle = tt_app_get_parameters(app);
bool has_url = false;
bool has_path = false;
if (bundle) {
char url_buf[512] = {0};
char path_buf[512] = {0};
bool override_val = false;
if (tt_bundle_opt_string(bundle, "url", url_buf, sizeof(url_buf)) ||
tt_bundle_opt_string(bundle, "file", url_buf, sizeof(url_buf))) {
strncpy(G.url, url_buf, sizeof(G.url)-1);
has_url = true;
}
if (tt_bundle_opt_string(bundle, "path", path_buf, sizeof(path_buf)) ||
tt_bundle_opt_string(bundle, "dest", path_buf, sizeof(path_buf)) ||
tt_bundle_opt_string(bundle, "file_dest", path_buf, sizeof(path_buf))) {
strncpy(G.path, path_buf, sizeof(G.path)-1);
has_path = true;
}
if (tt_bundle_opt_bool(bundle, "override", &override_val)) {
G.override_existing = override_val;
} else {
bool ow2 = false;
if (tt_bundle_opt_bool(bundle, "overwrite", &ow2)) G.override_existing = ow2;
}
}
if (!has_url) strncpy(G.url, DEFAULT_URL, sizeof(G.url)-1);
if (!has_path) strncpy(G.path, DEFAULT_PATH, sizeof(G.path)-1);
mkdir("/sdcard", 0777);
mkdir("/sdcard/download", 0777);
mkdir("/sdcard/dm", 0777);
build_ui();
bool should_autostart = has_url && has_path;
if (!has_url && !has_path) {
should_autostart = true;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Demo mode no args, using defaults");
tt_lvgl_unlock();
}
ESP_LOGI(TAG, "Autostart=%d url=%s path=%s override=%d", should_autostart, G.url, G.path, G.override_existing);
if (should_autostart) {
BaseType_t res = xTaskCreate(download_task, "sd_dl", 8192, NULL, 5, &G.dl_handle);
ESP_LOGI(TAG, "xTaskCreate res=%d handle=%p", res, G.dl_handle);
if (res != pdPASS) update_ui_status("Failed to create DL task");
} else {
update_ui_status("Missing url/path args");
}
}
static void onHide(AppHandle app, void* data) {
(void)app; (void)data;
if (G.dl_handle) {
G.cancel_req = true;
for (int i=0;i<20 && G.dl_handle!=NULL; i++) vTaskDelay(pdMS_TO_TICKS(100));
if (G.dl_handle) { vTaskDelete(G.dl_handle); G.dl_handle = NULL; G.downloading = false; }
}
}
int main(int argc, char* argv[]) {
(void)argc; (void)argv;
tt_app_register((AppRegistration){ .onShow = onShow, .onHide = onHide });
return 0;
}
+7
View File
@@ -0,0 +1,7 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.sddownloader
app.name=SD Downloader
app.version.name=1.0.0
app.version.code=1
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.serialconsole
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.7.0
[app] app.version.code=7
id=one.tactility.serialconsole app.name=Serial Console
versionName=0.4.0
versionCode=4
name=Serial Console
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.snake
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.8.0
[app] app.version.code=8
id=one.tactility.snake app.name=Snake
versionName=0.5.0 app.description=Classic Snake game
versionCode=5
name=Snake
description=Classic Snake game
-1
View File
@@ -57,7 +57,6 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
if (sfxEngine == nullptr) { if (sfxEngine == nullptr) {
sfxEngine = new SfxEngine(); sfxEngine = new SfxEngine();
sfxEngine->start(); sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
// Load settings // Load settings
bool soundEnabled; bool soundEnabled;
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.tamatac
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.4.0
[app] app.version.code=4
id=one.tactility.tamatac app.name=TamaTac
versionName=0.1.0 app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
versionCode=1
name=TamaTac
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.todolist
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.5.0
[app] app.version.code=5
id=one.tactility.todolist app.name=Todo List
versionName=0.2.0 app.description=Simple task list manager
versionCode=2
name=Todo List
description=Simple task list manager
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.twoeleven
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.7.0
[app] app.version.code=7
id=one.tactility.twoeleven app.name=2048
versionName=0.4.0 app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
versionCode=4
name=2048
description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
+109
View File
@@ -30,6 +30,18 @@
#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes #define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes
#define AUDIO_BUF_SIZE 4096 #define AUDIO_BUF_SIZE 4096
// ─── Scroll animation flags ───
// LVGL label scroll animation (LV_LABEL_LONG_SCROLL_*) causes continuous
// invalidations and screen refreshes that can starve the audio_stream task.
// LVGL's lv_list_add_button() internally sets SCROLL_CIRCULAR by default (see
// lv_list.c), so we must override it for memo names.
//
// User request: apply flag only to recordings names on the list.
#define ENABLE_SCROLL_ANIM_WHEN_IDLE 1
#define ENABLE_SCROLL_ANIM_DURING_AUDIO 0
#define ENABLE_MEMO_LIST_SCROLL_ANIM 0 // 0 = DOT truncation (no anim, safe for audio), 1 = SCROLL_CIRCULAR
#define ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO 0 // Force DOT during recording/playback regardless of above
#define MAX_MEMOS 50 #define MAX_MEMOS 50
#define MAX_FILENAME_LEN 64 #define MAX_FILENAME_LEN 64
@@ -208,6 +220,8 @@ static void record_task(void* arg) {
size_t total_written = 0; size_t total_written = 0;
uint32_t start_time = xTaskGetTickCount(); uint32_t start_time = xTaskGetTickCount();
uint32_t last_ui_sec = 0;
bool first_ui = true;
while (ctx->state == STATE_RECORDING) { while (ctx->state == STATE_RECORDING) {
size_t bytes_read = 0; size_t bytes_read = 0;
@@ -223,6 +237,10 @@ static void record_task(void* arg) {
} }
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ; uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
// Throttle UI updates to once per second to reduce LVGL invalidations during recording
if (first_ui || elapsed_sec != last_ui_sec) {
first_ui = false;
last_ui_sec = elapsed_sec;
char status_buf[64]; char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60)); snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
@@ -231,6 +249,7 @@ static void record_task(void* arg) {
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF); lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
tt_lvgl_unlock(); tt_lvgl_unlock();
} }
}
audio_stream_close(ctx->input_handle); audio_stream_close(ctx->input_handle);
ctx->input_handle = NULL; ctx->input_handle = NULL;
@@ -349,6 +368,9 @@ static void play_task(void* arg) {
} }
bool out_open = true; bool out_open = true;
int last_pct = -1;
uint32_t last_elapsed_sec = 0;
bool first_ui = true;
while (total_played < data_size && ctx->state != STATE_IDLE) { while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) { if (ctx->state == STATE_PAUSED) {
@@ -407,6 +429,11 @@ static void play_task(void* arg) {
uint32_t elapsed_sec = total_played / bytes_per_sec; uint32_t elapsed_sec = total_played / bytes_per_sec;
uint32_t total_sec = data_size / bytes_per_sec; uint32_t total_sec = data_size / bytes_per_sec;
// Throttle UI updates only when % or second changes, to reduce LVGL work during audio
if (first_ui || pct != last_pct || elapsed_sec != last_elapsed_sec) {
first_ui = false;
last_pct = pct;
last_elapsed_sec = elapsed_sec;
char status_buf[64]; char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u", snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u",
(unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60), (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60),
@@ -417,6 +444,7 @@ static void play_task(void* arg) {
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock(); tt_lvgl_unlock();
} }
}
fclose(f); fclose(f);
ESP_LOGI(TAG, "Playback task complete"); ESP_LOGI(TAG, "Playback task complete");
@@ -549,17 +577,91 @@ static void refresh_memo_list(AppCtx* ctx) {
for (int i = 0; i < ctx->memo_count; i++) { for (int i = 0; i < ctx->memo_count; i++) {
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]); lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx); lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
// ─── Fix: LVGL list buttons default to SCROLL_CIRCULAR (see lv_list.c)
// This causes constant invalidations that starve audio_stream.
// User wants flag to disable this for recording names.
// Safe handling: last child is always the label (icon is first if present)
uint32_t child_cnt = lv_obj_get_child_cnt(btn);
if (child_cnt > 0) {
lv_obj_t* label = lv_obj_get_child(btn, child_cnt - 1);
if (label) {
#if ENABLE_MEMO_LIST_SCROLL_ANIM
// Allow scrolling when flag enabled but force DOT during audio if that flag is off
#if ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#else
// During audio we still force DOT; this func runs at list refresh (idle time)
// To respect flag for idle, we set SCROLL here; update_ui will force DOT during audio if needed
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#endif
#else
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
#endif
}
}
if (i == ctx->selected_index) { if (i == ctx->selected_index) {
lv_obj_add_state(btn, LV_STATE_CHECKED); lv_obj_add_state(btn, LV_STATE_CHECKED);
} }
} }
} }
// Helper to force DOT on memo list during audio playback/recording if needed
static void set_memo_list_scroll_enabled(AppCtx* ctx, bool enable) {
if (!ctx->lst_memos) return;
#if ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO
// If flag allows scroll during audio, do nothing
if (enable) return;
#endif
// Force all memo labels to DOT (no animation) when audio active
uint32_t list_child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
for (uint32_t i = 0; i < list_child_cnt; i++) {
lv_obj_t* btn = lv_obj_get_child(ctx->lst_memos, i);
if (!btn) continue;
uint32_t btn_child_cnt = lv_obj_get_child_cnt(btn);
if (btn_child_cnt == 0) continue;
lv_obj_t* label = lv_obj_get_child(btn, btn_child_cnt - 1);
if (label) {
lv_label_set_long_mode(label, enable ?
#if ENABLE_MEMO_LIST_SCROLL_ANIM
LV_LABEL_LONG_SCROLL_CIRCULAR
#else
LV_LABEL_LONG_DOT
#endif
: LV_LABEL_LONG_DOT);
}
}
}
// Helper to control label scrolling based on audio state
static void set_status_scroll_enabled(AppCtx* ctx, bool enable) {
if (!ctx->lbl_status) return;
if (enable) {
#if ENABLE_SCROLL_ANIM_WHEN_IDLE
// Allow scrolling when idle if flag says so, otherwise DOT
// We intentionally keep status as DOT even when idle to avoid
// unnecessary redraws, but this hook lets future tweaks enable it.
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#else
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#endif
} else {
#if ENABLE_SCROLL_ANIM_DURING_AUDIO
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_SCROLL_CIRCULAR);
#else
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#endif
}
}
static void update_ui(AppCtx* ctx) { static void update_ui(AppCtx* ctx) {
if (ctx->lbl_status == NULL) return; if (ctx->lbl_status == NULL) return;
switch (ctx->state) { switch (ctx->state) {
case STATE_RECORDING: case STATE_RECORDING:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED);
@@ -567,6 +669,8 @@ static void update_ui(AppCtx* ctx) {
break; break;
case STATE_PLAYING: case STATE_PLAYING:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE); lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE);
@@ -575,6 +679,8 @@ static void update_ui(AppCtx* ctx) {
break; break;
case STATE_PAUSED: case STATE_PAUSED:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY); lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY);
@@ -584,6 +690,8 @@ static void update_ui(AppCtx* ctx) {
case STATE_IDLE: case STATE_IDLE:
default: default:
set_status_scroll_enabled(ctx, true);
set_memo_list_scroll_enabled(ctx, true);
lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED);
if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) { if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) {
@@ -642,6 +750,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95)); lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
lv_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_DOT);
lv_label_set_text(g_ctx.lbl_status, "Scanning memos..."); lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
g_ctx.bar_progress = lv_bar_create(card); g_ctx.bar_progress = lv_bar_create(card);
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3
sdk=0.8.0-dev app.id=one.tactility.voicerecorder
platforms=esp32s3 app.version.name=1.0.0
[app] app.version.code=1
id=one.tactility.voicerecorder app.name=Voice Recorder
name=Voice Recorder
versionName=1.0.0
versionCode=1
+70
View File
@@ -0,0 +1,70 @@
import os
import sys
import boto3
SHELL_COLOR_RED = "\033[91m"
SHELL_COLOR_ORANGE = "\033[93m"
SHELL_COLOR_RESET = "\033[m"
def print_warning(message):
print(f"{SHELL_COLOR_ORANGE}WARNING: {message}{SHELL_COLOR_RESET}")
def print_error(message):
print(f"{SHELL_COLOR_RED}ERROR: {message}{SHELL_COLOR_RESET}")
def print_help():
print("Usage: python upload-app-files.py [path] [sdkVersion] [cloudflareAccountId] [cloudflareTokenName] [cloudflareTokenValue]")
print("")
print("Options:")
print(" --index-only Upload only apps.json")
def exit_with_error(message):
print_error(message)
sys.exit(1)
def main(path: str, sdk_version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool):
if not os.path.exists(path):
exit_with_error(f"Path not found: {path}")
s3 = boto3.client(
service_name="s3",
endpoint_url=f"https://{cloudflare_account_id}.r2.cloudflarestorage.com",
aws_access_key_id=cloudflare_token_name,
aws_secret_access_key=cloudflare_token_value,
region_name="auto"
)
files_to_upload = os.listdir(path)
if index_only:
files_to_upload = [f for f in files_to_upload if f == 'apps.json']
else:
# Ensure apps.json is uploaded last so it never references files that
# haven't finished uploading yet.
files_to_upload.sort(key=lambda f: f == 'apps.json')
counter = 1
total = len(files_to_upload)
for file_name in files_to_upload:
object_path = f"apps/{sdk_version}/{file_name}"
print(f"[{counter}/{total}] Uploading {file_name} to {object_path}")
file_path = os.path.join(path, file_name)
try:
s3.upload_file(file_path, "tactility", object_path)
except Exception as e:
exit_with_error(f"Failed to upload {file_name}: {str(e)}")
counter += 1
if __name__ == "__main__":
print("Tactility CDN Apps Uploader")
if "--help" in sys.argv:
print_help()
sys.exit()
# Argument validation
if len(sys.argv) < 6:
print_help()
sys.exit(1)
main(
path=sys.argv[1],
sdk_version=sys.argv[2],
cloudflare_account_id=sys.argv[3],
cloudflare_token_name=sys.argv[4],
cloudflare_token_value=sys.argv[5],
index_only="--index-only" in sys.argv
)
+23
View File
@@ -0,0 +1,23 @@
# Peanut-GB vendored library
Source: https://github.com/deltabeard/Peanut-GB
File: peanut_gb.h (single-header emulator)
License: MIT License - Copyright (c) 2018-2023 Mahyar Koshkouei
Date Vendored: 2026-07-17 UTC
Upstream: master branch latest as fetched 2026-07-17
Commit URL: https://github.com/deltabeard/Peanut-GB/tree/master
Size: ~4044 lines
MIT license text is preserved intact at top of peanut_gb.h header.
SameBoy-derived portions: Copyright (c) 2015-2019 Lior Halphon, also MIT.
Usage:
```c
#define ENABLE_SOUND 0
#define ENABLE_LCD 1
#include "peanut_gb.h"
```
No modifications applied — used as-is.
For Tactility GameBoy app, audio is disabled (ENABLE_SOUND 0) per prototype spec.
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -35,11 +35,8 @@ if (!engine->start()) {
ESP_LOGE(TAG, "Failed to start SfxEngine"); ESP_LOGE(TAG, "Failed to start SfxEngine");
return; return;
} }
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
engine->play(SfxId::Coin); // Predefined SFX engine->play(SfxId::Coin); // Predefined SFX
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
engine->setVolume(0.7f); // Volume control
engine->stop(); engine->stop();
delete engine; delete engine;
@@ -87,9 +84,11 @@ idf_component_register(
- `void stopVoice(voice)` - Stop specific voice - `void stopVoice(voice)` - Stop specific voice
### Settings ### Settings
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
- `void setEnabled(bool)` - Mute/unmute - `void setEnabled(bool)` - Mute/unmute
- `void applyVolumePreset(VolumePreset)` - Apply Quiet/Normal/Loud preset (configures volume, gate, normalization)
Loudness is controlled by the system output volume (set via the audio_stream device / Settings UI),
not by SfxEngine itself -- a fixed app-side gain on top of hardware attenuation gets swamped at low
system volumes, so there's no separate volume control here.
### Mixing (consistent with SoundEngine) ### Mixing (consistent with SoundEngine)
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on) - `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
+55 -40
View File
@@ -1,14 +1,23 @@
import json import json
import subprocess
import tarfile import tarfile
import os import os
import tempfile import tempfile
import configparser
import sys import sys
from datetime import datetime, UTC
def read_properties_file(path): def read_properties_file(path):
config = configparser.RawConfigParser() properties = {}
config.read(path) with open(path, "r") as file:
return config for line in file:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
def get_manifest(appPath): def get_manifest(appPath):
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz """Extract only the file named 'manifest.properties' from the given tar/tar.gz
@@ -62,51 +71,49 @@ def get_manifest(appPath):
return None return None
def get_versioned_file_name(manifest): def get_versioned_file_name(manifest):
app_id = manifest["app"]["id"] app_id = manifest["app.id"]
version_code = manifest["app"]["versionCode"] version_code = manifest["app.version.code"]
return f"{app_id}-{version_code}.app" return f"{app_id}-{version_code}.app"
def get_os_version(manifest): def get_os_version(manifest):
sdk = manifest["target"]["sdk"] sdk = manifest["target.sdk"]
# Remove trailing hyphen suffix if present # Remove trailing hyphen suffix if present
if "-" in sdk: if "-" in sdk:
return sdk.rsplit("-", 1)[0].strip() return sdk.rsplit("-", 1)[0].strip()
else: else:
return sdk return sdk
def manifest_config_to_flat_json(manifest): def check_and_get_sdk_version(manifest_map):
"""Convert a ConfigParser manifest into a flat JSON-like dict. """Ensure all apps target the same (simplified) SDK version and return it."""
versions = {get_os_version(manifest) for manifest in manifest_map.values()}
if len(versions) != 1:
print(f"ERROR: Apps target multiple SDK versions: {sorted(versions)}. All apps must target the same SDK version.")
sys.exit(1)
return next(iter(versions))
Expected sections/keys (case-insensitive for keys): def get_git_commit_hash():
- [app] return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
id -> appId
versionName -> appVersionName def manifest_config_to_flat_json(manifest):
versionCode -> appVersionCode (int) """Convert a flat (V2) manifest dict into a flat JSON-like dict.
name -> appName
description -> appDescription (optional; default "") Expected keys:
- [target] app.id -> appId
sdk -> targetSdk app.version.name -> appVersionName
platforms -> targetPlatforms (comma-separated list) app.version.code -> appVersionCode (int)
app.name -> appName
app.description -> appDescription (optional; default "")
target.sdk -> targetSdk
target.platforms -> targetPlatforms (comma-separated list)
Unknown/missing values fall back to sensible defaults per requirements. Unknown/missing values fall back to sensible defaults per requirements.
""" """
def get_opt(section, option, default=None):
if not manifest.has_section(section):
return default
# try exact option then lowercase (RawConfigParser lowercases by default)
if manifest.has_option(section, option):
return manifest.get(section, option)
low = option.lower()
if manifest.has_option(section, low):
return manifest.get(section, low)
return default
# Map values # Map values
app_id = get_opt("app", "id", "") app_id = manifest.get("app.id", "")
app_version_name = get_opt("app", "versionName", "") app_version_name = manifest.get("app.version.name", "")
app_version_code_raw = get_opt("app", "versionCode", "0") app_version_code_raw = manifest.get("app.version.code", "0")
app_name = get_opt("app", "name", "") app_name = manifest.get("app.name", "")
app_description = get_opt("app", "description", "") or "" app_description = manifest.get("app.description", "") or ""
# Coerce version code to int safely # Coerce version code to int safely
try: try:
@@ -114,8 +121,8 @@ def manifest_config_to_flat_json(manifest):
except Exception: except Exception:
app_version_code = 0 app_version_code = 0
target_sdk = get_opt("target", "sdk", "") target_sdk = manifest.get("target.sdk", "")
platforms_raw = get_opt("target", "platforms", "") platforms_raw = manifest.get("target.platforms", "")
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else [] target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
filename = get_versioned_file_name(manifest) filename = get_versioned_file_name(manifest)
@@ -142,15 +149,23 @@ if __name__ == "__main__":
sys.exit() sys.exit()
app_directory = sys.argv[1] app_directory = sys.argv[1]
manifest_map = {} manifest_map = {}
output_json = {
"apps": []
}
any_manifest = None any_manifest = None
if os.path.exists(app_directory): if os.path.exists(app_directory):
for file in os.listdir(app_directory): for file in os.listdir(app_directory):
if file.endswith(".app"): if file.endswith(".app"):
file_path = os.path.join(app_directory, file) file_path = os.path.join(app_directory, file)
manifest_map[file_path] = get_manifest(file_path) manifest_map[file_path] = get_manifest(file_path)
# All bundled apps must target the same SDK version; this becomes the CDN path segment
sdk_version = check_and_get_sdk_version(manifest_map)
with open("sdk_version.txt", "w") as f:
f.write(sdk_version)
print(f"SDK version: {sdk_version}")
output_json = {
"sdkVersion": sdk_version,
"created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
"gitCommit": get_git_commit_hash(),
"apps": []
}
# Rename files and collect manifest data into output json object # Rename files and collect manifest data into output json object
for file_path in manifest_map.keys(): for file_path in manifest_map.keys():
print(f"Processing {file_path}: {manifest_map[file_path]}") print(f"Processing {file_path}: {manifest_map[file_path]}")
+23 -36
View File
@@ -1,4 +1,3 @@
import configparser
import json import json
import os import os
import re import re
@@ -13,7 +12,7 @@ import tarfile
from urllib.parse import urlparse from urllib.parse import urlparse
ttbuild_path = ".tactility" ttbuild_path = ".tactility"
ttbuild_version = "3.5.1" ttbuild_version = "4.1.0"
ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_cdn = "https://cdn.tactilityproject.org"
ttbuild_sdk_json_validity = 3600 # seconds ttbuild_sdk_json_validity = 3600 # seconds
ttport = 6666 ttport = 6666
@@ -106,9 +105,17 @@ def get_url(ip, path):
return f"http://{ip}:{ttport}{path}" return f"http://{ip}:{ttport}{path}"
def read_properties_file(path): def read_properties_file(path):
config = configparser.RawConfigParser() properties = {}
config.read(path) with open(path, "r") as file:
return config for line in file:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
#endregion Core #endregion Core
@@ -185,7 +192,7 @@ def fetch_sdkconfig_files(platform_targets):
for platform in platform_targets: for platform in platform_targets:
sdkconfig_filename = f"sdkconfig.app.{platform}" sdkconfig_filename = f"sdkconfig.app.{platform}"
target_path = os.path.join(ttbuild_path, sdkconfig_filename) target_path = os.path.join(ttbuild_path, sdkconfig_filename)
if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path): if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
exit_with_error(f"Failed to download sdkconfig file for {platform}") exit_with_error(f"Failed to download sdkconfig file for {platform}")
#endregion SDK helpers #endregion SDK helpers
@@ -231,32 +238,12 @@ def read_manifest():
return read_properties_file("manifest.properties") return read_properties_file("manifest.properties")
def validate_manifest(manifest): def validate_manifest(manifest):
# [manifest] for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"):
if not "manifest" in manifest: if key not in manifest:
exit_with_error("Invalid manifest format: [manifest] not found") exit_with_error(f"Invalid manifest format: {key} not found")
if not "version" in manifest["manifest"]:
exit_with_error("Invalid manifest format: [manifest] version not found")
# [target]
if not "target" in manifest:
exit_with_error("Invalid manifest format: [target] not found")
if not "sdk" in manifest["target"]:
exit_with_error("Invalid manifest format: [target] sdk not found")
if not "platforms" in manifest["target"]:
exit_with_error("Invalid manifest format: [target] platforms not found")
# [app]
if not "app" in manifest:
exit_with_error("Invalid manifest format: [app] not found")
if not "id" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] id not found")
if not "versionName" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] versionName not found")
if not "versionCode" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] versionCode not found")
if not "name" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] name not found")
def is_valid_manifest_platform(manifest, platform): def is_valid_manifest_platform(manifest, platform):
manifest_platforms = manifest["target"]["platforms"].split(",") manifest_platforms = manifest["target.platforms"].split(",")
return platform in manifest_platforms return platform in manifest_platforms
def validate_manifest_platform(manifest, platform): def validate_manifest_platform(manifest, platform):
@@ -265,7 +252,7 @@ def validate_manifest_platform(manifest, platform):
def get_manifest_target_platforms(manifest, requested_platform): def get_manifest_target_platforms(manifest, requested_platform):
if requested_platform == "" or requested_platform is None: if requested_platform == "" or requested_platform is None:
return manifest["target"]["platforms"].split(",") return manifest["target.platforms"].split(",")
else: else:
validate_manifest_platform(manifest, requested_platform) validate_manifest_platform(manifest, requested_platform)
return [requested_platform] return [requested_platform]
@@ -512,7 +499,7 @@ def build_action(manifest, platform_arg, skip_build):
if use_local_sdk: if use_local_sdk:
global local_base_path global local_base_path
local_base_path = os.environ.get("TACTILITY_SDK_PATH") local_base_path = os.environ.get("TACTILITY_SDK_PATH")
validate_local_sdks(platforms_to_build, manifest["target"]["sdk"]) validate_local_sdks(platforms_to_build, manifest["target.sdk"])
if should_fetch_sdkconfig_files(platforms_to_build): if should_fetch_sdkconfig_files(platforms_to_build):
fetch_sdkconfig_files(platforms_to_build) fetch_sdkconfig_files(platforms_to_build)
@@ -521,7 +508,7 @@ def build_action(manifest, platform_arg, skip_build):
sdk_json = read_sdk_json() sdk_json = read_sdk_json()
validate_self(sdk_json) validate_self(sdk_json)
# Build # Build
sdk_version = manifest["target"]["sdk"] sdk_version = manifest["target.sdk"]
if not use_local_sdk: if not use_local_sdk:
if not sdk_download_all(sdk_version, platforms_to_build): if not sdk_download_all(sdk_version, platforms_to_build):
exit_with_error("Failed to download one or more SDKs") exit_with_error("Failed to download one or more SDKs")
@@ -570,7 +557,7 @@ def get_device_info(ip):
print_status_error(f"Device info request failed: {e}") print_status_error(f"Device info request failed: {e}")
def run_action(manifest, ip): def run_action(manifest, ip):
app_id = manifest["app"]["id"] app_id = manifest["app.id"]
print_status_busy("Running") print_status_busy("Running")
url = get_url(ip, "/app/run") url = get_url(ip, "/app/run")
params = {'id': app_id} params = {'id': app_id}
@@ -614,7 +601,7 @@ def install_action(ip, platforms):
return False return False
def uninstall_action(manifest, ip): def uninstall_action(manifest, ip):
app_id = manifest["app"]["id"] app_id = manifest["app.id"]
print_status_busy("Uninstalling") print_status_busy("Uninstalling")
url = get_url(ip, "/app/uninstall") url = get_url(ip, "/app/uninstall")
params = {'id': app_id} params = {'id': app_id}
@@ -670,7 +657,7 @@ if __name__ == "__main__":
exit_with_error("manifest.properties not found") exit_with_error("manifest.properties not found")
manifest = read_manifest() manifest = read_manifest()
validate_manifest(manifest) validate_manifest(manifest)
all_platform_targets = manifest["target"]["platforms"].split(",") all_platform_targets = manifest["target.platforms"].split(",")
# Update SDK cache (tool.json) # Update SDK cache (tool.json)
if not use_local_sdk and should_update_tool_json() and not update_tool_json(): if not use_local_sdk and should_update_tool_json() and not update_tool_json():
exit_with_error("Failed to retrieve SDK info") exit_with_error("Failed to retrieve SDK info")