6 Commits

Author SHA1 Message Date
Adolfo 2795dddc12 feat(RobotArm): cute MCP arm controller with 2x tall sliders, sequencer colored blocks, smooth move_all_joints
- 6 joints vertical sliders 22x72 (2x tall), 3 cols, pastel cute cards, home-centered ranges shoulder max 70
- open/close inverted fix (0=open 180=close)
- sequencer bottom not fixed, colored blocks 22x22 no info, current highlight white 3px, larger buttons 38x32 Play 56x32
- smooth concurrent moves via move_all_joints tool (tools/list discovered) not one-by-one
- scroll fixes: clearflag SCROLLABLE on cards/grid/hdr/brow/seqbar, root LV_DIR_VER only (no horiz scroll)
- safe for device .129: custom my_htons avoids missing lwip_htons, 52U 0 missing symbols, 14K ELF
- live reading from 192.168.68.103/api/mcp get_arm_state + move_joint
- enjoyed by son :3
2026-07-17 11:19:07 -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
262 changed files with 409 additions and 30829 deletions
@@ -1,259 +0,0 @@
---
name: tactility-app-development
description: Use when building, compiling, deploying, or debugging Tactility OS external ELF apps (ESP32-S3/P4). Covers SDK env, missing-symbol loader errors, asset packaging, and immersive LVGL patterns.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [tactility, esp32, elf-loader, lvgl, esp-idf, embedded]
related_skills: [systematic-debugging]
---
# Tactility OS App Development
## Overview
Tactility loads third-party apps as dynamic ELF binaries at runtime. The host firmware exports a symbol table (`g_esp_espidf_elfsyms` / `g_customer_elfsyms` via `ESP_ELFSYM_EXPORT` / `DEFINE_MODULE_SYMBOL`). If your ELF references anything not exported, it fails at load time with `E ELF: Can't find symbol X`. The build system also requires ESP-IDF environment variables and a specific Python interpreter to avoid venv hijacking.
This skill consolidates the build-env fix, symbol hygiene, and asset/offline-data patterns discovered across sessions.
## When to Use
- Building `Apps/MyApp` via `tactility.py ... build esp32s3 --local-sdk`
- Fixing `ELF: Can't find symbol` on device serial
- Packaging large offline assets (bibles, fonts, images) for constrained RAM
- Implementing immersive single-screen LVGL UI with tap-to-toggle chrome
- Handling settings/progress persistence on SD card
Don't use for:
- Simulator (`FirmwareSim`) builds (statically linked C++ `tt::app::App` subclass, different workflow)
- Board support (devicetree, display drivers) — see `tactility-board-support` project skill
## Build Workflow (Local SDK)
Always source IDF first, export SDK path, and use IDF's Python binary directly:
```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
# Use IDF python to avoid Hermes/host venv pollution (urllib3 | type error on 3.9):
/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env/bin/python tactility.py Apps/MyApp clean
/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env/bin/python tactility.py Apps/MyApp build esp32s3 --local-sdk --verbose
```
Manifest requirements (`Apps/MyApp/manifest.properties`):
```properties
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.myapp
versionName=1.0.0
versionCode=1
name=My App
```
CMake component (`main/CMakeLists.txt`):
```cmake
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(SRCS ${SOURCE_FILES} REQUIRES TactilitySDK)
# Relax noisy werrors that other apps also ignore
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable)
```
Verification: `build/*.app` (tar) contains `assets/` + ELF; `build/cmake-build-esp32s3/*.app.elf` should exist.
## Resolving Missing-Symbol Loader Errors
Tactility's ELF loader resolves via firmware symbol table. Pre-flight check before flashing:
```python
# After IDF export.sh, run:
# xtensa-esp32s3-elf-nm -D Apps/MyApp/build/cmake-build-esp32s3/MyApp.app.elf | grep " U "
# Compare against firmware export macros ESP_ELFSYM_EXPORT / DEFINE_MODULE_SYMBOL via grep -R in firmware/
```
### Known Missing Symbols & Workarounds
- `lv_font_montserrat_14`, `lv_font_montserrat_18`**Not exported**. Use exported abstraction:
```c
#include "tactility/lvgl_fonts.h"
lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
// FONT_SIZE_SMALL/DEFAULT/LARGE mapped to board-specific sizes
```
- `lv_obj_set_style_bg_grad_color/dir/stop` → **Not exported** on 0.8.0-dev (caused `Application failed to start: missing symbol` blue OK dialog). Use solid `0x0E0E14` instead.
- `LV_OPA_95`, `LV_OPA_5`, etc. → **Not defined**. Only 0/10/20/30/40/50/60/70/80/90/100/COVER/TRANSP exist in vendored LVGL 8.x. Use `COVER` or `90`.
- `lv_arc_create`, `lv_arc_set_range/value/bg_angles`, `lv_arc_get_value` → **Not exported on 0.7.0-dev**, added in PR #496 `dff93cb6 Add lv_arc.h symbols` after 0.7 release. Device modal `Error / Application failed to start: missing symbol` with heap still healthy (~31KB free) indicates loader failure, not OOM. Fix: arc-free horizontal slide using `lv_slider_create` + swipe via `lv_indev_get_point` / `lv_indev_active` (exported since 0.6). See `references/book-browser.md`. `lv_roller` also not exported on 0.7/0.8.
- `lv_anim_init`, `lv_anim_set_var/values/duration/exec_cb/path_cb/start`, `lv_anim_path_ease_out/linear` → **Exported since 0.6** (lines 435-446). Safe for 0.7/0.8 fleet. Use for verse fade 220/300ms editorial transition. Verified no leak, heap stable 54KB on .129.
- `lv_binfont_create/destroy` → **Exported on 0.8.0-dev** (`lv_binfont_create`) but VFS path handling fragile — `tt_app_get_assets_path` returns void, check `buf[0]!=0`. Prefer **embedded C LVGL fonts** (v5 July 12): `lv_font_conv --format lvgl --lv-include lvgl.h --bpp 4 -r 32-126 -r 0x2018-0x201D` → `Source/fonts/*.c` ~40-85KB each, `LV_FONT_DECLARE`, `(lv_font_t*)&georgia_italic_26`, lives in PSRAM via `CONFIG_ELF_LOADER_LOAD_PSRAM=y`. Heap stable 47539 free. See `references/pretty-font.md`. Legacy binfont `.fnt` also bundled in `assets/fonts/` 52KB but not used by default.
- `LV_SYMBOL_STAR` → Symbols may be absent depending on LVGL symbol set. Use `LV_SYMBOL_OK`, `LV_SYMBOL_DUMMY`, or plain UTF-8.
- `lv_obj_set_style_max_width` → **Not exported on 0.7.0-dev and 0.8.0-dev** (July 16 2026 — `check_symbols.py` found 1 missing, `E ELF: Can't find symbol`). Triggers `Application failed to start: missing symbol` blue OK dialog (`/api/screenshot` returns PNG 320x240). Fix: use `LV_PCT(92)` / `LV_PCT(80)` fixed percents for side gutters, no max_width. Verified `xtensa-esp32s3-elf-nm -D` 89 undef → 0 missing after removal.
- `lv_obj_set_scrollbar_mode` → Version-dependent, not reliably exported on 0.7/0.8. Avoid — use default scroll (add `LV_OBJ_FLAG_SCROLLABLE`) and let LVGL handle it. On QVGA intro, make scrollArea scrollable and use flex column with `LV_FLEX_FLOW_COLUMN` + `pad_row 8`.
- `xTaskGetCurrentTaskHandle` → Not exported. Replace wait loops with `tt_lvgl_unlock(); vTaskDelay(10); tt_lvgl_lock(portMAX_DELAY);`
- `lv_image_set_rotation` → Use `lv_obj_set_style_transform_rotation(obj, 1800, 0)` (value 1800 = 180°)
- `cJSON`, `esp_websocket_client`, `lwip_htons` macros → Vendor sources directly or use raw `lwip_socket`/`lwip_connect` etc., which *are* exported.
- C++ lambdas in `.c` files → `auto make_btn = [](...){}` fails compile. Keep app source as pure C (no `auto`, no lambdas) or rename to `.cpp` with `extern \"C\"` shims.
- **How to bring new widgets (arc, roller, chart, etc)** → Edit `firmware/Modules/lvgl-module/source/symbols.c`, add `DEFINE_MODULE_SYMBOL(lv_arc_create)`, etc., rebuild device firmware via `python device.py es3c28p && idf.py build flash -p /dev/cu.usbmodem101`, then rebuild app SDK. Device .129 on 0.8.0-dev already has arc, but .112 0.7.0-dev does not. See session July 12 teaching how to expose more LV elements.
See `references/symbols.md` for full list.
## Asset & Offline Data Pattern
Large data (e.g., 4.8MB Zefania XML bible) must be split to keep RAM tiny. Pattern proven for BibleVerse:
- Monolith → per-book split: `assets/bible/<bnum>.bin` = concatenated NUL-terminated UTF-8 verses, `<bnum>.idx` = header `BIBK` + version u16 + count u32 + entries `{offset u32, cnum u16, vnum u16}`, `books.json` catalog.
- Biggest chunk Psalms ~215KB vs 4.1MB monolith → load only one book at a time via `tt_app_get_assets_path`.
- Hybrid fallback: try `A:` assets path, else scan `/sdcard/bibles/WEB/` same binary format for user translations.
- Always `tar tvf build/*.app | wc -l` to verify 60+ bins packaged.
See `references/offline-data.md` for converter recipe and `templates/convert_bible.py`.
## Persistence (Settings-like File)
Follow BookPlayer/EpubReader pattern:
```c
char ud_path[320]; // from tt_app_get_user_data_path(app, ud_path, sizeof)
char prog_path[320];
snprintf(prog_path, sizeof prog_path, "%s/progress.txt", ud_path); // via tt_app_get_user_data_child_path(app, "progress.txt", ...)
```
Store `bnum cnum vnum globalIdx`; write atomically on verse change. Same for `favorites.txt`.
## Immersive UI Pattern
- Single verse fullscreen centered, container `LV_OBJ_FLAG_SCROLLABLE` removed to avoid drag recalc.
- Header + bottom toolbar hidden by default (`LV_OBJ_FLAG_HIDDEN`), tap toggles.
- **Adaptive scaling**: `apply_verse_scaling()` measures `strlen(verse)` → selects `FONT_SIZE_LARGE/DEFAULT/SMALL` + line_space 4-14 + pad tightened v4 2-10 + letter_spacing. Ultra-short (<40c) gets letter_spacing 1 and breathing room. Very long (350c+ Esther 8:9) gets compact small.
- **Editorial v4/v5 (July 12)**: Warmer verse `#F2F0E8` flat no bubble, ref uppercase `EPHESIANS 1:4` tracking 2 `#9A9AA8`, gap 10 (was 14) for closeness like PSALMS 23:1 reference image, fade transition 220ms verse / 300ms ref via `lv_anim_init/set_var/values/duration/exec_cb/path_ease_out/start` (exported since 0.6, stable heap 54KB). **v5 pretty font**: Georgia Italic embedded C 26/22/20/18/16pt + regular 24 via `lv_font_conv --format lvgl --lv-include lvgl.h` → `LV_FONT_DECLARE` + resolve helpers `resolve_verse_font/book_font` fallback to `lvgl_get_text_font()`, lives in PSRAM 315KB `.rodata`, heap 47539 stable, vision confirms "serif italic editorial like PSALMS 23:1" (Habakkuk 1:17). See `references/pretty-font.md`.
- Center column `90%x80%` centered (not 88x84) to avoid bottom ref crop on 240px screens.
- Auto-advance: `lv_timer_create(timer_cb, 60000, ctx)` + `lv_timer_reset` on manual nav.
- Rate-limit label updates (store last values) to avoid audio stutter if audio plays.
- Reference: `references/immersive-ui.md`
```c
static void apply_verse_scaling(AppCtx* ctx, const char* verse) {
size_t len = strlen(verse);
enum LvglFontSize f; int line_sp, pad_v, pad_h, letter_sp=0;
if (len < 40) { f=FONT_SIZE_LARGE; line_sp=14; pad_v=32; pad_h=36; letter_sp=1; } // ultra-short
else if (len < 100) { f=FONT_SIZE_LARGE; line_sp=11; pad_v=24; pad_h=28; } // short
else if (len < 200) { f=FONT_SIZE_DEFAULT; line_sp=9; pad_v=18; pad_h=22; } // medium
else if (len < 350) { f=FONT_SIZE_SMALL; line_sp=6; pad_v=12; pad_h=18; } // long
else { f=FONT_SIZE_SMALL; line_sp=4; pad_v=8; pad_h=14; } // epic 350+
lv_obj_set_style_text_font(ctx->lbl_verse, lvgl_get_text_font(f), 0);
lv_obj_set_style_text_line_space(ctx->lbl_verse, line_sp, 0);
lv_obj_set_style_text_letter_space(ctx->lbl_verse, letter_sp, 0);
lv_obj_set_style_pad_top(ctx->lbl_verse, pad_v, 0);
lv_obj_set_style_pad_bottom(ctx->lbl_verse, pad_v, 0);
lv_obj_set_style_pad_left(ctx->lbl_verse, pad_h, 0);
lv_obj_set_style_pad_right(ctx->lbl_verse, pad_h, 0);
}
// Call after lv_label_set_text(verse)
```
Tiers tuned from real device screenshots: short verses get giant breathing room, long verses shrink + tighten.
- Reference: `references/immersive-ui.md`
### QVGA intro layout: left info + right small icon rail (PocketDungeon v5-v7 July 16-17)
For 320x240 / 320x198 usable (after statusbar) intro screens cramp when vertically stacked (title+cards+wide text buttons = clipped GOAL text, `Loot gold, survive monsters` truncated). Pattern that fixes it:
- Structure: header (title+subtitle+best compact) + `mainRow` flex row (`ROW`, `flex_grow 1`, pad 4/8 column, bottom 8) = left `leftCol` flex_grow 1 scrollable column (pad_row 8, pad_bottom 6) + right `rightRail` fixed 44px narrow column (size CONTENT height, pad_row 10, no scroll).
- Left cards iteration (v6-v7): v6 had GOAL + HOW TO PLAY (2 cards, CONTROLS merged into HOW TO PLAY line `Arrows/WASD/Swipe/Quadrant`) + right icons 38/30. User then asked: keep only GOAL + CONTROLS on intro, teach symbols via actual playable tutorial levels instead of text explanation. So v7 final: `makeCard(leftCol, "GOAL", "Reach deepest floor. Collect gold, survive."), makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit")` = 2 cards, airy, no HOW TO PLAY. Each card `PCT 100%` width, `SIZE_CONTENT` height, pad 9, row 3, bg `COLOR_PANEL` radius 10, labels WRAP width 100%.
- Right icons: small icons on right as user requested — replace text `ENTER DUNGEON` button with icons: play 38px accent bg `COLOR_ACCENT` radius `s/3`, close/exit 30px muted bg `0x252836`, both `lv_btn_create` + centered label `LV_SYMBOL_PLAY` / `LV_SYMBOL_CLOSE`. No text hint below (was clipped). Saves vertical space, gives 40-50px more for cards.
- Verification via `/api/screenshot`: `curl -s http://<ip>/api/screenshot -o /tmp/intro.png` then vision check for clipping/padding. Screenshot shows 6.2-6.6KB PNG, header Best F3 G18, left 75% cards, right 25% icons. If bottom clipped, reduce cards to 2, shrink rail to 40px, icons 32/26.
- Symbol hygiene: no `lv_obj_set_style_max_width` (unexported, causes `missing symbol` blue OK dialog), no `lv_obj_set_scrollbar_mode` — use `PCT 92/80/70` for gutters and default scroll via `LV_OBJ_FLAG_SCROLLABLE`.
See `references/intro-fullscreen.md` v5-v7 and `references/sdk-mismatch-missing-symbol.md` for SDK 0.8.0-dev bump (board .112 OS 0.8.0-dev vs manifest 0.7.0-dev) and `lv_obj_set_style_max_width` removal (89 undef → 0 missing).
### Tutorial level pattern: 2-row teach-by-play (PocketDungeon v7 July 17)
User wanted: instead of text explanation in intro, have 3 intro playable levels — first presents player and stairs, next bat and gold, both with 2 rows regular cols, then go current first level. Implementation:
- `DungeonModel.h`: `TUTORIAL_ROWS=2`, `renderRows` field in `DungeonState`, `floor 0,1 = tutorial`, `displayFloor()` maps internal floor to user-visible (tutorial → 1), `isTutorial()`, `tutorialId()`, `setBests`, `reset(seed, tutorials=true)`, `generateTutorialFloor(tId)`, `clearAllTiles()`.
- `DungeonModel.cpp`: `reset` sets floor 0 if tutorials else 2 (first real = 2 internally → display 1), `clearAllTiles` border walls, `generateTutorialFloor` sets `renderRows=TUTORIAL_ROWS`, hides rows >=3 via walls, player {1,1}, Tut0: Stairs at {COLS-2,1}, Tut1: Treasure {3,1} + Stairs + Bat {5,1} hp1, no random walls, `moveMonsters` skipped when `isTutorial()`, best update only floor>=2.
- `DungeonRenderer.cpp`: respects `state.renderRows`, hides cells y>=renderRows via `HIDDEN` flag, re-centers board for tutorial `set_height grid.cellPx*TUTORIAL_ROWS align CENTER 0,-10`, status shows `TUTORIAL X/2 HP Gold`, message `TUT 1: Move to > @=you` / `TUT 2: Kill b, take $, use >` vs normal `Arrows/Swipe/Tap`.
- `PocketDungeon.cpp` intro final per user July 17: only GOAL + CONTROLS cards (2), symbols taught via play, not text.
- Verification: hermes-verify-tutorial*.py checks 2 cards, no HOW TO PLAY, tutorial gen Bat+Treasure+Stairs, renderRows, `TUTORIAL_ROWS`, isTutorial, build package ok, nm 0 missing.
- Result: flow Intro (Goal+Controls left, icons right) → Tut1 2×9 @→> → Tut2 2×9 @+b+$→> → Real game F1 infinite (display floor). Installed to 192.168.68.112, screenshots intro_tutorial.png 6.3K.
See `references/pocket-dungeon.md` tutorial update and `references/intro-fullscreen.md` v7.
## Deployment & Running
Old docs say `POST :6666/app/install`. Firmware 0.7.0-dev+ dashboard moved to port 80:
- `PUT /api/apps/install` multipart `file` field (not POST, not :6666)
- `POST /api/apps/run?id=<appId>`, `GET /api/apps`
`tactility.py` tool v3.5.1 still uses :6666 and needs Developer mode enabled on device. If port 6666 refuses, use dashboard API directly or enable dev server in Settings. Always pass `esp32s3` platform arg to `install` unless you built all platforms.
Full recipe + discovery in `references/deploy.md`.
## Common Pitfalls
1. **Hermes venv hijacks IDF Python — two flavors**
- **Flavor A (tactility.py)**: `requests` import fails with `TypeError: unsupported operand type(s) for |: 'type' and 'type'` on Python 3.9 because `PYTHONPATH` includes Hermes venv's `urllib3` using Python 3.10+ union syntax.
- **Flavor B (idf.py build/flash)**: `Cannot import module "pydantic_core._pydantic_core"` / `ModuleNotFoundError` — Hermes 3.11 venv's `pydantic_core/*.so` shadows IDF's 3.9/3.10 venv. Even `idf.py --version` fails.
- **Fix both**: clear `PYTHONPATH=""` and explicitly set `IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env` before sourcing export.sh. Also:
```bash
unset PYTHONPATH; unset PYTHONHOME
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env
. /Users/adolforeyna/esp/esp-idf/export.sh
```
- Stale Python version: If project was configured with `idf5.3_py3.10_env` but you now use 3.9 env → `idf.py fullclean` required or you get "project was configured with .../idf5.3_py3.10_env/bin/python". Do fullclean then re-generate `python device.py <id>`.
- Verification:
```bash
$IDF_PYTHON_ENV_PATH/bin/python -c "import pydantic_core; print('ok')"
env -u PYTHONPATH -u PYTHONHOME idf.py --version # ESP-IDF v5.3.2-dirty
```
2. **Werror=format-truncation** — `snprintf` with 320B buffers flagged. Add `-Wno-format-truncation` via `target_compile_options` (safe, buffers intentionally oversized).
3. **Direct font symbols** — Any `&lv_font_montserrat_*` will compile but fail at runtime. Always use `lvgl_get_text_font()`.
4. **Asset size bloat** — Bundling 4MB monolithic bin triggers OOM at runtime. Split per-book and stream.
4b. **Book browser OOM — 66 buttons = 275B heap free** (July 11 BibleVerse on 192.168.68.112 0.7.0-dev). Building 66× `lv_button_create` + labels + nested flex rows = 132-264 LVGL objs → heap `total 310KB free 275B min_free 23 largest_block 23` → `failed to create temp directory` on install, screenshot 500. Fix: v4 clean horizontal slide ~10 objs (no top bar/Go/card, center LARGE 50% bigger directly on bg tappable, bottom row [X left][slider flex][39/66 right]), swipe via `LV_EVENT_PRESSED/PRESSING` tracking `book_drag_start_x` 28px threshold, plus `lv_slider` fast scrub. After fix heap `10943B free largest 7168` on .112, `54471→54355` stable on .129 0.8.0-dev (0.8 has more heap: 64KB pre). ELF itself lives in PSRAM (`CONFIG_ELF_LOADER_LOAD_PSRAM=y`, 5MB free), but LVGL objects are internal heap only — PSRAM doesn't help browser. Editorial v4 also adds fade transition `lv_anim_*` 220/300ms no leak.
5. **Touch input swallowed by full-screen containers — GameKit bubbling pitfall** (PocketDungeon July 16 v3) — Symptom: after adding `introContainer` + `gameContainer` full-screen covering `root`, `GameKit::attachInput(root, scene)` alone stopped working. Tap/swipe dead. Root cause: LVGL event bubbling — topmost `LV_OBJ_FLAG_CLICKABLE` container captures `CLICKED`/`GESTURE` before `root`. Also `rulesBox` inside introContainer swallowed taps needed for `pointToQuadrant`. Fix pattern `attachInputToCurrent()`:
- Re-attach input to root + current active containers with `CLICKABLE|GESTURE_BUBBLE`
- Make inner info boxes non-clickable/non-scrollable so taps bubble to parent
- Start button calls `lv_event_stop_bubbling(e)` to prevent double trigger
- Handle `Touch`/`Swipe` InputKind at game-over restart
- After fix + rebuild `esp32s3`, touch quadrants + swipe + keys work on 192.168.68.112. See `references/intro-fullscreen.md` v3 section.
6. **Fullscreen toolbar-less apps trap user — no exit** (PocketDungeon July 16 v4) — Removing `tt_lvgl_toolbar_create_for_app` gains 40-50px but loses system back button → no way to exit. Fix: three exit paths:
- (1) Hardware: `InputKind::Cancel/Menu` (Q/ESC → Cancel via `keyToInput` maps q/Q → Cancel, `LV_KEY_ESC` → Cancel) → `tt_app_stop()`, include via `TactilityCpp/App.h` (pulls `tt_app.h`), save bests + SFX Cancel
- (2) X button top-right in game phase (`LV_SYMBOL_CLOSE` 36×28 bg #2A2D40 radius 6 → `onExitButtonClicked`)
- (3) Long-press board `LV_EVENT_LONG_PRESSED` on `gameContainer` → pause overlay `AppPhase::Paused` with Resume/Exit Game (overlay bg #000 OPA_70, box 180px, Resume blue accent #365CF5, Exit grey #3A3A4A, hint Q/ESC = exit / Long press = pause); any input in Paused resumes. Intro also has Exit button. Header adds `pauseOverlay` + `Paused` + 3 static callbacks. Verification: hermes-verify-exit*.py checks `tt_app_stop`, `pauseOverlay`, `LONG_PRESSED`, `Cancel`, build ok.
7. **Black bar crop + Georgia sizing + 15px lift correction** (BibleVerse v6 July 12) — Verse cropped by header bar `size 100%x44 bg #15151F COVER + border 1 #232338` overlapping center col. User: "black bar on top cropping text" + "reduce text size for this font" + "main text can be moved a bit up 30px" then corrected "should have been 15px". Fix: header `36px` (was 44), `bg 0x0E0E14 TRANSP 0` (was 15151F COVER), border 0, pad ver 4, center `PCT(90)x82% align CENTER 0,8` initially → `-22` (30px up) → corrected to `-7` (15px up: base +8 -15). Ctrl bar also TRANSP, verse pad 4. Georgia Italic wider than Montserrat → downtier: `LARGE→22` (was 26), `DEFAULT→18` (was 20), `SMALL→16`, ultra `<40c` still 26 via `resolve_verse_font_ultra`. Pads tighter `6/4/3/2/1` (was 10/8/6/4/2). Vision: Hab 2:9 "No extra black bar overlapping, fully visible" + Zeph 1:3 "balanced now, optical center correct" PASS, heap 46215-47591 stable. Screenshot rate-limit 2s+ to avoid `ConnectionResetError 54` heap spike. Gotcha: `lv_timer_t` incomplete in SDK — `t->user_data` invalid, must use `lv_timer_get_user_data(t)` (exported). See `references/black-bar-crop.md` + `references/pretty-font.md`.
8. **Book browser animation + auto-show 10s** (BibleVerse v6 July 12) — User: "add animation to the bible book selection. Also let's start the app with the menu showing, but make the menu UI auto hide after 10s." Impl: `AppCtx { lv_timer_t* chrome_auto_hide_timer; bool ui_visible; }`, `chrome_auto_hide_cb(lv_timer_t* t){ AppCtx* ctx = lv_timer_get_user_data(t); if(book_browser_visible) return; set_chrome_visible(false); delete timer; }`, `schedule_chrome_auto_hide(ctx,10000)` creates timer, `onShowApp` `set_chrome_visible(true)` + schedule 10s, `toggle_ui` re-arms 10s when shown else deletes, `toggle_book_browser` deletes auto-hide timer, `onHideApp` cleanup. Book browser anim: `book_y_anim_cb(void* var,int32 v){ lv_obj_set_y((lv_obj_t*)var,v); }` + `book_fade_anim_cb` → `text_opa`, `set_book_browser_visible(true)` → `remove FLAG_HIDDEN`, `mid_wrap y 20→0 260ms ease_out` + name fade `OPA_20→COVER 220ms`, `book_dial_update_center` also fades name `60→255 180ms` on swipe/slider change. Verified .129: 8683 bytes at 0.8s after run menu visible → 6882 hidden at ~2s, auto-hide 10s configured. See `references/immersive-ui.md` + `references/book-browser.md`.
9. **Assuming `lv_font_montserrat_18` exists** — Not even compiled for all boards. Stick to SMALL/DEFAULT/LARGE.
10. **Build cache stale after main.c edit** — `tactility.py clean` before rebuild when CMake glob changes.
11. **Install without platform arg** — `tactility.py Apps/X install <ip>` tries all `manifest platforms`; if only esp32s3 ELF built you get `ELF file not built for esp32p4`. Pass `esp32s3` explicitly.
12. **6666 refused** — Dev server disabled. Use dashboard `PUT /api/apps/install` on port 80, or enable Developer mode on device Settings first.
13. **Wrong board flashed (color vs RLCD)** — Family has two boards: ES3C28P (2.8" color, `es3c28p`) and waveshare-esp32-s3-rlcd (4.2" mono). Symptoms: monochrome UI = flashed RLCD firmware onto color board. Always `grep CONFIG_TT_DEVICE_ID sdkconfig` and `ls /dev/cu.usbmodem*` before flash. Family color board is ES3C28P at `/dev/cu.usbmodem101`, 16MB flash@80MHz, `partitions-16mb-with-sd.csv`.
14. **Personal remote app not yet pulled locally** — User says pull it from repo (2026-07-16). PocketDungeon existed only on personal remote (git.reynafamily.com/adolforeyna/tactility_apps) commit d976595 feat: add Pocket Dungeon game prototype, not in local Apps/ after fetch of origin. Fix `cd apps && git fetch personal && git merge personal/main`.
15. **Board ending in 112 shorthand** — User shorthand ending on 112 means WiFi IP in local subnet. Discovery: 192.168.68.0/24 scan, 192.168.68.112 ping OK (ESP32-S3, osVersion 0.8.0-dev, MAC 14:c1:9f:d1:56:90), 192.168.68.111 also online = 2 boards active. No USB present is fine for network install via port 6666 or dashboard 80. Use arp + socket probe. Must pass esp32s3 platform arg else fails with ELF file not built for esp32.
16. **GameKit / SfxEngine shared libraries (2026-07-16)** — New pattern for game apps. `main/CMakeLists.txt` globs `../../Libraries/GameKit/Source/*.c*` + `SfxEngine` + INCLUDE_DIRS + REQUIRES `TactilitySDK esp_driver_i2s esp_driver_gpio`. GameKit provides Loop, Scene, Input (arrows/WASD+gestures+quadrant), Grid, Draw, Prefs. App entry still registerApp. 40KB .app tar contains elf/esp32s3.elf. Extended v2-v4 with AppPhase Intro/Playing/Paused, introContainer+gameContainer+pauseOverlay, `attachInputToCurrent()` bubbling fix, exit handling via Cancel/Menu + X + long-press. See `references/pocket-dungeon.md` + `references/intro-fullscreen.md` v3/v4.
## Verification Checklist
- [ ] `TACTILITY_SDK_PATH` set, IDF `export.sh` sourced, using IDF python binary for `tactility.py`
- [ ] `build/<App>.app` exists, `tar tvf` shows expected assets
- [ ] `xtensa-esp32s3-elf-nm -D *.app.elf` undefined checked; 0 missing vs firmware exports (0 undef total v4 editorial = healthy)
- [ ] No direct `lv_font_montserrat_*` or `LV_SYMBOL_STAR` refs in source (grep `lv_font_montserrat`)
- [ ] `main/CMakeLists.txt` has `-Wno-format-truncation` allowance
- [ ] Settings path via `tt_app_get_user_data_child_path`, tested load/save
- [ ] If immersive UI: tap toggle, timer 60s, hidden chrome by default verified on device/sim, fade 220/300ms no leak heap stable >10KB
- [ ] If book browser: ~10 objs, [X left][slider flex][N/66 right], no top bar/Go/card, LARGE name 50% bigger tappable, swipe 28px, heap >10KB (275B = OOM, 31KB+error dialog = missing symbol)
## References
- `references/build-env.md` — full env error transcript + fix
- `references/symbols.md` — exported vs missing symbol list, OOM vs missing-symbol diagnosis, heap sizes `275B vs 31KB vs 54KB stable`, anim/binfont exports
- `references/offline-data.md` — per-book binary format and converter usage
- `references/deploy.md` — dashboard vs dev-port install, PUT /api/apps/install, platform arg handling, discovery via browser tools, .112 0.7.0-dev vs .129 0.8.0-dev fleet
- `references/pocket-dungeon.md` — 2026-07-16 PocketDungeon GameKit+SfxEngine via GLOB_RECURSE, personal remote pull (git.reynafamily.com), board shorthand 112 discovery, PYTHONPATH fix, 40KB .app build + network install esp32s3
- `references/intro-fullscreen.md` — Intro state machine AppPhase + fullscreen no-toolbar, rules/controls screen, HideStatusBar flag not parsed for ELF apps
- `references/sdk-mismatch-missing-symbol.md` — SDK/OS version mismatch .112 0.8.0-dev vs 0.7 manifest + unexported `lv_obj_set_style_max_width` / `scrollbar_mode` causing `missing symbol` dialog, QVGA cramped fix, `/api/screenshot` remote QA
- `references/immersive-ui.md` — UI pattern v4 editorial: warm #F2F0E8, uppercase tracking 2 ref #9A9AA8 gap 10, fade transition anim, binfont future
- `references/book-browser.md` — book selector evolution: 66-button OOM → arc rotary (0.7 missing) → horizontal slide v3 → clean v4 no top/Go/card LARGE name + [X][slider][N/66], PSRAM vs heap
- `references/black-bar-crop.md` — v6 black bar 44px #15151F COVER overlapping verse crop fix, Georgia down-tier LARGE→22 DEFAULT→18 memo, screenshot rate-limit
- `references/audio-apps.md` — RLCD Mp3Player task stack/prio/YIELD + VoiceRecorder stereo→mono downmix + ES7210 vs ES8311 MCLK/DMA fixes (2026-07-12)
- `scripts/verify_symbols.py` — rerunnable undefined-symbol checker
@@ -1,71 +0,0 @@
# RLCD Audio Apps — Mp3Player & VoiceRecorder Fixes (2026-07-12)
## Context
Waveshare RLCD 4.2" uses ES8311 DAC @0x18 + ES7210 mic ADC @0x40, MCLK=12.288MHz GPIO16, BCLK=9 WS=45 TX=8 RX=10 AMP=46 HIGH.
Working reference: `MicroPython/test1/Screen/lib/audio_util.py` + `board_config.py` WAVESHARE_RLCD profile.
## Mp3Player Pitfalls
### 1. Task Stack & Priority
- Default `xTaskCreate(..., 4096, 5)` → stack overflow under minimp3 + SD + I2S + LVGL.
- Log: `i2s_alloc_dma_desc failed` sometimes masked as OOM, but also task crashes.
- Fix: `6144, prio 6`.
### 2. taskYIELD Causing Repetition
```c
// Before: decode one MP3 frame (1152 samples @16k = 72ms) then:
taskYIELD(); // gives CPU to LVGL but DMA empties → repeats last buffer
// After: remove YIELD, loop tight, let I2S write block (250ms timeout) drive pacing
```
- DMA 4x120 = 30ms total, smaller than frame time → underrun → repetition. Increased driver DMA to 6x160 (~60ms) + removed YIELD.
### 3. Sample Rate Assumptions
- RLCD files in repo are 16k mono (faith-mysterious-garden/page001.mp3 31652 bytes, 16k 1ch).
- `I2S configured: 16000 Hz, 1 channels` — must match ES8311 boot init that already programs 16k exact.
- Reclock hook that rewrites ES8311 0x06/0x07/0x08 during I2S start causes pop. For 16k, skip entirely.
- MCLK multiple: 16k*768=12.288MHz exact, 32k*384, 48k*256. Default SDK 256 gives 4.096MHz for 16k → robot sound. See `tactility-firmware/references/rlcd-audio-fix.md`.
## VoiceRecorder Pitfalls
### Mono vs Stereo for ES7210
Working MP:
```python
i2s = I2S(1, sck=9, ws=45, sd=10, mode=I2S.RX, rate=16000, bits=16, format=I2S.STEREO)
# then mono extract: for i in 0..n step 4: mono[j]=buf[i:i+2]
```
- ES7210 outputs stereo (2 mics) even if you want mono file.
- VoiceRecorder previously requested `channel_right = NONE` (mono slot mode) → driver expects 1 slot but codec sends 2 → low pitch mic-like.
- Fix: Request `channel_right=1` (STEREO), read `AUDIO_BUF_SIZE` (4096) stereo, downmix left channel in-place backward to avoid overwrite:
```c
size_t mono_bytes = bytes_read/2;
for(int src=bytes_read-4, dst=mono_bytes-2; src>=0; src-=4, dst-=2){
buf[dst]=buf[src]; buf[dst+1]=buf[src+1];
}
fwrite(buf,1,mono_bytes,f);
```
- WAV header stays mono (1 ch, byterate 32000) data correctly pitched.
### TDM vs STD
- Other boards (m5stack-stackchan, tab5) use TDM 4-slot for ES7210 via `i2s_controller_set_rx_tdm_config`.
- RLCD working MP uses STD stereo, not TDM. Keep STD for RLCD. TDM path DMA also needs reduction (was 8x512 → 4x120) or OOM.
## Firmware / Logging Interaction
### File Lock Flood
`File.cpp:getLock()` logged `WARN File lock function not set!` on every `listDirectory()` Tactility scans `/sdcard` every sec → 1Hz UART spam.
- UART ISR at 115200 contends with I2S GDMA ISR → jitter → small noises.
- Fix: once-only warning + listDir INFO→DEBUG.
### I2S Pin Logs
`esp32_i2s: Configuring I2S pins...` logged INFO on every play → same jitter.
- Fix: LOG_D.
### Verification Checklist
- Play 16k mono MP3: no pop at start, no robot, no repetition.
- Log after fix: no `File lock flood`, no `i2s_alloc_dma_desc failed`.
- Record WAV, download, play on PC: 16k mono correct pitch, not low/mic-like.
- Serial for voice: `I2S configured: 16000 Hz, 1 channels` for play, stereo read for record is internal.
## References
- `tactility-firmware/references/rlcd-audio-fix.md` GPIO5 conflict, correct pins, DMA OOM, MCLK, ES8311 reclock
- MicroPython working code: `~/Projects/MicroPython/test1/Screen/lib/board_config.py` WAVESHARE_RLCD
@@ -1,52 +0,0 @@
# v6 Black Bar Crop Fix + 15px Lift Correction — July 12 BibleVerse on .129 final 2142871
**Symptom:**
Vision: "black bar on the top cropping the text" — Habakkuk screenshots 4539 bytes showed 44px dark bar `#15151F COVER` + border overlapping center column, cutting first line. Also "reduce text size a bit for this font" — Georgia Italic 26pt wider than Montserrat LARGE. Then "main text can be moved a bit up 30px, seems padding favoring empty space on top." → corrected "We sent the text 30px up, but I think it should had been 15".
**Root cause header:**
```c
lv_obj_set_size(hb, LV_PCT(100), 44); bg #15151F COVER border 1 #232338
center_col lv_obj_center size 90%x80%
verse pad_all 6 font 26
```
**Fix v6 final 15px lift:**
1. Header transparent same root no border smaller:
```c
size 36 not 44; bg #0E0E14 same as parent root was #15151F; bg_opa TRANSP was COVER; border 0 was 1; pad_ver 4 was 0
```
2. Ctrl bar also TRANSP editorial: bg #0E0E14 TRANSP border 0 was #15151F COVER + top border
3. Center shift + lift correction:
```c
// was center
size 90%x82% // 80->82
align CENTER 0,8 // +8 down to clear statusbar
// then 30px up request -> 0,-22
// then correction 15px up -> 0,-7 = +8 base -15 = 15px up balanced final
// pad verse 4 not 6
```
Vision Zeph 1:3 after -7: "balanced now, optical center correct, prevents sitting on buttons, 1.5x padding above controls, no excessive top space" PASS. No black bar Hab 2:9 "No extra black bar overlapping, fully visible".
4. Georgia Italic down-tier wider than Montserrat:
```c
Before: LARGE->26 DEFAULT->20 SMALL->16
After v6: LARGE->22 was 26, DEFAULT->18 was 20, SMALL->16, ultra <40c still 26 via resolve_verse_font_ultra
pads tighter: <40 6/20 not 10/28, <100 4/16 not 8/24, <200 3/14 not 6/20, <350 2/12 not 4/16, 350+ 1/8 not 2/12
```
5. Menu auto-show 10s + book anim: See immersive-ui.md and book-browser.md v6 final. Timer gotcha: `t->user_data` incomplete typedef error in SDK 0.8.0-dev → must use `lv_timer_get_user_data(t)` (exported 382/388). Verified .129 menu show 8683 bytes.
**Verification final 2142871:**
- Build 4.3M `tactility.py build esp32s3 --local-sdk` ok, ELF 0 missing
- Install .129: heap `46215-47591 free largest 20480 psram 4810396` stable, ss `5335 serif` vs `3991 sans` vs `8683 menu visible` vs `6882 immersive`
- Vision checks: Hab 2:9 no crop, Zeph 1:3 balanced 15px lift, menu chrome visible at 0.8s then hidden showing premium cinematic
- Screenshot rate-limit >2s between to avoid `ConnectionResetError 54` heap spike from encoder
**Lessons:**
- Avoid opaque header on immersive — use root bg TRANSP so no crop when mistakenly visible
- When swapping Mont→Georgia reduce one tier — 26pt serif ~22pt sans visual
- Lift correction: base CENTER 0,8 too low empty top → 30px up → user corrected 15px up → -7 final, always confirm lift target in vision
- Timer incomplete typedef: use getter, not direct field
- Screenshot burst OOM — wait 2s+
See immersive-ui.md v6 final + pretty-font.md v6 final + book-browser.md v6 final. Beautiful per user final approval.
@@ -1,102 +0,0 @@
# Book Browser — Horizontal Slide + Clean v4 + Animated v6 Final (arc-free, low-RAM, beautiful)
## Problem history
1. **66-button list** — 66× `lv_button_create` + flex rows + labels = 132-264 LVGL objs.
- `GET /api/sysinfo`: `heap free 275B total 310KB min_free 23 largest_block 23` → OOM.
- Symptom: `PUT /api/apps/install 500 failed to create temp directory`, screenshot `500 25`.
- After self-crash: heap 33KB, install `200 ok` again.
2. **`lv_arc` rotary** — center name changes rotating ring, 6 objs ideal.
- Build 0 missing on 0.8.0-dev SDK (`symbols.c` PR #496 `dff93cb6`), but device `0.7.0-dev` (192.168.68.112) missing: blue dialog `Error / missing symbol / OK`, heap `31779` healthy → loader failure not OOM.
- `git log -S lv_arc_create` → added after 0.7 release. Must be arc-free for 0.7 fleet.
- `lv_roller` not exported on 0.7/0.8.
3. **v3 horizontal slide + slider** — works on 0.7, ~12 objs, heap `10943 free largest 7168` (112), `54355 free` on 129 0.8.0-dev after editorial.
## v4 Clean (July 12) — User: remove Go, card, top bar; larger 50% name; number 39/66 right of slider; cancel left of slide
- Removed: top header `Books — swipe`, Go button 200x36, card bg `15151F` radius 18
- New layout:
- `center_wrap` full-screen `PCT(100)` pad_top 0 pad_bottom 64 (was 52/88), transparent, CLICKABLE + swipe `PRESSED/PRESSING/RELEASED/PRESS_LOST`
- Left arrow 44x44 radius22 `1E1E2E` at `LEFT_MID 8,0`, right arrow same `RIGHT_MID -8,0`, click steps ±1
- Center `mid_wrap` 68% width `SIZE_CONTENT`, transparent, gap 10, flex column center, CLICKABLE → `on_book_center_click` jumps (tap name does Go)
- `book_center_name` `LARGE` (was DEFAULT, 50% bigger), width `100%`, `DOTS`, center aligned, pretty `georgia_regular_24` serif
- `book_center_verses` small dim `5A5A78`
- Bottom bar `100%x56` `15151F` top border `232338`, row `SPACE_BETWEEN` center, gap 10, pad hor10 ver8
- `[X cancel 36x32 radius8 252538 left] [slider flex_grow 1 height8 range 0..65 bg 232338 ind 4A4A6E knob E8E6E0] [num label 1/66 right 8A8AAA small]`
- Object count ~10 (vs 12 v3, vs 264 v1) → heap `54471→54355` stable on 129, `PSRAM 4.9MB`
## v6 Final Animated (July 12 beautiful build 2142871) — User: "add animation to the bible book selection. Also let's start the app with the menu showing, but make the menu UI auto hide after 10s." + lift correction 30→15px + beautiful approval
**Animation added**:
```c
static void book_y_anim_cb(void* var,int32_t v){ lv_obj_set_y((lv_obj_t*)var,v); }
static void book_fade_anim_cb(void* var,int32_t v){ lv_obj_set_style_text_opa((lv_obj_t*)var,v,0); }
set_book_browser_visible(true):
ctx->book_dial_idx = cur_book_idx; book_dial_update_center(ctx);
remove FLAG_HIDDEN
// pop center mid_wrap y 20->0 260ms ease_out
mid_wrap y = 20; lv_anim 20->0 260ms ease_out via book_y_anim_cb
// fade name OPA_20->COVER 220ms ease_out via book_fade_anim_cb
book_dial_update_center: on swipe/slider change name fade 60->255 180ms ease_out
```
**Menu auto-show 10s** (see immersive-ui.md): `chrome_auto_hide_timer` field + `chrome_auto_hide_cb` uses `lv_timer_get_user_data` not `t->user_data` (incomplete typedef error), `onShowApp` `set_chrome_visible(true)` + `schedule 10000`, `toggle_ui` re-arms 10s, `toggle_book_browser` deletes timer, `onHide` deletes. Verified 8683 bytes menu visible `Zephaniah 1:3 / 31094` + bottom pills X/List/Prev/Pause/Next at 0.8s after run, 6882 immersive hidden at ~2s (configured 10s, screenshot race). "This is beatiful, well done."
**Lift correction**: `CENTER 0,8` too low → `0,-22` 30px up → user corrected "should had been 15" → `0,-7` = `+8 base -15` = 15px up balanced, vision "optical center correct, prevents sitting on buttons".
## AppCtx fields v6 final
```c
lv_obj_t* book_browser;
lv_obj_t* book_center_name; // LARGE 50% bigger, directly on bg, tappable jumps, georgia_regular_24
lv_obj_t* book_center_num; // bottom right 39/66
lv_obj_t* book_center_verses;
lv_obj_t* book_slider;
int book_dial_idx;
int book_drag_start_x;
bool book_dragging;
bool book_browser_visible;
lv_timer_t* chrome_auto_hide_timer; // v6 auto-hide
bool ui_visible;
```
Center update syncs slider + number + fade:
```c
static void book_dial_update_center(AppCtx* ctx){
BookInfo* b=&ctx->books[ctx->book_dial_idx];
// fade anim 60->255 180ms
lv_obj_set_style_text_opa(center_name,100,0);
lv_anim_t an; lv_anim_init(&an); lv_anim_set_var(&an,center_name); lv_anim_set_values(&an,60,255); lv_anim_set_duration(&an,180);
lv_anim_set_exec_cb(&an,book_fade_anim_cb); lv_anim_set_path_cb(&an,lv_anim_path_ease_out); lv_anim_start(&an);
lv_label_set_text(book_center_name,b->bname);
lv_obj_set_style_text_font(book_center_name,resolve_book_font(ctx),0); // georgia_regular_24 50% bigger
snprintf(num,"%2d / %d",b->bnumber,book_count); lv_label_set_text(book_center_num,num);
snprintf(vers,"%d verses",b->verse_count); lv_label_set_text(book_center_verses,vers);
lv_slider_set_value(slider,dial,LV_ANIM_OFF);
}
```
Swipe 28px per book via `lv_indev_get_point/active`.
## PSRAM vs heap
- `CONFIG_ELF_LOADER_LOAD_PSRAM=y` → app `.text/.rodata` SPIRAM 4.2-4.3MB ELF, free 4.8-5.2MB
- LVGL objects internal heap 310KB only. 264 objs OOM, 10 objs safe, 13 objs with anim safe 46-47KB
## Compat table v6 final
| widget | 0.7.0-dev .112 | 0.8.0-dev .129 | notes |
|---|---|---|---|
| `lv_arc_*` | MISSING | exported PR #496 | true rotary if fleet 0.8+ |
| `lv_roller_*` | MISSING | MISSING | not exported |
| `lv_slider_*` | OK | OK | scrub |
| `lv_indev_get_point/active` | OK | OK | swipe 28px |
| `lv_anim_*` | OK | OK | fade 220/300, book slide 260ms |
| `lv_timer_get_user_data` | OK | OK | auto-hide timer, need getter not direct access |
| `georgia_italic_*` embedded | OK via fallback | OK 4.3M PSRAM | pretty serif |
## Verification v6 final beautiful
- Heap 275B OOM → 10943 .112 → 54471→46215 .129 stable with fonts+anim
- Screenshots: reading 3756-4347, editorial fade 3991, menu show 8683, immersive 6882, error dialog 2755 missing symbol
- Boards .112 0.7.0 33KB, .129 0.8.0 46-54KB
- Build 2142871 editorial v6 beautiful approved
@@ -1,92 +0,0 @@
# Build Env — Transcript & Fix
## Error 1: Hermes venv pollution — TypeError on `X | Y`
```
File .../hermes-agent/venv/lib/python3.11/site-packages/requests/__init__.py
import urllib3
File .../urllib3/_base_connection.py
bytes, typing.IO[typing.Any], typing.Iterable[bytes | str], str
TypeError: unsupported operand type(s) for |: 'type' and 'type'
```
IDF Python is 3.9.6 (no `X | Y` unions). Hermes venv injects `PYTHONPATH` with Python 3.11's `urllib3` that uses 3.10+ syntax. Affects `tactility.py` which imports `requests`.
### Fix
```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
export PYTHONPATH="" # critical
/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env/bin/python tactility.py Apps/BibleVerse build esp32s3 --local-sdk
```
## Error 2: pydantic_core shadowing — idf.py itself crashes
```
Cannot import module "pydantic_core._pydantic_core".
Please use idf.py only in an ESP-IDF shell environment.
ModuleNotFoundError: No module named 'pydantic_core._pydantic_core'
```
Hermes venv 3.11's `pydantic_core` has `_pydantic_core.cpython-311-darwin.so` built for 3.11. IDF venv 3.9/3.10 has `.cpython-39/310.so`. Because `PYTHONPATH` includes Hermes site-packages, Python tries to load 3.11 `.so` under 3.9/3.10 → fails.
Same error manifests as:
```
'/.../idf5.3_py3.9_env/bin/python' is currently active ... project was configured with '/.../idf5.3_py3.10_env/bin/python'. Run 'idf.py fullclean'
```
Solution: clear env, set IDF_PYTHON_ENV_PATH, fullclean.
### Full Fix (this session — ES3C28P firmware build)
```bash
cd /Users/adolforeyna/Projects/Tactility/firmware
unset PYTHONPATH; unset PYTHONHOME
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env
. /Users/adolforeyna/esp/esp-idf/export.sh
# Verify fix:
$IDF_PYTHON_ENV_PATH/bin/python -c "import pydantic_core; print('ok')"
idf.py --version # ESP-IDF v5.3.2-dirty
python device.py es3c28p # regenerates sdkconfig
idf.py fullclean # when switching python version
idf.py build # -> build/Tactility.bin
idf.py -p /dev/cu.usbmodem101 flash # flash family board
```
Build output for ES3C28P (16MB, 80MHz, partitions-16mb-with-sd.csv):
```
Wrote 2863136 bytes (1653045 compressed) at 0x00010000 in 19.9s (1149.4 kbit/s)
bootloader 0x0, partition-table 0x8000, Tactility.bin 0x10000, system.bin 0x410000
```
## Error 3: IDF_PATH unset in subshell
`zsh -c 'python3 tactility.py...'` loses `IDF_PATH`. Always source inside same shell or use wrapper script.
## Error 4: format-truncation as error
IDF adds `-Werror=all`. `snprintf(dst, 320, "%s/bible", assets_path)` flagged even though 320B is generous.
Fix: `target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable)`
## Correct build commands
Firmware core:
```
cd /Users/adolforeyna/Projects/Tactility/firmware
unset PYTHONPATH; unset PYTHONHOME
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env
. /Users/adolforeyna/esp/esp-idf/export.sh
python device.py es3c28p
idf.py build
idf.py -p /dev/cu.usbmodem101 flash
```
ELF app (BibleVerse):
```
cd /Users/adolforeyna/Projects/Tactility/apps
. ~/esp/esp-idf/export.sh > /dev/null
env -u PYTHONPATH /Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env/bin/python tactility.py Apps/BibleVerse build esp32s3 --local-sdk
# => build/BibleVerse.app 4.2MB, ESP32S3 ELF 17K
```
## Related Files
- `/Users/adolforeyna/Projects/Tactility/board_compilation_learnings.md` — full v5.3.2 compatibility fixes (I2C backport, missing cstring, etc.)
- This session also fixed wrong board: `waveshare-esp32-s3-rlcd` flashed onto color `es3c28p`. Always `grep CONFIG_TT_DEVICE_ID sdkconfig`.
@@ -1,95 +0,0 @@
# Deploy & Run on Device (v0.7+ Dashboard)
## Board Discovery (.112 case)
User said "ip ending on 112". Local subnet found via `ifconfig` -> `192.168.68.114/24`, so target is `192.168.68.112`.
Discovery recipe:
```bash
arp -a | grep 112
# ping sweep
for cand in 192.168.68.112 192.168.1.112 192.168.0.112; do
ping -c1 -W 500 $cand
curl -m2 -s http://$cand/ | head
done
```
Found: port 80 responds with HTTP 302 -> `/dashboard.html`. Port 6666 (old dev port) Connection refused.
## Install Endpoint Change
`tactility.py` (tool v3.5.1) still tries `POST http://<ip>:6666/app/install`. That requires Developer mode enabled on device (Settings > Development > Enable dev server). When disabled, install fails with:
```
❌ Install request failed: HTTPConnectionPool(host='192.168.68.112', port=6666): ... Connection refused
```
Firmware v0.7.0-dev Dashboard is on port 80 with new API:
- Discovered via browser devtools / `document.documentElement.innerHTML` JS fetch search:
- `GET /api/apps` -> list
- `PUT /api/apps/install` multipart field `file`
- `POST /api/apps/run?id=<appId>`
- `POST /api/apps/uninstall?id=<appId>`
- `/api/sysinfo`, `/api/screenshot`, etc.
### Working install via curl / python
```bash
curl http://192.168.68.112/api/apps | jq
# PUT, not POST
curl -X PUT http://192.168.68.112/api/apps/install \
-F "file=@build/BibleVerse.app;type=application/octet-stream"
# => 200 ok
```
Python:
```python
import requests, pathlib
ip="192.168.68.112"
path="build/BibleVerse.app"
with open(path,'rb') as f:
r=requests.put(f"http://{ip}/api/apps/install",
files={'file': ('BibleVerse.app', f, 'application/octet-stream')},
timeout=120)
print(r.status_code, r.text[:500])
# run
requests.post(f"http://{ip}/api/apps/run?id=one.tactility.bibleverse", timeout=10)
```
## Platform Arg Required
`tactility.py Apps/MyApp install <ip>` without platform tries all `manifest platforms` (esp32s3,esp32p4) -> fails if only esp32s3 ELF built:
```
❌ ELF file not built for esp32p4
```
Fix: always pass platform explicitly matching what you built:
```bash
tactility.py Apps/BibleVerse install 192.168.68.112 esp32s3
# or
tactility.py Apps/BibleVerse build esp32s3,esp32p4 --local-sdk # build all first
```
## Browser Verification Recipe
Use Hermes browser tools:
```js
// navigate to http://<ip>/dashboard.html -> Apps tab
document.documentElement.innerHTML.match(/\/api\/apps[^\s"']*/g)
// returns ["/api/apps", "/api/apps/run?id=", "/api/apps/uninstall?id=", "/api/apps/install"]
// inline scripts contain:
function installAppFile(file) { fetch('/api/apps/install', {method:'PUT', body: formData}) }
```
This avoids guessing old docs.
## Checklist After Install
- [ ] GET /api/apps contains your app id
- [ ] POST /api/apps/run?id= -> 200 ok (app shows on device)
- [ ] If install via dashboard UI: drag .app onto upload zone works same as PUT
@@ -1,67 +0,0 @@
# Immersive Single-Verse UI — BibleVerse pattern (v6 final 15px lift + menu auto-show)
From BookPlayer + BibleVerse: fullscreen verse, chrome now auto-shows 10s on start then hides, tap toggles, adaptive scaling + editorial + fade + embedded Georgia Italic serif + clean book browser + animations.
## v6 Final July 12 — Beautiful build 2142871
User: "We sent the text 30px up, but I think it should had been 15" → corrected -22 → -7 (15px up). Also "It seems as if there is a black bar on the top cropping the text. Also it seems we need to reduce the text size a bit for this font." + "First, let's save the learnings about UI and Fonts on the app skill, and then let's also add animation to the bible book selection. Also let's start the app with the menu showing, but make the menu UI auto hide after 10s." + "This is beatiful, well done."
**Black bar crop**: Header `44px bg #15151F COVER border 1 #232338` overlapping verse. Fix `36px bg 0x0E0E14 TRANSP 0` same as root, border 0, pad ver 4. Ctrl bar TRANSP. Vision Hab 2:9 "No extra black bar overlapping, fully visible" PASS.
**Georgia sizing down**: Georgia Italic wider than Montserrat → LARGE→22 was 26, DEFAULT→18 was 20, SMALL→16, ultra <40c still 26 via `resolve_verse_font_ultra`. Pads tighter `6/4/3/2/1` was 10/8/6/4/2. Heap 47591 stable.
**Lift correction 30px→15px**: Was `CENTER 0,8` too low (empty top). 30px attempt `0,-22`. User corrected to 15px → `0,-7` = `+8 base -15`. Vision Zeph 1:3 "balanced now, optical center correct, prevents sitting on buttons, 1.5x padding above controls" PASS. 8683 bytes menu visible, 6882 immersive.
**Menu auto-show 10s**: Previously hidden by default. Now:
```c
typedef struct { lv_timer_t* chrome_auto_hide_timer; bool ui_visible; } AppCtx;
static void chrome_auto_hide_cb(lv_timer_t* t){
AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(t); // NOT t->user_data incomplete -> invalid use error, must use getter (exported 382/388)
if(ctx->book_browser_visible) return;
if(ctx->ui_visible) set_chrome_visible(ctx,false);
lv_timer_delete(t); ctx->chrome_auto_hide_timer=NULL;
}
static void schedule_chrome_auto_hide(AppCtx* ctx,uint32_t ms){
if(ctx->chrome_auto_hide_timer){ lv_timer_delete(ctx->chrome_auto_hide_timer); ctx->chrome_auto_hide_timer=NULL; }
ctx->chrome_auto_hide_timer = lv_timer_create(chrome_auto_hide_cb, ms, ctx);
}
onShowApp: set_chrome_visible(&g_ctx,true); schedule_chrome_auto_hide(&g_ctx,10000);
toggle_ui: if new_vis schedule 10s else delete; toggle_book_browser deletes timer; onHide deletes.
```
Verified .129 192.168.68.129: 0.8s after run 8683 bytes menu visible top `Zephaniah 1:3 / 31094` + bottom pills X/List/Prev/Pause/Next "premium cinematic Audible-like" -> ~2s 6882 hidden (timer + screenshot heap race, configured 10000). Heap 46215-47399 stable PSRAM 4.8MB.
**Book browser animation**:
```c
static void book_y_anim_cb(void* var,int32_t v){ lv_obj_set_y((lv_obj_t*)var,v); }
static void book_fade_anim_cb(void* var,int32_t v){ lv_obj_set_style_text_opa((lv_obj_t*)var,v,0); }
set_book_browser_visible(true): remove FLAG_HIDDEN; mid_wrap y 20->0 260ms ease_out + name fade OPA_20->COVER 220ms
book_dial_update_center: name fade 60->255 180ms on swipe/slider change
```
Swipe 28px per book via `lv_indev_get_point/active`, slider fast scrub, bottom row [X left][slider flex][39/66 right], no card/Go/top bar, LARGE pretty name 50% bigger tappable `georgia_regular_24`.
**Timer gotcha**: `lv_timer_t user_data` incomplete in SDK 0.8.0-dev -> `t->user_data` = `invalid use of incomplete typedef`. Must use `lv_timer_get_user_data(t)` (DEFINE_MODULE_SYMBOL 382/388). Grep `grep timer symbols.c`.
**Screenshot rate-limit**: Rapid double `GET /api/screenshot` causes `ConnectionResetError 54` + heap spike crash (encoder uses internal heap). Wait 2s+ between shots, check `GET /api/sysinfo` heap recovery to ~47KB before next.
## Scaling tiers v6 final (Georgia resolved)
| len | Fallback | Georgia | line_sp | pad | Example |
|-----|----------|---------|---------|-----|---------|
| <40 | LARGE | 26 ultra | 12 | 6/20 | John 11:35 11c |
| 40-99 | LARGE | 22 | 8 | 4/16 | Gen 2:10 77c |
| 100-199 | DEFAULT | 18 | 6 | 3/14 | typical |
| 200-349 | SMALL | 16 | 5 | 2/12 | long |
| 350+ | SMALL | 16 | 4 | 1/8 | Esther 8:9 |
Center `90%x82% align 0,-7` = 15px up balanced.
## v5 Pretty Serif — Georgia Italic like PSALMS 23:1 card
Generation: `npx -y lv_font_conv --font "/System/Library/Fonts/Supplemental/Georgia Italic.ttf" -r 32-126 -r 0x2018-0x201D --size 26 --bpp 4 --format lvgl --lv-include "lvgl.h" --no-compress -o Source/fonts/georgia_italic_26.c` repeat 22/20/18/16 + regular 24. Sizes 26=85KB 22=65KB 20=57KB 18=50KB 16=43KB regular24=68KB ~368KB src ~315KB .rodata PSRAM via `CONFIG_ELF_LOADER_LOAD_PSRAM=y`. `LV_FONT_DECLARE` + resolve helpers fallback to `lvgl_get_text_font()`. Heap 47539 stable vs 47855 pre, 5336 bytes serif vs 3991 sans.
## v4 Editorial — PSALMS 23:1 white reference
Verse warmer `#F2F0E8` flat radius 0 pad 4, ref uppercase `EPHESIANS 1:4` tracking 2 `#9A9AA8` gap 10 was 14, fade 220ms verse /300ms ref via `lv_anim_*` exported since 0.6 safe, heap 54KB stable.
## Checklist v6 final
- [ ] header TRANSP bg 0E0E14 36px border 0, ctrl TRANSP, center -7 15px up balanced PASS Zeph 1:3
- [ ] serif downtier LARGE->22 DEFAULT->18 SMALL->16 ultra 26 pads 6/4/3/2/1
- [ ] menu shows 8683 on start then auto-hide 10s via lv_timer_get_user_data, tap re-arms, book browser deletes timer
- [ ] book browser slide up 20->0 260ms + fade 20->COVER 220ms + dial fade 60->255 180ms
- [ ] heap >40KB, PSRAM 4.8MB, 0 missing syms, beautiful per user commit 2142871
@@ -1,152 +0,0 @@
# Intro screen + fullscreen pattern for Tactility games (2026-07-16 v2-v4)
## Problem
User wants:
1. Game engine assessment
2. Fullscreen games
3. Intro screen with rules/movements before game
4. No way to exit (fullscreen lost toolbar back button)
5. Touch unresponsive after fullscreen refactor
## Engine assessment given
GameKit Loop/Scene/Input/Grid/Draw/Prefs is enough for MVP:
- Input unification (LV_KEY_* + WASD + gesture + touch quadrant via pointToQuadrant) covers all 40+ boards
- Grid helpers keep 9x7 logic clean
- Model/Renderer split solid, Prefs + SfxEngine integration painless
- Gaps: no scene-stack, no tween/flash, proc-gen fixed 2 walls + 2 enemies, 8 entity cap, no FOV, no inventory
## Fullscreen reality
- Firmware has `AppManifest::Flags::HideStatusBar` used by Boot, Setup, TouchCalibration (GuiService.cpp hide/show via `flags.hideStatusbar`)
- **BUT** ELF external manifest parsing `AppManifestParsingV1.cpp / V2.cpp` only parses:
- id, name, versionName, versionCode, target.sdk, target.platforms
- **Ignores flags** — so external apps cannot hide statusbar without firmware patch
- What you CAN do today: don't call `tt_lvgl_toolbar_create_for_app(parent, app)` → gain ~40-50px vertical
- Breakout, Snake keep toolbar for scores/lives
- For PocketDungeon we dropped it: root bg set to COLOR_BG, container 100% x 100% no border, board centered with dynamic cell calc `(screenW-22)/COLS` and `(screenH-70)/ROWS`
- If true edge-to-edge needed: patch `AppManifestParsingV1/V2` to parse `app.flags=HideStatusBar` or `manifest.properties` line `flags=...`, rebuild firmware via `python device.py es3c28p && idf.py build flash -p /dev/cu.usbmodem101`, rebuild SDK, rebuild app
## Intro screen implementation (state machine AppPhase) — v2
Pattern added to PocketDungeon.cpp/h:
### Header
```cpp
enum class AppPhase : uint8_t { Intro, Playing, Paused };
class PocketDungeon final : public App, public GameKit::Scene {
lv_obj_t* introContainer = nullptr;
lv_obj_t* gameContainer = nullptr;
lv_obj_t* pauseOverlay = nullptr;
AppPhase phase = AppPhase::Intro;
void showIntro();
void showGame();
void showPause(); void hidePause();
void clearIntro(); void clearGame();
void renderIntro();
void attachInputToCurrent();
void exitGame();
static void onStartButtonClicked(lv_event_t* e);
static void onResumeButtonClicked(lv_event_t* e);
static void onExitButtonClicked(lv_event_t* e);
static void onGameContainerLongPress(lv_event_t* e);
}
```
### Flow v2
- `onShow`: set bg, prefs load, model.reset(lv_tick_get()), Sfx start, phase=Intro, onEnter, loop.start(200ms, this) — NO toolbar creation for fullscreen-ish
- `onEnter`: GameKit::attachInput(root,this), showIntro()
- `showIntro()`: clearGame+clearIntro, phase=Intro, renderIntro()
- renderIntro creates full-screen col flex container bg=COLOR_BG, title "POCKET DUNGEON", subtitle, divider line 120x2 accent, rulesBox 90% width flex_grow 1 bg=COLOR_PANEL radius 10:
- GOAL (gold #D99B23): "Reach deepest floor..."
- HOW TO PLAY (green #3B9D58): "@ = You s = Slime b = Bat $ = Treasure (+5) > = Stairs # = Wall Bump to attack"
- CONTROLS (blue #365CF5): "Arrows / WASD / Swipe / Quadrant tap, Q/ESC = exit, Long press = pause"
- Start button 160x44 bg accent radius10 -> showGame(), Exit button 160x36 bg #2A2D40, footer Best
- `showGame()`: clearIntro+clearGame, phase=Playing, create gameContainer 100% TRANSP no scroll, CLICKABLE|GESTURE_BUBBLE, renderer.create, add X button top-right 36x28 bg #2A2D40 SYMBOL_CLOSE -> exitGame, attachInputToCurrent, render, sfx Warp
- `onInput`: Cancel/Menu -> exitGame (Q/ESC). Intro: any Unknown != -> showGame. Paused: any -> hidePause. Playing: dx/dy, gameOver reset, else movePlayer
- `render()`: if Intro|Paused return, else renderer.render
## v3 Touch unresponsive fix — THE CRITICAL PITFALL
**Symptom**: After adding introContainer + gameContainer full-screen covering root, touch/swipe dead, only hardware keys work.
**Root cause**: LVGL event bubbling. `GameKit::attachInput(target, scene)` does:
```cpp
lv_obj_add_flag(target, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(target, inputEvent, LV_EVENT_KEY, scene);
lv_obj_add_event_cb(target, inputEvent, LV_EVENT_GESTURE, scene);
lv_obj_add_event_cb(target, inputEvent, LV_EVENT_CLICKED, scene);
```
If you only attach to `root`, but `introContainer` (100% child) is CLICKABLE and covers root, it receives CLICKED/GESTURE first and doesn't forward to root. Also `rulesBox` inside introContainer swallowed quadrant taps.
**Fix pattern `attachInputToCurrent()`**:
```cpp
void attachInputToCurrent() {
if (root) GameKit::attachInput(root, this);
if (introContainer) {
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
GameKit::attachInput(introContainer, this);
}
if (gameContainer) {
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
GameKit::attachInput(gameContainer, this);
lv_obj_add_event_cb(gameContainer, onGameContainerLongPress, LV_EVENT_LONG_PRESSED, this);
}
if (pauseOverlay) {
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
GameKit::attachInput(pauseOverlay, this);
}
}
```
- Call on every phase change.
- `rulesBox` must be `remove_flag SCROLLABLE` and NOT CLICKABLE — let taps bubble to introContainer for `pointToQuadrant`.
- Start button calls `lv_event_stop_bubbling(e)` to prevent double trigger (button click + quadrant).
- Accept `Touch`/`Swipe` InputKind at gameOver restart.
## v4 Exit handling for toolbar-less fullscreen
**Problem**: No toolbar = no system back button = trapped.
**Three exit paths implemented**:
1. Hardware: `InputKind::Cancel` (Q/q key via `keyToInput` maps q/Q -> Cancel, LV_KEY_ESC -> Cancel) or `Menu` -> `exitGame()` -> `tt_app_stop()` (from `tt_app.h`, already included via `TactilityCpp/App.h`). Call `saveBests()` + Sfx `Cancel`.
2. X button: small 36x28 top-right in game phase, bg #2A2D40 radius 6, `LV_SYMBOL_CLOSE`, event `onExitButtonClicked` -> `tt_app_stop()`.
3. Long-press: `LV_EVENT_LONG_PRESSED` on `gameContainer` -> `showPause()` creates overlay 100% bg #000 OPA_70 flex center, box 180px bg PANEL radius 12, Resume (accent) + Exit Game (grey) + hint. `AppPhase::Paused` — any input resumes, buttons stop bubbling.
Intro also has Exit button. All paths save bests.
### Header additions v4
```cpp
lv_obj_t* pauseOverlay = nullptr;
enum AppPhase { Intro, Playing, Paused };
void showPause(); void hidePause(); void exitGame();
static void onResumeButtonClicked(lv_event_t* e);
static void onExitButtonClicked(lv_event_t* e);
static void onGameContainerLongPress(lv_event_t* e);
```
### Verification (ad-hoc tmp script hermes-verify-*.py)
Checks: no toolbar_create_for_app, has AppPhase Intro/Playing/Paused, showIntro/showGame/showPause, ENTER DUNGEON, GOAL+CONTROLS, CLICKABLE+GESTURE_BUBBLE, LONG_PRESSED, tt_app_stop, Cancel/Menu exit, build produces package esp32s3.
Build: `PYTHONPATH='' python3 tactility.py Apps/PocketDungeon build esp32s3 --local-sdk` -> package OK
Install: `... install 192.168.68.112 esp32s3 --local-sdk` + `run`
### v5-v6 QVGA left-info + right-small-icons redesign
User feedback July 16: "Still not great layout. I think we can replace text button with icons small on the right, and have the information on the side left."
Implementation:
- Header: title POCKET DUNGEON + subtitle + Best F3 G18 compact (pad 6, row 2)
- mainRow: flex ROW, flex_grow 1, pad 4/8 column, bottom 8, no scroll, border 0 TRANSP
- leftCol: flex_grow 1, height 100%, pad_right 4 bottom 6, column row 8, SCROLLABLE (safety for QVGA overflow)
- makeCard(PCT 100%, SIZE_CONTENT, pad 9/3, bg PANEL radius10): GOAL (gold) + HOW TO PLAY (green) only = 2 cards for airy fit; HOW TO PLAY merges symbols + Controls hint "Arrows/WASD/Swipe/Quadrant"
- rightRail: fixed 44px wide, SIZE_CONTENT height, column center, row 10, SCROLLABLE removed
- makeIconBtn lambda (symbol, bg, cb, user, size): btn size s×s, bg, radius s/3, pad 0, event CLICKED
- Play 38px accent #365CF5 SYMBOL_PLAY, Exit 30px muted #252836 SYMBOL_CLOSE
- Result: info left 75%, icons small right 25%, no bottom clipping (2 cards vs 3 saves ~60px), screenshot intro_v6.png 6.4K vision: "Structure correct 2 cards left, play/exit right but scrollbar visible indicating near overflow; still dense but no clip" → final fix adds pad_bottom to mainRow + leftCol and reduces rail to 44px CONTENT vs 100% height to avoid LVGL flex 100% child overflow bug.
Pitfalls fixed:
- LVGL flex row with child height 100% inside flex_grow parent can overflow on 320x198 usable (QVGA after 20px statusbar). Use rightRail SIZE_CONTENT, not 100%, and leftCol SCROLLABLE with pad_bottom.
- Avoid `set_style_max_width` / `set_scrollbar_mode` (unexported) → use PCT widths 92/80 for gutters.
- Keep card count ≤2 for QVGA intro to stay airy; third card pushes to scroll clip.
### Future ideas
- Game-over -> intro loop instead of immediate restart
- Cell flash animation on Attacked/Blocked using lv_anim (exported since 0.6, safe)
- Push to personal/main after user likes exit flow
- True statusbar hide via firmware patch parsing flags
@@ -1,46 +0,0 @@
# Per-Book Offline Bible Assets
Source: https://github.com/sajeevavahini/bibles — Zefania XML `World English Bible.xml` ~4.8MB, format `XMLBIBLE/BIBLEBOOK/CHAPTER/VERS` attrs `bnumber/bname/cnumber/vnumber`.
## Problem
- 4.8MB XML → 3.8MB blob + 304KB idx monolith = too big for RAM, packaging bloats `.app` to 4.2MB monolith load.
- Device has ~8MB PSRAM but LVGL/lwIP compete; loading whole bible at once OOM-prone.
## Solution: per-book split
Output `assets/bible/`:
- `books.json`: `[{bnumber, bname, verses, chapters}, ...]` 66 entries
- `<bnum>.bin` (01.bin..66.bin): concatenated NUL-terminated UTF-8 verse strings
- `<bnum>.idx`: binary: header 8 bytes: magic `BIBK` ascii, version u16 (1), count u32; then per entry 8 bytes: `offset u32 LE, cnum u16 LE, vnum u16 LE` sorted by chapter/verse.
Worst book: Psalms 19.bin ~220KB, still fits. Total ~4.1MB split but loaded one at a time → RAM tiny.
## Converter
`Apps/BibleVerse/tools/convert_bible.py` (also template):
Usage:
```bash
python3 tools/convert_bible.py /path/to/World\ English\ Bible.xml assets/bible/
# or fetch then convert
```
Implementation sketch (see templates/convert_bible.py):
- xml.etree.ElementTree iterative parse `BIBLEBOOK` to keep memory low.
- Accumulate bin buffer + idx entries per book.
- Write books.json last.
## Hybrid resolution on device
```c
// 1. Try embedded A: assets via tt_app_get_assets_path
// 2. Fallback scan /sdcard/bibles/WEB/ same binary format for user-provided translations
// 3. build target: load BookInfo array, then on demand load_book(bnumber) frees previous.
```
## Verify packaging
```bash
tar tvf build/BibleVerse.app | grep "\.bin" | wc -l # expect 66
ls -lh assets/bible/*.bin | sort -k5 -hr | head
```
@@ -1,52 +0,0 @@
# PocketDungeon — GameKit + SfxEngine + tutorial levels (2026-07-16/17)
User said "board ending on 112" → WiFi discovery to 192.168.68.112. Repo pull needed from personal remote. Infinite floors, tutorial-by-play intro.
## Repo layout & build
- `Apps/PocketDungeon/CMakeLists.txt`: standard `tactility_project`
- `main/CMakeLists.txt`: GLOB_RECURSE GameKit + SfxEngine via `../../../Libraries/...` + `REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio`
- `manifest.properties`: sdk=0.8.0-dev (bumped from 0.7 to match board OS 0.8.0-dev, else missing symbol dialog), platforms esp32,esp32s3,esp32c6,esp32p4
- GameKit: Loop, Scene (Tick, InputKind), Input keyToInput/gestureToInput/pointToQuadrant/attachInput, Grid, Draw, Prefs
- Personal remote `git.reynafamily.com/adolforeyna/tactility_apps` commit d976595 feat: add Pocket Dungeon prototype, pull via `git fetch personal && git merge personal/main`
- Build env pitfall: Hermes PYTHONPATH pollution → `PYTHONPATH='' python3 tactility.py Apps/PocketDungeon build esp32s3 --local-sdk` + `IDF_PYTHON_ENV_PATH=idf5.3_py3.9_env`
- Board shorthand .112: ping 192.168.68.112 OK (ESP32-S3 MAC 14:c1:9f:d1:56:90, os 0.8.0-dev), .111 also online. Install needs platform arg `esp32s3` else fails ELF not built for esp32. Dashboard port 80 `PUT /api/apps/install`, dev 6666.
## v2-v4: Intro + fullscreen + input + exit
- Engine enough for MVP: Loop/Scene/Input/Grid, but no scene-stack/tween/proc-gen depth.
- Fullscreen: firmware HideStatusBar flag ignored by ELF parser V1/V2 → cannot hide statusbar without firmware patch. Gain ~40-50px by not calling `tt_lvgl_toolbar_create_for_app`.
- Intro state machine AppPhase Intro/Playing/Paused, introContainer+gameContainer+pauseOverlay, attachInputToCurrent() re-attaches CLICKABLE|GESTURE_BUBBLE to root+active container, rulesBox non-clickable so quadrant taps bubble, start button stop_bubbling.
- Touch dead after fullscreen → fixed via attachInputToCurrent() pattern.
- Exit trap fullscreen → Q/ESC Cancel/Menu → tt_app_stop() (via TactilityCpp/App.h), X top-right 32x26 SYMBOL_CLOSE, long-press LONG_PRESSED → pause overlay (bg #000 OPA_70, box 180px Resume accent #365CF5 grey Exit #252836).
- See references/intro-fullscreen.md v3/v4.
## v5-v6: QVGA left info + right small icons (cramped fix)
User Jul 16: "Still not great layout. I think we can replace text button with icons small on the right, and have the information on the side left."
- mainRow flex ROW flex_grow 1 pad 4/8 column bottom 8, leftCol flex_grow 1 scrollable (pad_row 8 bottom 6), rightRail fixed 44px SIZE_CONTENT column center row 10.
- Left cards initially GOAL + HOW TO PLAY (2 cards), right icons Play 38 accent + Exit 30 muted (SYMBOL_PLAY/CLOSE radius s/3). Verified via /api/screenshot 6-7KB PNG, no max_width/scrollbar_mode (unexported → missing symbol blue OK dialog). SDK bump 0.7→0.8 fixed.
- Symbol hygiene: no `lv_obj_set_style_max_width`, no `set_scrollbar_mode`. Use PCT widths.
## v7: Tutorial levels instead of text explanations (Jul 17)
User: "Let's add three intro levels instead of explanations. First level present the player and the stairs. Next level the bat and the gold. Both with two rows and regular cols. Then go the current first level. So on the initial page, only keep the goal and the controls explanation."
Implementation:
- Model.h: `TUTORIAL_ROWS=2`, `renderRows` in DungeonState, `floor 0,1 = tutorial`, `displayFloor()` maps tutorial→1 real floor-1, `isTutorial()`, `tutorialId()`, `reset(seed, tutorials=true)` floor 0 if tutorials else 2, `generateTutorialFloor(tId)`, `clearAllTiles()`.
- Model.cpp:
- clearAllTiles border walls
- Tut0: renderRows=TUTORIAL_ROWS, hide rows >=3 walls, player {1,1}, stairs COLS-2,1 only
- Tut1: + treasure {3,1} + bat {5,1} hp1 + stairs, no random walls
- Real >=2: previous logic treasure [5][2+floor%2], walls, enemies scale realFloor=floor-1, best update only floor>=2, moveMonsters skipped when isTutorial
- Renderer.cpp: respects renderRows hides cells HIDDEN, board height = cellPx*TUTORIAL_ROWS centered -10 for tutorial, status "TUTORIAL X/2 HP Gold" message "TUT 1: Move to > @=you" / "TUT 2: Kill b, take $, use >"
- PocketDungeon.cpp: intro only GOAL + CONTROLS (2 cards), no HOW TO PLAY symbols taught via play. Right rail 44px Play 38 Exit 30 stays.
Flow: Intro (Goal+Controls left, icons right) → Tut1 2×9 @→> movement → Tut2 2×9 @+b+$→> combat/loot → Real F1 infinite (displayFloor). Infinite floors answer to user "how many floors" = infinite.
Verification: hermes-verify-tutorial*.py checks 2 cards, no HOW TO PLAY, tutorial gen, renderRows, TUTORIAL_ROWS, isTutorial, build package, nm -D 0 missing (89 undef). Installed 192.168.68.112 intro_tutorial.png 6.3K.
## Floors
Infinite unbounded, difficulty scales slime hp floor/3, extra enemy >=3. No win boss. User asked, answered infinite.
## Next todos
- Push to personal/main (requested earlier)
- True HideStatusBar via firmware patch AppManifestParsingV1/V2 flags
- Victory floor 10 boss + win screen
- Cell flash anim Attacked/Blocked via lv_anim (exported since 0.6)
@@ -1,92 +0,0 @@
# Pretty Serif Font — Georgia Italic embedded (BibleVerse v5/v6 final July 12 - beautiful build 2142871)
## Problem
User: "The main text has a pretty basic font" + white PSALMS 23:1 reference serif italic high-contrast. Montserrat sans functional not editorial.
Goal: serif italic verse, elegant book dial, heap stable 47KB, 0 missing syms, PSRAM-aware, beautiful per user "This is beatiful, well done."
## Options
1. `lv_binfont_create(path)``.fnt` bin via VFS A:/assets — exported 0.8 but path fragile, logs not avail, needs `tt_app_get_assets_path` void check `buf[0]!='\0'`.
2. **Embedded C LVGL fonts (chosen)**`lv_font_conv --format lvgl``LV_FONT_DECLARE` → linked direct. Lives ELF `.rodata``MALLOC_CAP_SPIRAM` via `CONFIG_ELF_LOADER_LOAD_PSRAM=y` → PSRAM 4.8MB free, not internal 310KB heap. No VFS, no runtime open, 0 missing syms.
## Generation v6 final
```bash
FONT="/System/Library/Fonts/Supplemental/Georgia Italic.ttf"
RFONT="/System/Library/Fonts/Supplemental/Georgia.ttf"
OUT=Apps/BibleVerse/main/Source/fonts
mkdir -p $OUT
for SZ in 26 22 20 18 16; do npx -y lv_font_conv --font "$FONT" -r 32-126 -r 0x2018-0x201D --size $SZ --bpp 4 --format lvgl --lv-include "lvgl.h" --no-compress -o $OUT/georgia_italic_${SZ}.c; done
npx -y lv_font_conv --font "$RFONT" -r 32-126 --size 24 --bpp 4 --format lvgl --lv-include "lvgl.h" --no-compress -o $OUT/georgia_regular_24.c
# ls -lh $OUT: 26=85KB 22=65KB 20=57KB 18=50KB 16=43KB regular24=68KB ~368KB src ~315KB binary .rodata
```
Fix: `--lv-include "lvgl.h"` else generates `#include "lvgl/lvgl.h"` fail in SDK (header is `lvgl.h`).
CMake: `file(GLOB_RECURSE SOURCE_FILES Source/*.c)` auto-includes `Source/fonts/*.c`.
## Code wiring v6 final (15px lift)
```c
LV_FONT_DECLARE(georgia_italic_26)
LV_FONT_DECLARE(georgia_italic_22)
LV_FONT_DECLARE(georgia_italic_20)
LV_FONT_DECLARE(georgia_italic_18)
LV_FONT_DECLARE(georgia_italic_16)
LV_FONT_DECLARE(georgia_regular_24)
typedef struct {
lv_font_t* font_serif_26; lv_font_t* font_serif_22; lv_font_t* font_serif_20;
lv_font_t* font_serif_18; lv_font_t* font_serif_16; lv_font_t* font_book_24;
bool fonts_loaded;
// + chrome_auto_hide_timer etc
} AppCtx;
// v6 downtier: Georgia wider/taller than Montserrat → map down one to avoid crop
static const lv_font_t* resolve_verse_font(AppCtx* ctx, enum LvglFontSize f){
if(!ctx->fonts_loaded) return lvgl_get_text_font(f);
if(f==FONT_SIZE_LARGE && ctx->font_serif_22) return ctx->font_serif_22; // was 26 too big -> 22
if(f==FONT_SIZE_DEFAULT && ctx->font_serif_18) return ctx->font_serif_18; // was 20 -> 18
if(f==FONT_SIZE_SMALL && ctx->font_serif_16) return ctx->font_serif_16;
return lvgl_get_text_font(f);
}
static const lv_font_t* resolve_verse_font_ultra(AppCtx* ctx){
return ctx->font_serif_26 ? (lv_font_t*)&georgia_italic_26 : lvgl_get_text_font(FONT_SIZE_LARGE);
}
static const lv_font_t* resolve_book_font(AppCtx* ctx){
if(ctx->font_book_24) return ctx->font_book_24;
if(ctx->font_serif_22) return ctx->font_serif_22;
return lvgl_get_text_font(FONT_SIZE_LARGE);
}
onShowApp:
g_ctx.font_serif_26 = (lv_font_t*)&georgia_italic_26; ...
g_ctx.font_book_24 = (lv_font_t*)&georgia_regular_24;
g_ctx.fonts_loaded = true;
onHide: static const - no free (live in PSRAM ELF)
apply_verse_scaling: pads tighter v6 6/4/3/2/1 was 10/8/6/4/2 + resolve_verse_font + lift -7 (15px up not 30px)
const lv_font_t* f = is_ultra ? resolve_verse_font_ultra(ctx) : resolve_verse_font(ctx, font_sz);
lv_obj_set_style_text_font(lbl_verse, f, 0);
```
## v6 black-bar crop fix + 15px lift correction
Symptom: black bar top cropping Habakkuk 1:6 + "reduce text size for this font" + "main text can be moved a bit up 30px" → corrected "should had been 15".
Root cause: header `44px bg #15151F COVER border 1 #232338` at TOP_MID overlapping center_col 90%x80%.
Fix:
- header `36px` not 44, `bg 0x0E0E14` same as root not 15151F, `TRANSP 0` not COVER, border 0, pad ver 4
- ctrl bar `TRANSP 0` + `bg 0x0E0E14`
- center `90%x82% align CENTER 0,8``0,-22` (30px up) → `0,-7` (15px up: +8 base -15) per user correction
- verse `pad_all 4` not 6
Vision: Hab 2:9 "No extra black bar overlapping, fully visible", Zeph 1:3 "balanced now, optical center correct, 1.5x padding above controls" PASS. Screenshots 5336 bytes serif vs 3991 sans, 8683 with chrome (menu visible) vs 6882 immersive.
## Verification v6 final beautiful
- Build 4.3MB .app ELF 0 missing `nm -D` vs `symbols.c` exports, 0 undef LVGL
- Install .129 0.8.0-dev: heap 46215-47591 free largest 20480 psram 4810396 stable vs pre 47855 → fonts PSRAM not heap
- Screenshots: 8683 menu show (top Zeph 1:3 / 31094 + bottom pills X/List/Prev/Pause/Next "premium cinematic Audible-like") → 6882 immersive, serif italic vision PASS Hab 1:17 & Zeph 1:3
- Fonts fallback works on 0.7 fleet without C files
## Auto-hide menu + book browser animation v6 final (see immersive-ui.md detail)
## Tradeoffs
- PSRAM 315KB .rodata fine (4.8MB free), heap delta 0
- Curly quotes 0x2018-0x201D included for “The Lord is my shepherd;” reference image
- Cormorant Garamond download failed via curl HTML 404, Georgia system font editorial highly legible reverent, user approved beautiful
@@ -1,114 +0,0 @@
# SDK mismatch & missing-symbol crash PocketDungeon July 16 2026
## Symptom
Board `192.168.68.112` on `{"cpuFamily":"esp32s3","osVersion":"0.8.0-dev"}` shows error dialog:
```
Error
Application failed to start: missing symbol
[OK]
```
Screenshot via `curl http://192.168.68.112/api/screenshot -o /tmp/shot.png` -> PNG 320x240 palettized.
## Root cause (2-fold)
1. **SDK / OS version mismatch**
- `manifest.properties` said `sdk=0.7.0-dev`
- Board runs `0.8.0-dev` (checked `GET /info` and `GET /api/screenshot` header)
- Compiled against `0.7.0-dev-esp32s3/TactilitySDK` but device exports differ.
2. **Unexported LVGL style API used**
- Code added for less-cramped QVGA intro used:
- `lv_obj_set_style_max_width(...)` -> **NOT exported** in 0.7 nor 0.8 (verified via grep firmware `ESP_ELFSYM_EXPORT` / `DEFINE_MODULE_SYMBOL`)
- `lv_obj_set_scrollbar_mode(...)` -> version-dependent / not reliably exported
- Header exists in SDK's vendored LVGL, so it compiles, but loader fails at runtime.
- `check_symbols.py` pattern:
```bash
source $IDF_PATH/export.sh && xtensa-esp32s3-elf-nm -D build/.../*.app.elf | grep " U "
grep -R "ESP_ELFSYM_EXPORT\|DEFINE_MODULE_SYMBOL" firmware/
```
- Found exactly 1 missing: `lv_obj_set_style_max_width`.
## Fix checklist
1. **Query board version first**
```bash
curl -s http://192.168.68.112:6666/info
curl -s http://192.168.68.112/api/screenshot -o /tmp/intro.png
```
2. **Bump manifest to match board**
```properties
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
```
3. **Avoid unexported LVGL**
Replace `set_style_max_width` with fixed percents:
```cpp
// BAD (unexported)
lv_obj_set_style_max_width(obj, 220, LV_PART_MAIN);
// GOOD gives side gutters on 320px QVGA
lv_obj_set_width(card, LV_PCT(92));
lv_obj_set_width(btn, LV_PCT(80));
lv_obj_set_width(exitBtn, LV_PCT(70));
```
For scroll:
```cpp
// BAD
lv_obj_set_scrollbar_mode(area, LV_SCROLLBAR_MODE_AUTO);
// GOOD default scroll works, add flag explicitly
lv_obj_add_flag(scrollArea, LV_OBJ_FLAG_SCROLLABLE);
```
4. **Rebuild with correct SDK**
```bash
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
rm -rf Apps/PocketDungeon/build/cmake-build-esp32s3
PYTHONPATH="" python3 tactility.py Apps/PocketDungeon build esp32s3 --local-sdk
# -> Using SDK .../0.8.0-dev-esp32s3/TactilitySDK
# -> Building esp32s3 ELF OK
# -> Building package OK
```
5. **Symbol verification (from app skill)**
Run the skill's `check_symbols.py` should show 89 undefined, 0 missing after fix.
6. **Install + screenshot verification**
```bash
PYTHONPATH="" python3 tactility.py Apps/PocketDungeon install 192.168.68.112 esp32s3 --local-sdk
PYTHONPATH="" python3 tactility.py Apps/PocketDungeon run 192.168.68.112
curl -s http://192.168.68.112/api/screenshot -o /tmp/intro_v3.png
# vision analyze: not cramped, airy, no clipping
```
## QVGA 320x240 cramped UI fix applied
Original cramped (intro.png vision):
- GOAL card edge-to-edge, text `Loot gold, survive monsters` clipped at bottom
- Buttons 85-90% wide touching edges, 4-8px gaps, Best touching bottom
- No outer margin
Fixed v3 (intro_v3.png vision PASS):
- Outer container `pad_all 10`, `pad_row 8`
- Cards `PCT 92` width, `SIZE_CONTENT` height, `pad_all 10`, `pad_row 5`, radius 10, bg #1D2030
- ScrollArea `PCT 100`, `flex_grow 1`, scrollable flag, flex col pad_row 8
- TitleBox pad_row 3, title #F8F3E6, subtitle #9AA0B8
- BtnRow `PCT 100`, `SIZE_CONTENT`, flex center, pad_row 8
- Primary btn `PCT 80` 40px accent #365CF5 radius 10
- Secondary exit `PCT 70` 30px bg #252836 radius 8
- Shortened card bodies to fit QVGA: GOAL 1 line, HOW TO PLAY 3 lines, CONTROLS 4 lines
- Ends up airy with 12-16px margins, no clipping.
Keep text short on QVGA; use `LV_LABEL_LONG_WRAP` + `PCT(100)`.
## Lessons
- Always probe board osVersion before build
- The `/api/screenshot` endpoint (port 80, not 6666) is invaluable for both crash diagnosis (Error dialog) and layout QA without serial
- `max_width` and `scrollbar_mode` specifically are trap APIs prefer PCT widths for side gutters
- TactilitySDK release folder contains both 0.7 and 0.8 subfolders pick matching one via TACTILITY_SDK_PATH + --local-sdk automatically selects subfolder
@@ -1,77 +0,0 @@
# Symbol Check — BibleVerse session (updated July 12 v5 pretty font)
## Tool: undefined symbols vs firmware exports
```python
import pathlib, re, subprocess
elf = pathlib.Path("Apps/BibleVerse/build/cmake-build-esp32s3/BibleVerse.app.elf")
r = subprocess.run(["zsh","-c", f"source ~/esp/esp-idf/export.sh >/dev/null 2>&1; xtensa-esp32s3-elf-nm -D {elf}"], capture_output=True, text=True)
undef = [l.split()[-1] for l in r.stdout.splitlines() if len(l.split())>=2 and l.split()[-2]=='U']
pat = re.compile(r'(?:ESP_ELFSYM_EXPORT|DEFINE_MODULE_SYMBOL)\s*\(\s*([A-Za-z0-9_]+)\s*\)')
exp=set()
for fp in pathlib.Path("../firmware").rglob("*.*"):
if 'build' in str(fp): continue
if fp.suffix not in ('.c','.h','.cpp'): continue
try: exp.update(m.group(1) for m in pat.finditer(fp.read_text(errors='ignore')))
except: pass
missing = [s for s in undef if s not in exp]
print(f"undef {len(undef)} missing {missing}")
```
## Session results
- Before fix: `undef 68, missing ['lv_font_montserrat_14']` — source used `&lv_font_montserrat_14`.
- After `lvgl_get_text_font(FONT_SIZE_SMALL)`: `undef 68, missing []`
- Editorial v4: 0 missing, anim symbols ok, app 4413440 bytes, heap 54KB stable on .129
- **v5 pretty font**: Georgia Italic embedded C 26/22/20/18/16 + regular 24 via `lv_font_conv`, 315KB `.rodata` in PSRAM, 0 missing, app 4.3MB, heap `47539 free largest 20480 psram 4810944` stable, screenshot 5336 bytes Habakkuk 1:17 serif italic editorial.
- **v6 final beautiful 2142871**: 0 missing, `undef 0`, app 4.3M, heap `46215-47591 free largest 20480 psram 4810396` stable, screenshots 8683 menu visible (top Zeph 1:3 / 31094 + bottom pills) + 6882 immersive hidden, lift -7 15px corrected from -22 30px per user, black bar fixed. Gotcha `lv_timer_t user_data` field incomplete → `t->user_data` invalid → must use `lv_timer_get_user_data(t)` + `lv_timer_set_user_data` (exports 372/373/382/388). Checked `grep timer symbols.c`. Timer used for chrome auto-hide 10s + book browser anim.
## Known missing table
| Symbol | Status | Workaround |
|---|---|---|
| `lv_font_montserrat_14/18/24` | Not exported | `lvgl_get_text_font(FONT_SIZE_SMALL/DEFAULT/LARGE)` OR embedded C serif via `lv_font_conv` → see `pretty-font.md` |
| `LV_SYMBOL_STAR` | May be absent | Use `LV_SYMBOL_OK`, `LV_SYMBOL_DUMMY` |
| `xTaskGetCurrentTaskHandle` | Not exported | Avoid task identity check; release lock |
| `lv_image_set_rotation` | Not exported | `lv_obj_set_style_transform_rotation(obj, deg*10, 0)` |
| `cJSON`, `esp_websocket_client` | Static | Vendor source or raw `lwip_socket` APIs |
### New missing from v2/v3/v4/v5 UI passes
- `lv_obj_set_style_bg_grad_color/dir/stop`**Not exported** 0.8.0-dev. Gradient fails `missing symbol`. Use solid `0x0E0E14`.
- `LV_OPA_95`**Not defined** (only 0/10/20/30/40/50/60/70/80/90/100/COVER/TRANSP). Use `COVER` or `90`.
- `lv_arc_create`, `lv_arc_set_range/value/bg_angles`, `lv_arc_get_value`**Not exported on 0.7.0-dev**, added `dff93cb6 Add lv_arc.h symbols (#496)` after 0.7 release. Symptom blue modal `Error / Application failed to start: missing symbol / OK`, heap healthy ~31KB not OOM. Fix horizontal slide using `lv_slider_create` + `lv_indev_get_point/active`. See `book-browser.md`.
- `lv_roller_create`**Not exported** 0.7 nor 0.8.
- `lv_indev_get_point/active`, `lv_slider_create/range/value/set_value`**Exported since 0.6** safe.
- `lv_anim_init`, `set_var/values/duration/exec_cb/path_cb/start`, `path_ease_out/linear/ease_in`, `path_ease_in_out`**Exported since 0.6** safe (checked lines 435-446). Used for editorial fade 220/300ms. Safe for 0.7/0.8 fleet.
- `lv_binfont_create/destroy`**Exported** 0.8 but VFS path fragile — `tt_app_get_assets_path` returns void, not bool (check `buf[0]!='\0'`). Prefer **embedded C** fonts via `LV_FONT_DECLARE` — lives in PSRAM (315KB `.rodata`), no FS needed. See `pretty-font.md`. Old `.fnt` bins 52KB still bundled in `assets/fonts/` but not used.
- `lv_font_t` embedded C — no symbol needed, static const.
## Heap OOM vs missing-symbol distinction
- **OOM**: `heap free 275B total 310KB largest_block 23`, install `500 failed to create temp directory`, screenshot `500`. Cause 66-button browser 132-264 objs. Fix ~10 obj browser.
- **Missing symbol**: `heap free 31779 largest 12288` healthy, install `200 ok`, run `200 ok`, but app shows blue `Error missing symbol` dialog, screenshot `200 2755`. Fix check `symbols.c`.
## Font scaling v4/v5
Only 3 sizes via `lvgl_get_text_font()` fallback, but v5 uses embedded serif as primary:
```c
size_t len = strlen(verse);
enum LvglFontSize fs; int line_sp, pad_v, pad_h;
if (len < 40) { fs=FONT_SIZE_LARGE; line_sp=14; pad_v=10; pad_h=28; letter_sp=1; } // v4 tightened
else if (len <100) { fs=FONT_SIZE_LARGE; line_sp=10; pad_v=8; pad_h=24; }
else if (len <200) { fs=FONT_SIZE_DEFAULT; line_sp=8; pad_v=6; pad_h=20; }
else if (len <350) { fs=FONT_SIZE_SMALL; line_sp=6; pad_v=4; pad_h=16; }
else { fs=FONT_SIZE_SMALL; line_sp=4; pad_v=2; pad_h=12; } // Esther 492c
// v5: resolve to embedded Georgia Italic 26/20/16 else fallback
lv_obj_set_style_text_font(lbl_verse, resolve_verse_font(ctx, fs), 0);
```
Proven .112 Genesis 2:10 77c LARGE, .129 Ephesians 1:4 4138 bytes screenshot editorial warm `#F2F0E8` + ref uppercase tracking 2 `#9A9AA8` gap 10, v5 Habakkuk 1:17 5336 bytes serif italic editorial vision PASS.
Exported: `lvgl_get_text_font`, `get_text_font_height`, `get_shared_icon_font`, `set_style_text_font`, `timer_create`, `label_set_text`, `text_line_space`, `pad_*`, `text_opa`, `anim_*`, `LV_FONT_DECLARE` (macro, not symbol).
CI: fail if missing>0, heap stable >40KB with fonts.
@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""
Verify ELF symbols against firmware exports. Rerunnable from skill.
Usage:
python3 scripts/verify_symbols.py [--elf Apps/BibleVerse/build/cmake-build-esp32s3/BibleVerse.app.elf] [--firmware ../firmware]
Requires: xtensa-esp32s3-elf-nm in PATH after sourcing ~/esp/esp-idf/export.sh
"""
import argparse, pathlib, re, subprocess, sys
def get_undef(elf_path: pathlib.Path):
r = subprocess.run(
["zsh","-c", f"source /Users/adolforeyna/esp/esp-idf/export.sh >/dev/null 2>&1; xtensa-esp32s3-elf-nm -D {elf_path}"],
capture_output=True, text=True
)
undef=[]
for line in r.stdout.splitlines():
parts=line.split()
if len(parts)>=2 and parts[-2]=='U':
undef.append(parts[-1])
return undef, r.stdout
def get_exports(firmware_dir: pathlib.Path):
pat=re.compile(r'(?:ESP_ELFSYM_EXPORT|DEFINE_MODULE_SYMBOL)\s*\(\s*([A-Za-z0-9_]+)\s*\)')
exp=set()
for fp in firmware_dir.rglob("*.*"):
if 'build' in str(fp): continue
if fp.suffix not in ('.c','.h','.cpp'): continue
try:
exp.update(m.group(1) for m in pat.finditer(fp.read_text(errors='ignore')))
except: pass
return exp
def main():
p=argparse.ArgumentParser()
p.add_argument('--elf', default='Apps/BibleVerse/build/cmake-build-esp32s3/BibleVerse.app.elf')
p.add_argument('--firmware', default='../firmware')
args=p.parse_args()
elf=pathlib.Path(args.elf)
fw=pathlib.Path(args.firmware)
if not elf.exists():
print(f"ELF not found: {elf}", file=sys.stderr)
sys.exit(2)
undef, raw = get_undef(elf)
exp = get_exports(fw)
missing=[s for s in undef if s not in exp]
print(f"Undefined: {len(undef)}")
print(f"Exported: {len(exp)}")
if missing:
print(f"Missing {len(missing)}:")
for s in sorted(missing):
print(f" - {s}")
sys.exit(1)
print("✅ All symbols resolved")
sys.exit(0)
if __name__ == '__main__':
main()
+21
View File
@@ -0,0 +1,21 @@
name: Publish Apps
inputs:
sdk_version:
description: The SDK version that determines the path on the CDN
required: true
runs:
using: 'composite'
steps:
- name: 'Download cdn-files'
uses: actions/download-artifact@v4
with:
name: 'cdn-files'
path: cdn_files
- name: 'Install boto3'
shell: bash
run: pip install boto3
- name: 'Upload files'
shell: bash
run: python Buildscripts/CDN/upload-app-files.py cdn_files ${{ inputs.sdk_version }} ${{ env.CDN_ID }} ${{ env.CDN_TOKEN_NAME }} ${{ env.CDN_TOKEN_VALUE }}
+9 -1
View File
@@ -1,5 +1,10 @@
name: Release Apps name: Release Apps
outputs:
sdk_version:
description: 'Common SDK version shared by all bundled apps'
value: ${{ steps.release.outputs.sdk_version }}
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
@@ -11,8 +16,11 @@ runs:
run: rsync -av downloaded_apps/*/*.app cdn_files/ run: rsync -av downloaded_apps/*/*.app cdn_files/
shell: bash shell: bash
- name: 'Create CDN release files' - name: 'Create CDN release files'
run: python release.py cdn_files/ id: release
shell: bash shell: bash
run: |
python release.py cdn_files/
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
- name: 'Upload Artifact' - name: 'Upload Artifact'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
+22 -1
View File
@@ -12,10 +12,12 @@ jobs:
Build: Build:
strategy: strategy:
matrix: matrix:
app_name: [Brainfuck, Breakout, BookPlayer, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven] app_name: [Brainfuck, Breakout, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Build" - name: "Build"
uses: ./.github/actions/build-app uses: ./.github/actions/build-app
with: with:
@@ -23,7 +25,26 @@ jobs:
Bundle: Bundle:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Build] needs: [Build]
outputs:
sdk_version: ${{ steps.release.outputs.sdk_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: "Build" - name: "Build"
id: release
uses: ./.github/actions/release-apps uses: ./.github/actions/release-apps
PublishApps:
runs-on: ubuntu-latest
needs: [Bundle]
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Publish Apps"
env:
CDN_ID: ${{ secrets.CDN_ID }}
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
uses: ./.github/actions/publish-apps
with:
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
-148
View File
@@ -1,148 +0,0 @@
# AGENTS.md — Tactility Apps
This file is for AI agents (and humans) working in this repo. It documents local workflow, owned hardware, and in-repo skills.
## Repo
Tactility external ELF apps — loaded at runtime via firmware symbol table. Each app: `Apps/<Name>/manifest.properties` + `main/CMakeLists.txt` + `Source/*.c`.
Upstream: `https://github.com/TactilityProject/TactilityApps`
Personal Gitea mirror: `https://git.reynafamily.com/adolforeyna/tactility_apps` (`personal` remote)
Dev board IP: `192.168.68.112` (S3, OS 0.8.0-dev, SDK 0.8.0-dev) — main deploy target. `.111` also online. OS image is 0.8.0-dev so manifest should use `sdk=0.8.0-dev` or loader warns.
## Local Workstation
- Host: M2 Air (14,2) macOS 26.5.2
- Project: `/Users/adolforeyna/Projects/Tactility/apps`
- ESP-IDF: `/Users/adolforeyna/esp/esp-idf`, Python env `idf5.3_py3.9_env` at `~/.espressif/python_env/idf5.3_py3.9_env`
- Tactility SDK: `/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK` (built locally, `--local-sdk`)
### Hardware
| Board | IP | Port | Notes |
|-------|-----|------|-------|
| ES3C28P 2.8" color 240x320 | `192.168.68.112` | `/dev/cu.usbmodem101` | Primary deploy target, 16MB flash, OCT PSRAM, SD for ELF assets |
| Waveshare RLCD 4.2" mono 400x300 ST7305 | `192.168.68.111`? .1101 USB | `/dev/cu.usbmodem1101` | Secondary, compact density, mono threshold 60, font 18 |
User shorthand: "ending in 112" → `192.168.68.112`.
## Build, Deploy, Run — Correct Env Wrapper
Hermes desktop Python 3.11 venv pollutes `PYTHONPATH``tactility.py` crashes with `TypeError: unsupported operand type(s) for |` (urllib3 uses `X | Y` 3.10+). Also `idf.py` crashes with `pydantic_core` mismatch.
Always:
```bash
cd /Users/adolforeyna/Projects/Tactility/apps
unset PYTHONPATH; unset PYTHONHOME
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env
source /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
# Build
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/MyApp clean
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/MyApp build esp32s3 --local-sdk --verbose
# → build/MyApp.app (tar), build/cmake-build-esp32s3/MyApp.app.elf
# Symbol check (0 missing = good, any = "Application failed to start: missing symbol" blue OK dialog)
xtensa-esp32s3-elf-nm -D Apps/MyApp/build/cmake-build-esp32s3/MyApp.app.elf | grep " U "
# or automated:
python .claude/skills/tactility-app-development/scripts/verify_symbols.py Apps/MyApp
# Install to device (dashboard API port 80, not dev port 6666)
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/MyApp install 192.168.68.112 esp32s3
# or dashboard directly:
curl -X PUT http://192.168.68.112/api/apps/install -F "file=@build/MyApp.app"
# Run
curl -X POST "http://192.168.68.112/api/apps/run?id=one.tactility.myapp"
# screenshot QA
curl -s http://192.168.68.112/api/screenshot -o /tmp/fb.png && open /tmp/fb.png
curl http://192.168.68.112/api/apps # list installed
# Dev server fallback (needs Developer mode enabled in Settings, port 6666)
# If 6666 refused → use dashboard PUT above
```
### manifest.properties
```properties
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.myapp
versionName=1.0.0
versionCode=1
name=My App
```
Always pass `esp32s3` platform arg to `install` when only that ELF is built.
### CMake Component Template
```cmake
file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
# optionally include shared libs:
# file(GLOB_RECURSE GAMEKIT Source/*.c* ../../Libraries/GameKit/Source/*.c* ...)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS "Source" "../../Libraries/GameKit/Include" ...
REQUIRES TactilitySDK esp_driver_i2s ...
)
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable)
```
Keep app source as pure C (no `auto`, no lambdas in `.c`) or rename to `.cpp`.
### ELF Loader — Missing Symbol Pattern
Tactility firmware exports symbols via `ESP_ELFSYM_EXPORT` / `DEFINE_MODULE_SYMBOL` in `firmware/Modules/lvgl-module/source/symbols.c`. If ELF references an unexported symbol → device modal `Error / Application failed to start: missing symbol`, heap still healthy (~31KB free) → NOT OOM.
Known missing on 0.8.0-dev:
- `lv_font_montserrat_14/18` → use `lvgl_get_text_font(SMALL/DEFAULT/LARGE)` via `tactility/lvgl_fonts.h`
- `lv_obj_set_style_bg_grad_color/dir/stop`, `LV_OPA_95/5`, `LV_SYMBOL_STAR` → use solid colors, COVER/90
- `lv_obj_set_style_max_width` → use `LV_PCT(92)` fixed percents for gutters
- `lv_obj_set_scrollbar_mode`, `lv_arc_create` (pre PR #496 on 0.7), `lv_roller_create`
- `lv_anim_*` → exported since 0.6, safe for 0.7/0.8
- `lv_binfont_create` → exported on 0.8.0-dev but VFS fragile, prefer embedded C fonts via `lv_font_conv --format lvgl`
If OOM not missing-symbol: heap `total 310KB free 275B min_free 23` → 66 buttons case = too many LVGL objects, not PSRAM (ELF lives in PSRAM, LVGL objs internal heap only). Fix: reduce obj count, horizontal slide pattern.
### App Patterns in This Fleet
| Pattern | Summary |
|---------|---------|
| **Immersive full-screen** | Single screen, header+toolbar hidden default, tap toggles chrome, `LV_OBJ_FLAG_HIDDEN`. No `tt_lvgl_toolbar_create_for_app` → saves 40px but need custom exit. |
| **QVGA intro layout** | Header + flex row = left scrollable column (GOAL+CONTROLS cards 90% width) + right 44px rail (play 38px accent + close 30px muted icons). Fixes clipped bottom on 320x198 usable. |
| **Tutorial teach-by-play** | Dungeon 2-row `TUTORIAL_ROWS=2`, floor 0→@+>, 1→@+b+$+>, real from 2. `isTutorial()` skip monster moves, `renderRows` + `HIDDEN` flag, re-center board. |
| **Adaptive scaling** | `apply_verse_scaling()` strlen→ font LARGE/DEFAULT/SMALL + line_space + pad + letter_spacing. Long (>350c) → compact small. |
| **Book browser** | Clean slide ~10 objs: [X left][slider flex][N/66 right], center LARGE 50% bigger tappable, swipe 28px threshold, anim 220-300ms via `lv_anim_*`, fade ref name. Avoid 66 buttons → OOM. |
| **Asset splitting** | 4.8MB XML bible → per-book `.bin` (NUL-joined verses) + `.idx` (BIBK header u16+u32+offsets) + `books.json`. `tt_app_get_assets_path()` + fallback `/sdcard/bibles/`. |
| **Persistence** | `tt_app_get_user_data_child_path(app, "progress.txt", buf, len)``snprintf`. Atomic write on change. |
| **Exit paths** | (1) `InputKind::Cancel/Menu` Q/ESC → `tt_app_stop()`, (2) X button 36×28, (3) long-press `LV_EVENT_LONG_PRESSED` → pause overlay Resume/Exit. |
| **GameKit Input** | `attachInputToCurrent()` — re-attach `GameKit::attachInput(root, scene)` after container changes, non-clickable info boxes so taps bubble, `CLICKABLE|GESTURE_BUBBLE` on containers, stop_bubbling on start button. |
| **Screenshot rate-limit** | `/api/screenshot` 2s+ between calls or `ConnectionResetError 54` heap spike. |
## In-Repo Skill
| Skill | When to use |
|-------|-------------|
| `tactility-app-development` | ELF app build/env wrapper, missing-symbol loader triage, asset packaging (per-book binary), immersive LVGL UI, toolbar-less app exit, GameKit/GameInput bubbling pitfall, screenshot rate-limit, QVGA intro rail vs wide buttons, tutorial level pattern, audio apps RLCD pin conflict |
### Key references
- `tactility-app-development/references/build-env.md` — Hermes venv PYTHONPATH error transcript + fix
- `tactility-app-development/references/symbols.md` — exported vs missing list, OOM vs missing-symbol diagnosis, heap sizes
- `tactility-app-development/references/deploy.md` — dashboard vs :6666 install, PUT vs POST, platform arg
- `tactility-app-development/references/immersive-ui.md` — editorial v4 warm #F2F0E8, gap 10, fade 220/300ms
- `tactility-app-development/references/intro-fullscreen.md` — fullscreen no-toolbar, GameKit bubbling fix, QVGA rail, v7 GOAL+CONTROLS + icons
- `tactility-app-development/references/pocket-dungeon.md` — GameKit+SfxEngine GLOB_RECURSE, 112 discovery, 9×7 grid, tutorial 2-row
- `tactility-app-development/references/black-bar-crop.md` — header 44→36 TRANSP, Georgia down-tier LARGE→22, 15px lift
- `tactility-app-development/references/sdk-mismatch-missing-symbol.md` — sdk bump + `max_width` removal
- `tactility-app-development/scripts/verify_symbols.py` — undefined symbol checker
Load via `skill_view(name='tactility-app-development')` in agent sessions.
-16
View File
@@ -1,16 +0,0 @@
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(AudioTest)
tactility_project(AudioTest)
-6
View File
@@ -1,6 +0,0 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK
)
-337
View File
@@ -1,337 +0,0 @@
/**
* Audio Test - Record & Playback
*
* Records from the ES8311 microphone via I2S and plays it back through
* the FM8002E speaker amplifier. Uses the Tactility kernel I2S controller API.
*
* UI: Two buttons - Record and Play
* Record: holds recording for up to ~3 seconds (16kHz 16-bit mono ≈ 96KB)
* Play: plays back the last recorded buffer
*/
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/device.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#define TAG "AudioTest"
/* ─── Audio params ─── */
#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE 16
#define RECORD_SECONDS 3
/* 16-bit mono → 2 bytes per sample */
#define AUDIO_BUF_SIZE (SAMPLE_RATE * (BITS_PER_SAMPLE / 8) * RECORD_SECONDS)
/* Chunk size for read/write calls (512 samples = 1024 bytes at 16-bit) */
#define CHUNK_SAMPLES 512
#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8))
/* ─── App state ─── */
typedef enum {
STATE_IDLE,
STATE_RECORDING,
STATE_PLAYING
} AudioState;
typedef struct {
struct Device* i2s_dev;
uint8_t* audio_buf;
size_t recorded_bytes;
AudioState state;
lv_obj_t* btn_record;
lv_obj_t* btn_play;
lv_obj_t* lbl_status;
lv_obj_t* bar_progress;
TaskHandle_t task_handle;
} AppCtx;
/* ─── Forward decls ─── */
static void record_task(void* arg);
static void play_task(void* arg);
static void update_ui(AppCtx* ctx);
/* ─── Callbacks ─── */
static void on_record_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->state != STATE_IDLE) return;
if (ctx->i2s_dev == NULL) return;
ctx->state = STATE_RECORDING;
ctx->recorded_bytes = 0;
update_ui(ctx);
xTaskCreate(record_task, "audio_rec", 4096, ctx, 5, &ctx->task_handle);
}
static void on_play_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->state != STATE_IDLE) return;
if (ctx->recorded_bytes == 0) return;
if (ctx->i2s_dev == NULL) return;
ctx->state = STATE_PLAYING;
update_ui(ctx);
xTaskCreate(play_task, "audio_play", 4096, ctx, 5, &ctx->task_handle);
}
/* ─── Update UI labels/buttons from any context ─── */
static void update_ui(AppCtx* ctx) {
if (ctx->lbl_status == NULL) return;
switch (ctx->state) {
case STATE_RECORDING:
lv_label_set_text(ctx->lbl_status, "🔴 Recording...");
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
break;
case STATE_PLAYING:
lv_label_set_text(ctx->lbl_status, "🔊 Playing...");
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
break;
case STATE_IDLE:
default:
if (ctx->recorded_bytes > 0) {
char buf[64];
snprintf(buf, sizeof(buf), "Ready (%u bytes recorded)", (unsigned)ctx->recorded_bytes);
lv_label_set_text(ctx->lbl_status, buf);
} else {
lv_label_set_text(ctx->lbl_status, "Press Record to capture audio");
}
lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED);
if (ctx->recorded_bytes > 0) {
lv_obj_clear_state(ctx->btn_play, LV_STATE_DISABLED);
} else {
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
}
break;
}
}
/* ─── Record task ─── */
static void record_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
size_t total = 0;
size_t bytes_read = 0;
/* Configure I2S for recording */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channel_left = 0,
.channel_right = I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to set I2S config for recording: %d", err);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Recording started (max %d bytes)", AUDIO_BUF_SIZE);
while (total < AUDIO_BUF_SIZE && ctx->state == STATE_RECORDING) {
size_t remaining = AUDIO_BUF_SIZE - total;
size_t to_read = (remaining < CHUNK_BYTES) ? remaining : CHUNK_BYTES;
err = i2s_controller_read(ctx->i2s_dev,
ctx->audio_buf + total,
to_read,
&bytes_read,
pdMS_TO_TICKS(1000));
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "I2S read error: %d", err);
break;
}
total += bytes_read;
/* Update progress bar */
int pct = (int)((total * 100) / AUDIO_BUF_SIZE);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
ctx->recorded_bytes = total;
ctx->state = STATE_IDLE;
ctx->task_handle = NULL;
ESP_LOGI(TAG, "Recording complete: %u bytes", (unsigned)total);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF);
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
}
/* ─── Play task ─── */
static void play_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
size_t total = 0;
size_t bytes_written = 0;
/* Configure I2S for playback */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channel_left = 0,
.channel_right = I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Playback started (%u bytes)", (unsigned)ctx->recorded_bytes);
while (total < ctx->recorded_bytes && ctx->state == STATE_PLAYING) {
size_t remaining = ctx->recorded_bytes - total;
size_t to_write = (remaining < CHUNK_BYTES) ? remaining : CHUNK_BYTES;
err = i2s_controller_write(ctx->i2s_dev,
ctx->audio_buf + total,
to_write,
&bytes_written,
pdMS_TO_TICKS(1000));
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "I2S write error: %d", err);
break;
}
total += bytes_written;
/* Update progress bar */
int pct = (int)((total * 100) / ctx->recorded_bytes);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
ctx->state = STATE_IDLE;
ctx->task_handle = NULL;
ESP_LOGI(TAG, "Playback complete");
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF);
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
}
/* ─── App lifecycle ─── */
static AppCtx g_ctx;
static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
memset(&g_ctx, 0, sizeof(g_ctx));
/* Allocate audio buffer */
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
if (g_ctx.audio_buf == NULL) {
ESP_LOGE(TAG, "Failed to allocate audio buffer (%d bytes)", AUDIO_BUF_SIZE);
}
/* Find I2S device */
g_ctx.i2s_dev = device_find_by_name("i2s0");
if (g_ctx.i2s_dev == NULL) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
} else {
ESP_LOGI(TAG, "Found I2S device: %s", g_ctx.i2s_dev->name);
}
/* ─── UI ─── */
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
/* Status label */
g_ctx.lbl_status = lv_label_create(parent);
lv_label_set_text(g_ctx.lbl_status, "Initializing...");
lv_obj_set_width(g_ctx.lbl_status, lv_pct(90));
lv_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(g_ctx.lbl_status, LV_ALIGN_TOP_MID, 0, 50);
/* Progress bar */
g_ctx.bar_progress = lv_bar_create(parent);
lv_obj_set_size(g_ctx.bar_progress, lv_pct(80), 10);
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
lv_obj_align(g_ctx.bar_progress, LV_ALIGN_CENTER, 0, -10);
/* Button container */
lv_obj_t* btn_container = lv_obj_create(parent);
lv_obj_remove_style_all(btn_container);
lv_obj_set_size(btn_container, lv_pct(90), 50);
lv_obj_set_flex_flow(btn_container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btn_container, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_align(btn_container, LV_ALIGN_CENTER, 0, 30);
/* Record button */
g_ctx.btn_record = lv_btn_create(btn_container);
lv_obj_set_size(g_ctx.btn_record, 100, 40);
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO " Record");
lv_obj_center(lbl_rec);
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
/* Play button */
g_ctx.btn_play = lv_btn_create(btn_container);
lv_obj_set_size(g_ctx.btn_play, 100, 40);
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play);
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY " Play");
lv_obj_center(lbl_play);
lv_obj_add_event_cb(g_ctx.btn_play, on_play_click, LV_EVENT_CLICKED, &g_ctx);
/* Check initialization status */
if (g_ctx.i2s_dev == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S device not found");
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play, LV_STATE_DISABLED);
} else if (g_ctx.audio_buf == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: Out of memory");
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play, LV_STATE_DISABLED);
} else {
g_ctx.state = STATE_IDLE;
update_ui(&g_ctx);
}
}
int main(int argc, char* argv[]) {
tt_app_register((AppRegistration) {
.onShow = onShowApp
});
return 0;
}
-10
View File
@@ -1,10 +0,0 @@
[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
-16
View File
@@ -1,16 +0,0 @@
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(BibleVerse)
tactility_project(BibleVerse)
-84
View File
@@ -1,84 +0,0 @@
# BibleVerse for Tactility
Single-verse bible reader. Shows one verse fullscreen, auto-advances every minute. Tap to toggle controls.
## UX
- **Default view (no chrome)**: immersive context — just the verse text + reference, centered, large font. Like BookPlayer image-only view.
- **Tap**: shows header (current ref + position) and footer controls:
- Close X, Prev, Pause/Play (auto-advance), Next, Highlight (star), Audio memo placeholder.
- Highlighted verses saved to `favorites.txt` in app user data. Long-term: record voice memo, read aloud TTS.
## Bible data format (split per book)
We fetch public domain bibles from https://github.com/sajeevavahini/bibles (Zefania XML).
Offline conversion:
```
python3 tools/convert_bible.py --src "World English Bible.xml" --dst assets/bible
```
Result structure:
```
assets/bible/
books.json # {"books":[{"bnumber":1,"bname":"Genesis","verses":1533,...}], total_verses, translation, version}
01.bin / 01.idx # Genesis
02.bin / 02.idx # Exodus
...
66.bin / 66.idx
```
- `.bin`: concatenated NUL-terminated UTF8 verse texts. No parsing needed, low RAM.
- `.idx`: header 10 bytes: magic 'BIBK', uint16 version, uint32 count. Then per entry 8 bytes: uint32 offset, uint16 cnum, uint16 vnum.
This split makes memory footprint tiny for ESP32:
- Only one book loaded at a time (max ~215KB for Psalms, typical 12-100KB).
- Index ~19KB max for Psalms, typically <5KB.
## SD card fallback
App also scans:
- `/sdcard/bibles/` and `/sdcard/bible/`
If `books.json` not found in assets, it will try to use SD folder. This lets users drop extra translations offline.
## Settings persistence
Same pattern as EpubReader / BookPlayer (but those two didn't persist in BookPlayer, EpubReader does):
Using Tactility app user data paths:
- `tt_app_get_user_data_path()` -> e.g. `/sdcard/user/app/one.tactility.bibleverse`
- `progress.txt`: stores global verse index + b/c/v for debug
```
1234
1 1 1
0 0
```
- `favorites.txt`: list of global indices, one per line.
This lives on SD card (or internal data partition), survives app reinstalls.
## Build (color screen device, esp32s3)
Same as BookPlayer — external app, compiled for esp32s3 target (covers CYD-2432s028r, m5stack-cores3, etc).
```bash
# source IDF (example)
source ~/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/path/to/TactilitySDK # or use --local-sdk
python3 tactility.py Apps/BibleVerse build esp32s3 --local-sdk
```
Output: `build/BibleVerse.app` installable via Tactility file manager or `tactility.py ... install <ip>`
## Future extensions
- Voice memo: record to `/sdcard/bible_memos/<global>.wav` using same I2S code as BookPlayer
- TTS read aloud: use esp tts or pre-generated mp3 like BookPlayer pages
- Book picker UI (like BookPlayer picker but for bible books)
- Highlight color, search, jump to reference dialog
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More