27 Commits

Author SHA1 Message Date
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
56 changed files with 7420 additions and 457 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
outputs:
sdk_version:
description: 'Common SDK version shared by all bundled apps'
value: ${{ steps.release.outputs.sdk_version }}
runs:
using: 'composite'
steps:
@@ -11,8 +16,11 @@ runs:
run: rsync -av downloaded_apps/*/*.app cdn_files/
shell: bash
- name: 'Create CDN release files'
run: python release.py cdn_files/
id: release
shell: bash
run: |
python release.py cdn_files/
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
with:
+22 -1
View File
@@ -12,10 +12,12 @@ jobs:
Build:
strategy:
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
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Build"
uses: ./.github/actions/build-app
with:
@@ -23,7 +25,26 @@ jobs:
Bundle:
runs-on: ubuntu-latest
needs: [Build]
outputs:
sdk_version: ${{ steps.release.outputs.sdk_version }}
steps:
- uses: actions/checkout@v4
- name: "Build"
id: release
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]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.audiotest
versionName=0.1.0
versionCode=1
name=Audio Test
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.audiotest
app.version.name=0.1.0
app.version.code=1
app.name=Audio Test
+15 -14
View File
@@ -33,7 +33,7 @@ LV_FONT_DECLARE(georgia_regular_24)
#define MAX_BIBLE_BOOKS 66
#define MAX_BOOK_NAME 40
#define MAX_VERSE_TEXT 2048
#define MAX_PATH 320
#define MAX_PATH 128
typedef struct {
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, '}');
if (!obj_end) break;
// parse fields within obj_start..obj_end
char temp[512];
char temp[256];
size_t len = (size_t)(obj_end - obj_start + 1);
if (len >= sizeof(temp)) len = sizeof(temp)-1;
memcpy(temp, obj_start, len);
@@ -672,27 +672,27 @@ static void show_current_verse(AppCtx* ctx) {
const char* text_ptr = "(empty)";
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
char verse_buf[MAX_VERSE_TEXT];
strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1);
verse_buf[sizeof(verse_buf)-1]='\0';
// Fix stack overflow: verse_buf was 2048 bytes on stack, causing gui task (4096) overflow
// Use heap allocation
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);
// Editorial reference style like your image: "PSALMS 23:1" with tracking
char ref_pretty[108];
// 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];
char ref_pretty[80];
char bname_upper[40];
strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
bname_upper[sizeof(bname_upper)-1]='\0';
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);
if (ctx->lbl_verse) {
// start faded for transition
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
lv_label_set_text(ctx->lbl_verse, 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);
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
}
free(verse_buf);
save_progress(ctx);
verse_fade_in(ctx);
}
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.bibleverse
versionName=0.1.0
versionCode=1
name=Bible Verse
description=Single verse at a time, advances each minute. Tap to show controls.
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.bibleverse
app.version.name=0.1.0
app.version.code=2
app.name=Bible Verse
app.description=Single verse at a time, advances each minute. Tap to show controls.
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3
[app]
id=one.tactility.bookplayer
versionName=1.0.0
versionCode=1
name=Book Player
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.bookplayer
app.version.name=1.0.0
app.version.code=1
app.name=Book Player
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.brainfuck
versionName=0.2.0
versionCode=2
name=Brainfuck interpreter
description=Brainfuck esoteric language interpreter
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.brainfuck
app.version.name=0.5.0
app.version.code=5
app.name=Brainfuck interpreter
app.description=Brainfuck esoteric language interpreter
-1
View File
@@ -124,7 +124,6 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
if (!sfxEngine) {
sfxEngine = new SfxEngine();
sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
sfxEngine->setEnabled(soundEnabled);
}
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.breakout
versionName=0.2.0
versionCode=2
name=Breakout
description=Classic brick-breaking arcade game
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.breakout
app.version.name=0.5.0
app.version.code=5
app.name=Breakout
app.description=Classic brick-breaking arcade game
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.calculator
versionName=0.3.0
versionCode=3
name=Calculator
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.calculator
app.version.name=0.6.0
app.version.code=6
app.name=Calculator
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.diceware
versionName=0.3.0
versionCode=3
name=Diceware
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.diceware
app.version.name=0.6.0
app.version.code=6
app.name=Diceware
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.epubreader
versionName=0.1.0
versionCode=1
name=Epub Reader
description=Epub and text file reader. Requires PSRAM!
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.epubreader
app.version.name=0.4.0
app.version.code=4
app.name=Epub Reader
app.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]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.gpio
versionName=0.4.0
versionCode=4
name=GPIO
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.gpio
app.version.name=0.7.0
app.version.code=7
app.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]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.graphicsdemo
versionName=0.3.0
versionCode=3
name=Graphics Demo
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.graphicsdemo
app.version.name=0.6.0
app.version.code=6
app.name=Graphics Demo
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.helloworld
versionName=0.3.0
versionCode=3
name=Hello World
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.helloworld
app.version.name=0.6.0
app.version.code=6
app.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)
+988
View File
@@ -0,0 +1,988 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/lvgl_fonts.h>
#include <esp_log.h>
#include <esp_http_client.h>
#include <esp_heap_caps.h>
#include <esp_random.h>
#include <cJSON.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <dirent.h>
#include <time.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
static const char* TAG = "ImmichElias";
#define IMMICH_BASE_URL "http://192.168.68.110:2283"
#define IMMICH_API_KEY "tactility-elias-2af3d9c44b72f987f41afc3438c3dd740862417797a3e5a6"
#define IMMICH_PERSON_ID "cbece6a9-720f-482a-a945-c9ba5ca41fd4"
#define PERSON_NAME "Elias"
#define PERSON_COUNT_STR "10739"
#define RESIZE_PROXY_URL "http://192.168.68.110:8106"
#define MAX_PHOTOS 100
#define JSON_BUF_SIZE (128 * 1024)
#define THUMB_BUF_SIZE (512 * 1024)
#define FALLBACK_W 320
#define FALLBACK_H 240
typedef struct {
char id[64];
char name[32];
char count_str[16];
} PersonInfo;
static const PersonInfo PERSONS[] = {
{"cbece6a9-720f-482a-a945-c9ba5ca41fd4", "Elias", "10739"},
{"ad40f024-c34b-4ec6-a19e-2fa157b259e5", "Grace", ""},
{"6d9e4ae6-143d-43c9-bd42-437e3edfe2df", "Alicia", ""},
{"3ddbc731-e857-473c-8226-8af97e5cb86e", "Adolfo", ""},
};
#define PERSONS_COUNT (sizeof(PERSONS)/sizeof(PERSONS[0]))
typedef struct {
char id[64];
char thumbnail_url[256];
char fileCreatedAt[64];
char originalFileName[128];
char type[16];
int width;
int height;
} Photo;
static Photo photos[MAX_PHOTOS];
static int photo_count = 0;
typedef struct {
uint8_t* buf;
size_t cap;
size_t len;
} HttpBuf;
static esp_err_t http_evt_handler(esp_http_client_event_t* evt) {
HttpBuf* hb = (HttpBuf*)evt->user_data;
if (hb == NULL) return ESP_OK;
if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data && evt->data_len > 0) {
size_t copy_len = (size_t)evt->data_len;
if (hb->len + copy_len > hb->cap) {
size_t remaining = (hb->cap > hb->len) ? hb->cap - hb->len : 0;
if (remaining < copy_len) copy_len = remaining;
}
if (copy_len > 0) {
memcpy(hb->buf + hb->len, evt->data, copy_len);
hb->len += copy_len;
}
}
return ESP_OK;
}
static int http_get_with_auth(const char* url, HttpBuf* out) {
if (out) out->len = 0;
esp_http_client_config_t cfg = {
.url = url,
.event_handler = http_evt_handler,
.user_data = out,
.timeout_ms = 8000,
.buffer_size = 4096,
.buffer_size_tx = 1024,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) return -1;
esp_http_client_set_method(client, HTTP_METHOD_GET);
esp_http_client_set_header(client, "x-api-key", IMMICH_API_KEY);
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
return (err == ESP_OK) ? status : -1;
}
static int http_get_no_auth(const char* url, HttpBuf* out) {
if (out) out->len = 0;
esp_http_client_config_t cfg = {
.url = url,
.event_handler = http_evt_handler,
.user_data = out,
.timeout_ms = 8000,
.buffer_size = 4096,
.buffer_size_tx = 1024,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) return -1;
esp_http_client_set_method(client, HTTP_METHOD_GET);
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
ESP_LOGI(TAG, "GET noauth %s status %d len %d", url, status, out ? (int)out->len : -1);
esp_http_client_cleanup(client);
return (err == ESP_OK) ? status : -1;
}
static int http_post_json(const char* url, const char* json_body, HttpBuf* out) {
if (out) out->len = 0;
esp_http_client_config_t cfg = {
.url = url,
.event_handler = http_evt_handler,
.user_data = out,
.timeout_ms = 8000,
.buffer_size = 4096,
.buffer_size_tx = 2048,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) return -1;
esp_http_client_set_method(client, HTTP_METHOD_POST);
esp_http_client_set_header(client, "x-api-key", IMMICH_API_KEY);
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, json_body, strlen(json_body));
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
return (err == ESP_OK) ? status : -1;
}
static void shuffle_photos(void) {
if (photo_count <= 1) return;
for (int i = photo_count - 1; i > 0; i--) {
uint32_t rnd = esp_random();
int j = rnd % (i + 1);
if (i != j) {
Photo tmp = photos[i];
photos[i] = photos[j];
photos[j] = tmp;
}
}
ESP_LOGI(TAG, "Photos shuffled random, count %d", photo_count);
}
static int fetch_person_assets(const char* person_id) {
if (!person_id || person_id[0]=='\0') person_id = PERSONS[0].id;
char* json_buf = heap_caps_malloc(JSON_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!json_buf) json_buf = heap_caps_malloc(JSON_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
if (!json_buf) return -1;
HttpBuf hb = { .buf = (uint8_t*)json_buf, .cap = JSON_BUF_SIZE - 1, .len = 0 };
char url[256];
snprintf(url, sizeof(url), "%s/api/search/metadata", IMMICH_BASE_URL);
// Pick random page to get variety from 10k+ photos
int total_estimate = 10739;
// Try to use count from PERSONS if available
for (int pi=0; pi<PERSONS_COUNT; pi++) {
if (strcmp(PERSONS[pi].id, person_id)==0 && PERSONS[pi].count_str[0]!='\0') {
total_estimate = atoi(PERSONS[pi].count_str);
if (total_estimate<=0) total_estimate=10739;
break;
}
}
int max_page = total_estimate / MAX_PHOTOS;
if (max_page < 1) max_page = 1;
int random_page = (esp_random() % max_page) + 1;
char body[320];
snprintf(body, sizeof(body), "{\"personIds\":[\"%s\"],\"size\":%d,\"page\":%d}", person_id, MAX_PHOTOS, random_page);
ESP_LOGI(TAG, "Fetching person %s assets page %d/%d size %d", person_id, random_page, max_page, MAX_PHOTOS);
int status = http_post_json(url, body, &hb);
if (status != 200) { heap_caps_free(json_buf); return -1; }
if (hb.len >= JSON_BUF_SIZE) hb.len = JSON_BUF_SIZE - 1;
json_buf[hb.len] = '\0';
cJSON* root = cJSON_Parse(json_buf);
heap_caps_free(json_buf);
if (!root) return -1;
cJSON* assets = cJSON_GetObjectItem(root, "assets");
cJSON* items = NULL;
if (assets) items = cJSON_GetObjectItem(assets, "items");
else { items = cJSON_GetObjectItem(root, "items"); if (!items && cJSON_IsArray(root)) items = root; }
if (!items || !cJSON_IsArray(items)) { cJSON_Delete(root); return -1; }
int n = cJSON_GetArraySize(items);
photo_count = 0;
for (int i = 0; i < n && photo_count < MAX_PHOTOS; i++) {
cJSON* it = cJSON_GetArrayItem(items, i);
if (!it) continue;
cJSON* jid = cJSON_GetObjectItem(it, "id");
if (!jid || !cJSON_IsString(jid)) continue;
// Filter only IMAGE type to avoid video that won't display
cJSON* jt_tmp = cJSON_GetObjectItem(it, "type");
if (jt_tmp && cJSON_IsString(jt_tmp)) {
if (strcmp(jt_tmp->valuestring, "IMAGE") != 0) {
ESP_LOGI(TAG, "Skipping non-IMAGE asset %s type %s", jid->valuestring, jt_tmp->valuestring);
continue;
}
}
Photo* p = &photos[photo_count];
memset(p, 0, sizeof(Photo));
strncpy(p->id, jid->valuestring, sizeof(p->id)-1);
cJSON* jw = cJSON_GetObjectItem(it, "width"); if (jw && cJSON_IsNumber(jw)) p->width = jw->valueint;
cJSON* jh = cJSON_GetObjectItem(it, "height"); if (jh && cJSON_IsNumber(jh)) p->height = jh->valueint;
cJSON* jfc = cJSON_GetObjectItem(it, "fileCreatedAt"); if (jfc && cJSON_IsString(jfc)) strncpy(p->fileCreatedAt, jfc->valuestring, sizeof(p->fileCreatedAt)-1);
cJSON* jfn = cJSON_GetObjectItem(it, "originalFileName"); if (jfn && cJSON_IsString(jfn)) strncpy(p->originalFileName, jfn->valuestring, sizeof(p->originalFileName)-1);
cJSON* jt = cJSON_GetObjectItem(it, "type"); if (jt && cJSON_IsString(jt)) strncpy(p->type, jt->valuestring, sizeof(p->type)-1);
photo_count++;
}
cJSON_Delete(root);
shuffle_photos();
return photo_count;
}
static bool ensure_dir_exists(const char* file_path) {
char dir[320];
strncpy(dir, file_path, sizeof(dir)-1);
dir[sizeof(dir)-1] = '\0';
char* last_slash = strrchr(dir, '/');
if (!last_slash) return true;
*last_slash = '\0';
if (strlen(dir) == 0) return true;
struct stat st;
if (stat(dir, &st) == 0 && S_ISDIR(st.st_mode)) return true;
char tmp[320]; tmp[0]='\0';
if (dir[0]=='/') strcpy(tmp, "/");
char work[320];
strncpy(work, dir, sizeof(work)-1);
work[sizeof(work)-1]='\0';
char* p = work;
if (p[0]=='/') p++;
char* start = p;
while (1) {
char* slash = strchr(start, '/');
bool last = false;
if (slash) *slash='\0'; else last=true;
if (strlen(start)>0) {
if (strlen(tmp)>0 && tmp[strlen(tmp)-1]!='/') strncat(tmp, "/", sizeof(tmp)-strlen(tmp)-1);
strncat(tmp, start, sizeof(tmp)-strlen(tmp)-1);
mkdir(tmp, 0777);
}
if (last) break;
*slash='/';
start = slash+1;
if (*start=='\0') break;
}
return true;
}
typedef struct {
AppHandle app;
volatile bool visible;
volatile bool stop_requested;
// Picker (persons) like Bible book selector
lv_obj_t* picker_wrapper;
lv_obj_t* lst_persons;
// Player (photos)
lv_obj_t* player_wrapper;
lv_obj_t* header_bar;
lv_obj_t* ctrl_bar;
lv_obj_t* lbl_book_title;
lv_obj_t* lbl_indicator;
lv_obj_t* img_page;
lv_obj_t* lbl_info;
lv_obj_t* lbl_status;
lv_obj_t* btn_prev;
lv_obj_t* btn_next;
lv_obj_t* btn_reload;
lv_obj_t* btn_back;
lv_timer_t* slideshow_timer;
lv_timer_t* autohide_timer;
int current_idx;
char status_text[256];
volatile bool fetch_in_progress;
volatile bool thumb_in_progress;
uint8_t* thumb_buf;
size_t thumb_len;
TaskHandle_t fetch_task;
TaskHandle_t thumb_task;
char saved_img_path[320];
volatile bool has_image;
int disp_w;
int disp_h;
int img_w;
int img_h;
// Current person
char current_person_id[64];
char current_person_name[32];
} AppCtx;
static bool get_cache_dir_path(AppCtx* ctx, char* out_dir, size_t out_size) {
if (ctx && ctx->app) {
size_t sz = out_size;
tt_app_get_user_data_child_path(ctx->app, "cache", out_dir, &sz);
if (out_dir[0]!='\0') { mkdir(out_dir, 0777); ensure_dir_exists(out_dir); return true; }
}
snprintf(out_dir, out_size, "/sdcard/tactility/immich_cache");
mkdir(out_dir, 0777);
return true;
}
static bool get_cache_path_for_asset(AppCtx* ctx, const char* assetId, char* out_path, size_t out_size) {
if (!assetId || !out_path || out_size==0) return false;
int w = ctx ? ctx->img_w : FALLBACK_W;
int h = ctx ? ctx->img_h : FALLBACK_H;
if (w<=0) w=FALLBACK_W;
if (h<=0) h=FALLBACK_H;
if (w>800) w=800;
if (h>600) h=600;
if (ctx && ctx->app) {
char child[160];
snprintf(child, sizeof(child), "cache/%s_%dx%d.png", assetId, w, h);
size_t sz = out_size;
tt_app_get_user_data_child_path(ctx->app, child, out_path, &sz);
if (out_path[0]!='\0') { ensure_dir_exists(out_path); return true; }
}
snprintf(out_path, out_size, "/sdcard/tactility/immich_cache/%s_%dx%d.png", assetId, w, h);
ensure_dir_exists(out_path);
return true;
}
static bool get_cache_path_legacy(AppCtx* ctx, const char* assetId, char* out_path, size_t out_size) {
if (!assetId || !out_path) return false;
if (ctx && ctx->app) {
char child[128];
snprintf(child, sizeof(child), "cache/%s.png", assetId);
size_t sz = out_size;
tt_app_get_user_data_child_path(ctx->app, child, out_path, &sz);
if (out_path[0]!='\0') { ensure_dir_exists(out_path); return true; }
}
snprintf(out_path, out_size, "/sdcard/tactility/immich_cache/%s.png", assetId);
ensure_dir_exists(out_path);
return true;
}
static bool get_image_save_path(AppCtx* ctx, char* out_path, size_t out_size) {
if (ctx && ctx->app) {
size_t sz = out_size;
tt_app_get_user_data_child_path(ctx->app, "immich_cur.png", out_path, &sz);
if (out_path[0]!='\0') { ensure_dir_exists(out_path); return true; }
}
snprintf(out_path, out_size, "/sdcard/immich_cur.png");
return true;
}
static const char* save_buffer_to_cache(AppCtx* ctx, const char* assetId, const uint8_t* buf, size_t len, char* out_saved_path, size_t out_saved_size) {
char path[320];
if (!get_cache_path_for_asset(ctx, assetId, path, sizeof(path))) return NULL;
ensure_dir_exists(path);
FILE* f = fopen(path, "wb");
if (!f) {
ESP_LOGW(TAG, "fopen fail cache %s", path);
return NULL;
}
size_t written = fwrite(buf, 1, len, f);
fclose(f);
ESP_LOGI(TAG, "Cached %d bytes to %s written %d (req %dx%d)", (int)len, path, (int)written, ctx ? ctx->img_w : 0, ctx ? ctx->img_h : 0);
if (written != len) return NULL;
if (out_saved_path && out_saved_size>0) { strncpy(out_saved_path, path, out_saved_size-1); out_saved_path[out_saved_size-1]='\0'; }
char legacy[320];
if (get_image_save_path(ctx, legacy, sizeof(legacy))) {
FILE* lf = fopen(legacy, "wb");
if (lf) { fwrite(buf,1,len,lf); fclose(lf); }
}
return out_saved_path;
}
static bool load_from_cache(AppCtx* ctx, const char* assetId, char* out_path, size_t out_size, size_t* out_file_size) {
char path[320];
if (!get_cache_path_for_asset(ctx, assetId, path, sizeof(path))) return false;
struct stat st;
if (stat(path, &st)==0 && st.st_size>0 && st.st_size <= THUMB_BUF_SIZE) {
FILE* f = fopen(path, "rb");
if (f) { fclose(f); strncpy(out_path, path, out_size-1); out_path[out_size-1]='\0'; if(out_file_size) *out_file_size=(size_t)st.st_size; ESP_LOGI(TAG,"Cache HIT %s size %d (sized)", path, (int)st.st_size); return true; }
}
// Try legacy without size
if (!get_cache_path_legacy(ctx, assetId, path, sizeof(path))) return false;
if (stat(path, &st)==0 && st.st_size>0 && st.st_size <= THUMB_BUF_SIZE) {
FILE* f = fopen(path, "rb");
if (f) { fclose(f); strncpy(out_path, path, out_size-1); out_path[out_size-1]='\0'; if(out_file_size) *out_file_size=(size_t)st.st_size; ESP_LOGI(TAG,"Cache HIT %s size %d (legacy)", path, (int)st.st_size); return true; }
}
return false;
}
static int scan_cache_dir_for_photos(AppCtx* ctx) {
char dir_path[320];
if (!get_cache_dir_path(ctx, dir_path, sizeof(dir_path))) return 0;
DIR* d = opendir(dir_path);
if (!d) return 0;
photo_count = 0;
struct dirent* ent;
while ((ent = readdir(d))!=NULL && photo_count < MAX_PHOTOS) {
if (ent->d_name[0]=='.') continue;
size_t len = strlen(ent->d_name);
if (len<5) continue;
const char* ext = ent->d_name + len -4;
if (!(ext[0]=='.' && (ext[1]=='p'||ext[1]=='P') && (ext[2]=='n'||ext[2]=='N') && (ext[3]=='g'||ext[3]=='G'))) continue;
char id_buf[64];
strncpy(id_buf, ent->d_name, sizeof(id_buf)-1);
id_buf[sizeof(id_buf)-1]='\0';
// Strip size suffix _WxH if present
char* us = strrchr(id_buf, '_');
if (us) {
// Check if after _ is like 320x240.png
char* dot = strrchr(id_buf, '.');
if (dot) *dot='\0';
// If us contains x, truncate at _
// Actually id may contain _, but we want id part before _
// For cache files like <id>_<w>x<h>.png, we want id before _
// The id itself is uuid without _, so first _ is separator
// So keep part before _
if (us != id_buf) {
// Ensure remaining part after _ contains x
if (strchr(us, 'x') || strchr(us, 'X')) {
*us='\0';
} else {
// No x, it was legacy without size, keep as is but already stripped extension
}
}
} else {
char* dot2 = strrchr(id_buf, '.');
if (dot2) *dot2='\0';
}
if (strlen(id_buf)==0) continue;
// Deduplicate: check if id already exists
bool dup=false;
for (int i=0;i<photo_count;i++) if (strcmp(photos[i].id, id_buf)==0) {dup=true; break;}
if (dup) continue;
Photo* p=&photos[photo_count];
memset(p,0,sizeof(Photo));
strncpy(p->id, id_buf, sizeof(p->id)-1);
strncpy(p->originalFileName, ent->d_name, sizeof(p->originalFileName)-1);
strncpy(p->type, "IMAGE", sizeof(p->type)-1);
photo_count++;
}
closedir(d);
if (photo_count>0) shuffle_photos();
return photo_count;
}
static int fetch_png_from_proxy(const char* assetId, uint8_t* buf, size_t cap, size_t* out_len, int req_w, int req_h) {
if (req_w<=0) req_w=FALLBACK_W;
if (req_h<=0) req_h=FALLBACK_H;
if (req_w>800) req_w=800;
if (req_h>600) req_h=600;
char url[384];
snprintf(url, sizeof(url), "%s/resize?assetId=%s&w=%d&h=%d&format=png", RESIZE_PROXY_URL, assetId, req_w, req_h);
ESP_LOGI(TAG, "Fetching proxy PNG %s (req %dx%d)", url, req_w, req_h);
HttpBuf hb = { .buf = buf, .cap = cap, .len = 0 };
int status = http_get_no_auth(url, &hb);
if (status==200 && hb.len>0) {
if (out_len) *out_len=hb.len;
ESP_LOGI(TAG, "Proxy PNG OK %d bytes for %dx%d", (int)hb.len, req_w, req_h);
return (int)hb.len;
}
ESP_LOGE(TAG, "Proxy PNG fail id %s %dx%d status %d", assetId, req_w, req_h, status);
return -1;
}
static void start_fetch_task(AppCtx* ctx);
static void start_thumb_task(AppCtx* ctx);
static void ui_update_all(AppCtx* ctx);
static void thumb_task_fn(void* arg);
static void fetch_task_fn(void* arg);
static void hide_chrome(AppCtx* ctx);
static void show_chrome(AppCtx* ctx);
static void detect_display_size(AppCtx* ctx, lv_obj_t* parent) {
if (!ctx) return;
int w=FALLBACK_W, h=FALLBACK_H;
lv_display_t* disp=NULL;
if (parent) disp = lv_obj_get_display(parent);
if (!disp) disp = lv_display_get_default();
if (disp) {
int dw = lv_display_get_horizontal_resolution(disp);
int dh = lv_display_get_vertical_resolution(disp);
if (dw>0 && dh>0) { w=dw; h=dh; }
}
if (w==FALLBACK_W && h==FALLBACK_H) {
int dw2 = lv_display_get_horizontal_resolution(NULL);
int dh2 = lv_display_get_vertical_resolution(NULL);
if (dw2>0 && dh2>0) { w=dw2; h=dh2; }
}
ctx->disp_w=w; ctx->disp_h=h;
// Black screen fix: 480x320 PNG decodes ~600KB, causes internal low 8723 bytes -> decoder fail -> black screen
// Use 320x240 (decoded ~300KB) for stability, widget still fullscreen 480x320 background
// 2x would be 480x320 but OOM, so keep 320x240 as best stable, or 240x180 for extra safety
int target_w = 320;
int target_h = 240;
ctx->img_w=target_w; ctx->img_h=target_h;
ESP_LOGI(TAG, "Display detected %dx%d -> img request %dx%d FULLSCREEN bg (320x240 stable, 480x320=2x OOM)", w,h,target_w,target_h);
}
static void hide_chrome(AppCtx* ctx) {
if (!ctx) return;
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
// Bible-style: only bottom controls, no top header
if (ctx->ctrl_bar) lv_obj_add_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
}
static void show_chrome(AppCtx* ctx) {
if (!ctx) return;
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
if (ctx->ctrl_bar) lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
if (tt_lvgl_lock(pdMS_TO_TICKS(300))) {
if (ctx->autohide_timer) { lv_timer_reset(ctx->autohide_timer); lv_timer_resume(ctx->autohide_timer); }
tt_lvgl_unlock();
}
}
static void autohide_timer_cb(lv_timer_t* timer) {
AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(timer);
if (!ctx || !ctx->visible || ctx->stop_requested) return;
hide_chrome(ctx);
if (tt_lvgl_lock(pdMS_TO_TICKS(200))) { lv_timer_pause(timer); tt_lvgl_unlock(); }
}
static void slideshow_timer_cb(lv_timer_t* timer) {
AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(timer);
if (!ctx || !ctx->visible || ctx->stop_requested) return;
if (ctx->fetch_in_progress || ctx->thumb_in_progress) return;
if (photo_count==0) return;
ESP_LOGI(TAG, "Slideshow auto-advance 60s %dx%d", ctx->img_w, ctx->img_h);
ctx->current_idx++;
if (ctx->current_idx >= photo_count) ctx->current_idx=0;
ctx->thumb_len=0; ctx->has_image=false;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Slideshow %d/%d %dx%d", ctx->current_idx+1, photo_count, ctx->img_w, ctx->img_h);
ui_update_all(ctx);
show_chrome(ctx);
start_thumb_task(ctx);
}
static void ui_update_all(AppCtx* ctx) {
if (!ctx || !ctx->visible || ctx->stop_requested) return;
if (!tt_lvgl_lock(pdMS_TO_TICKS(1000))) return;
if (!ctx->visible || ctx->stop_requested) { tt_lvgl_unlock(); return; }
if (ctx->lbl_book_title) lv_label_set_text_fmt(ctx->lbl_book_title, "%s - %s photos", PERSON_NAME, PERSON_COUNT_STR);
if (ctx->lbl_indicator) {
if (photo_count>0) lv_label_set_text_fmt(ctx->lbl_indicator, "%d/%d", ctx->current_idx+1, photo_count);
else lv_label_set_text(ctx->lbl_indicator, "0/0");
}
if (ctx->lbl_info) {
if (photo_count>0 && ctx->current_idx < photo_count) {
Photo* p=&photos[ctx->current_idx];
lv_label_set_text_fmt(ctx->lbl_info, "%s\n%.24s\n%dx%d disp %dx%d img %dx%d", p->originalFileName, p->fileCreatedAt, p->width, p->height, ctx->disp_w, ctx->disp_h, ctx->img_w, ctx->img_h);
} else lv_label_set_text(ctx->lbl_info, ctx->status_text);
}
if (ctx->lbl_status) {
if (ctx->thumb_len>0 && ctx->has_image) lv_label_set_text_fmt(ctx->lbl_status, "PNG %d bytes %dx%d", (int)ctx->thumb_len, ctx->img_w, ctx->img_h);
else lv_label_set_text(ctx->lbl_status, ctx->status_text);
}
if (ctx->img_page) {
if (ctx->has_image && ctx->saved_img_path[0]!='\0') {
FILE* test = fopen(ctx->saved_img_path, "rb");
if (test) {
fclose(test);
char lvgl_path[384];
snprintf(lvgl_path, sizeof(lvgl_path), "A:%s", ctx->saved_img_path);
lv_image_set_src(ctx->img_page, lvgl_path);
lv_obj_clear_flag(ctx->img_page, LV_OBJ_FLAG_HIDDEN);
// Fullscreen background: widget covers entire display
lv_obj_set_size(ctx->img_page, ctx->disp_w > 0 ? ctx->disp_w : ctx->img_w, ctx->disp_h > 0 ? ctx->disp_h : ctx->img_h);
lv_obj_center(ctx->img_page);
ESP_LOGI(TAG, "lv_img_set_src %s %dx%d", lvgl_path, ctx->disp_w, ctx->disp_h);
} else {
ESP_LOGW(TAG, "Image file not found for display: %s", ctx->saved_img_path);
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
lv_obj_clear_flag(ctx->img_page, LV_OBJ_FLAG_HIDDEN);
}
} else {
if (photo_count==0) {
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
lv_obj_clear_flag(ctx->img_page, LV_OBJ_FLAG_HIDDEN);
} else {
// Keep previous image to avoid black flash, or hide if you prefer
// lv_obj_add_flag(ctx->img_page, LV_OBJ_FLAG_HIDDEN);
}
}
}
tt_lvgl_unlock();
}
static void thumb_task_fn(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
if (!ctx) { vTaskDelete(NULL); return; }
if (ctx->stop_requested) { ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return; }
if (!ctx->thumb_buf) {
ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!ctx->thumb_buf) ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
}
if (!ctx->thumb_buf) { snprintf(ctx->status_text, sizeof(ctx->status_text), "OOM thumb buf"); ui_update_all(ctx); ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return; }
int idx = ctx->current_idx;
if (idx<0 || idx>=photo_count) idx=0;
Photo* p=&photos[idx];
char cache_path[320]={0}; size_t cached_size=0;
if (load_from_cache(ctx, p->id, cache_path, sizeof(cache_path), &cached_size)) {
strncpy(ctx->saved_img_path, cache_path, sizeof(ctx->saved_img_path)-1);
ctx->has_image=true; ctx->thumb_len=cached_size;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Cached PNG %d bytes %dx%d", (int)cached_size, ctx->img_w, ctx->img_h);
ui_update_all(ctx); show_chrome(ctx);
ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return;
}
ESP_LOGI(TAG, "Downloading PNG idx %d id %s %dx%d", idx, p->id, ctx->img_w, ctx->img_h);
snprintf(ctx->status_text, sizeof(ctx->status_text), "Fetching PNG %d/%d %dx%d", idx+1, photo_count, ctx->img_w, ctx->img_h);
ui_update_all(ctx);
if (ctx->stop_requested) { ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return; }
size_t out_len=0;
int r = fetch_png_from_proxy(p->id, ctx->thumb_buf, THUMB_BUF_SIZE, &out_len, ctx->img_w, ctx->img_h);
// Fallback to 320x240 if requested size fails
if (r<=0 && (ctx->img_w != 320 || ctx->img_h != 240)) {
ESP_LOGW(TAG, "Proxy fail %dx%d, trying fallback 320x240", ctx->img_w, ctx->img_h);
r = fetch_png_from_proxy(p->id, ctx->thumb_buf, THUMB_BUF_SIZE, &out_len, 320, 240);
}
if (ctx->stop_requested) { ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return; }
if (r>0) {
ctx->thumb_len=out_len;
char saved_tmp[320]={0};
const char* saved = save_buffer_to_cache(ctx, p->id, ctx->thumb_buf, out_len, saved_tmp, sizeof(saved_tmp));
if (saved) { strncpy(ctx->saved_img_path, saved_tmp, sizeof(ctx->saved_img_path)-1); ctx->has_image=true; snprintf(ctx->status_text, sizeof(ctx->status_text), "PNG %d bytes %dx%d", (int)out_len, ctx->img_w, ctx->img_h); }
else { ctx->has_image=false; snprintf(ctx->status_text, sizeof(ctx->status_text), "PNG %d save fail", (int)out_len); }
} else {
ctx->thumb_len=0; ctx->has_image=false; snprintf(ctx->status_text, sizeof(ctx->status_text), "Proxy PNG fail %d", r);
}
ui_update_all(ctx); show_chrome(ctx);
ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL);
}
static void start_thumb_task(AppCtx* ctx) {
if (!ctx || ctx->thumb_in_progress || ctx->stop_requested || photo_count==0) return;
ctx->thumb_in_progress=true; ctx->thumb_len=0; ctx->has_image=false;
if (xTaskCreate(thumb_task_fn, "thumb_fetch", 8192, ctx, 5, &ctx->thumb_task)!=pdPASS) { ctx->thumb_in_progress=false; ctx->thumb_task=NULL; }
}
static void fetch_task_fn(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
if (!ctx) { vTaskDelete(NULL); return; }
if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; }
ctx->fetch_in_progress=true;
const char* fetch_name = ctx->current_person_name[0] ? ctx->current_person_name : PERSON_NAME;
const char* fetch_id = ctx->current_person_id[0] ? ctx->current_person_id : PERSONS[0].id;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Fetching %s photos...", fetch_name);
ui_update_all(ctx);
int n = fetch_person_assets(fetch_id);
if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; }
if (n>0) {
ctx->current_idx=0;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Loaded %d photos (random %dx%d)", n, ctx->img_w, ctx->img_h);
ui_update_all(ctx);
if (!ctx->thumb_buf) {
ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!ctx->thumb_buf) ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
}
if (ctx->thumb_buf && !ctx->stop_requested) {
Photo* p=&photos[0];
char cache_path[320]={0}; size_t cached_size=0;
if (load_from_cache(ctx, p->id, cache_path, sizeof(cache_path), &cached_size)) {
strncpy(ctx->saved_img_path, cache_path, sizeof(ctx->saved_img_path)-1);
ctx->has_image=true; ctx->thumb_len=cached_size;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Cached PNG %d random %dx%d", (int)cached_size, ctx->img_w, ctx->img_h);
ui_update_all(ctx); show_chrome(ctx);
} else {
size_t ol=0;
int r=fetch_png_from_proxy(p->id, ctx->thumb_buf, THUMB_BUF_SIZE, &ol, ctx->img_w, ctx->img_h);
if (r<=0 && (ctx->img_w != 320 || ctx->img_h != 240)) {
ESP_LOGW(TAG, "First image proxy fail %dx%d, trying 320x240", ctx->img_w, ctx->img_h);
r=fetch_png_from_proxy(p->id, ctx->thumb_buf, THUMB_BUF_SIZE, &ol, 320, 240);
}
if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; }
if (r>0) {
ctx->thumb_len=ol;
char saved_tmp[320]={0};
const char* saved=save_buffer_to_cache(ctx, p->id, ctx->thumb_buf, ol, saved_tmp, sizeof(saved_tmp));
if (saved) { strncpy(ctx->saved_img_path, saved_tmp, sizeof(ctx->saved_img_path)-1); ctx->has_image=true; snprintf(ctx->status_text, sizeof(ctx->status_text), "PNG %d random %dx%d", (int)ol, ctx->img_w, ctx->img_h); }
} else {
snprintf(ctx->status_text, sizeof(ctx->status_text), "First PNG fail %d", r);
}
ui_update_all(ctx); show_chrome(ctx);
}
}
} else {
int cached = scan_cache_dir_for_photos(ctx);
if (cached>0) {
ctx->current_idx=0;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Offline: %d cached", cached);
ui_update_all(ctx);
Photo* p=&photos[0];
char cp[320]={0}; size_t sz=0;
if (load_from_cache(ctx, p->id, cp, sizeof(cp), &sz)) { strncpy(ctx->saved_img_path, cp, sizeof(ctx->saved_img_path)-1); ctx->has_image=true; ctx->thumb_len=sz; snprintf(ctx->status_text, sizeof(ctx->status_text), "Offline cached %d %dx%d", (int)sz, ctx->img_w, ctx->img_h); ui_update_all(ctx); show_chrome(ctx); }
} else {
snprintf(ctx->status_text, sizeof(ctx->status_text), "Fetch failed %s - no cache", IMMICH_BASE_URL);
ui_update_all(ctx);
}
}
ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL);
}
static void start_fetch_task(AppCtx* ctx) {
if (!ctx || ctx->fetch_in_progress || ctx->stop_requested) return;
ctx->fetch_in_progress=true;
if (xTaskCreate(fetch_task_fn, "immich_fetch", 12288, ctx, 5, &ctx->fetch_task)!=pdPASS) { ctx->fetch_in_progress=false; ctx->fetch_task=NULL; }
}
static void on_image_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx) return;
bool is_hidden = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(200))) {
if (ctx->ctrl_bar) is_hidden = lv_obj_has_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
else if (ctx->header_bar) is_hidden = lv_obj_has_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
}
if (is_hidden) show_chrome(ctx); else hide_chrome(ctx);
}
static void prev_event_cb(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx || photo_count==0 || ctx->thumb_in_progress || ctx->fetch_in_progress || ctx->stop_requested) return;
ctx->current_idx--; if (ctx->current_idx<0) ctx->current_idx=photo_count-1;
ctx->thumb_len=0; ctx->has_image=false;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Loading %d/%d %dx%d", ctx->current_idx+1, photo_count, ctx->img_w, ctx->img_h);
ui_update_all(ctx); show_chrome(ctx); start_thumb_task(ctx);
}
static void next_event_cb(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx || photo_count==0 || ctx->thumb_in_progress || ctx->fetch_in_progress || ctx->stop_requested) return;
ctx->current_idx++; if (ctx->current_idx>=photo_count) ctx->current_idx=0;
ctx->thumb_len=0; ctx->has_image=false;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Loading %d/%d %dx%d", ctx->current_idx+1, photo_count, ctx->img_w, ctx->img_h);
ui_update_all(ctx); show_chrome(ctx); start_thumb_task(ctx);
}
static void reload_event_cb(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx || ctx->fetch_in_progress || ctx->thumb_in_progress || ctx->stop_requested) return;
photo_count=0; ctx->current_idx=0; ctx->thumb_len=0; ctx->has_image=false;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Reloading %dx%d...", ctx->img_w, ctx->img_h);
ui_update_all(ctx); show_chrome(ctx); start_fetch_task(ctx);
}
static void show_picker(AppCtx* ctx);
static void show_player(AppCtx* ctx);
static void back_event_cb(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx) { tt_app_stop(); return; }
// If in player, go back to person picker like Bible book selector
if (ctx->picker_wrapper && ctx->player_wrapper) {
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) { tt_app_stop(); return; }
bool player_visible = !lv_obj_has_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
if (player_visible) {
show_picker(ctx);
return;
}
}
tt_app_stop();
}
static void* create_data(void) {
AppCtx* ctx = calloc(1, sizeof(AppCtx));
if (ctx) {
ctx->current_idx=0; ctx->disp_w=FALLBACK_W; ctx->disp_h=FALLBACK_H; ctx->img_w=FALLBACK_W; ctx->img_h=FALLBACK_H;
strncpy(ctx->current_person_id, PERSONS[0].id, sizeof(ctx->current_person_id)-1);
strncpy(ctx->current_person_name, PERSONS[0].name, sizeof(ctx->current_person_name)-1);
snprintf(ctx->status_text, sizeof(ctx->status_text), "Init %s proxy %s (random) person %s", IMMICH_BASE_URL, RESIZE_PROXY_URL, ctx->current_person_name);
ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!ctx->thumb_buf) ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
}
return ctx;
}
static void destroy_data(void* data) {
AppCtx* ctx = (AppCtx*)data; if (!ctx) return;
if (ctx->fetch_task || ctx->thumb_task) {
int waits=100;
while ((ctx->fetch_task || ctx->thumb_task) && waits-- >0) vTaskDelay(pdMS_TO_TICKS(50));
}
if (ctx->fetch_task || ctx->thumb_task) return; // leak to avoid crash
if (ctx->thumb_buf) { heap_caps_free(ctx->thumb_buf); ctx->thumb_buf=NULL; }
free(ctx);
}
static void on_create(AppHandle app, void* data) { AppCtx* ctx=(AppCtx*)data; if(ctx) ctx->app=app; ESP_LOGI(TAG,"onCreate proxy=%s random %dx%d person %s", RESIZE_PROXY_URL, ctx?ctx->img_w:0, ctx?ctx->img_h:0, ctx?ctx->current_person_name:"?"); }
static void on_destroy(AppHandle app, void* data) { (void)app;(void)data; }
static void person_selected_cb(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx) return;
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
if (idx < 0 || idx >= PERSONS_COUNT) return;
const PersonInfo* p = &PERSONS[idx];
strncpy(ctx->current_person_id, p->id, sizeof(ctx->current_person_id)-1);
strncpy(ctx->current_person_name, p->name, sizeof(ctx->current_person_name)-1);
ESP_LOGI(TAG, "Person selected %s (%s)", p->name, p->id);
photo_count = 0;
ctx->current_idx = 0;
ctx->thumb_len = 0;
ctx->has_image = false;
show_player(ctx);
start_fetch_task(ctx);
}
static void build_person_picker(AppCtx* ctx, lv_obj_t* parent) {
ctx->picker_wrapper = lv_obj_create(parent);
lv_obj_set_size(ctx->picker_wrapper, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_border_width(ctx->picker_wrapper, 0, 0);
lv_obj_set_style_pad_all(ctx->picker_wrapper, 0, 0);
lv_obj_set_style_bg_color(ctx->picker_wrapper, lv_color_hex(0x121212), LV_PART_MAIN);
lv_obj_set_style_bg_opa(ctx->picker_wrapper, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(ctx->picker_wrapper, ctx->app);
ctx->lst_persons = lv_list_create(ctx->picker_wrapper);
lv_obj_set_width(ctx->lst_persons, LV_PCT(100));
lv_obj_align_to(ctx->lst_persons, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
// Calculate height
int32_t th = 44;
// Use parent height minus toolbar
lv_obj_set_height(ctx->lst_persons, LV_PCT(90));
lv_obj_set_style_bg_color(ctx->lst_persons, lv_color_hex(0x121212), LV_PART_MAIN);
for (int i=0;i<PERSONS_COUNT;i++) {
const PersonInfo* person = &PERSONS[i];
char label[64];
if (person->count_str[0]) snprintf(label, sizeof(label), "%s (%s)", person->name, person->count_str);
else snprintf(label, sizeof(label), "%s", person->name);
lv_obj_t* btn = lv_list_add_button(ctx->lst_persons, LV_SYMBOL_DIRECTORY, label);
lv_obj_set_user_data(btn, (void*)(intptr_t)i);
lv_obj_add_event_cb(btn, person_selected_cb, LV_EVENT_CLICKED, ctx);
}
}
static void show_picker(AppCtx* ctx) {
if (!ctx) return;
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
if (ctx->player_wrapper) lv_obj_add_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
if (ctx->picker_wrapper) lv_obj_remove_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
}
static void show_player(AppCtx* ctx) {
if (!ctx) return;
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
if (ctx->picker_wrapper) lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
if (ctx->player_wrapper) lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
show_chrome(ctx);
}
static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
AppCtx* ctx=(AppCtx*)data; if(!ctx) return;
ctx->visible=true; ctx->stop_requested=false; ctx->app=app;
detect_display_size(ctx, parent);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_border_width(parent, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
lv_obj_set_style_pad_gap(parent, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(parent, lv_color_hex(0x121212), LV_PART_MAIN);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, LV_PART_MAIN);
// Picker for persons (like Bible book selector)
build_person_picker(ctx, parent);
// Fullscreen player like Bible app - image background, only bottom controls
ctx->player_wrapper = lv_obj_create(parent);
lv_obj_set_size(ctx->player_wrapper, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_border_width(ctx->player_wrapper, 0, 0);
lv_obj_set_style_pad_all(ctx->player_wrapper, 0, 0);
lv_obj_set_style_pad_gap(ctx->player_wrapper, 0, 0);
lv_obj_set_style_bg_color(ctx->player_wrapper, lv_color_hex(0x121212), LV_PART_MAIN);
lv_obj_set_style_bg_opa(ctx->player_wrapper, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
// Image covers entire background
ctx->img_page = lv_image_create(ctx->player_wrapper);
lv_obj_set_size(ctx->img_page, LV_PCT(100), LV_PCT(100));
lv_obj_align(ctx->img_page, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_bg_opa(ctx->img_page, LV_OPA_TRANSP, 0);
lv_obj_set_style_pad_all(ctx->img_page, 0, 0);
lv_obj_set_style_border_width(ctx->img_page, 0, 0);
lv_obj_remove_flag(ctx->img_page, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(ctx->img_page, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(ctx->img_page, on_image_click, LV_EVENT_CLICKED, ctx);
ctx->header_bar = NULL;
ctx->lbl_book_title = NULL;
ctx->lbl_indicator = NULL;
ctx->lbl_info = NULL;
ctx->lbl_status = NULL;
ctx->btn_back = NULL;
ctx->ctrl_bar = lv_obj_create(ctx->player_wrapper);
lv_obj_set_size(ctx->ctrl_bar, LV_PCT(100), 44);
lv_obj_align(ctx->ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_radius(ctx->ctrl_bar, 0, 0);
lv_obj_set_style_bg_color(ctx->ctrl_bar, lv_color_hex(0x0E0E14), LV_PART_MAIN);
lv_obj_set_style_bg_opa(ctx->ctrl_bar, LV_OPA_80, LV_PART_MAIN);
lv_obj_set_style_border_width(ctx->ctrl_bar, 0, 0);
lv_obj_set_style_pad_all(ctx->ctrl_bar, 6, 0);
lv_obj_set_style_pad_gap(ctx->ctrl_bar, 8, 0);
lv_obj_set_flex_flow(ctx->ctrl_bar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctx->ctrl_bar, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
ctx->btn_back = lv_btn_create(ctx->ctrl_bar);
lv_obj_set_size(ctx->btn_back, 44, 32);
lv_obj_set_style_radius(ctx->btn_back, 8, 0);
lv_obj_set_style_bg_color(ctx->btn_back, lv_color_hex(0x252538), LV_PART_MAIN);
lv_obj_t* lbl_back = lv_label_create(ctx->btn_back); lv_label_set_text(lbl_back, LV_SYMBOL_LEFT); lv_obj_center(lbl_back);
lv_obj_add_event_cb(ctx->btn_back, back_event_cb, LV_EVENT_CLICKED, ctx);
ctx->btn_prev = lv_btn_create(ctx->ctrl_bar);
lv_obj_set_size(ctx->btn_prev, 54, 32);
lv_obj_set_style_radius(ctx->btn_prev, 16, 0);
lv_obj_set_style_bg_color(ctx->btn_prev, lv_color_hex(0x313244), LV_PART_MAIN);
lv_obj_t* lp = lv_label_create(ctx->btn_prev); lv_label_set_text(lp, LV_SYMBOL_PREV); lv_obj_center(lp);
lv_obj_add_event_cb(ctx->btn_prev, prev_event_cb, LV_EVENT_CLICKED, ctx);
ctx->btn_reload = lv_btn_create(ctx->ctrl_bar);
lv_obj_set_size(ctx->btn_reload, 44, 32);
lv_obj_set_style_radius(ctx->btn_reload, 16, 0);
lv_obj_set_style_bg_color(ctx->btn_reload, lv_color_hex(0x313244), LV_PART_MAIN);
lv_obj_t* lr = lv_label_create(ctx->btn_reload); lv_label_set_text(lr, LV_SYMBOL_REFRESH); lv_obj_center(lr);
lv_obj_add_event_cb(ctx->btn_reload, reload_event_cb, LV_EVENT_CLICKED, ctx);
ctx->btn_next = lv_btn_create(ctx->ctrl_bar);
lv_obj_set_size(ctx->btn_next, 54, 32);
lv_obj_set_style_radius(ctx->btn_next, 16, 0);
lv_obj_set_style_bg_color(ctx->btn_next, lv_color_hex(0x89B4FA), LV_PART_MAIN);
lv_obj_set_style_text_color(ctx->btn_next, lv_color_hex(0x11111B), LV_PART_MAIN);
lv_obj_t* ln = lv_label_create(ctx->btn_next); lv_label_set_text(ln, LV_SYMBOL_NEXT); lv_obj_center(ln);
lv_obj_add_event_cb(ctx->btn_next, next_event_cb, LV_EVENT_CLICKED, ctx);
if (tt_lvgl_lock(pdMS_TO_TICKS(500))) {
if (ctx->slideshow_timer) { lv_timer_delete(ctx->slideshow_timer); ctx->slideshow_timer=NULL; }
ctx->slideshow_timer = lv_timer_create(slideshow_timer_cb, 60000, ctx);
if (ctx->autohide_timer) { lv_timer_delete(ctx->autohide_timer); ctx->autohide_timer=NULL; }
ctx->autohide_timer = NULL;
tt_lvgl_unlock();
}
// Always show person picker first like Bible book selector
show_picker(ctx);
// If we already have photos for current person, user can still see them after selecting
// Don't auto-start fetch here; wait for person selection to fetch fresh random page
// But if we have cached photos from previous session for current person, we could show player directly
// For now, just show picker and let user choose person
if (photo_count>0 && ctx->current_person_id[0]!='\0') {
// Optionally prefetch in background
snprintf(ctx->status_text,sizeof(ctx->status_text),"Cached %d for %s - select person", photo_count, ctx->current_person_name);
} else {
snprintf(ctx->status_text,sizeof(ctx->status_text),"Select a person");
}
}
static void on_hide(AppHandle app, void* data) {
(void)app; AppCtx* ctx=(AppCtx*)data; if(!ctx) return;
ESP_LOGI(TAG, "onHide start, stopping tasks");
ctx->stop_requested=true; ctx->visible=false;
if (tt_lvgl_lock(pdMS_TO_TICKS(500))) {
if (ctx->slideshow_timer) { lv_timer_delete(ctx->slideshow_timer); ctx->slideshow_timer=NULL; }
if (ctx->autohide_timer) { lv_timer_delete(ctx->autohide_timer); ctx->autohide_timer=NULL; }
tt_lvgl_unlock();
}
int timeout=200;
while((ctx->fetch_task || ctx->thumb_task) && timeout>0){ vTaskDelay(pdMS_TO_TICKS(50)); timeout--; }
if (ctx->fetch_task || ctx->thumb_task) ESP_LOGW(TAG, "onHide timeout fetch=%p thumb=%p", ctx->fetch_task, ctx->thumb_task);
else ESP_LOGI(TAG, "onHide tasks stopped cleanly");
}
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;
}
+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]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.m5unittest
versionName=0.1.0
versionCode=1
name=M5 Unit Test
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.m5unittest
app.version.name=0.4.0
app.version.code=4
app.name=M5 Unit Test
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.magic8ball
versionName=0.2.0
versionCode=2
name=Magic 8-Ball
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.magic8ball
app.version.name=0.5.0
app.version.code=5
app.name=Magic 8-Ball
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.mcpscreen
versionName=0.1.0
versionCode=1
name=MCP Screen
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.mcpscreen
app.version.name=0.1.0
app.version.code=1
app.name=MCP Screen
+46 -14
View File
@@ -211,12 +211,29 @@ void MediaKeys::startHid() {
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) {
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
_isEnabled = enabled;
if (enabled) {
_btDevice = bluetooth_find_first_ready_device();
_btDevice = device_find_first_by_type(&BLUETOOTH_TYPE);
if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false;
@@ -224,6 +241,19 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
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");
// Register callback before enabling radio so we don't miss the state-change event.
@@ -247,12 +277,10 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
} else {
_radioEnabling = false;
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 (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_radioWasOff = false;
_btDevice = nullptr;
_hidDevice = nullptr;
teardownBt();
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);
// 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*/) {
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;
_radioWasOff = false;
_isEnabled = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
teardownBt();
if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr;
+6 -3
View File
@@ -2,6 +2,7 @@
#include <TactilityCpp/App.h>
#include <lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h>
#include <tt_app.h>
@@ -23,9 +24,10 @@ class MediaKeys final : public App {
struct Device* _hidDevice = nullptr;
// State - accessed from both LVGL thread and BT callback thread
std::atomic<bool> _isEnabled {false};
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> _isEnabled {false};
std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON
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 void onSwitchToggled(lv_event_t* e);
@@ -36,6 +38,7 @@ class MediaKeys final : public App {
static void sendKeyTask(void* param);
// Instance methods called by static callbacks
void teardownBt(); // remove callback + stop HID + restore radio/device state
void handleSwitchToggle(bool enabled);
void handleButtonPress(uint32_t buttonId);
void startHid(); // called once radio is confirmed ON
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.mediakeys
versionName=0.1.0
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.
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.mediakeys
app.version.name=0.4.0
app.version.code=4
app.name=Media Keys
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.
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3
[app]
id=one.tactility.mp3player
versionName=1.0.0
versionCode=1
name=MP3 Player
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.mp3player
app.version.name=1.0.0
app.version.code=1
app.name=MP3 Player
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.mystifydemo
versionName=0.3.0
versionCode=3
name=Mystify Demo
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.mystifydemo
app.version.name=0.6.0
app.version.code=6
app.name=Mystify Demo
+9 -12
View File
@@ -1,12 +1,9 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.pocketdungeon
versionName=0.1.2
versionCode=3
name=Pocket Dungeon
description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
flags=HideStatusBar
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.pocketdungeon
app.version.name=0.1.2
app.version.code=3
app.name=Pocket Dungeon
app.description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
app.flags=HideStatusBar
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.reynabot
versionName=1.0.0
versionCode=1
name=ReynaBot
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.reynabot
app.version.name=1.0.0
app.version.code=1
app.name=ReynaBot
+173 -79
View File
@@ -1,6 +1,7 @@
#include <tt_app.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/lvgl_fonts.h>
#include <tt_mdns.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
@@ -8,10 +9,8 @@
#include <unistd.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include "esp_log.h"
#define TAG "RobotArm"
#define ARM_HOST "192.168.68.103"
#define ARM_PORT 80
#define ARM_PATH "/api/mcp"
#define NUM_JOINTS 6
@@ -41,68 +40,68 @@ static int seq_len = 0, seq_idx = -1;
static bool seq_playing = false;
static int seq_play_pos = 0;
static char arm_host[64] = "";
static bool arm_connected = false;
typedef struct {
AppHandle app;
lv_obj_t* root;
lv_obj_t* content; // scrollable column container
lv_obj_t* status;
lv_obj_t* sliders[6];
lv_obj_t* vals[6];
lv_obj_t* seq_label;
lv_obj_t* play_label;
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];
bool has[6];
int ticks[6];
lv_timer_t* poll;
lv_timer_t* seq_timer;
lv_timer_t* connect_timer;
int connect_attempts;
} Ctx;
static Ctx* g = NULL;
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){
uint32_t ip=ipaddr_addr(host);
if(ip==0 || ip==0xFFFFFFFF) return -2;
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
if(fd<0) return -1;
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);
struct timeval tv={5,0};
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ip;
struct timeval tv={2,0};
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&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;}
char hdr[256];
int bl=strlen(body);
char hdr[256]; 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);
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
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;}
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;}
out[tot]='\0'; close(fd);
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
return tot>0?0:-4;
}
static bool jget(const char* js,const char* key,int* out){
const char* p=strstr(js,key);
if(!p) return false;
p+=strlen(key);
while(*p && *p!=':'){
p++;
if(!*p) return false;
}
p++;
const char* p=strstr(js,key); if(!p) return false;
p+=strlen(key); while(*p && *p!=':'){p++; if(!*p) return false;} p++;
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
int sign=1;
if(*p=='-'){sign=-1;p++;}
float v=0,frac=0.1f;
bool dot=false,got=false;
int sign=1; if(*p=='-'){sign=-1;p++;}
float v=0,frac=0.1f; bool dot=false,got=false;
while(*p){
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 break;
p++;
else if(*p=='.'&&!dot) dot=true; else break; p++;
}
if(!got) return false;
*out=(int)(v*sign+0.5f);
return true;
*out=(int)(v*sign+0.5f); return true;
}
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
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){
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));
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);
}
static bool rpc_move(const char* j,int a){
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}}}",
j,a);
char r[1024]; memset(r,0,sizeof(r));
return http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",j,a);
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){
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}}}",
b,s,e,p,ro,gr,dur);
char r[1024]; memset(r,0,sizeof(r));
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;
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_home(void){
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 apply_ui(void){
if(!g) return;
@@ -150,7 +143,7 @@ static void apply_ui(void){
}
}
static void refresh_arm(void){
set_status("Reading .103...");
set_status("Reading...");
int 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;
@@ -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;}}
apply_ui();
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 init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
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++){
if(!g->has[i]) continue;
g->ticks[i]++; if(g->ticks[i]<3) continue;
g->has[i]=false; g->ticks[i]=0;
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
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);
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;
}
}
@@ -185,8 +178,6 @@ static void slider_cb(lv_event_t* e){
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
}
}
/* ── sequencer ── */
static void update_seq_ui(void){
if(!g) return;
if(g->seq_label){
@@ -216,26 +207,21 @@ static void load_frame(int fidx){
apply_ui(); seq_idx=fidx; update_seq_ui();
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){
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
int rem=seq_idx;
for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
int rem=seq_idx; 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;}
if(seq_idx>=seq_len) seq_idx=seq_len-1;
load_frame(seq_idx);
if(seq_idx>=seq_len) seq_idx=seq_len-1; 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_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){
(void)t; if(!seq_playing||seq_len==0) return;
int f=seq_play_pos%seq_len;
int* v=seq[f].v;
int f=seq_play_pos%seq_len; int* v=seq[f].v;
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;
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play");
return;
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play"); 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;}}
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_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 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 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 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){
(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;
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);
// Root fills screen below toolbar - NOT scrollable itself, content will be
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_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_bg_color(root,lv_color_hex(0xFFF8F0),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_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_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_color(title,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(title,2,0);
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn .103...");
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0);
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_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_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);
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},};
@@ -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);
}
/* ── large nice vertical sliders: 3 cols, 2x height 22x72 per request ── */
lv_obj_t* grid=lv_obj_create(root);
lv_obj_t* grid=lv_obj_create(c->main_box);
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_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_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_OFF);
for(int i=0;i<NUM_JOINTS;i++){
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_pad_all(card,3,0);
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_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);
}
/* ── sequencer at bottom, NOT fixed — scrollable with content ──
visual: colored blocks, no info, current highlighted, larger buttons */
lv_obj_t* seqbar=lv_obj_create(root);
lv_obj_t* seqbar=lv_obj_create(c->main_box);
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_opa(seqbar,LV_OPA_90,0);
lv_obj_set_style_radius(seqbar,12,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_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_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);
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_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[]={
{"-",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},};
@@ -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_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
}
uint32_t blk_cols[16]={
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
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();
c->poll=lv_timer_create(poll_cb,100,NULL);
c->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
lv_timer_create(init_cb,600,NULL);
c->connect_timer=lv_timer_create(connect_timer_cb, 1000, NULL);
connect_timer_cb(NULL);
}
static void onHide(AppHandle app,void* data){
@@ -422,8 +515,9 @@ static void onHide(AppHandle app,void* data){
if(g){
if(g->poll) lv_timer_delete(g->poll);
if(g->seq_timer) lv_timer_delete(g->seq_timer);
if(g->connect_timer) lv_timer_delete(g->connect_timer);
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;}
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.serialconsole
versionName=0.4.0
versionCode=4
name=Serial Console
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.serialconsole
app.version.name=0.7.0
app.version.code=7
app.name=Serial Console
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.snake
versionName=0.5.0
versionCode=5
name=Snake
description=Classic Snake game
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.snake
app.version.name=0.8.0
app.version.code=8
app.name=Snake
app.description=Classic Snake game
-1
View File
@@ -57,7 +57,6 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
if (sfxEngine == nullptr) {
sfxEngine = new SfxEngine();
sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
// Load settings
bool soundEnabled;
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.tamatac
versionName=0.1.0
versionCode=1
name=TamaTac
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.tamatac
app.version.name=0.4.0
app.version.code=4
app.name=TamaTac
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.todolist
versionName=0.2.0
versionCode=2
name=Todo List
description=Simple task list manager
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.todolist
app.version.name=0.5.0
app.version.code=5
app.name=Todo List
app.description=Simple task list manager
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.twoeleven
versionName=0.4.0
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).
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.twoeleven
app.version.name=0.7.0
app.version.code=7
app.name=2048
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).
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3
[app]
id=one.tactility.voicerecorder
name=Voice Recorder
versionName=1.0.0
versionCode=1
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.voicerecorder
app.version.name=1.0.0
app.version.code=1
app.name=Voice Recorder
+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");
return;
}
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
engine->play(SfxId::Coin); // Predefined SFX
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
engine->setVolume(0.7f); // Volume control
engine->stop();
delete engine;
@@ -87,9 +84,11 @@ idf_component_register(
- `void stopVoice(voice)` - Stop specific voice
### Settings
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
- `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)
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
+56 -41
View File
@@ -1,14 +1,23 @@
import json
import subprocess
import tarfile
import os
import tempfile
import configparser
import sys
from datetime import datetime, UTC
def read_properties_file(path):
config = configparser.RawConfigParser()
config.read(path)
return config
properties = {}
with open(path, "r") as file:
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):
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz
@@ -62,51 +71,49 @@ def get_manifest(appPath):
return None
def get_versioned_file_name(manifest):
app_id = manifest["app"]["id"]
version_code = manifest["app"]["versionCode"]
app_id = manifest["app.id"]
version_code = manifest["app.version.code"]
return f"{app_id}-{version_code}.app"
def get_os_version(manifest):
sdk = manifest["target"]["sdk"]
sdk = manifest["target.sdk"]
# Remove trailing hyphen suffix if present
if "-" in sdk:
return sdk.rsplit("-", 1)[0].strip()
else:
return sdk
def manifest_config_to_flat_json(manifest):
"""Convert a ConfigParser manifest into a flat JSON-like dict.
def check_and_get_sdk_version(manifest_map):
"""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):
- [app]
id -> appId
versionName -> appVersionName
versionCode -> appVersionCode (int)
name -> appName
description -> appDescription (optional; default "")
- [target]
sdk -> targetSdk
platforms -> targetPlatforms (comma-separated list)
def get_git_commit_hash():
return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
def manifest_config_to_flat_json(manifest):
"""Convert a flat (V2) manifest dict into a flat JSON-like dict.
Expected keys:
app.id -> appId
app.version.name -> appVersionName
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.
"""
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
app_id = get_opt("app", "id", "")
app_version_name = get_opt("app", "versionName", "")
app_version_code_raw = get_opt("app", "versionCode", "0")
app_name = get_opt("app", "name", "")
app_description = get_opt("app", "description", "") or ""
app_id = manifest.get("app.id", "")
app_version_name = manifest.get("app.version.name", "")
app_version_code_raw = manifest.get("app.version.code", "0")
app_name = manifest.get("app.name", "")
app_description = manifest.get("app.description", "") or ""
# Coerce version code to int safely
try:
@@ -114,8 +121,8 @@ def manifest_config_to_flat_json(manifest):
except Exception:
app_version_code = 0
target_sdk = get_opt("target", "sdk", "")
platforms_raw = get_opt("target", "platforms", "")
target_sdk = manifest.get("target.sdk", "")
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 []
filename = get_versioned_file_name(manifest)
@@ -142,15 +149,23 @@ if __name__ == "__main__":
sys.exit()
app_directory = sys.argv[1]
manifest_map = {}
output_json = {
"apps": []
}
any_manifest = None
if os.path.exists(app_directory):
for file in os.listdir(app_directory):
if file.endswith(".app"):
file_path = os.path.join(app_directory, file)
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
for file_path in manifest_map.keys():
print(f"Processing {file_path}: {manifest_map[file_path]}")
@@ -164,4 +179,4 @@ if __name__ == "__main__":
any_manifest = manifest
# Write JSON
output_json_path = os.path.join(app_directory, "apps.json")
write_json(output_json_path, output_json)
write_json(output_json_path, output_json)
+23 -36
View File
@@ -1,4 +1,3 @@
import configparser
import json
import os
import re
@@ -13,7 +12,7 @@ import tarfile
from urllib.parse import urlparse
ttbuild_path = ".tactility"
ttbuild_version = "3.5.1"
ttbuild_version = "4.1.0"
ttbuild_cdn = "https://cdn.tactilityproject.org"
ttbuild_sdk_json_validity = 3600 # seconds
ttport = 6666
@@ -106,9 +105,17 @@ def get_url(ip, path):
return f"http://{ip}:{ttport}{path}"
def read_properties_file(path):
config = configparser.RawConfigParser()
config.read(path)
return config
properties = {}
with open(path, "r") as file:
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
@@ -185,7 +192,7 @@ def fetch_sdkconfig_files(platform_targets):
for platform in platform_targets:
sdkconfig_filename = f"sdkconfig.app.{platform}"
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}")
#endregion SDK helpers
@@ -231,32 +238,12 @@ def read_manifest():
return read_properties_file("manifest.properties")
def validate_manifest(manifest):
# [manifest]
if not "manifest" in manifest:
exit_with_error("Invalid manifest format: [manifest] 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")
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"):
if key not in manifest:
exit_with_error(f"Invalid manifest format: {key} not found")
def is_valid_manifest_platform(manifest, platform):
manifest_platforms = manifest["target"]["platforms"].split(",")
manifest_platforms = manifest["target.platforms"].split(",")
return platform in manifest_platforms
def validate_manifest_platform(manifest, platform):
@@ -265,7 +252,7 @@ def validate_manifest_platform(manifest, platform):
def get_manifest_target_platforms(manifest, requested_platform):
if requested_platform == "" or requested_platform is None:
return manifest["target"]["platforms"].split(",")
return manifest["target.platforms"].split(",")
else:
validate_manifest_platform(manifest, requested_platform)
return [requested_platform]
@@ -512,7 +499,7 @@ def build_action(manifest, platform_arg, skip_build):
if use_local_sdk:
global local_base_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):
fetch_sdkconfig_files(platforms_to_build)
@@ -521,7 +508,7 @@ def build_action(manifest, platform_arg, skip_build):
sdk_json = read_sdk_json()
validate_self(sdk_json)
# Build
sdk_version = manifest["target"]["sdk"]
sdk_version = manifest["target.sdk"]
if not use_local_sdk:
if not sdk_download_all(sdk_version, platforms_to_build):
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}")
def run_action(manifest, ip):
app_id = manifest["app"]["id"]
app_id = manifest["app.id"]
print_status_busy("Running")
url = get_url(ip, "/app/run")
params = {'id': app_id}
@@ -614,7 +601,7 @@ def install_action(ip, platforms):
return False
def uninstall_action(manifest, ip):
app_id = manifest["app"]["id"]
app_id = manifest["app.id"]
print_status_busy("Uninstalling")
url = get_url(ip, "/app/uninstall")
params = {'id': app_id}
@@ -670,7 +657,7 @@ if __name__ == "__main__":
exit_with_error("manifest.properties not found")
manifest = read_manifest()
validate_manifest(manifest)
all_platform_targets = manifest["target"]["platforms"].split(",")
all_platform_targets = manifest["target.platforms"].split(",")
# Update SDK cache (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")