Compare commits
23 Commits
214287193e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9def43b4ed | |||
| c258aebca1 | |||
| 4a153099f9 | |||
| aebee50d25 | |||
| 6d9001fcd7 | |||
| 653ad8902f | |||
| e4d1d35da4 | |||
| 7c3c4ad2fe | |||
| 2a45c98fb3 | |||
| 25b966a340 | |||
| b2e9046438 | |||
| f07d347fd9 | |||
| 8f46e03070 | |||
| 6eb032cb53 | |||
| 7121009d0d | |||
| 07749d86a1 | |||
| e68909d64d | |||
| d976595879 | |||
| 8721a653e7 | |||
| 694b68de1a | |||
| 71bf2631f4 | |||
| 4ab2377970 | |||
| 8a0f9ef4e4 |
@@ -0,0 +1,259 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/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()
|
||||||
@@ -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 }}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ jobs:
|
|||||||
Build:
|
Build:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
app_name: [Brainfuck, Breakout, BookPlayer, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven]
|
app_name: [AudioTest, BibleVerse, BookPlayer, Brainfuck, Breakout, Calculator, Diceware, EpubReader, EspNowBridge, GPIO, GameBoy, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, McpScreen, MediaKeys, Mp3Player, MystifyDemo, PocketDungeon, ReynaBot, RobotArm, SerialConsole, Snake, TamaTac, TodoList, TwoEleven, VoiceRecorder]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: "Build"
|
- name: "Build"
|
||||||
uses: ./.github/actions/build-app
|
uses: ./.github/actions/build-app
|
||||||
with:
|
with:
|
||||||
@@ -23,7 +25,26 @@ jobs:
|
|||||||
Bundle:
|
Bundle:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [Build]
|
needs: [Build]
|
||||||
|
outputs:
|
||||||
|
sdk_version: ${{ steps.release.outputs.sdk_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: "Build"
|
- name: "Build"
|
||||||
|
id: release
|
||||||
uses: ./.github/actions/release-apps
|
uses: ./.github/actions/release-apps
|
||||||
|
PublishApps:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [Bundle]
|
||||||
|
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: "Publish Apps"
|
||||||
|
env:
|
||||||
|
CDN_ID: ${{ secrets.CDN_ID }}
|
||||||
|
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
|
||||||
|
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
|
||||||
|
uses: ./.github/actions/publish-apps
|
||||||
|
with:
|
||||||
|
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.audiotest
|
||||||
platforms=esp32s3
|
app.version.name=0.1.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.audiotest
|
app.name=Audio Test
|
||||||
versionName=0.1.0
|
|
||||||
versionCode=1
|
|
||||||
name=Audio Test
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ LV_FONT_DECLARE(georgia_regular_24)
|
|||||||
#define MAX_BIBLE_BOOKS 66
|
#define MAX_BIBLE_BOOKS 66
|
||||||
#define MAX_BOOK_NAME 40
|
#define MAX_BOOK_NAME 40
|
||||||
#define MAX_VERSE_TEXT 2048
|
#define MAX_VERSE_TEXT 2048
|
||||||
#define MAX_PATH 320
|
#define MAX_PATH 128
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t offset;
|
uint32_t offset;
|
||||||
@@ -239,7 +239,7 @@ static bool parse_books_json(AppCtx* ctx, const char* json_str) {
|
|||||||
const char* obj_end = strchr(obj_start, '}');
|
const char* obj_end = strchr(obj_start, '}');
|
||||||
if (!obj_end) break;
|
if (!obj_end) break;
|
||||||
// parse fields within obj_start..obj_end
|
// parse fields within obj_start..obj_end
|
||||||
char temp[512];
|
char temp[256];
|
||||||
size_t len = (size_t)(obj_end - obj_start + 1);
|
size_t len = (size_t)(obj_end - obj_start + 1);
|
||||||
if (len >= sizeof(temp)) len = sizeof(temp)-1;
|
if (len >= sizeof(temp)) len = sizeof(temp)-1;
|
||||||
memcpy(temp, obj_start, len);
|
memcpy(temp, obj_start, len);
|
||||||
@@ -672,27 +672,27 @@ static void show_current_verse(AppCtx* ctx) {
|
|||||||
const char* text_ptr = "(empty)";
|
const char* text_ptr = "(empty)";
|
||||||
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
|
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
|
||||||
|
|
||||||
char verse_buf[MAX_VERSE_TEXT];
|
// Fix stack overflow: verse_buf was 2048 bytes on stack, causing gui task (4096) overflow
|
||||||
strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1);
|
// Use heap allocation
|
||||||
verse_buf[sizeof(verse_buf)-1]='\0';
|
char* verse_buf = (char*)malloc(MAX_VERSE_TEXT);
|
||||||
|
if (!verse_buf) {
|
||||||
|
ESP_LOGE(TAG, "Failed to malloc verse_buf");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
strncpy(verse_buf, text_ptr, MAX_VERSE_TEXT-1);
|
||||||
|
verse_buf[MAX_VERSE_TEXT-1]='\0';
|
||||||
|
|
||||||
char ref_buf[96];
|
char ref_buf[64];
|
||||||
snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
|
snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
|
||||||
|
|
||||||
// Editorial reference style like your image: "PSALMS 23:1" with tracking
|
char ref_pretty[80];
|
||||||
char ref_pretty[108];
|
char bname_upper[40];
|
||||||
// Use uppercase for book? Keep title for now but uppercase style for editorial match
|
|
||||||
// We'll format as "— PSALMS 23:1 —" feel? For dark mode keep minimal like ref image
|
|
||||||
// Reference image shows lines flanking citation - we do with letterspacing + dim
|
|
||||||
// For now: "PSALMS 23:1" style - uppercase via runtime? We'll uppercase book name
|
|
||||||
char bname_upper[48];
|
|
||||||
strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
|
strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
|
||||||
bname_upper[sizeof(bname_upper)-1]='\0';
|
bname_upper[sizeof(bname_upper)-1]='\0';
|
||||||
for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p);
|
for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p);
|
||||||
snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum);
|
snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum);
|
||||||
|
|
||||||
if (ctx->lbl_verse) {
|
if (ctx->lbl_verse) {
|
||||||
// start faded for transition
|
|
||||||
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
|
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
|
||||||
lv_label_set_text(ctx->lbl_verse, verse_buf);
|
lv_label_set_text(ctx->lbl_verse, verse_buf);
|
||||||
apply_verse_scaling(ctx, verse_buf);
|
apply_verse_scaling(ctx, verse_buf);
|
||||||
@@ -741,6 +741,7 @@ static void show_current_verse(AppCtx* ctx) {
|
|||||||
if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||||
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
|
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||||
}
|
}
|
||||||
|
free(verse_buf);
|
||||||
save_progress(ctx);
|
save_progress(ctx);
|
||||||
verse_fade_in(ctx);
|
verse_fade_in(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.8.0-dev
|
app.id=one.tactility.bibleverse
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.1.0
|
||||||
[app]
|
app.version.code=2
|
||||||
id=one.tactility.bibleverse
|
app.name=Bible Verse
|
||||||
versionName=0.1.0
|
app.description=Single verse at a time, advances each minute. Tap to show controls.
|
||||||
versionCode=1
|
|
||||||
name=Bible Verse
|
|
||||||
description=Single verse at a time, advances each minute. Tap to show controls.
|
|
||||||
|
|||||||
+100
-125
@@ -4,7 +4,7 @@
|
|||||||
#include <tt_app_alertdialog.h>
|
#include <tt_app_alertdialog.h>
|
||||||
|
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
#include <tactility/drivers/audio_stream.h>
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -45,7 +45,8 @@ typedef struct {
|
|||||||
} BookMetadata;
|
} BookMetadata;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
struct Device* i2s_dev;
|
struct Device* stream_dev;
|
||||||
|
AudioStreamHandle stream_handle;
|
||||||
PlaybackState state;
|
PlaybackState state;
|
||||||
int volume;
|
int volume;
|
||||||
|
|
||||||
@@ -86,19 +87,19 @@ typedef struct {
|
|||||||
} AppCtx;
|
} AppCtx;
|
||||||
|
|
||||||
typedef struct __attribute__((packed)) {
|
typedef struct __attribute__((packed)) {
|
||||||
char riff[4]; // "RIFF"
|
char riff[4];
|
||||||
uint32_t overall_size; // file size - 8
|
uint32_t overall_size;
|
||||||
char wave[4]; // "WAVE"
|
char wave[4];
|
||||||
char fmt_chunk_marker[4]; // "fmt "
|
char fmt_chunk_marker[4];
|
||||||
uint32_t length_of_fmt; // 16 for PCM
|
uint32_t length_of_fmt;
|
||||||
uint16_t format_type; // 1 for PCM
|
uint16_t format_type;
|
||||||
uint16_t channels; // 1 for mono
|
uint16_t channels;
|
||||||
uint32_t sample_rate; // 16000
|
uint32_t sample_rate;
|
||||||
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
|
uint32_t byterate;
|
||||||
uint16_t block_align; // channels * (bits_per_sample / 8)
|
uint16_t block_align;
|
||||||
uint16_t bits_per_sample; // 16
|
uint16_t bits_per_sample;
|
||||||
char data_chunk_header[4]; // "data"
|
char data_chunk_header[4];
|
||||||
uint32_t data_size; // data size in bytes
|
uint32_t data_size;
|
||||||
} WavHeader;
|
} WavHeader;
|
||||||
|
|
||||||
static AppCtx g_ctx;
|
static AppCtx g_ctx;
|
||||||
@@ -113,6 +114,47 @@ static void play_mp3(AppCtx* ctx);
|
|||||||
static void play_wav(AppCtx* ctx);
|
static void play_wav(AppCtx* ctx);
|
||||||
static void scan_books(AppCtx* ctx);
|
static void scan_books(AppCtx* ctx);
|
||||||
|
|
||||||
|
/* ─── Audio-stream helpers ─── */
|
||||||
|
static bool find_audio_stream_device(AppCtx* ctx) {
|
||||||
|
// Prefer device_find_first_by_type but keep compatibility with name lookup
|
||||||
|
struct Device* dev = device_find_by_name("audio-stream");
|
||||||
|
if (dev) {
|
||||||
|
ctx->stream_dev = dev;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||||
|
if (dev) {
|
||||||
|
ctx->stream_dev = dev;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void close_stream_if_open(AppCtx* ctx) {
|
||||||
|
if (ctx->stream_handle) {
|
||||||
|
audio_stream_close(ctx->stream_handle);
|
||||||
|
ctx->stream_handle = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool open_output_stream(AppCtx* ctx, uint32_t sample_rate, uint8_t channels, uint8_t bits) {
|
||||||
|
close_stream_if_open(ctx);
|
||||||
|
if (!ctx->stream_dev) return false;
|
||||||
|
struct AudioStreamConfig cfg = {
|
||||||
|
.sample_rate = sample_rate,
|
||||||
|
.bits_per_sample = bits,
|
||||||
|
.channels = channels
|
||||||
|
};
|
||||||
|
error_t err = audio_stream_open_output(ctx->stream_dev, &cfg, &ctx->stream_handle);
|
||||||
|
if (err != ERROR_NONE) {
|
||||||
|
ESP_LOGE(TAG, "audio_stream_open_output failed: %d (rate=%u ch=%u)", err, (unsigned)sample_rate, channels);
|
||||||
|
ctx->stream_handle = NULL;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "audio_stream output opened: %u Hz %u ch %u-bit", (unsigned)sample_rate, channels, bits);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── UI Helper to update status labels & button states ─── */
|
/* ─── UI Helper to update status labels & button states ─── */
|
||||||
static void update_ui(AppCtx* ctx) {
|
static void update_ui(AppCtx* ctx) {
|
||||||
if (!ctx->btn_play_pause) return;
|
if (!ctx->btn_play_pause) return;
|
||||||
@@ -185,7 +227,6 @@ static void on_auto_advance_timer(lv_timer_t* timer) {
|
|||||||
|
|
||||||
static void handle_audio_finished(AppCtx* ctx) {
|
static void handle_audio_finished(AppCtx* ctx) {
|
||||||
if (ctx->current_page + 1 < ctx->page_count) {
|
if (ctx->current_page + 1 < ctx->page_count) {
|
||||||
// Create a one-shot timer with repeat count 1 to run on the GUI thread
|
|
||||||
lv_timer_t* timer = lv_timer_create(on_auto_advance_timer, 0, ctx);
|
lv_timer_t* timer = lv_timer_create(on_auto_advance_timer, 0, ctx);
|
||||||
lv_timer_set_repeat_count(timer, 1);
|
lv_timer_set_repeat_count(timer, 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -208,7 +249,6 @@ static void wait_for_playback_task_to_exit(AppCtx* ctx) {
|
|||||||
|
|
||||||
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
|
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
|
||||||
static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||||
// 1. Stop current audio if running and wait for thread to finish
|
|
||||||
wait_for_playback_task_to_exit(ctx);
|
wait_for_playback_task_to_exit(ctx);
|
||||||
|
|
||||||
if (page_index < 0 || page_index >= ctx->page_count) return;
|
if (page_index < 0 || page_index >= ctx->page_count) return;
|
||||||
@@ -238,15 +278,13 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
|||||||
lv_image_set_src(ctx->img_page, lv_img_path);
|
lv_image_set_src(ctx->img_page, lv_img_path);
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGW(TAG, "Image file not found: %s", img_path);
|
ESP_LOGW(TAG, "Image file not found: %s", img_path);
|
||||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); // Show default fallback icon
|
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Page index indicator
|
||||||
|
|
||||||
// Update Page index indicator (e.g. "1 / 12")
|
|
||||||
char ind_buf[32];
|
char ind_buf[32];
|
||||||
snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count);
|
snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count);
|
||||||
lv_label_set_text(ctx->lbl_indicator, ind_buf);
|
lv_label_set_text(ctx->lbl_indicator, ind_buf);
|
||||||
@@ -258,18 +296,16 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
|||||||
ctx->current_audio_path[0] = '\0';
|
ctx->current_audio_path[0] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI states (like Prev/Next buttons)
|
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
|
|
||||||
// Start playback if requested and valid
|
// Start playback if requested and valid
|
||||||
if (start_audio && ctx->current_audio_path[0] != '\0') {
|
if (start_audio && ctx->current_audio_path[0] != '\0') {
|
||||||
// Verify audio file exists
|
|
||||||
FILE* audio_file = fopen(ctx->current_audio_path, "rb");
|
FILE* audio_file = fopen(ctx->current_audio_path, "rb");
|
||||||
if (audio_file) {
|
if (audio_file) {
|
||||||
fclose(audio_file);
|
fclose(audio_file);
|
||||||
ctx->state = STATE_PLAYING;
|
ctx->state = STATE_PLAYING;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
|
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
|
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
|
||||||
const char* buttons[] = {"OK"};
|
const char* buttons[] = {"OK"};
|
||||||
@@ -314,9 +350,8 @@ static void audio_playback_task(void* arg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── MP3 Decoder Routine ─── */
|
/* ─── MP3 Decoder Routine (audio-stream) ─── */
|
||||||
static void play_mp3(AppCtx* ctx) {
|
static void play_mp3(AppCtx* ctx) {
|
||||||
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(400));
|
vTaskDelay(pdMS_TO_TICKS(400));
|
||||||
|
|
||||||
FILE* file = fopen(ctx->current_audio_path, "rb");
|
FILE* file = fopen(ctx->current_audio_path, "rb");
|
||||||
@@ -354,14 +389,12 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
int sample_rate = 0;
|
int sample_rate = 0;
|
||||||
int channels = 0;
|
int channels = 0;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (%d bytes)", ctx->current_audio_path, file_size);
|
ESP_LOGI(TAG, "Starting MP3 playback via audio-stream: %s (%d bytes)", ctx->current_audio_path, file_size);
|
||||||
|
|
||||||
while (ctx->state != STATE_IDLE) {
|
while (ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
if (sample_rate != 0) {
|
if (ctx->stream_handle) {
|
||||||
device_lock(ctx->i2s_dev);
|
close_stream_if_open(ctx);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
sample_rate = 0;
|
sample_rate = 0;
|
||||||
channels = 0;
|
channels = 0;
|
||||||
}
|
}
|
||||||
@@ -390,7 +423,6 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
|
|
||||||
if (info.frame_bytes <= 0) {
|
if (info.frame_bytes <= 0) {
|
||||||
if (eof) break;
|
if (eof) break;
|
||||||
// Resync
|
|
||||||
memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes);
|
memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -400,22 +432,10 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
|
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
|
||||||
|
|
||||||
if (samples > 0) {
|
if (samples > 0) {
|
||||||
// Configure I2S
|
// Open/reopen stream on format change
|
||||||
if (sample_rate != info.hz || channels != info.channels) {
|
if (sample_rate != info.hz || channels != info.channels) {
|
||||||
struct I2sConfig config = {
|
if (!open_output_stream(ctx, (uint32_t)info.hz, (uint8_t)info.channels, 16)) {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
ESP_LOGE(TAG, "Failed to open audio stream for MP3: %d Hz %d ch", info.hz, info.channels);
|
||||||
.sample_rate = (uint32_t)info.hz,
|
|
||||||
.bits_per_sample = 16,
|
|
||||||
.channel_left = 0,
|
|
||||||
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
|
||||||
};
|
|
||||||
|
|
||||||
device_lock(ctx->i2s_dev);
|
|
||||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
|
|
||||||
if (err != ERROR_NONE) {
|
|
||||||
ESP_LOGE(TAG, "Failed to configure I2S: %d", err);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sample_rate = info.hz;
|
sample_rate = info.hz;
|
||||||
@@ -431,15 +451,15 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
samples_ptr[i] = (int16_t)scaled;
|
samples_ptr[i] = (int16_t)scaled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write PCM to I2S
|
// Write via audio_stream (resampled to native 44100 internally)
|
||||||
size_t offset = 0;
|
size_t offset = 0;
|
||||||
size_t data_size = sample_count * sizeof(int16_t);
|
size_t data_size = sample_count * sizeof(int16_t);
|
||||||
bool write_err = false;
|
bool write_err = false;
|
||||||
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
|
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(1000));
|
||||||
if (err != ERROR_NONE || written == 0) {
|
if (err != ERROR_NONE || written == 0) {
|
||||||
ESP_LOGE(TAG, "I2S write error: %d", err);
|
ESP_LOGE(TAG, "audio_stream_write error: %d", err);
|
||||||
write_err = true;
|
write_err = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -465,12 +485,7 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fclose(file);
|
fclose(file);
|
||||||
|
close_stream_if_open(ctx);
|
||||||
if (ctx->i2s_dev) {
|
|
||||||
device_lock(ctx->i2s_dev);
|
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool stopped_externally = (ctx->state == STATE_IDLE);
|
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||||
|
|
||||||
@@ -489,9 +504,8 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── WAV Decoder Routine ─── */
|
/* ─── WAV Decoder Routine (audio-stream) ─── */
|
||||||
static void play_wav(AppCtx* ctx) {
|
static void play_wav(AppCtx* ctx) {
|
||||||
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(400));
|
vTaskDelay(pdMS_TO_TICKS(400));
|
||||||
|
|
||||||
FILE* file = fopen(ctx->current_audio_path, "rb");
|
FILE* file = fopen(ctx->current_audio_path, "rb");
|
||||||
@@ -538,49 +552,36 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
fseek(file, sizeof(WavHeader), SEEK_SET);
|
fseek(file, sizeof(WavHeader), SEEK_SET);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct I2sConfig config = {
|
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
ESP_LOGE(TAG, "Failed to open audio stream for WAV: %u Hz %u ch", (unsigned)header.sample_rate, header.channels);
|
||||||
.sample_rate = header.sample_rate,
|
|
||||||
.bits_per_sample = header.bits_per_sample,
|
|
||||||
.channel_left = 0,
|
|
||||||
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
|
||||||
};
|
|
||||||
|
|
||||||
device_lock(ctx->i2s_dev);
|
|
||||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
|
|
||||||
if (err != ERROR_NONE) {
|
|
||||||
ESP_LOGE(TAG, "Failed to configure WAV I2S: %d", err);
|
|
||||||
fclose(file);
|
fclose(file);
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
ctx->state = STATE_IDLE;
|
||||||
|
update_ui(ctx);
|
||||||
|
tt_lvgl_unlock();
|
||||||
ctx->playback_task_handle = NULL;
|
ctx->playback_task_handle = NULL;
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Starting WAV playback: %s (%u Hz, %u channels)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
|
ESP_LOGI(TAG, "Starting WAV via audio-stream: %s (%u Hz, %u ch)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
|
||||||
|
|
||||||
size_t total_played = 0;
|
size_t total_played = 0;
|
||||||
bool i2s_configured = true;
|
bool stream_open = true;
|
||||||
|
|
||||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
if (i2s_configured) {
|
if (stream_open) {
|
||||||
device_lock(ctx->i2s_dev);
|
close_stream_if_open(ctx);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
stream_open = false;
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
i2s_configured = false;
|
|
||||||
}
|
}
|
||||||
vTaskDelay(pdMS_TO_TICKS(50));
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!i2s_configured) {
|
if (!stream_open) {
|
||||||
device_lock(ctx->i2s_dev);
|
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) break;
|
||||||
err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
stream_open = true;
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
if (err != ERROR_NONE) break;
|
|
||||||
i2s_configured = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t to_read = (data_size - total_played < MP3_INPUT_BUFFER_SIZE) ? (data_size - total_played) : MP3_INPUT_BUFFER_SIZE;
|
size_t to_read = (data_size - total_played < MP3_INPUT_BUFFER_SIZE) ? (data_size - total_played) : MP3_INPUT_BUFFER_SIZE;
|
||||||
@@ -596,14 +597,14 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
samples_ptr[i] = (int16_t)scaled;
|
samples_ptr[i] = (int16_t)scaled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play to I2S
|
// Write via audio_stream
|
||||||
size_t offset = 0;
|
size_t offset = 0;
|
||||||
bool write_err = false;
|
bool write_err = false;
|
||||||
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
|
error_t err = audio_stream_write(ctx->stream_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||||
if (err != ERROR_NONE || written == 0) {
|
if (err != ERROR_NONE || written == 0) {
|
||||||
ESP_LOGE(TAG, "I2S WAV write error: %d", err);
|
ESP_LOGE(TAG, "audio_stream WAV write error: %d", err);
|
||||||
write_err = true;
|
write_err = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -614,7 +615,6 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
|
|
||||||
total_played += read_bytes;
|
total_played += read_bytes;
|
||||||
|
|
||||||
// Update progress bar
|
|
||||||
int pct = (int)((total_played * 100) / data_size);
|
int pct = (int)((total_played * 100) / data_size);
|
||||||
if (pct < 0) pct = 0;
|
if (pct < 0) pct = 0;
|
||||||
if (pct > 100) pct = 100;
|
if (pct > 100) pct = 100;
|
||||||
@@ -629,12 +629,7 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fclose(file);
|
fclose(file);
|
||||||
|
close_stream_if_open(ctx);
|
||||||
if (ctx->i2s_dev) {
|
|
||||||
device_lock(ctx->i2s_dev);
|
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool stopped_externally = (ctx->state == STATE_IDLE);
|
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||||
|
|
||||||
@@ -704,7 +699,6 @@ static void on_book_selected(lv_event_t* e) {
|
|||||||
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
||||||
lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
// Load first page (no auto-play initially)
|
|
||||||
load_page(ctx, 0, false);
|
load_page(ctx, 0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,7 +789,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
|||||||
if (ctx->state == STATE_IDLE) {
|
if (ctx->state == STATE_IDLE) {
|
||||||
ctx->state = STATE_PLAYING;
|
ctx->state = STATE_PLAYING;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
|
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
|
||||||
} else if (ctx->state == STATE_PLAYING) {
|
} else if (ctx->state == STATE_PLAYING) {
|
||||||
ctx->state = STATE_PAUSED;
|
ctx->state = STATE_PAUSED;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
@@ -857,10 +851,9 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Find I2S sound device
|
// Find audio-stream device (provides resampling to native 44100)
|
||||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
if (!find_audio_stream_device(&g_ctx)) {
|
||||||
if (!g_ctx.i2s_dev) {
|
ESP_LOGE(TAG, "audio-stream device not found! Tried 'audio-stream' name and AUDIO_STREAM_TYPE");
|
||||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create dual layouts
|
// Create dual layouts
|
||||||
@@ -870,13 +863,11 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_set_style_border_width(g_ctx.picker_wrapper, 0, 0);
|
lv_obj_set_style_border_width(g_ctx.picker_wrapper, 0, 0);
|
||||||
lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0);
|
lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0);
|
||||||
lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0);
|
lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0); // Dark background
|
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0);
|
||||||
|
|
||||||
// Toolbar in picker
|
|
||||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
|
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
|
||||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
|
||||||
// Picker list
|
|
||||||
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
|
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
|
||||||
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
|
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
|
||||||
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||||
@@ -893,10 +884,9 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_set_style_pad_all(g_ctx.player_wrapper, 0, 0);
|
lv_obj_set_style_pad_all(g_ctx.player_wrapper, 0, 0);
|
||||||
lv_obj_set_style_pad_gap(g_ctx.player_wrapper, 0, 0);
|
lv_obj_set_style_pad_gap(g_ctx.player_wrapper, 0, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.player_wrapper, lv_color_hex(0x1E1E2E), 0);
|
lv_obj_set_style_bg_color(g_ctx.player_wrapper, lv_color_hex(0x1E1E2E), 0);
|
||||||
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE); // Lock page scrolling
|
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
lv_obj_add_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_add_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
// Page Image (covers the whole screen as background)
|
|
||||||
g_ctx.img_page = lv_image_create(g_ctx.player_wrapper);
|
g_ctx.img_page = lv_image_create(g_ctx.player_wrapper);
|
||||||
lv_obj_align(g_ctx.img_page, LV_ALIGN_CENTER, 0, 0);
|
lv_obj_align(g_ctx.img_page, LV_ALIGN_CENTER, 0, 0);
|
||||||
lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0);
|
lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0);
|
||||||
@@ -906,19 +896,17 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_add_flag(g_ctx.img_page, LV_OBJ_FLAG_CLICKABLE);
|
lv_obj_add_flag(g_ctx.img_page, LV_OBJ_FLAG_CLICKABLE);
|
||||||
lv_obj_add_event_cb(g_ctx.img_page, on_image_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.img_page, on_image_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Custom Player header (Back button, Book Title, Page Indicator)
|
|
||||||
g_ctx.header_bar = lv_obj_create(g_ctx.player_wrapper);
|
g_ctx.header_bar = lv_obj_create(g_ctx.player_wrapper);
|
||||||
lv_obj_t* header_bar = g_ctx.header_bar;
|
lv_obj_t* header_bar = g_ctx.header_bar;
|
||||||
lv_obj_set_size(header_bar, LV_PCT(100), 36);
|
lv_obj_set_size(header_bar, LV_PCT(100), 36);
|
||||||
lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0);
|
lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
lv_obj_set_style_radius(header_bar, 0, 0);
|
lv_obj_set_style_radius(header_bar, 0, 0);
|
||||||
lv_obj_set_style_bg_color(header_bar, lv_color_hex(0x11111B), 0);
|
lv_obj_set_style_bg_color(header_bar, lv_color_hex(0x11111B), 0);
|
||||||
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0); // Solid header bar
|
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0);
|
||||||
lv_obj_set_style_border_color(header_bar, lv_color_hex(0x313244), 0);
|
lv_obj_set_style_border_color(header_bar, lv_color_hex(0x313244), 0);
|
||||||
lv_obj_set_style_border_width(header_bar, 1, LV_PART_MAIN);
|
lv_obj_set_style_border_width(header_bar, 1, LV_PART_MAIN);
|
||||||
lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
// Back button
|
|
||||||
lv_obj_t* btn_back = lv_button_create(header_bar);
|
lv_obj_t* btn_back = lv_button_create(header_bar);
|
||||||
lv_obj_set_size(btn_back, 44, 26);
|
lv_obj_set_size(btn_back, 44, 26);
|
||||||
lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0);
|
lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0);
|
||||||
@@ -929,28 +917,25 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_center(lbl_back);
|
lv_obj_center(lbl_back);
|
||||||
lv_obj_add_event_cb(btn_back, on_back_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(btn_back, on_back_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Title
|
|
||||||
g_ctx.lbl_book_title = lv_label_create(header_bar);
|
g_ctx.lbl_book_title = lv_label_create(header_bar);
|
||||||
lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_CENTER, 0, 0);
|
lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_CENTER, 0, 0);
|
||||||
lv_obj_set_width(g_ctx.lbl_book_title, 180);
|
lv_obj_set_width(g_ctx.lbl_book_title, 180);
|
||||||
lv_obj_set_style_text_align(g_ctx.lbl_book_title, LV_TEXT_ALIGN_CENTER, 0);
|
lv_obj_set_style_text_align(g_ctx.lbl_book_title, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xCDD6F4), 0);
|
lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xCDD6F4), 0);
|
||||||
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT); // Static text with dots to prevent dynamic redraw overhead
|
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT);
|
||||||
|
|
||||||
// Page Indicator
|
|
||||||
g_ctx.lbl_indicator = lv_label_create(header_bar);
|
g_ctx.lbl_indicator = lv_label_create(header_bar);
|
||||||
lv_obj_align(g_ctx.lbl_indicator, LV_ALIGN_RIGHT_MID, 0, 0);
|
lv_obj_align(g_ctx.lbl_indicator, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.lbl_indicator, lv_color_hex(0xA6ADC8), 0);
|
lv_obj_set_style_text_color(g_ctx.lbl_indicator, lv_color_hex(0xA6ADC8), 0);
|
||||||
lv_label_set_text(g_ctx.lbl_indicator, "1 / 1");
|
lv_label_set_text(g_ctx.lbl_indicator, "1 / 1");
|
||||||
|
|
||||||
// Bottom Controls container (overlaying background image)
|
|
||||||
g_ctx.ctrl_bar = lv_obj_create(g_ctx.player_wrapper);
|
g_ctx.ctrl_bar = lv_obj_create(g_ctx.player_wrapper);
|
||||||
lv_obj_t* ctrl_bar = g_ctx.ctrl_bar;
|
lv_obj_t* ctrl_bar = g_ctx.ctrl_bar;
|
||||||
lv_obj_set_size(ctrl_bar, LV_PCT(100), 40);
|
lv_obj_set_size(ctrl_bar, LV_PCT(100), 40);
|
||||||
lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
|
lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||||||
lv_obj_set_style_radius(ctrl_bar, 0, 0);
|
lv_obj_set_style_radius(ctrl_bar, 0, 0);
|
||||||
lv_obj_set_style_bg_color(ctrl_bar, lv_color_hex(0x11111B), 0);
|
lv_obj_set_style_bg_color(ctrl_bar, lv_color_hex(0x11111B), 0);
|
||||||
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0); // Solid control bar
|
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0);
|
||||||
lv_obj_set_style_border_width(ctrl_bar, 0, 0);
|
lv_obj_set_style_border_width(ctrl_bar, 0, 0);
|
||||||
lv_obj_set_style_pad_all(ctrl_bar, 0, 0);
|
lv_obj_set_style_pad_all(ctrl_bar, 0, 0);
|
||||||
lv_obj_set_style_pad_gap(ctrl_bar, 0, 0);
|
lv_obj_set_style_pad_gap(ctrl_bar, 0, 0);
|
||||||
@@ -958,7 +943,6 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_set_flex_align(ctrl_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
lv_obj_set_flex_align(ctrl_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
lv_obj_remove_flag(ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
// Progress Bar (thin bar running along the top of control bar)
|
|
||||||
g_ctx.bar_progress = lv_bar_create(g_ctx.player_wrapper);
|
g_ctx.bar_progress = lv_bar_create(g_ctx.player_wrapper);
|
||||||
lv_obj_set_size(g_ctx.bar_progress, LV_PCT(100), 4);
|
lv_obj_set_size(g_ctx.bar_progress, LV_PCT(100), 4);
|
||||||
lv_obj_align_to(g_ctx.bar_progress, ctrl_bar, LV_ALIGN_OUT_TOP_MID, 0, 0);
|
lv_obj_align_to(g_ctx.bar_progress, ctrl_bar, LV_ALIGN_OUT_TOP_MID, 0, 0);
|
||||||
@@ -967,7 +951,6 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
|
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
|
||||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||||
|
|
||||||
// Prev Button
|
|
||||||
g_ctx.btn_prev = lv_button_create(ctrl_bar);
|
g_ctx.btn_prev = lv_button_create(ctrl_bar);
|
||||||
lv_obj_set_size(g_ctx.btn_prev, 54, 30);
|
lv_obj_set_size(g_ctx.btn_prev, 54, 30);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0);
|
lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0);
|
||||||
@@ -977,7 +960,6 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_center(lbl_prev);
|
lv_obj_center(lbl_prev);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Play/Pause Button
|
|
||||||
g_ctx.btn_play_pause = lv_button_create(ctrl_bar);
|
g_ctx.btn_play_pause = lv_button_create(ctrl_bar);
|
||||||
lv_obj_set_size(g_ctx.btn_play_pause, 94, 30);
|
lv_obj_set_size(g_ctx.btn_play_pause, 94, 30);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0);
|
lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0);
|
||||||
@@ -988,7 +970,6 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_center(lbl_play_btn);
|
lv_obj_center(lbl_play_btn);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Next Button
|
|
||||||
g_ctx.btn_next = lv_button_create(ctrl_bar);
|
g_ctx.btn_next = lv_button_create(ctrl_bar);
|
||||||
lv_obj_set_size(g_ctx.btn_next, 54, 30);
|
lv_obj_set_size(g_ctx.btn_next, 54, 30);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_next, 15, 0);
|
lv_obj_set_style_radius(g_ctx.btn_next, 15, 0);
|
||||||
@@ -1002,18 +983,12 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
g_ctx.state = STATE_IDLE;
|
g_ctx.state = STATE_IDLE;
|
||||||
update_ui(&g_ctx);
|
update_ui(&g_ctx);
|
||||||
|
|
||||||
// Scan Books
|
|
||||||
scan_books(&g_ctx);
|
scan_books(&g_ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void onHideApp(AppHandle app, void* data) {
|
static void onHideApp(AppHandle app, void* data) {
|
||||||
wait_for_playback_task_to_exit(&g_ctx);
|
wait_for_playback_task_to_exit(&g_ctx);
|
||||||
|
close_stream_if_open(&g_ctx);
|
||||||
if (g_ctx.i2s_dev) {
|
|
||||||
device_lock(g_ctx.i2s_dev);
|
|
||||||
i2s_controller_reset(g_ctx.i2s_dev);
|
|
||||||
device_unlock(g_ctx.i2s_dev);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (g_ctx.manifest_root) {
|
if (g_ctx.manifest_root) {
|
||||||
cJSON_Delete(g_ctx.manifest_root);
|
cJSON_Delete(g_ctx.manifest_root);
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.8.0-dev
|
app.id=one.tactility.bookplayer
|
||||||
platforms=esp32s3
|
app.version.name=1.0.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.bookplayer
|
app.name=Book Player
|
||||||
versionName=1.0.0
|
|
||||||
versionCode=1
|
|
||||||
name=Book Player
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.brainfuck
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.5.0
|
||||||
[app]
|
app.version.code=5
|
||||||
id=one.tactility.brainfuck
|
app.name=Brainfuck interpreter
|
||||||
versionName=0.2.0
|
app.description=Brainfuck esoteric language interpreter
|
||||||
versionCode=2
|
|
||||||
name=Brainfuck interpreter
|
|
||||||
description=Brainfuck esoteric language interpreter
|
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
|||||||
if (!sfxEngine) {
|
if (!sfxEngine) {
|
||||||
sfxEngine = new SfxEngine();
|
sfxEngine = new SfxEngine();
|
||||||
sfxEngine->start();
|
sfxEngine->start();
|
||||||
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
|
||||||
sfxEngine->setEnabled(soundEnabled);
|
sfxEngine->setEnabled(soundEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.breakout
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.5.0
|
||||||
[app]
|
app.version.code=5
|
||||||
id=one.tactility.breakout
|
app.name=Breakout
|
||||||
versionName=0.2.0
|
app.description=Classic brick-breaking arcade game
|
||||||
versionCode=2
|
|
||||||
name=Breakout
|
|
||||||
description=Classic brick-breaking arcade game
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.calculator
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.calculator
|
app.name=Calculator
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Calculator
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.diceware
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.diceware
|
app.name=Diceware
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Diceware
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.epubreader
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.4.0
|
||||||
[app]
|
app.version.code=4
|
||||||
id=one.tactility.epubreader
|
app.name=Epub Reader
|
||||||
versionName=0.1.0
|
app.description=Epub and text file reader. Requires PSRAM!
|
||||||
versionCode=1
|
|
||||||
name=Epub Reader
|
|
||||||
description=Epub and text file reader. Requires PSRAM!
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(EspNowBridge)
|
||||||
|
tactility_project(EspNowBridge)
|
||||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES
|
||||||
|
Source/*.c*
|
||||||
|
)
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES}
|
||||||
|
# Library headers must be included directly,
|
||||||
|
# because all regular dependencies get stripped by elf_loader's cmake script
|
||||||
|
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
|
||||||
|
REQUIRES TactilitySDK bootloader_support esp_app_format
|
||||||
|
)
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
#include "EspNowBridge.h"
|
||||||
|
|
||||||
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/wifi.h>
|
||||||
|
#include <tactility/wifi_auto_scan.h>
|
||||||
|
#include <tactility/firmware/firmware.h>
|
||||||
|
|
||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_app_fileselection.h>
|
||||||
|
#include <tt_bundle.h>
|
||||||
|
#include <tt_lock.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
|
||||||
|
#include <esp_app_desc.h>
|
||||||
|
#include <esp_app_format.h>
|
||||||
|
#include <esp_system.h>
|
||||||
|
|
||||||
|
#include <tactility/log.h>
|
||||||
|
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cinttypes>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
static constexpr auto* TAG = "EspNowBridge";
|
||||||
|
static constexpr size_t CHUNK_SIZE = 1500;
|
||||||
|
static constexpr uint32_t TRANSPORT_WAIT_TIMEOUT_MS = 5000;
|
||||||
|
static constexpr uint32_t UPDATE_TASK_STACK_SIZE = 8192;
|
||||||
|
|
||||||
|
AutoScanPauseGuard::AutoScanPauseGuard() { wifi_auto_scan_set_paused(true); }
|
||||||
|
AutoScanPauseGuard::~AutoScanPauseGuard() { wifi_auto_scan_set_paused(false); }
|
||||||
|
|
||||||
|
// Binary partition table format (gen_esp32part.py STRUCT_FORMAT '<2sBBLL16sL'): a flat array of
|
||||||
|
// 32-byte little-endian records starting at flash offset PARTITION_TABLE_OFFSET, terminated by
|
||||||
|
// an all-0xFF entry or an MD5-checksum record (magic 0xEBEB). Not exposed as a C header by
|
||||||
|
// ESP-IDF (only the Python generator knows the format) - this is a hand-ported minimal reader,
|
||||||
|
// just enough to locate the app partition inside a merged/factory bin.
|
||||||
|
static constexpr size_t PARTITION_TABLE_OFFSET = 0x8000;
|
||||||
|
static constexpr size_t PARTITION_TABLE_MAX_ENTRIES = 128; // covers the largest partition table IDF supports (0x1000 / 32)
|
||||||
|
static constexpr uint16_t PARTITION_ENTRY_MAGIC = 0x50AA; // little-endian bytes 0xAA, 0x50
|
||||||
|
static constexpr uint16_t PARTITION_MD5_MAGIC = 0xEBEB;
|
||||||
|
static constexpr uint8_t PARTITION_TYPE_APP = 0x00;
|
||||||
|
static constexpr uint8_t PARTITION_SUBTYPE_FACTORY = 0x00;
|
||||||
|
static constexpr uint8_t PARTITION_SUBTYPE_OTA_0 = 0x10;
|
||||||
|
|
||||||
|
struct __attribute__((packed)) PartitionEntry {
|
||||||
|
uint16_t magic;
|
||||||
|
uint8_t type;
|
||||||
|
uint8_t subtype;
|
||||||
|
uint32_t offset;
|
||||||
|
uint32_t size;
|
||||||
|
char name[16];
|
||||||
|
uint32_t flags;
|
||||||
|
};
|
||||||
|
static_assert(sizeof(PartitionEntry) == 32, "partition table entry must be 32 bytes");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the partition table embedded in a merged/factory bin (at PARTITION_TABLE_OFFSET) for
|
||||||
|
* the app partition to flash: prefers "factory" if present, otherwise the first OTA slot
|
||||||
|
* (ota_0) - matches what a real M5Stack ESP-Hosted factory image contains.
|
||||||
|
* @return true if an app partition was found, with appOffset/appSize set to its location
|
||||||
|
* within the file (these are the same as the absolute flash offsets the merged bin preserves).
|
||||||
|
*/
|
||||||
|
static bool findAppPartitionInMergedBin(FILE* file, size_t& appOffset, size_t& appSize) {
|
||||||
|
if (fseek(file, static_cast<long>(PARTITION_TABLE_OFFSET), SEEK_SET) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool foundFactory = false;
|
||||||
|
bool foundOta0 = false;
|
||||||
|
size_t factoryOffset = 0, factorySize = 0;
|
||||||
|
size_t ota0Offset = 0, ota0Size = 0;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < PARTITION_TABLE_MAX_ENTRIES; i++) {
|
||||||
|
PartitionEntry entry;
|
||||||
|
if (fread(&entry, 1, sizeof(entry), file) != sizeof(entry)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.magic == PARTITION_MD5_MAGIC) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.magic != PARTITION_ENTRY_MAGIC) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.type == PARTITION_TYPE_APP) {
|
||||||
|
if (entry.subtype == PARTITION_SUBTYPE_FACTORY) {
|
||||||
|
foundFactory = true;
|
||||||
|
factoryOffset = entry.offset;
|
||||||
|
factorySize = entry.size;
|
||||||
|
} else if (entry.subtype == PARTITION_SUBTYPE_OTA_0 && !foundOta0) {
|
||||||
|
foundOta0 = true;
|
||||||
|
ota0Offset = entry.offset;
|
||||||
|
ota0Size = entry.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundFactory) {
|
||||||
|
appOffset = factoryOffset;
|
||||||
|
appSize = factorySize;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (foundOta0) {
|
||||||
|
appOffset = ota0Offset;
|
||||||
|
appSize = ota0Size;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the app image at the given file offset and extracts its version string. The actual
|
||||||
|
* transfer size used for the OTA loop is just the real remaining file size from appOffset (see
|
||||||
|
* performUpdate) - hand-computing the image's "logical" size from segment headers + checksum/
|
||||||
|
* hash padding drifts a bit short of the real length, so we just use the file size instead.
|
||||||
|
*/
|
||||||
|
static bool parseImageHeader(FILE* file, size_t appOffset, char* versionOut, size_t versionOutLen, std::string* errorOut = nullptr) {
|
||||||
|
esp_image_header_t imageHeader;
|
||||||
|
if (fseek(file, static_cast<long>(appOffset), SEEK_SET) != 0 ||
|
||||||
|
fread(&imageHeader, 1, sizeof(imageHeader), file) != sizeof(imageHeader)) {
|
||||||
|
if (errorOut != nullptr) {
|
||||||
|
*errorOut = "Failed to read image header";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageHeader.magic != ESP_IMAGE_HEADER_MAGIC) {
|
||||||
|
if (errorOut != nullptr) {
|
||||||
|
*errorOut = "Selected file is not a valid firmware image (bad magic)";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail fast on a wrong-chip image (e.g. an ESP32 or S3 binary picked by mistake) before
|
||||||
|
// streaming the whole file over the paced, slow bridge link - esp_hosted_slave_ota_end()
|
||||||
|
// would eventually catch this too, but only after the entire transfer already completed.
|
||||||
|
if (imageHeader.chip_id != ESP_CHIP_ID_ESP32C6) {
|
||||||
|
if (errorOut != nullptr) {
|
||||||
|
char buf[96];
|
||||||
|
snprintf(buf, sizeof(buf), "Wrong chip: image targets chip id %u, expected ESP32-C6",
|
||||||
|
(unsigned)imageHeader.chip_id);
|
||||||
|
*errorOut = buf;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_image_segment_header_t segmentHeader;
|
||||||
|
size_t firstSegmentOffset = appOffset + sizeof(imageHeader);
|
||||||
|
if (fseek(file, static_cast<long>(firstSegmentOffset), SEEK_SET) != 0 ||
|
||||||
|
fread(&segmentHeader, 1, sizeof(segmentHeader), file) != sizeof(segmentHeader)) {
|
||||||
|
if (errorOut != nullptr) {
|
||||||
|
*errorOut = "Failed to read first segment header";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_app_desc_t appDesc;
|
||||||
|
size_t appDescOffset = appOffset + sizeof(imageHeader) + sizeof(segmentHeader);
|
||||||
|
if (fseek(file, static_cast<long>(appDescOffset), SEEK_SET) == 0 && fread(&appDesc, 1, sizeof(appDesc), file) == sizeof(appDesc)) {
|
||||||
|
strncpy(versionOut, appDesc.version, versionOutLen - 1);
|
||||||
|
versionOut[versionOutLen - 1] = '\0';
|
||||||
|
} else {
|
||||||
|
strncpy(versionOut, "unknown", versionOutLen - 1);
|
||||||
|
versionOut[versionOutLen - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool getCurrentVersionString(const FirmwareOps* ops, void* ctx, char* versionOut, size_t versionOutLen) {
|
||||||
|
FirmwareInfo info = {};
|
||||||
|
if (ops == nullptr || ops->get_info(ctx, &info) != ERROR_NONE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.name[0] != '\0') {
|
||||||
|
snprintf(versionOut, versionOutLen, "%u.%u.%u (%s)",
|
||||||
|
(unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch, info.name);
|
||||||
|
} else {
|
||||||
|
snprintf(versionOut, versionOutLen, "%u.%u.%u",
|
||||||
|
(unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only slave firmware >= v2.6.0 implements esp_hosted_slave_ota_activate() - older slaves
|
||||||
|
* reject/lack the RPC entirely. Matches upstream's host_performs_slave_ota example. */
|
||||||
|
static bool activateSupported(uint32_t major, uint32_t minor) {
|
||||||
|
return (major > 2) || (major == 2 && minor > 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::atomic<EspNowBridge*> EspNowBridge::liveInstance_{nullptr};
|
||||||
|
|
||||||
|
void EspNowBridge::onCreate(AppHandle app) {
|
||||||
|
appHandle_ = app;
|
||||||
|
taskDoneSemaphore_ = xSemaphoreCreateBinary();
|
||||||
|
liveInstance_ = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onDestroy(AppHandle /*app*/) {
|
||||||
|
// Clear liveInstance_ first so any task still running bails out at its next liveInstance_
|
||||||
|
// check instead of continuing to touch this instance's members.
|
||||||
|
liveInstance_ = nullptr;
|
||||||
|
|
||||||
|
// Wait for any outstanding background task (OTA update, transport-wait) to actually finish -
|
||||||
|
// the app framework frees this instance shortly after onDestroy() returns, so a task that
|
||||||
|
// outlives it would dereference freed memory.
|
||||||
|
while (outstandingTasks_.load() > 0) {
|
||||||
|
if (taskDoneSemaphore_ != nullptr) {
|
||||||
|
xSemaphoreTake(taskDoneSemaphore_, pdMS_TO_TICKS(1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskDoneSemaphore_ != nullptr) {
|
||||||
|
vSemaphoreDelete(taskDoneSemaphore_);
|
||||||
|
taskDoneSemaphore_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::refreshCurrentVersion() {
|
||||||
|
char versionStr[32];
|
||||||
|
if (getCurrentVersionString(firmwareOps_, firmwareCtx_, versionStr, sizeof(versionStr))) {
|
||||||
|
lv_label_set_text_fmt(currentVersionLabel_, "Co-processor firmware: %s", versionStr);
|
||||||
|
} else {
|
||||||
|
lv_label_set_text(currentVersionLabel_, "Co-processor firmware: unknown (link not up)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EspNowBridge::isWifiRadioOn() {
|
||||||
|
if (wifiDevice_ == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
WifiRadioState radioState = WIFI_RADIO_STATE_OFF;
|
||||||
|
if (wifi_get_radio_state(wifiDevice_, &radioState) != ERROR_NONE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ON with any station state (disconnected/pending/connected) is fine - the ESP-NOW bridge
|
||||||
|
// just needs the radio + esp_hosted transport up, not a completed AP connection.
|
||||||
|
return radioState == WIFI_RADIO_STATE_ON;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::refreshWifiPrompt() {
|
||||||
|
if (isWifiRadioOn()) {
|
||||||
|
lv_obj_add_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
setUpdateButtonsDisabled(false);
|
||||||
|
} else {
|
||||||
|
lv_obj_clear_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
setUpdateButtonsDisabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::setUpdateButtonsDisabled(bool disabled) {
|
||||||
|
if (disabled) {
|
||||||
|
lv_obj_add_state(updateButton_, LV_STATE_DISABLED);
|
||||||
|
lv_obj_add_state(updateBundledButton_, LV_STATE_DISABLED);
|
||||||
|
} else {
|
||||||
|
lv_obj_clear_state(updateButton_, LV_STATE_DISABLED);
|
||||||
|
lv_obj_clear_state(updateBundledButton_, LV_STATE_DISABLED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::setStatus(const std::string& text) {
|
||||||
|
lv_label_set_text(statusLabel_, text.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::setProgress(int percent) {
|
||||||
|
lv_bar_set_value(progressBar_, percent, LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct UiDispatchPayload {
|
||||||
|
EspNowBridge* instance;
|
||||||
|
void (*work)(EspNowBridge&, void*);
|
||||||
|
void* context;
|
||||||
|
void (*freeContext)(void*);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)) {
|
||||||
|
auto* payload = new UiDispatchPayload{this, work, context, freeContext};
|
||||||
|
// lv_async_call() itself is an LVGL operation and must be lock-guarded when called from a
|
||||||
|
// non-LVGL task (see tt_lvgl_lock()'s doc comment) - the OTA worker task calls dispatchToUi()
|
||||||
|
// repeatedly during the transfer, and without this lock most of those calls were silently
|
||||||
|
// racing LVGL's own task and getting lost (only the very last status update, right before
|
||||||
|
// esp_restart(), happened to land - everything else stayed stuck at "Waiting for
|
||||||
|
// co-processor link...").
|
||||||
|
bool locked = tt_lvgl_lock(TT_LVGL_DEFAULT_LOCK_TIME);
|
||||||
|
if (!locked) {
|
||||||
|
// Without the lock, lv_async_call() itself would be touching LVGL's internal timer list
|
||||||
|
// unguarded - and if it happened to still enqueue successfully, the callback below would
|
||||||
|
// later fire against `payload` after we've already freed it here. Drop the update instead.
|
||||||
|
if (freeContext != nullptr) {
|
||||||
|
freeContext(context);
|
||||||
|
}
|
||||||
|
delete payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_result_t result = lv_async_call([](void* userData) {
|
||||||
|
auto* payload = static_cast<UiDispatchPayload*>(userData);
|
||||||
|
if (EspNowBridge::liveInstance_.load() == payload->instance && payload->instance->isShown_.load()) {
|
||||||
|
payload->work(*payload->instance, payload->context);
|
||||||
|
}
|
||||||
|
if (payload->freeContext != nullptr) {
|
||||||
|
payload->freeContext(payload->context);
|
||||||
|
}
|
||||||
|
delete payload;
|
||||||
|
}, payload);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
if (result != LV_RESULT_OK) {
|
||||||
|
if (freeContext != nullptr) {
|
||||||
|
freeContext(context);
|
||||||
|
}
|
||||||
|
delete payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void workSetStatus(EspNowBridge& app, void* context) {
|
||||||
|
app.setStatus(*static_cast<std::string*>(context));
|
||||||
|
}
|
||||||
|
void freeString(void* context) { delete static_cast<std::string*>(context); }
|
||||||
|
|
||||||
|
void workSetProgress(EspNowBridge& app, void* context) {
|
||||||
|
app.setProgress(*static_cast<int*>(context));
|
||||||
|
}
|
||||||
|
void freeInt(void* context) { delete static_cast<int*>(context); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void EspNowBridge::performUpdate(const std::string& filePath) {
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setUpdateButtonsDisabled(true);
|
||||||
|
app.setProgress(0);
|
||||||
|
app.setStatus("Waiting for co-processor link...");
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
|
||||||
|
if (firmwareOps_ == nullptr) {
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("This WiFi device has no updatable co-processor");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firmwareOps_->wait_ready(firmwareCtx_, TRANSPORT_WAIT_TIMEOUT_MS)) {
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Co-processor link not available - update cancelled");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE* file = fopen(filePath.c_str(), "rb");
|
||||||
|
if (file == nullptr) {
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to open selected file");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fseek(file, 0, SEEK_END);
|
||||||
|
long fileSizeSigned = ftell(file);
|
||||||
|
if (fileSizeSigned <= 0) {
|
||||||
|
fclose(file);
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to determine file size");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t fileSize = static_cast<size_t>(fileSizeSigned);
|
||||||
|
|
||||||
|
// Support both a plain app image (starting with the app image header at offset 0) and a
|
||||||
|
// merged/factory bin (e.g. M5Stack's official ESP-Hosted factory image) - detected by whether
|
||||||
|
// a valid partition table is found at PARTITION_TABLE_OFFSET.
|
||||||
|
size_t appOffset = 0;
|
||||||
|
size_t partitionSize = 0;
|
||||||
|
bool isMergedBin = findAppPartitionInMergedBin(file, appOffset, partitionSize);
|
||||||
|
if (isMergedBin && appOffset >= fileSize) {
|
||||||
|
fclose(file);
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Merged bin's app partition is outside the file - selected file looks truncated");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char newVersion[32];
|
||||||
|
std::string parseError;
|
||||||
|
if (!parseImageHeader(file, appOffset, newVersion, sizeof(newVersion), &parseError)) {
|
||||||
|
fclose(file);
|
||||||
|
dispatchToUi(workSetStatus, new std::string(parseError), freeString);
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged bins pad the app partition to its declared size; a plain app image is exactly as
|
||||||
|
// long as the app itself. Transfer whichever is smaller.
|
||||||
|
size_t remainingInFile = fileSize - appOffset;
|
||||||
|
size_t firmwareSize = isMergedBin ? std::min(partitionSize, remainingInFile) : remainingInFile;
|
||||||
|
|
||||||
|
std::string versionStr(newVersion);
|
||||||
|
{
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "Pushing firmware %s...", versionStr.c_str());
|
||||||
|
dispatchToUi(workSetStatus, new std::string(buf), freeString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held on the app instance (not a local variable) so it outlives this function - see
|
||||||
|
// heldAutoScanPauseGuard_'s declaration for why. Released when the host actually restarts
|
||||||
|
// (moot, since esp_restart() doesn't return) or if the update fails early below.
|
||||||
|
heldAutoScanPauseGuard_.emplace();
|
||||||
|
|
||||||
|
FirmwareUpdateRequest updateRequest = {};
|
||||||
|
updateRequest.image_size = firmwareSize;
|
||||||
|
FirmwareUpdateHandle* handle = nullptr;
|
||||||
|
if (firmwareOps_->begin(firmwareCtx_, &updateRequest, &handle) != ERROR_NONE) {
|
||||||
|
fclose(file);
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to start OTA on co-processor");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fseek(file, static_cast<long>(appOffset), SEEK_SET) != 0) {
|
||||||
|
fclose(file);
|
||||||
|
firmwareOps_->abort(handle);
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to seek to firmware start");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t chunk[CHUNK_SIZE];
|
||||||
|
size_t sent = 0;
|
||||||
|
bool writeFailed = false;
|
||||||
|
int lastReportedPercent = -1;
|
||||||
|
|
||||||
|
while (sent < firmwareSize) {
|
||||||
|
size_t toRead = (firmwareSize - sent > CHUNK_SIZE) ? CHUNK_SIZE : (firmwareSize - sent);
|
||||||
|
size_t actuallyRead = fread(chunk, 1, toRead, file);
|
||||||
|
if (actuallyRead != toRead) {
|
||||||
|
LOG_E(TAG, "Failed to read file at offset %zu", sent);
|
||||||
|
writeFailed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firmwareOps_->write(handle, chunk, actuallyRead) != ERROR_NONE) {
|
||||||
|
LOG_E(TAG, "firmwareOps_->write() failed at offset %zu", sent);
|
||||||
|
writeFailed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pace the transfer - esp_hosted's SDIO driver only retries a write twice with no
|
||||||
|
// backoff before giving up and restarting the host. Back-to-back chunk writes with zero
|
||||||
|
// gap were observed to saturate the bus enough to trigger a genuine SDIO timeout
|
||||||
|
// mid-transfer, not just around the post-activate reboot.
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(5));
|
||||||
|
|
||||||
|
sent += actuallyRead;
|
||||||
|
|
||||||
|
// Only touch LVGL every couple of percent, not every 1500-byte chunk - frequent
|
||||||
|
// display-bus activity during the transfer was implicated in SDIO transport crashes
|
||||||
|
// under sustained OTA write load.
|
||||||
|
int percent = (int)((sent * 100) / firmwareSize);
|
||||||
|
if (percent != lastReportedPercent) {
|
||||||
|
dispatchToUi(workSetProgress, new int(percent), freeInt);
|
||||||
|
lastReportedPercent = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose(file);
|
||||||
|
|
||||||
|
if (writeFailed) {
|
||||||
|
firmwareOps_->abort(handle);
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Update failed while transferring firmware");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firmwareOps_->finish(handle) != ERROR_NONE) {
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to finalize OTA on co-processor");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the *currently running* (pre-update) slave version - the new image isn't running
|
||||||
|
// yet - and skip straight to the required host restart for older slaves.
|
||||||
|
FirmwareInfo runningInfo = {};
|
||||||
|
bool canActivate = firmwareOps_->get_info(firmwareCtx_, &runningInfo) == ERROR_NONE
|
||||||
|
&& activateSupported(runningInfo.fw_major, runningInfo.fw_minor);
|
||||||
|
|
||||||
|
if (canActivate) {
|
||||||
|
if (firmwareOps_->activate(firmwareCtx_) != ERROR_NONE) {
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to activate new firmware - co-processor still running old firmware");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldAutoScanPauseGuard_ is deliberately left held (never explicitly released) - the host
|
||||||
|
// restarts itself immediately below, and there's no safe window to resume normal WiFi
|
||||||
|
// activity before that.
|
||||||
|
{
|
||||||
|
char buf[80];
|
||||||
|
if (canActivate) {
|
||||||
|
snprintf(buf, sizeof(buf), "Firmware %s activated - restarting...", versionStr.c_str());
|
||||||
|
} else {
|
||||||
|
snprintf(buf, sizeof(buf), "Firmware %s pushed - restarting to apply...", versionStr.c_str());
|
||||||
|
}
|
||||||
|
dispatchToUi(workSetStatus, new std::string(buf), freeString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the status message above a moment to actually be seen before the restart cuts the
|
||||||
|
// display, then restart.
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1500));
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::updateTaskEntry(void* arg) {
|
||||||
|
auto* self = static_cast<EspNowBridge*>(arg);
|
||||||
|
self->performUpdate(self->pendingUpdateFilePath_);
|
||||||
|
self->updateTask_ = nullptr;
|
||||||
|
if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) {
|
||||||
|
xSemaphoreGive(self->taskDoneSemaphore_);
|
||||||
|
}
|
||||||
|
vTaskDelete(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::startUpdateTask(const std::string& filePath) {
|
||||||
|
if (updateTask_ != nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingUpdateFilePath_ = filePath;
|
||||||
|
outstandingTasks_.fetch_add(1);
|
||||||
|
if (xTaskCreate(updateTaskEntry, "espnow_bridge_ota", UPDATE_TASK_STACK_SIZE / sizeof(StackType_t), this, tskIDLE_PRIORITY + 1, &updateTask_) != pdPASS) {
|
||||||
|
outstandingTasks_.fetch_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onUpdateButtonClicked(lv_event_t* /*event*/) {
|
||||||
|
auto* self = liveInstance_.load();
|
||||||
|
if (self == nullptr || !self->isWifiRadioOn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->pickFileLaunchId_ = tt_app_fileselection_start_for_existing_file();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name of the slave bridge firmware bundled in this app's assets/ folder
|
||||||
|
// lets users flash the known-good bridge firmware without needing to source/copy a
|
||||||
|
// .bin onto the SD card themselves. The SD-card picker (onUpdateButtonClicked above) stays
|
||||||
|
// available too, for factory-image downgrades or custom builds.
|
||||||
|
static constexpr auto* BUNDLED_FIRMWARE_ASSET_NAME = "espnow_bridge_slave_c6.bin";
|
||||||
|
|
||||||
|
void EspNowBridge::onUpdateBundledButtonClicked(lv_event_t* /*event*/) {
|
||||||
|
auto* self = liveInstance_.load();
|
||||||
|
if (self == nullptr || !self->isWifiRadioOn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char assetPath[256] = {};
|
||||||
|
size_t assetPathSize = sizeof(assetPath);
|
||||||
|
tt_app_get_assets_child_path(self->appHandle_, BUNDLED_FIRMWARE_ASSET_NAME, assetPath, &assetPathSize);
|
||||||
|
if (assetPath[0] == '\0') {
|
||||||
|
LOG_E(TAG, "Failed to resolve bundled firmware asset path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->startUpdateTask(assetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onEnableWifiButtonClicked(lv_event_t* /*event*/) {
|
||||||
|
auto* self = liveInstance_.load();
|
||||||
|
if (self == nullptr || self->wifiDevice_ == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
device_start(self->wifiDevice_);
|
||||||
|
// start_device() allocates a fresh driver context (Platforms/platform-esp32's
|
||||||
|
// esp32_wifi.cpp), which wipes any event callback registered before the device was started -
|
||||||
|
// re-register now that it's actually running. Also refresh once directly rather than relying
|
||||||
|
// solely on the next WifiEvent, so the "WiFi on" prompt updates immediately even though the
|
||||||
|
// co-processor firmware version below isn't available yet.
|
||||||
|
wifi_add_event_callback(self->wifiDevice_, self, onWifiEvent);
|
||||||
|
self->refreshWifiPrompt();
|
||||||
|
self->refreshCurrentVersion();
|
||||||
|
|
||||||
|
// The co-processor RPC transport isn't up the instant device_start() returns - it comes up
|
||||||
|
// asynchronously (~1-2s later) - so firmwareOps_->get_info() above reliably fails right after
|
||||||
|
// enabling WiFi. Nothing else reliably re-triggers a version refresh once the transport
|
||||||
|
// actually comes up (WifiEvent only covers radio/station state, not transport readiness), so
|
||||||
|
// wait for it explicitly on a background task and refresh once it's ready.
|
||||||
|
if (self->firmwareOps_ != nullptr) {
|
||||||
|
self->outstandingTasks_.fetch_add(1);
|
||||||
|
if (xTaskCreate(waitForTransportTaskEntry, "espnow_bridge_wait", 4096 / sizeof(StackType_t), self, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) {
|
||||||
|
self->outstandingTasks_.fetch_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::waitForTransportTaskEntry(void* arg) {
|
||||||
|
auto* self = static_cast<EspNowBridge*>(arg);
|
||||||
|
constexpr uint32_t WAIT_TIMEOUT_MS = 10000;
|
||||||
|
// liveInstance_ must be checked before touching any member of self - if onDestroy() already
|
||||||
|
// ran, `self` may be freed, and dereferencing self->firmwareOps_ first would be a
|
||||||
|
// use-after-free even just to read the pointer.
|
||||||
|
if (liveInstance_.load() == self && self->firmwareOps_ != nullptr
|
||||||
|
&& self->firmwareOps_->wait_ready(self->firmwareCtx_, WAIT_TIMEOUT_MS)
|
||||||
|
&& liveInstance_.load() == self) {
|
||||||
|
self->dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.refreshCurrentVersion();
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) {
|
||||||
|
xSemaphoreGive(self->taskDoneSemaphore_);
|
||||||
|
}
|
||||||
|
vTaskDelete(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onWifiEvent(Device* /*device*/, void* callbackContext, WifiEvent /*event*/) {
|
||||||
|
auto* self = static_cast<EspNowBridge*>(callbackContext);
|
||||||
|
if (liveInstance_.load() != self) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.refreshWifiPrompt();
|
||||||
|
app.refreshCurrentVersion();
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) {
|
||||||
|
isShown_ = true;
|
||||||
|
|
||||||
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
|
||||||
|
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||||
|
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
|
||||||
|
auto* wrapper = lv_obj_create(parent);
|
||||||
|
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_style_pad_all(wrapper, 8, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(wrapper, 1);
|
||||||
|
|
||||||
|
currentVersionLabel_ = lv_label_create(wrapper);
|
||||||
|
lv_obj_set_style_pad_bottom(currentVersionLabel_, 12, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
enableWifiButton_ = lv_button_create(wrapper);
|
||||||
|
lv_obj_add_event_cb(enableWifiButton_, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||||
|
auto* enableWifiButtonLabel = lv_label_create(enableWifiButton_);
|
||||||
|
lv_label_set_text(enableWifiButtonLabel, "Enable WiFi (required for co-processor link)");
|
||||||
|
lv_obj_set_style_pad_bottom(enableWifiButton_, 12, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
updateBundledButton_ = lv_button_create(wrapper);
|
||||||
|
lv_obj_add_event_cb(updateBundledButton_, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||||
|
auto* updateBundledButtonLabel = lv_label_create(updateBundledButton_);
|
||||||
|
lv_label_set_text(updateBundledButtonLabel, "Update to bundled firmware");
|
||||||
|
lv_obj_set_style_pad_bottom(updateBundledButton_, 12, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
updateButton_ = lv_button_create(wrapper);
|
||||||
|
lv_obj_add_event_cb(updateButton_, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||||
|
auto* updateButtonLabel = lv_label_create(updateButton_);
|
||||||
|
lv_label_set_text(updateButtonLabel, "Update from SD card...");
|
||||||
|
lv_obj_set_style_pad_bottom(updateButton_, 12, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
progressBar_ = lv_bar_create(wrapper);
|
||||||
|
lv_obj_set_size(progressBar_, LV_PCT(100), LV_PCT(6));
|
||||||
|
lv_bar_set_range(progressBar_, 0, 100);
|
||||||
|
lv_bar_set_value(progressBar_, 0, LV_ANIM_OFF);
|
||||||
|
|
||||||
|
statusLabel_ = lv_label_create(wrapper);
|
||||||
|
lv_label_set_text(statusLabel_, "Ready");
|
||||||
|
|
||||||
|
wifiDevice_ = wifi_find_first_registered_device();
|
||||||
|
if (wifiDevice_ != nullptr) {
|
||||||
|
wifi_add_event_callback(wifiDevice_, this, onWifiEvent);
|
||||||
|
if (wifi_get_firmware_ops(wifiDevice_, &firmwareOps_, &firmwareCtx_) != ERROR_NONE) {
|
||||||
|
firmwareOps_ = nullptr;
|
||||||
|
firmwareCtx_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCurrentVersion();
|
||||||
|
refreshWifiPrompt();
|
||||||
|
|
||||||
|
// If an SD-card file was picked before this onShow() ran (FileSelection tears down and
|
||||||
|
// rebuilds this app's whole widget tree), perform the update now that widgets are valid
|
||||||
|
// again. The bundled-firmware button doesn't go through this path - it calls
|
||||||
|
// startUpdateTask() directly since there's no separate app launch/result round trip involved.
|
||||||
|
if (!pendingUpdateFilePath_.empty()) {
|
||||||
|
std::string path = std::move(pendingUpdateFilePath_);
|
||||||
|
pendingUpdateFilePath_.clear();
|
||||||
|
startUpdateTask(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onHide(AppHandle /*app*/) {
|
||||||
|
isShown_ = false;
|
||||||
|
if (wifiDevice_ != nullptr) {
|
||||||
|
wifi_remove_event_callback(wifiDevice_, onWifiEvent);
|
||||||
|
wifiDevice_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, AppResult result, BundleHandle resultData) {
|
||||||
|
if (launchId != pickFileLaunchId_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pickFileLaunchId_ = 0;
|
||||||
|
|
||||||
|
if (result == APP_RESULT_OK && resultData != nullptr) {
|
||||||
|
char pathBuf[256] = {};
|
||||||
|
if (tt_app_fileselection_get_result_path(resultData, pathBuf, sizeof(pathBuf))) {
|
||||||
|
pendingUpdateFilePath_ = pathBuf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <TactilityCpp/App.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <lvgl.h>
|
||||||
|
|
||||||
|
#include <tactility/drivers/wifi.h>
|
||||||
|
|
||||||
|
/** RAII guard: pauses WifiService's background auto-connect scan for the guard's lifetime. See
|
||||||
|
* tactility/wifi_auto_scan.h - belt-and-suspenders measure, not sufficient on its own (see the
|
||||||
|
* REBOOT comment in EspNowBridge.cpp). */
|
||||||
|
class AutoScanPauseGuard {
|
||||||
|
public:
|
||||||
|
AutoScanPauseGuard();
|
||||||
|
~AutoScanPauseGuard();
|
||||||
|
AutoScanPauseGuard(const AutoScanPauseGuard&) = delete;
|
||||||
|
AutoScanPauseGuard& operator=(const AutoScanPauseGuard&) = delete;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EspNowBridge final : public App {
|
||||||
|
public:
|
||||||
|
EspNowBridge() = default;
|
||||||
|
EspNowBridge(const EspNowBridge&) = delete;
|
||||||
|
EspNowBridge& operator=(const EspNowBridge&) = delete;
|
||||||
|
|
||||||
|
void onCreate(AppHandle app) override;
|
||||||
|
void onDestroy(AppHandle app) override;
|
||||||
|
void onShow(AppHandle app, lv_obj_t* parent) override;
|
||||||
|
void onHide(AppHandle app) override;
|
||||||
|
void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
|
||||||
|
|
||||||
|
// Public so the free-function dispatchToUi() work callbacks in EspNowBridge.cpp (which run
|
||||||
|
// outside any member-function's lexical scope, unlike the inline lambdas in performUpdate())
|
||||||
|
// can call them.
|
||||||
|
void setStatus(const std::string& text);
|
||||||
|
void setProgress(int percent);
|
||||||
|
|
||||||
|
private:
|
||||||
|
AppHandle appHandle_ = nullptr;
|
||||||
|
AppLaunchId pickFileLaunchId_ = 0;
|
||||||
|
std::string pendingUpdateFilePath_;
|
||||||
|
Device* wifiDevice_ = nullptr;
|
||||||
|
|
||||||
|
// Resolved once in onShow() via wifi_get_firmware_ops() - null on a WiFi device with no
|
||||||
|
// updatable co-processor (e.g. a native, non-hosted chip). All OTA/version-query calls go
|
||||||
|
// through this generic interface, not any esp_hosted-specific API directly.
|
||||||
|
const FirmwareOps* firmwareOps_ = nullptr;
|
||||||
|
void* firmwareCtx_ = nullptr;
|
||||||
|
|
||||||
|
// Set once in onShow(), false once onHide() tears the widget tree down - checked (via
|
||||||
|
// dispatchToUi(), below) before touching any lv_obj_t*, since the OTA worker task and the
|
||||||
|
// WiFi-event callback can both outlive a hide/app-switch.
|
||||||
|
std::atomic<bool> isShown_{false};
|
||||||
|
|
||||||
|
// Only one EspNowBridge instance is ever live at a time (app loader owns a single instance
|
||||||
|
// per running app), so a single static "is this instance still current" pointer, guarded by
|
||||||
|
// an atomic, substitutes for the internal app's shared_ptr-based lifetime guard - the OTA
|
||||||
|
// worker task and dispatchToUi()'s lv_async_call closures check liveInstance_ == this before
|
||||||
|
// touching any member, instead of holding a shared_ptr to keep `this` alive.
|
||||||
|
static std::atomic<EspNowBridge*> liveInstance_;
|
||||||
|
|
||||||
|
TaskHandle_t updateTask_ = nullptr;
|
||||||
|
|
||||||
|
// Number of background tasks (updateTaskEntry, waitForTransportTaskEntry) currently running
|
||||||
|
// against this instance's members. onDestroy() must wait for this to hit 0 before returning -
|
||||||
|
// the app framework frees this instance shortly after onDestroy() returns (see Loader.cpp),
|
||||||
|
// so any task still touching `this` past that point is a use-after-free.
|
||||||
|
std::atomic<int> outstandingTasks_{0};
|
||||||
|
SemaphoreHandle_t taskDoneSemaphore_ = nullptr;
|
||||||
|
|
||||||
|
// Outlives performUpdate() deliberately, so auto-scan stays paused across the async gap
|
||||||
|
// between performUpdate() returning and the automatic restart - see performUpdate().
|
||||||
|
std::optional<AutoScanPauseGuard> heldAutoScanPauseGuard_;
|
||||||
|
|
||||||
|
lv_obj_t* currentVersionLabel_ = nullptr;
|
||||||
|
lv_obj_t* statusLabel_ = nullptr;
|
||||||
|
lv_obj_t* progressBar_ = nullptr;
|
||||||
|
lv_obj_t* updateButton_ = nullptr;
|
||||||
|
lv_obj_t* updateBundledButton_ = nullptr;
|
||||||
|
lv_obj_t* enableWifiButton_ = nullptr;
|
||||||
|
|
||||||
|
void refreshCurrentVersion();
|
||||||
|
bool isWifiRadioOn();
|
||||||
|
void refreshWifiPrompt();
|
||||||
|
/** Enables/disables both update-trigger buttons together - only one performUpdate() can run
|
||||||
|
* at a time (see updateTask_), regardless of which button started it. */
|
||||||
|
void setUpdateButtonsDisabled(bool disabled);
|
||||||
|
/** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance_ is
|
||||||
|
* still this instance (checked at dispatch time and again right before running, on the LVGL
|
||||||
|
* task) and isShown_ is true (this app's widget tree exists). */
|
||||||
|
void dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*));
|
||||||
|
void performUpdate(const std::string& filePath);
|
||||||
|
void startUpdateTask(const std::string& filePath);
|
||||||
|
|
||||||
|
static void updateTaskEntry(void* arg);
|
||||||
|
static void onUpdateButtonClicked(lv_event_t* event);
|
||||||
|
static void onUpdateBundledButtonClicked(lv_event_t* event);
|
||||||
|
static void onEnableWifiButtonClicked(lv_event_t* event);
|
||||||
|
static void onWifiEvent(Device* device, void* callbackContext, WifiEvent event);
|
||||||
|
static void waitForTransportTaskEntry(void* arg);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#include "EspNowBridge.h"
|
||||||
|
#include <TactilityCpp/App.h>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
registerApp<EspNowBridge>();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32p4
|
||||||
|
app.id=one.tactility.espnowbridge
|
||||||
|
app.version.name=0.1.0
|
||||||
|
app.version.code=1
|
||||||
|
app.name=ESP-NOW Bridge
|
||||||
|
app.description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support.
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.gpio
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.7.0
|
||||||
[app]
|
app.version.code=7
|
||||||
id=one.tactility.gpio
|
app.name=GPIO
|
||||||
versionName=0.4.0
|
|
||||||
versionCode=4
|
|
||||||
name=GPIO
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(GameBoy)
|
||||||
|
tactility_project(GameBoy)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# GameBoy Emulator (Tactility Prototype, No Audio)
|
||||||
|
|
||||||
|
DMG Game Boy emulator for Tactility side-loaded apps, using **Peanut-GB** (MIT) as CPU/LCD core.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Prototype v0.1.0-dev – compiling draft focused on architecture, not yet production-hardened.
|
||||||
|
- **No audio** (ENABLE_SOUND 0). Audio path stubbed for future MiniGB APU or i2s-driven implementation.
|
||||||
|
- Supports MBC1/MBC2/MBC3/MBC5 via Peanut-GB.
|
||||||
|
- ROM loader from SD card.
|
||||||
|
- Save RAM persistence via `.sav` file next to ROM.
|
||||||
|
- LVGL framebuffer: native 160x144 RGB565, integer-scaled if display large enough, centered on black background.
|
||||||
|
- Input: on-screen D-pad + A/B + Start/Select + hardware keyboard arrows + LVGL key events (z= A, x= B).
|
||||||
|
- Timer-driven at ~16ms (~60Hz) calling `gb_run_frame()`.
|
||||||
|
|
||||||
|
## ROM Location
|
||||||
|
|
||||||
|
- Scanned directory: `/sdcard/roms/gb/` for `*.gb`, `*.gbc`, `*.bin` (up to 64 entries).
|
||||||
|
- Default quick-load: `/sdcard/roms/gb/default.gb` – if present on app show, autoloads and jumps directly to emulation.
|
||||||
|
- Place your legally dumped ROMs there; no ROMs are bundled.
|
||||||
|
|
||||||
|
## Save RAM Path Design (stubbed + implemented minimal)
|
||||||
|
|
||||||
|
- Save file = ROM path with extension replaced by `.sav` (e.g. `/sdcard/roms/gb/tetris.gb` -> `/sdcard/roms/gb/tetris.sav`).
|
||||||
|
- Loaded on ROM load, saved on:
|
||||||
|
- switching back to menu
|
||||||
|
- app hide
|
||||||
|
- error recovery path
|
||||||
|
- Size queried via `gb_get_save_size_s()` / `gb_get_save_size()`.
|
||||||
|
- Future improvement: also mirror to app user-data dir (`tt_app_get_user_data_child_path`) if SD is read-only.
|
||||||
|
|
||||||
|
## Memory Considerations (ESP32-S3 / PSRAM)
|
||||||
|
|
||||||
|
- Large buffers allocated via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)` with fallback to internal.
|
||||||
|
- ROM buffer: up to 2MB (MBC5 max-ish) – PSRAM preferred.
|
||||||
|
- Cart RAM: variable, often 8KB-32KB, PSRAM.
|
||||||
|
- Framebuffer: 160*144*2 = 46,080 bytes (~45KB) native RGB565. PSRAM preferred. No double buffering needed (line callback writes directly).
|
||||||
|
- No huge heap allocations.
|
||||||
|
- Emulator context `struct gb_s` is static inside AppCtx (~few KB).
|
||||||
|
|
||||||
|
## Controls / Input Mapping
|
||||||
|
|
||||||
|
| GB | On-screen | Keyboard | Remarks |
|
||||||
|
|----|-----------|----------|---------|
|
||||||
|
| D-pad | 4 arrow buttons | LV_KEY_ arrows | press/release tracked |
|
||||||
|
| A | A button (right cluster) | z | |
|
||||||
|
| B | B button (right cluster) | x | |
|
||||||
|
| Start | Sta | Enter / Space | |
|
||||||
|
| Select | Sel | Esc / Backspace | |
|
||||||
|
| Touch | Quadrants not yet separated – buttons cover |
|
||||||
|
|
||||||
|
Future: touch quadrants mapping via `pointToQuadrant` like GameKitInput.
|
||||||
|
|
||||||
|
## LCD Rendering
|
||||||
|
|
||||||
|
- Peanut-GB calls `lcd_draw_line(gb, pixels[160], line)` per scanline.
|
||||||
|
- `pixels` low 2 bits = shade 0-3.
|
||||||
|
- Mapped to olive/gray palette RGB565 (editable).
|
||||||
|
- Canvas buffer is the framebuffer itself.
|
||||||
|
- Scale: if display resolution >= 320x432 => 2x, >=480x576 => 3x via LVGL transform scale (keeps native buffer).
|
||||||
|
|
||||||
|
## No-Audio Limitation
|
||||||
|
|
||||||
|
- `ENABLE_SOUND 0` – audio callbacks not compiled.
|
||||||
|
- To add audio:
|
||||||
|
1. Vendor MiniGB APU (`minigb_apu`) or similar.
|
||||||
|
2. Implement `audio_read` / `audio_write` forwarding to APU.
|
||||||
|
3. Define ENABLE_SOUND 1, include APU, create audio task similar to BookPlayer (i2s_controller).
|
||||||
|
4. Feed APU samples in timer / separate task.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Same as other Tactility apps:
|
||||||
|
|
||||||
|
```
|
||||||
|
. $IDF_PATH/export.sh
|
||||||
|
export TACTILITY_SDK_PATH=...
|
||||||
|
python3 tactility.py Apps/GameBoy build esp32s3 --local-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
- App code: GPLv3 (same as Tactility Apps).
|
||||||
|
- Peanut-GB vendored lib: MIT (Copyright (c) 2018-2023 Mahyar Koshkouei). License preserved in `Libraries/PeanutGB/peanut_gb.h`.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [ ] Improve input: add touch quadrant → D-pad, repeat timers for held buttons.
|
||||||
|
- [ ] Add pause/resume UI, FPS display.
|
||||||
|
- [ ] Add palette selector (auto_assign_palette logic from peanut_sdl).
|
||||||
|
- [ ] Add file picker dialog (`tt_app_selectiondialog_start`) improvement + recursive folder browsing.
|
||||||
|
- [ ] Audio: vendoring `minigb_apu` and creating I2S task.
|
||||||
|
- [ ] Save state beyond cart RAM (full emu snapshot).
|
||||||
|
- [ ] RTC persistence for MBC3 RTC games.
|
||||||
|
- [ ] Error dialog via `tt_app_alertdialog_start`.
|
||||||
|
- [ ] Validate with Cppcheck / clang-format and ESP-IDF build.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES}
|
||||||
|
INCLUDE_DIRS Source ../../../Libraries/PeanutGB
|
||||||
|
REQUIRES TactilitySDK
|
||||||
|
)
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
/**
|
||||||
|
* @file main.c
|
||||||
|
* @brief GameBoy DMG Emulator for Tactility (Peanut-GB prototype, no audio)
|
||||||
|
*
|
||||||
|
* MIT licensed Peanut-GB core vendored in Libraries/PeanutGB/peanut_gb.h
|
||||||
|
* See app README for notes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
#include <tt_app_alertdialog.h>
|
||||||
|
#include <tt_lvgl_keyboard.h>
|
||||||
|
|
||||||
|
#include <lvgl.h>
|
||||||
|
/* lv_image_cache_drop is not in public LVGL headers but exported by Tactility firmware */
|
||||||
|
void lv_image_cache_drop(const void * src);
|
||||||
|
#include <esp_log.h>
|
||||||
|
|
||||||
|
/* Board firmware 0.8.0-dev/IDF 5.3.2 does not export esp_log for side-loaded ELFs. */
|
||||||
|
#undef ESP_LOGI
|
||||||
|
#undef ESP_LOGW
|
||||||
|
#undef ESP_LOGE
|
||||||
|
#define ESP_LOGI(tag, fmt, ...) do { (void)(tag); } while (0)
|
||||||
|
#define ESP_LOGW(tag, fmt, ...) do { (void)(tag); } while (0)
|
||||||
|
#define ESP_LOGE(tag, fmt, ...) do { (void)(tag); } while (0)
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
#include <esp_timer.h>
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <strings.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#define ENABLE_SOUND 0
|
||||||
|
#define ENABLE_LCD 1
|
||||||
|
#include "peanut_gb.h"
|
||||||
|
|
||||||
|
#define TAG "GameBoy"
|
||||||
|
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb"
|
||||||
|
#define ROMS_DIR "/data/roms/gb"
|
||||||
|
#define MAX_ROMS 64
|
||||||
|
#define MAX_PATH 512
|
||||||
|
#define MAX_ROM_SIZE (2 * 1024 * 1024)
|
||||||
|
#define FRAME_W 160
|
||||||
|
#define FRAME_H 144
|
||||||
|
#define TICK_MS 16
|
||||||
|
|
||||||
|
/* RGB565 direct – no bitfield endian ambiguity */
|
||||||
|
static const uint16_t GB_PALETTE[4] = {
|
||||||
|
0xFFFF, // white
|
||||||
|
0x8C51, // light gray ~ 0b10001 100010 10001 but pre-tuned
|
||||||
|
0x4A49, // dark gray
|
||||||
|
0x0000 // black
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char filename[MAX_PATH];
|
||||||
|
char fullpath[MAX_PATH];
|
||||||
|
} RomEntry;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
struct gb_s gb;
|
||||||
|
uint8_t* rom_data;
|
||||||
|
size_t rom_size;
|
||||||
|
uint8_t* cart_ram;
|
||||||
|
size_t cart_ram_size;
|
||||||
|
char rom_path[MAX_PATH];
|
||||||
|
char rom_title[32];
|
||||||
|
uint8_t joypad_state;
|
||||||
|
|
||||||
|
uint16_t* fb_native; /* RGB565 tightly packed 160*144, raw u16 avoids lv_color16_t bitfield */
|
||||||
|
lv_draw_buf_t* fb_draw_buf; /* draw_buf that canvas src points to – owned by us so we can invalidate cache */
|
||||||
|
lv_obj_t* canvas;
|
||||||
|
lv_obj_t* root_wrapper;
|
||||||
|
lv_obj_t* browser_wrapper;
|
||||||
|
lv_obj_t* emu_wrapper;
|
||||||
|
lv_obj_t* toolbar;
|
||||||
|
lv_obj_t* status_label;
|
||||||
|
lv_obj_t* rom_list;
|
||||||
|
lv_obj_t* controls_cont;
|
||||||
|
lv_timer_t* emu_timer;
|
||||||
|
|
||||||
|
RomEntry roms[MAX_ROMS];
|
||||||
|
int rom_count;
|
||||||
|
int selected_rom_idx;
|
||||||
|
|
||||||
|
lv_obj_t* btn_up;
|
||||||
|
lv_obj_t* btn_down;
|
||||||
|
lv_obj_t* btn_left;
|
||||||
|
lv_obj_t* btn_right;
|
||||||
|
lv_obj_t* btn_a;
|
||||||
|
lv_obj_t* btn_b;
|
||||||
|
lv_obj_t* btn_start;
|
||||||
|
lv_obj_t* btn_select;
|
||||||
|
|
||||||
|
AppHandle app_handle;
|
||||||
|
AppMode mode;
|
||||||
|
bool emu_running;
|
||||||
|
bool framebuffer_allocated;
|
||||||
|
bool rom_loaded;
|
||||||
|
uint32_t fps_frames;
|
||||||
|
int64_t fps_last_us;
|
||||||
|
uint32_t lines_drawn; /* debug: should be 144 per frame */
|
||||||
|
} AppCtx;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
AppCtx* ctx;
|
||||||
|
uint8_t joypad_bit;
|
||||||
|
} BtnUserData;
|
||||||
|
|
||||||
|
/* PSRAM helpers */
|
||||||
|
static void* alloc_psram(size_t size) {
|
||||||
|
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
|
||||||
|
if (!p) p = malloc(size);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peanut-GB callbacks */
|
||||||
|
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
|
||||||
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
|
if (!ctx || !ctx->rom_data) return 0xFF;
|
||||||
|
if (addr < ctx->rom_size) return ctx->rom_data[addr];
|
||||||
|
return 0xFF;
|
||||||
|
}
|
||||||
|
static uint8_t gb_cart_ram_read(struct gb_s* gb, const uint_fast32_t addr) {
|
||||||
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
|
if (!ctx || !ctx->cart_ram) return 0xFF;
|
||||||
|
if (addr < ctx->cart_ram_size) return ctx->cart_ram[addr];
|
||||||
|
return 0xFF;
|
||||||
|
}
|
||||||
|
static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const uint8_t val) {
|
||||||
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
|
if (!ctx || !ctx->cart_ram) return;
|
||||||
|
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
|
||||||
|
}
|
||||||
|
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) {
|
||||||
|
const char* err_str = "UNKNOWN";
|
||||||
|
switch (err) {
|
||||||
|
case GB_INVALID_OPCODE: err_str = "INVALID OPCODE"; break;
|
||||||
|
case GB_INVALID_READ: err_str = "INVALID READ"; break;
|
||||||
|
case GB_INVALID_WRITE: err_str = "INVALID WRITE"; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
ESP_LOGE(TAG, "GB error %s (%d) @ %04X", err_str, err, addr);
|
||||||
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
|
if (ctx && ctx->cart_ram_size) {
|
||||||
|
char sp[MAX_PATH];
|
||||||
|
strncpy(sp, ctx->rom_path, MAX_PATH-1); sp[MAX_PATH-1]='\0';
|
||||||
|
char* dot=strrchr(sp,'.'); char* sl=strrchr(sp,'/');
|
||||||
|
if (dot && (!sl || dot>sl)) snprintf(dot, MAX_PATH-(dot-sp), ".sav"); else strncat(sp,".sav",MAX_PATH-strlen(sp)-1);
|
||||||
|
FILE* f=fopen(sp,"wb"); if(f){fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
|
||||||
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
|
if (!ctx || !ctx->fb_native) return;
|
||||||
|
if (line >= FRAME_H) return;
|
||||||
|
uint16_t* dst = &ctx->fb_native[line * FRAME_W];
|
||||||
|
for (int x=0;x<FRAME_W;x++) {
|
||||||
|
uint8_t shade = pixels[x] & 0x03;
|
||||||
|
dst[x] = GB_PALETTE[shade];
|
||||||
|
}
|
||||||
|
ctx->lines_drawn++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Save path */
|
||||||
|
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
|
||||||
|
if (!rom_path || !out) return;
|
||||||
|
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
|
||||||
|
char* dot=strrchr(out,'.'); char* sl=strrchr(out,'/');
|
||||||
|
if (dot && (!sl || dot>sl)) { snprintf(dot, out_len-(dot-out), ".sav"); }
|
||||||
|
else { strncat(out,".sav",out_len-strlen(out)-1); }
|
||||||
|
}
|
||||||
|
static void load_cart_ram(AppCtx* ctx) {
|
||||||
|
if (!ctx || !ctx->rom_path[0]) return;
|
||||||
|
char save_path[MAX_PATH];
|
||||||
|
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||||
|
size_t save_sz=0;
|
||||||
|
if (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
|
||||||
|
if (save_sz==0) { ESP_LOGI(TAG,"No save RAM"); return; }
|
||||||
|
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
|
||||||
|
ctx->cart_ram_size=save_sz;
|
||||||
|
ctx->cart_ram=alloc_psram(save_sz);
|
||||||
|
if (!ctx->cart_ram){ ESP_LOGE(TAG,"cart RAM alloc fail %zu",save_sz); ctx->cart_ram_size=0; return; }
|
||||||
|
memset(ctx->cart_ram,0,save_sz);
|
||||||
|
FILE* f=fopen(save_path,"rb");
|
||||||
|
if (!f){ ESP_LOGI(TAG,"No save %s",save_path); return; }
|
||||||
|
size_t r=fread(ctx->cart_ram,1,save_sz,f); fclose(f);
|
||||||
|
ESP_LOGI(TAG,"Loaded save %s %zu/%zu",save_path,r,save_sz);
|
||||||
|
}
|
||||||
|
static void save_cart_ram(AppCtx* ctx) {
|
||||||
|
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
|
||||||
|
if (!ctx->rom_path[0]) return;
|
||||||
|
char save_path[MAX_PATH];
|
||||||
|
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||||
|
FILE* f=fopen(save_path,"wb");
|
||||||
|
if (!f){ ESP_LOGE(TAG,"Save open fail %s",save_path); return; }
|
||||||
|
size_t w=fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
|
||||||
|
ESP_LOGI(TAG,"Saved RAM %s %zu",save_path,w);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ROM loading */
|
||||||
|
static bool load_rom_file(AppCtx* ctx, const char* path) {
|
||||||
|
if (!ctx || !path) return false;
|
||||||
|
ESP_LOGI(TAG,"Loading ROM %s",path);
|
||||||
|
FILE* f=fopen(path,"rb");
|
||||||
|
if (!f){ ESP_LOGE(TAG,"Open fail %s",path); return false; }
|
||||||
|
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
|
||||||
|
if (sz<=0 || sz>MAX_ROM_SIZE){ ESP_LOGE(TAG,"Bad size %ld %s",sz,path); fclose(f); return false; }
|
||||||
|
uint8_t* buf=alloc_psram((size_t)sz);
|
||||||
|
if (!buf){ ESP_LOGE(TAG,"Alloc fail %ld",sz); fclose(f); return false; }
|
||||||
|
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
|
||||||
|
if (read!=(size_t)sz){ ESP_LOGE(TAG,"Short read %zu vs %ld",read,sz); heap_caps_free(buf); return false; }
|
||||||
|
|
||||||
|
if (ctx->rom_data) { if (ctx->cart_ram) save_cart_ram(ctx); heap_caps_free(ctx->rom_data); }
|
||||||
|
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
|
||||||
|
|
||||||
|
ctx->rom_data=buf; ctx->rom_size=(size_t)sz;
|
||||||
|
strncpy(ctx->rom_path,path,sizeof(ctx->rom_path)-1); ctx->rom_path[sizeof(ctx->rom_path)-1]='\0';
|
||||||
|
memset(&ctx->gb,0,sizeof(ctx->gb));
|
||||||
|
ctx->joypad_state=0xFF;
|
||||||
|
enum gb_init_error_e err=gb_init(&ctx->gb, gb_rom_read, gb_cart_ram_read, gb_cart_ram_write, gb_error_handler, ctx);
|
||||||
|
if (err!=GB_INIT_NO_ERROR){ ESP_LOGE(TAG,"gb_init %d",err); heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
|
||||||
|
gb_init_lcd(&ctx->gb, lcd_draw_line);
|
||||||
|
load_cart_ram(ctx);
|
||||||
|
gb_reset(&ctx->gb);
|
||||||
|
char title[32]={0}; gb_get_rom_name(&ctx->gb, title); strncpy(ctx->rom_title,title,sizeof(ctx->rom_title)-1);
|
||||||
|
ESP_LOGI(TAG,"ROM OK title='%s' size=%zu save=%zu",ctx->rom_title,ctx->rom_size,ctx->cart_ram_size);
|
||||||
|
ctx->rom_loaded=true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
static void scan_rom_dir(AppCtx* ctx) {
|
||||||
|
ctx->rom_count=0;
|
||||||
|
DIR* dir=opendir(ROMS_DIR);
|
||||||
|
if (!dir){ ESP_LOGW(TAG,"ROMS dir missing %s",ROMS_DIR); return; }
|
||||||
|
struct dirent* ent;
|
||||||
|
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
|
||||||
|
if (ent->d_name[0]=='.') continue;
|
||||||
|
size_t len=strlen(ent->d_name);
|
||||||
|
if (len<3) continue;
|
||||||
|
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
|
||||||
|
if (!is_gb) continue;
|
||||||
|
RomEntry* e=&ctx->roms[ctx->rom_count++];
|
||||||
|
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
|
||||||
|
snprintf(e->fullpath, sizeof(e->fullpath), "%s/%s", ROMS_DIR, ent->d_name);
|
||||||
|
}
|
||||||
|
closedir(dir);
|
||||||
|
ESP_LOGI(TAG,"Found %d ROMs",ctx->rom_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emu timer – THIS IS WHERE "FPS but no image" WAS: missing cache drop */
|
||||||
|
static void emu_timer_cb(lv_timer_t* timer) {
|
||||||
|
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
|
||||||
|
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
|
||||||
|
ctx->lines_drawn = 0;
|
||||||
|
ctx->gb.direct.joypad = ctx->joypad_state;
|
||||||
|
gb_run_frame(&ctx->gb);
|
||||||
|
|
||||||
|
/* Critical fix: raw buffer mutated, LVGL image cache is stale.
|
||||||
|
Without this, canvas shows whatever was first uploaded (gray/black) and FPS label keeps updating,
|
||||||
|
giving "FPS but no game". */
|
||||||
|
if (ctx->fb_draw_buf) {
|
||||||
|
/* Invalidate both D-Cache (PSRAM) and LVGL image cache */
|
||||||
|
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
||||||
|
lv_image_cache_drop(ctx->fb_draw_buf);
|
||||||
|
/* Also invalidate area via the canvas src buf if different object */
|
||||||
|
if (ctx->canvas) {
|
||||||
|
lv_draw_buf_t* c_db = lv_canvas_get_draw_buf(ctx->canvas);
|
||||||
|
if (c_db && c_db != ctx->fb_draw_buf) {
|
||||||
|
lv_draw_buf_invalidate_cache(c_db, NULL);
|
||||||
|
lv_image_cache_drop(c_db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lv_obj_invalidate(ctx->canvas);
|
||||||
|
|
||||||
|
ctx->fps_frames++;
|
||||||
|
int64_t now = esp_timer_get_time();
|
||||||
|
if (ctx->fps_last_us == 0) ctx->fps_last_us = now;
|
||||||
|
int64_t elapsed = now - ctx->fps_last_us;
|
||||||
|
if (elapsed >= 1000000) {
|
||||||
|
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
|
||||||
|
if (ctx->status_label) {
|
||||||
|
lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||||
|
}
|
||||||
|
printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||||
|
ctx->fps_frames = 0;
|
||||||
|
ctx->fps_last_us = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input helpers */
|
||||||
|
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
|
||||||
|
if (!ctx) return;
|
||||||
|
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
|
||||||
|
else ctx->joypad_state |= bit;
|
||||||
|
}
|
||||||
|
static void input_down_cb(lv_event_t* e){
|
||||||
|
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
|
||||||
|
if (!ud) return;
|
||||||
|
set_joypad_bit(ud->ctx, ud->joypad_bit, true);
|
||||||
|
}
|
||||||
|
static void input_up_cb(lv_event_t* e){
|
||||||
|
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
|
||||||
|
if (!ud) return;
|
||||||
|
set_joypad_bit(ud->ctx, ud->joypad_bit, false);
|
||||||
|
}
|
||||||
|
static void key_press_cb(lv_event_t* e){
|
||||||
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
|
uint32_t key=lv_event_get_key(e);
|
||||||
|
switch(key){
|
||||||
|
case LV_KEY_UP: set_joypad_bit(ctx, JOYPAD_UP, true); break;
|
||||||
|
case LV_KEY_DOWN: set_joypad_bit(ctx, JOYPAD_DOWN, true); break;
|
||||||
|
case LV_KEY_LEFT: set_joypad_bit(ctx, JOYPAD_LEFT, true); break;
|
||||||
|
case LV_KEY_RIGHT: set_joypad_bit(ctx, JOYPAD_RIGHT, true); break;
|
||||||
|
case LV_KEY_ENTER: set_joypad_bit(ctx, JOYPAD_START, true); break;
|
||||||
|
case LV_KEY_ESC: set_joypad_bit(ctx, JOYPAD_SELECT, true); break;
|
||||||
|
default: {
|
||||||
|
if (key== (uint32_t)'z' || key== (uint32_t)'Z') set_joypad_bit(ctx, JOYPAD_A, true);
|
||||||
|
else if (key== (uint32_t)'x' || key== (uint32_t)'X') set_joypad_bit(ctx, JOYPAD_B, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forward declarations for UI switching */
|
||||||
|
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||||
|
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||||
|
static void switch_to_browser(AppCtx* ctx);
|
||||||
|
static void switch_to_emu(AppCtx* ctx);
|
||||||
|
|
||||||
|
/* ROM selection events */
|
||||||
|
static void rom_button_event(lv_event_t* e){
|
||||||
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
|
lv_obj_t* btn=lv_event_get_target_obj(e);
|
||||||
|
int* pIdx=(int*)lv_obj_get_user_data(btn);
|
||||||
|
if (!pIdx) return;
|
||||||
|
ctx->selected_rom_idx=*pIdx;
|
||||||
|
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
|
||||||
|
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
|
||||||
|
if (load_rom_file(ctx, path)){
|
||||||
|
switch_to_emu(ctx);
|
||||||
|
} else {
|
||||||
|
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void default_rom_event(lv_event_t* e){
|
||||||
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
|
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
||||||
|
switch_to_emu(ctx);
|
||||||
|
} else {
|
||||||
|
if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found in /data/roms/gb/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void back_to_menu_event(lv_event_t* e){
|
||||||
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
|
switch_to_browser(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UI builders */
|
||||||
|
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||||
|
lv_obj_clean(parent);
|
||||||
|
ctx->rom_list=NULL;
|
||||||
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_style_pad_all(parent, 4, 0);
|
||||||
|
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
||||||
|
|
||||||
|
lv_obj_t* info=lv_label_create(parent);
|
||||||
|
lv_label_set_text_fmt(info, "GameBoy (no audio) - Peanut-GB\nPlace ROMs in %s\nDefault: %s\nFound %d ROMs", ROMS_DIR, DEFAULT_ROM_PATH, ctx->rom_count);
|
||||||
|
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_width(info, LV_PCT(100));
|
||||||
|
lv_obj_set_style_text_color(info, lv_color_white(), 0);
|
||||||
|
|
||||||
|
ctx->status_label=lv_label_create(parent);
|
||||||
|
lv_label_set_text(ctx->status_label, "Select a ROM to start");
|
||||||
|
lv_obj_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
|
||||||
|
|
||||||
|
lv_obj_t* list=lv_list_create(parent);
|
||||||
|
lv_obj_set_width(list, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(list, 1);
|
||||||
|
ctx->rom_list=list;
|
||||||
|
|
||||||
|
if (ctx->rom_count==0){
|
||||||
|
lv_obj_t* lbl=lv_label_create(parent);
|
||||||
|
lv_label_set_text(lbl, "No ROMs found in /data/roms/gb\nPut .gb files there.");
|
||||||
|
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
|
||||||
|
} else {
|
||||||
|
for (int i=0;i<ctx->rom_count;i++){
|
||||||
|
lv_obj_t* btn=lv_list_add_btn(list, LV_SYMBOL_FILE, ctx->roms[i].filename);
|
||||||
|
int* idx=(int*)malloc(sizeof(int)); *idx=i;
|
||||||
|
lv_obj_set_user_data(btn, idx);
|
||||||
|
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* default_btn=lv_btn_create(parent);
|
||||||
|
lv_obj_set_width(default_btn, LV_PCT(100));
|
||||||
|
lv_obj_t* dlbl=lv_label_create(default_btn);
|
||||||
|
lv_label_set_text(dlbl, "Try default.gb");
|
||||||
|
lv_obj_center(dlbl);
|
||||||
|
lv_obj_add_event_cb(default_btn, default_rom_event, LV_EVENT_CLICKED, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||||
|
lv_obj_clean(parent);
|
||||||
|
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
||||||
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_style_pad_all(parent, 0, 0);
|
||||||
|
lv_obj_set_style_pad_row(parent, 0, 0);
|
||||||
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* info_bar=lv_obj_create(parent);
|
||||||
|
lv_obj_set_width(info_bar, LV_PCT(100));
|
||||||
|
lv_obj_set_height(info_bar, LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_pad_all(info_bar, 2, 0);
|
||||||
|
lv_obj_set_style_border_width(info_bar, 0, 0);
|
||||||
|
lv_obj_set_flex_flow(info_bar, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_style_bg_color(info_bar, lv_color_hex(0x222222), 0);
|
||||||
|
|
||||||
|
lv_obj_t* title_lbl=lv_label_create(info_bar);
|
||||||
|
ctx->status_label = title_lbl;
|
||||||
|
lv_label_set_text_fmt(title_lbl, "GB: %s FPS:--", ctx->rom_title[0]?ctx->rom_title:"GameBoy");
|
||||||
|
lv_obj_set_style_text_color(title_lbl, lv_color_white(), 0);
|
||||||
|
|
||||||
|
lv_obj_t* spacer=lv_obj_create(info_bar);
|
||||||
|
lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_border_width(spacer,0,0);
|
||||||
|
lv_obj_set_flex_grow(spacer,1);
|
||||||
|
|
||||||
|
lv_obj_t* back_btn=lv_btn_create(info_bar);
|
||||||
|
lv_obj_set_size(back_btn, 60, 28);
|
||||||
|
lv_obj_t* bl=lv_label_create(back_btn); lv_label_set_text(bl,"Menu"); lv_obj_center(bl);
|
||||||
|
lv_obj_add_event_cb(back_btn, back_to_menu_event, LV_EVENT_CLICKED, ctx);
|
||||||
|
|
||||||
|
lv_obj_t* canvas_cont=lv_obj_create(parent);
|
||||||
|
lv_obj_set_width(canvas_cont, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(canvas_cont,1);
|
||||||
|
lv_obj_set_style_bg_color(canvas_cont, lv_color_black(),0);
|
||||||
|
lv_obj_set_style_border_width(canvas_cont,0,0);
|
||||||
|
lv_obj_set_style_pad_all(canvas_cont,2,0);
|
||||||
|
lv_obj_set_flex_flow(canvas_cont, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
if (!ctx->fb_native){
|
||||||
|
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t);
|
||||||
|
ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes);
|
||||||
|
if (ctx->fb_native){
|
||||||
|
ctx->framebuffer_allocated=true;
|
||||||
|
memset(ctx->fb_native,0,fb_bytes);
|
||||||
|
/* Initial grey fill so we can visually confirm buffer ownership even before first gb_run_frame */
|
||||||
|
for(int i=0;i<FRAME_W*FRAME_H;i++) ctx->fb_native[i]=0x4208;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx->fb_native){
|
||||||
|
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
|
||||||
|
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
|
||||||
|
int avail_h = disp_h - 160;
|
||||||
|
int avail_w = disp_w - 8;
|
||||||
|
int scale = 1;
|
||||||
|
if (avail_w >= FRAME_W * 3 && avail_h >= FRAME_H * 3) scale = 3;
|
||||||
|
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
|
||||||
|
|
||||||
|
ctx->canvas = lv_canvas_create(canvas_cont);
|
||||||
|
/* lv_canvas_set_buffer creates internal static_buf header from our raw ptr */
|
||||||
|
lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565);
|
||||||
|
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
|
||||||
|
if (ctx->fb_draw_buf && ctx->fb_draw_buf->data) {
|
||||||
|
/* Force fresh cache state */
|
||||||
|
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
||||||
|
lv_image_cache_drop(ctx->fb_draw_buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scale > 1) {
|
||||||
|
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
|
||||||
|
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
|
||||||
|
}
|
||||||
|
lv_obj_set_style_border_width(ctx->canvas, 1, 0);
|
||||||
|
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x444444), 0);
|
||||||
|
lv_obj_center(ctx->canvas);
|
||||||
|
} else {
|
||||||
|
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0);
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* ctrl=lv_obj_create(parent);
|
||||||
|
ctx->controls_cont=ctrl;
|
||||||
|
lv_obj_set_width(ctrl, LV_PCT(100));
|
||||||
|
lv_obj_set_height(ctrl, 110);
|
||||||
|
lv_obj_set_style_pad_all(ctrl,2,0);
|
||||||
|
lv_obj_set_style_bg_color(ctrl, lv_color_hex(0x111111),0);
|
||||||
|
lv_obj_set_style_border_width(ctrl,0,0);
|
||||||
|
lv_obj_set_flex_flow(ctrl, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(ctrl, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
|
||||||
|
static BtnUserData btn_ud[8];
|
||||||
|
static bool ud_init=false;
|
||||||
|
if (!ud_init){ memset(btn_ud,0,sizeof(btn_ud)); ud_init=true; }
|
||||||
|
|
||||||
|
lv_obj_t* dpad=lv_obj_create(ctrl);
|
||||||
|
lv_obj_set_size(dpad,96,96);
|
||||||
|
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_border_width(dpad,0,0);
|
||||||
|
lv_obj_set_style_pad_all(dpad,0,0);
|
||||||
|
|
||||||
|
lv_obj_t* up=lv_btn_create(dpad); lv_obj_set_size(up,32,32); lv_obj_set_pos(up,32,0);
|
||||||
|
lv_obj_t* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
|
||||||
|
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
|
||||||
|
lv_obj_t* down=lv_btn_create(dpad); lv_obj_set_size(down,32,32); lv_obj_set_pos(down,32,64);
|
||||||
|
lv_obj_t* lbl; lbl=lv_label_create(up); lv_label_set_text(lbl, LV_SYMBOL_UP); lv_obj_center(lbl);
|
||||||
|
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); lv_obj_center(lbl);
|
||||||
|
lbl=lv_label_create(left); lv_label_set_text(lbl, LV_SYMBOL_LEFT); lv_obj_center(lbl);
|
||||||
|
lbl=lv_label_create(right); lv_label_set_text(lbl, LV_SYMBOL_RIGHT); lv_obj_center(lbl);
|
||||||
|
|
||||||
|
ctx->btn_up=up; ctx->btn_down=down; ctx->btn_left=left; ctx->btn_right=right;
|
||||||
|
btn_ud[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
|
||||||
|
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]);
|
||||||
|
btn_ud[2].ctx=ctx; btn_ud[2].joypad_bit=JOYPAD_LEFT; lv_obj_add_event_cb(left, input_down_cb, LV_EVENT_PRESSED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_RELEASED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[2]);
|
||||||
|
btn_ud[3].ctx=ctx; btn_ud[3].joypad_bit=JOYPAD_RIGHT;lv_obj_add_event_cb(right, input_down_cb, LV_EVENT_PRESSED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_RELEASED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[3]);
|
||||||
|
|
||||||
|
lv_obj_t* center_col=lv_obj_create(ctrl);
|
||||||
|
lv_obj_set_size(center_col,64,96);
|
||||||
|
lv_obj_set_style_bg_opa(center_col,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_border_width(center_col,0,0);
|
||||||
|
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_pad_row(center_col,4,0);
|
||||||
|
|
||||||
|
lv_obj_t* sel_btn=lv_btn_create(center_col); lv_obj_set_size(sel_btn,60,28); lbl=lv_label_create(sel_btn); lv_label_set_text(lbl,"Sel"); lv_obj_center(lbl);
|
||||||
|
lv_obj_t* sta_btn=lv_btn_create(center_col); lv_obj_set_size(sta_btn,60,28); lbl=lv_label_create(sta_btn); lv_label_set_text(lbl,"Sta"); lv_obj_center(lbl);
|
||||||
|
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
|
||||||
|
btn_ud[4].ctx=ctx; btn_ud[4].joypad_bit=JOYPAD_SELECT; lv_obj_add_event_cb(sel_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[4]);
|
||||||
|
btn_ud[5].ctx=ctx; btn_ud[5].joypad_bit=JOYPAD_START; lv_obj_add_event_cb(sta_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[5]);
|
||||||
|
|
||||||
|
lv_obj_t* ab=lv_obj_create(ctrl);
|
||||||
|
lv_obj_set_size(ab,96,96);
|
||||||
|
lv_obj_set_style_bg_opa(ab,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_border_width(ab,0,0);
|
||||||
|
lv_obj_set_style_pad_all(ab,0,0);
|
||||||
|
lv_obj_t* b_btn=lv_btn_create(ab); lv_obj_set_size(b_btn,40,40); lv_obj_set_pos(b_btn,0,24); lv_obj_set_style_radius(b_btn,20,0);
|
||||||
|
lv_obj_t* a_btn=lv_btn_create(ab); lv_obj_set_size(a_btn,40,40); lv_obj_set_pos(a_btn,48,8); lv_obj_set_style_radius(a_btn,20,0);
|
||||||
|
lbl=lv_label_create(b_btn); lv_label_set_text(lbl,"B"); lv_obj_center(lbl);
|
||||||
|
lbl=lv_label_create(a_btn); lv_label_set_text(lbl,"A"); lv_obj_center(lbl);
|
||||||
|
ctx->btn_a=a_btn; ctx->btn_b=b_btn;
|
||||||
|
btn_ud[6].ctx=ctx; btn_ud[6].joypad_bit=JOYPAD_B; lv_obj_add_event_cb(b_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[6]);
|
||||||
|
btn_ud[7].ctx=ctx; btn_ud[7].joypad_bit=JOYPAD_A; lv_obj_add_event_cb(a_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[7]);
|
||||||
|
|
||||||
|
lv_obj_add_event_cb(parent, key_press_cb, LV_EVENT_KEY, ctx);
|
||||||
|
if (tt_lvgl_hardware_keyboard_is_available()){
|
||||||
|
lv_group_t* g=lv_group_get_default();
|
||||||
|
if (g){ lv_group_add_obj(g, parent); lv_group_focus_obj(parent); lv_group_set_editing(g, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
|
||||||
|
ctx->fps_frames = 0;
|
||||||
|
ctx->fps_last_us = esp_timer_get_time();
|
||||||
|
ctx->lines_drawn = 0;
|
||||||
|
ctx->emu_timer=lv_timer_create(emu_timer_cb, TICK_MS, ctx);
|
||||||
|
ctx->emu_running=true;
|
||||||
|
ctx->mode=APP_MODE_EMU;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void switch_to_browser(AppCtx* ctx){
|
||||||
|
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
|
||||||
|
ctx->emu_running=false;
|
||||||
|
save_cart_ram(ctx);
|
||||||
|
ctx->mode=APP_MODE_BROWSER;
|
||||||
|
if (!ctx->browser_wrapper || !lv_obj_is_valid(ctx->browser_wrapper)) return;
|
||||||
|
if (ctx->emu_wrapper) lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
scan_rom_dir(ctx);
|
||||||
|
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||||
|
}
|
||||||
|
static void switch_to_emu(AppCtx* ctx){
|
||||||
|
if (!ctx->rom_loaded) return;
|
||||||
|
if (ctx->browser_wrapper) lv_obj_add_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (!ctx->emu_wrapper) return;
|
||||||
|
lv_obj_remove_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
build_emu_ui(ctx, ctx->emu_wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lifecycle */
|
||||||
|
static void* create_data(void){
|
||||||
|
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
|
||||||
|
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
static void destroy_data(void* data){
|
||||||
|
AppCtx* ctx=(AppCtx*)data;
|
||||||
|
if (!ctx) return;
|
||||||
|
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
|
||||||
|
if (ctx->fb_native) heap_caps_free(ctx->fb_native);
|
||||||
|
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
|
||||||
|
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
|
||||||
|
free(ctx);
|
||||||
|
}
|
||||||
|
static void on_create(AppHandle app, void* data){
|
||||||
|
AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app;
|
||||||
|
}
|
||||||
|
static void on_destroy(AppHandle app, void* data){ (void)app; (void)data; }
|
||||||
|
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
||||||
|
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
||||||
|
ctx->app_handle=app;
|
||||||
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_style_pad_all(parent,0,0);
|
||||||
|
lv_obj_set_style_pad_row(parent,0,0);
|
||||||
|
lv_obj_set_style_bg_color(parent, lv_color_black(),0);
|
||||||
|
ctx->toolbar=tt_lvgl_toolbar_create_for_app(parent, app);
|
||||||
|
lv_obj_set_style_bg_color(ctx->toolbar, lv_color_hex(0x111111),0);
|
||||||
|
|
||||||
|
ctx->root_wrapper=lv_obj_create(parent);
|
||||||
|
lv_obj_set_width(ctx->root_wrapper, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(ctx->root_wrapper,1);
|
||||||
|
lv_obj_set_style_pad_all(ctx->root_wrapper,0,0);
|
||||||
|
lv_obj_set_style_border_width(ctx->root_wrapper,0,0);
|
||||||
|
lv_obj_set_style_bg_color(ctx->root_wrapper, lv_color_black(),0);
|
||||||
|
lv_obj_set_flex_flow(ctx->root_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_remove_flag(ctx->root_wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
ctx->browser_wrapper=lv_obj_create(ctx->root_wrapper);
|
||||||
|
lv_obj_set_width(ctx->browser_wrapper, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(ctx->browser_wrapper,1);
|
||||||
|
lv_obj_set_style_pad_all(ctx->browser_wrapper,0,0);
|
||||||
|
lv_obj_set_style_border_width(ctx->browser_wrapper,0,0);
|
||||||
|
|
||||||
|
ctx->emu_wrapper=lv_obj_create(ctx->root_wrapper);
|
||||||
|
lv_obj_set_width(ctx->emu_wrapper, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(ctx->emu_wrapper,1);
|
||||||
|
lv_obj_set_style_pad_all(ctx->emu_wrapper,0,0);
|
||||||
|
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
|
||||||
|
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
|
scan_rom_dir(ctx);
|
||||||
|
struct stat st;
|
||||||
|
bool default_exists=(stat(DEFAULT_ROM_PATH,&st)==0);
|
||||||
|
if (default_exists){
|
||||||
|
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
||||||
|
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||||
|
switch_to_emu(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||||
|
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
ctx->mode=APP_MODE_BROWSER;
|
||||||
|
}
|
||||||
|
static void on_hide(AppHandle app, void* data){
|
||||||
|
(void)app;
|
||||||
|
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
||||||
|
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
|
||||||
|
ctx->emu_running=false;
|
||||||
|
if (ctx->rom_loaded) save_cart_ram(ctx);
|
||||||
|
ctx->canvas=NULL; ctx->fb_draw_buf=NULL; ctx->toolbar=NULL; ctx->root_wrapper=NULL; ctx->browser_wrapper=NULL; ctx->emu_wrapper=NULL;
|
||||||
|
ctx->status_label=NULL; ctx->rom_list=NULL; ctx->controls_cont=NULL;
|
||||||
|
ctx->btn_up=ctx->btn_down=ctx->btn_left=ctx->btn_right=NULL;
|
||||||
|
ctx->btn_a=ctx->btn_b=ctx->btn_start=ctx->btn_select=NULL;
|
||||||
|
ctx->app_handle=NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]){
|
||||||
|
(void)argc; (void)argv;
|
||||||
|
tt_app_register((AppRegistration){
|
||||||
|
.createData=create_data,
|
||||||
|
.destroyData=destroy_data,
|
||||||
|
.onCreate=on_create,
|
||||||
|
.onDestroy=on_destroy,
|
||||||
|
.onShow=on_show,
|
||||||
|
.onHide=on_hide
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[manifest]
|
||||||
|
version=0.1
|
||||||
|
[target]
|
||||||
|
sdk=0.8.0-dev
|
||||||
|
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
|
[app]
|
||||||
|
id=one.tactility.gameboy
|
||||||
|
versionName=0.1.0-dev
|
||||||
|
versionCode=1
|
||||||
|
name=GameBoy
|
||||||
|
description=DMG Game Boy emulator (no audio prototype, Peanut-GB)
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.graphicsdemo
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.graphicsdemo
|
app.name=Graphics Demo
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Graphics Demo
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.helloworld
|
||||||
platforms=esp32s3
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.helloworld
|
app.name=Hello World
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Hello World
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.m5unittest
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.4.0
|
||||||
[app]
|
app.version.code=4
|
||||||
id=one.tactility.m5unittest
|
app.name=M5 Unit Test
|
||||||
versionName=0.1.0
|
|
||||||
versionCode=1
|
|
||||||
name=M5 Unit Test
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.magic8ball
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.5.0
|
||||||
[app]
|
app.version.code=5
|
||||||
id=one.tactility.magic8ball
|
app.name=Magic 8-Ball
|
||||||
versionName=0.2.0
|
|
||||||
versionCode=2
|
|
||||||
name=Magic 8-Ball
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.mcpscreen
|
||||||
platforms=esp32s3
|
app.version.name=0.1.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.mcpscreen
|
app.name=MCP Screen
|
||||||
versionName=0.1.0
|
|
||||||
versionCode=1
|
|
||||||
name=MCP Screen
|
|
||||||
|
|||||||
@@ -211,12 +211,29 @@ void MediaKeys::startHid() {
|
|||||||
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
|
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MediaKeys::teardownBt() {
|
||||||
|
// Remove callback FIRST - stops any in-flight BT events from firing against
|
||||||
|
// our (possibly already freed) UI widget pointers after this returns.
|
||||||
|
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
||||||
|
// Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() /
|
||||||
|
// ble_gatts_start() which corrupts NimBLE heap while the host task is still
|
||||||
|
// running. HID device is a persistent kernel device; hid_device_start() cleans
|
||||||
|
// up stale context on next use. Explicit stop is handled by handleSwitchToggle.
|
||||||
|
// Restore the radio/device to the state we found them in.
|
||||||
|
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
||||||
|
if (_btDevice && _deviceWasStarted) device_stop(_btDevice);
|
||||||
|
_btDevice = nullptr;
|
||||||
|
_hidDevice = nullptr;
|
||||||
|
_radioWasOff = false;
|
||||||
|
_deviceWasStarted = false;
|
||||||
|
}
|
||||||
|
|
||||||
void MediaKeys::handleSwitchToggle(bool enabled) {
|
void MediaKeys::handleSwitchToggle(bool enabled) {
|
||||||
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
|
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
|
||||||
_isEnabled = enabled;
|
_isEnabled = enabled;
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
_btDevice = bluetooth_find_first_ready_device();
|
_btDevice = device_find_first_by_type(&BLUETOOTH_TYPE);
|
||||||
if (!_btDevice) {
|
if (!_btDevice) {
|
||||||
LOG_E(TAG, "No Bluetooth device found");
|
LOG_E(TAG, "No Bluetooth device found");
|
||||||
_isEnabled = false;
|
_isEnabled = false;
|
||||||
@@ -224,6 +241,19 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device may not be started yet (BT disabled in DTS by default to save memory).
|
||||||
|
if (!device_is_ready(_btDevice)) {
|
||||||
|
LOG_I(TAG, "BT device not started, starting now");
|
||||||
|
if (device_start(_btDevice) != ERROR_NONE) {
|
||||||
|
LOG_E(TAG, "Failed to start BT device");
|
||||||
|
_btDevice = nullptr;
|
||||||
|
_isEnabled = false;
|
||||||
|
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_deviceWasStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
|
bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
|
||||||
|
|
||||||
// Register callback before enabling radio so we don't miss the state-change event.
|
// Register callback before enabling radio so we don't miss the state-change event.
|
||||||
@@ -247,12 +277,10 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
|
|||||||
} else {
|
} else {
|
||||||
_radioEnabling = false;
|
_radioEnabling = false;
|
||||||
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
||||||
|
// Explicit user toggle-off: stop HID cleanly (safe here since we're on the
|
||||||
|
// LVGL task and the user intentionally disabled, so no race with app teardown).
|
||||||
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
|
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
|
||||||
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
teardownBt();
|
||||||
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
|
||||||
_radioWasOff = false;
|
|
||||||
_btDevice = nullptr;
|
|
||||||
_hidDevice = nullptr;
|
|
||||||
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,19 +349,23 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
|
// Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app).
|
||||||
|
struct Device* btDev = device_find_first_by_type(&BLUETOOTH_TYPE);
|
||||||
|
if (btDev && device_is_ready(btDev)) {
|
||||||
|
enum BtRadioState radioState;
|
||||||
|
if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) {
|
||||||
|
lv_obj_add_state(_switchWidget, LV_STATE_CHECKED);
|
||||||
|
handleSwitchToggle(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MediaKeys::onHide(AppHandle /*appHandle*/) {
|
void MediaKeys::onHide(AppHandle /*appHandle*/) {
|
||||||
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
|
|
||||||
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
|
||||||
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
|
||||||
_btDevice = nullptr;
|
|
||||||
_hidDevice = nullptr;
|
|
||||||
_isEnabled = false;
|
|
||||||
_radioEnabling = false;
|
_radioEnabling = false;
|
||||||
_radioWasOff = false;
|
_isEnabled = false;
|
||||||
|
|
||||||
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
||||||
|
teardownBt();
|
||||||
if (_keyHighlightTimer) {
|
if (_keyHighlightTimer) {
|
||||||
lv_timer_delete(_keyHighlightTimer);
|
lv_timer_delete(_keyHighlightTimer);
|
||||||
_keyHighlightTimer = nullptr;
|
_keyHighlightTimer = nullptr;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <TactilityCpp/App.h>
|
#include <TactilityCpp/App.h>
|
||||||
#include <lvgl.h>
|
#include <lvgl.h>
|
||||||
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/bluetooth.h>
|
#include <tactility/drivers/bluetooth.h>
|
||||||
#include <tactility/drivers/bluetooth_hid_device.h>
|
#include <tactility/drivers/bluetooth_hid_device.h>
|
||||||
#include <tt_app.h>
|
#include <tt_app.h>
|
||||||
@@ -24,8 +25,9 @@ class MediaKeys final : public App {
|
|||||||
|
|
||||||
// State - accessed from both LVGL thread and BT callback thread
|
// State - accessed from both LVGL thread and BT callback thread
|
||||||
std::atomic<bool> _isEnabled {false};
|
std::atomic<bool> _isEnabled {false};
|
||||||
std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON
|
std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON
|
||||||
std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off)
|
std::atomic<bool> _radioWasOff {false}; // true if we turned the radio on (restore on exit)
|
||||||
|
std::atomic<bool> _deviceWasStarted{false}; // true if we called device_start (restore on exit)
|
||||||
|
|
||||||
// Static event callbacks
|
// Static event callbacks
|
||||||
static void onSwitchToggled(lv_event_t* e);
|
static void onSwitchToggled(lv_event_t* e);
|
||||||
@@ -36,6 +38,7 @@ class MediaKeys final : public App {
|
|||||||
static void sendKeyTask(void* param);
|
static void sendKeyTask(void* param);
|
||||||
|
|
||||||
// Instance methods called by static callbacks
|
// Instance methods called by static callbacks
|
||||||
|
void teardownBt(); // remove callback + stop HID + restore radio/device state
|
||||||
void handleSwitchToggle(bool enabled);
|
void handleSwitchToggle(bool enabled);
|
||||||
void handleButtonPress(uint32_t buttonId);
|
void handleButtonPress(uint32_t buttonId);
|
||||||
void startHid(); // called once radio is confirmed ON
|
void startHid(); // called once radio is confirmed ON
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.mediakeys
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.4.0
|
||||||
[app]
|
app.version.code=4
|
||||||
id=one.tactility.mediakeys
|
app.name=Media Keys
|
||||||
versionName=0.1.0
|
app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
|
||||||
versionCode=1
|
|
||||||
name=Media Keys
|
|
||||||
description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
#include <tt_lvgl_toolbar.h>
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
#include <tactility/drivers/audio_stream.h>
|
||||||
|
#include <tactility/drivers/audio_codec.h>
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -27,7 +28,8 @@ typedef enum {
|
|||||||
} PlaybackState;
|
} PlaybackState;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
struct Device* i2s_dev;
|
struct Device* stream_dev;
|
||||||
|
AudioStreamHandle stream_handle;
|
||||||
char filepath[512];
|
char filepath[512];
|
||||||
PlaybackState state;
|
PlaybackState state;
|
||||||
|
|
||||||
@@ -119,17 +121,17 @@ static void mp3_playback_task(void* arg) {
|
|||||||
ctx->eof = false;
|
ctx->eof = false;
|
||||||
ctx->sample_rate = 0;
|
ctx->sample_rate = 0;
|
||||||
ctx->channels = 0;
|
ctx->channels = 0;
|
||||||
|
ctx->stream_handle = NULL;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
|
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
|
||||||
|
|
||||||
while (ctx->state != STATE_IDLE) {
|
while (ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
if (ctx->sample_rate != 0) {
|
if (ctx->stream_handle != NULL) {
|
||||||
// Reset I2S immediately on pause to stop DMA loops
|
// Close stream immediately on pause to stop DMA
|
||||||
device_lock(ctx->i2s_dev);
|
audio_stream_close(ctx->stream_handle);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
ctx->stream_handle = NULL;
|
||||||
device_unlock(ctx->i2s_dev);
|
// Clear cached format to force re-open on resume
|
||||||
// Clear configuration cache to force reconfig on resume
|
|
||||||
ctx->sample_rate = 0;
|
ctx->sample_rate = 0;
|
||||||
ctx->channels = 0;
|
ctx->channels = 0;
|
||||||
}
|
}
|
||||||
@@ -172,30 +174,30 @@ static void mp3_playback_task(void* arg) {
|
|||||||
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
|
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
|
||||||
|
|
||||||
if (samples > 0) {
|
if (samples > 0) {
|
||||||
// Configure I2S if format changed
|
// Configure audio-stream if format changed
|
||||||
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
|
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
|
||||||
struct I2sConfig config = {
|
if (ctx->stream_handle != NULL) {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
audio_stream_close(ctx->stream_handle);
|
||||||
|
ctx->stream_handle = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AudioStreamConfig config = {
|
||||||
.sample_rate = (uint32_t)info.hz,
|
.sample_rate = (uint32_t)info.hz,
|
||||||
.bits_per_sample = 16,
|
.bits_per_sample = 16,
|
||||||
.channel_left = 0,
|
.channels = (uint8_t)info.channels
|
||||||
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
|
||||||
};
|
};
|
||||||
|
|
||||||
device_lock(ctx->i2s_dev);
|
error_t err = audio_stream_open_output(ctx->stream_dev, &config, &ctx->stream_handle);
|
||||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
|
|
||||||
if (err != ERROR_NONE) {
|
if (err != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to set config: %d", err);
|
ESP_LOGE(TAG, "Failed to open audio stream: %d", err);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
ctx->sample_rate = info.hz;
|
ctx->sample_rate = info.hz;
|
||||||
ctx->channels = info.channels;
|
ctx->channels = info.channels;
|
||||||
ESP_LOGI(TAG, "I2S configured: %d Hz, %d channels", info.hz, info.channels);
|
ESP_LOGI(TAG, "Audio stream opened: %d Hz, %d channels", info.hz, info.channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust volume
|
// Adjust volume (after resampler)
|
||||||
int vol = ctx->volume;
|
int vol = ctx->volume;
|
||||||
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
|
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
|
||||||
size_t sample_count = (size_t)samples * info.channels;
|
size_t sample_count = (size_t)samples * info.channels;
|
||||||
@@ -204,15 +206,15 @@ static void mp3_playback_task(void* arg) {
|
|||||||
samples_ptr[i] = (int16_t)scaled;
|
samples_ptr[i] = (int16_t)scaled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play audio to I2S
|
// Play audio via audio-stream (resampled to native 44100)
|
||||||
size_t offset = 0;
|
size_t offset = 0;
|
||||||
size_t data_size = sample_count * sizeof(int16_t);
|
size_t data_size = sample_count * sizeof(int16_t);
|
||||||
bool write_err = false;
|
bool write_err = false;
|
||||||
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
|
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(500));
|
||||||
if (err != ERROR_NONE || written == 0) {
|
if (err != ERROR_NONE || written == 0) {
|
||||||
ESP_LOGE(TAG, "I2S write failed: %d", err);
|
ESP_LOGE(TAG, "Audio stream write failed: %d", err);
|
||||||
write_err = true;
|
write_err = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -232,18 +234,15 @@ static void mp3_playback_task(void* arg) {
|
|||||||
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
|
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
|
||||||
tt_lvgl_unlock();
|
tt_lvgl_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskYIELD removed to avoid audio gaps - decoder loop is fast enough
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose(ctx->file);
|
fclose(ctx->file);
|
||||||
ctx->file = NULL;
|
ctx->file = NULL;
|
||||||
|
|
||||||
// Reset I2S controller to clean up DMA channels and stop looping noise
|
// Close audio stream to clean up DMA / resampler task
|
||||||
if (ctx->i2s_dev) {
|
if (ctx->stream_handle != NULL) {
|
||||||
device_lock(ctx->i2s_dev);
|
audio_stream_close(ctx->stream_handle);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
ctx->stream_handle = NULL;
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Playback task finished");
|
ESP_LOGI(TAG, "Playback task finished");
|
||||||
@@ -292,10 +291,10 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
||||||
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||||
|
|
||||||
// Find device
|
// Find audio-stream device (resampling layer over ES8311 codec)
|
||||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
g_ctx.stream_dev = device_find_by_name("audio-stream");
|
||||||
if (!g_ctx.i2s_dev) {
|
if (!g_ctx.stream_dev) {
|
||||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
ESP_LOGE(TAG, "Audio-stream device not found!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse launch parameters
|
// Parse launch parameters
|
||||||
@@ -393,8 +392,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_center(lbl_stop);
|
lv_obj_center(lbl_stop);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
if (!g_ctx.i2s_dev) {
|
if (!g_ctx.stream_dev) {
|
||||||
lv_label_set_text(g_ctx.lbl_status, "Error: I2S Not Found");
|
lv_label_set_text(g_ctx.lbl_status, "Error: Audio Not Found");
|
||||||
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
|
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
|
||||||
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
||||||
} else if (!g_ctx.input_buf || !g_ctx.pcm_buf) {
|
} else if (!g_ctx.input_buf || !g_ctx.pcm_buf) {
|
||||||
@@ -409,7 +408,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
if (g_ctx.filepath[0] != '\0') {
|
if (g_ctx.filepath[0] != '\0') {
|
||||||
g_ctx.state = STATE_PLAYING;
|
g_ctx.state = STATE_PLAYING;
|
||||||
update_ui(&g_ctx);
|
update_ui(&g_ctx);
|
||||||
xTaskCreate(mp3_playback_task, "mp3_play", 4096, &g_ctx, 5, &g_ctx.playback_task_handle);
|
xTaskCreate(mp3_playback_task, "mp3_play", 6144, &g_ctx, 6, &g_ctx.playback_task_handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,11 +423,10 @@ static void onHideApp(AppHandle app, void* data) {
|
|||||||
vTaskDelay(pdMS_TO_TICKS(10));
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset I2S to ensure DMA channel is stopped
|
// Close any open audio stream
|
||||||
if (g_ctx.i2s_dev) {
|
if (g_ctx.stream_handle != NULL) {
|
||||||
device_lock(g_ctx.i2s_dev);
|
audio_stream_close(g_ctx.stream_handle);
|
||||||
i2s_controller_reset(g_ctx.i2s_dev);
|
g_ctx.stream_handle = NULL;
|
||||||
device_unlock(g_ctx.i2s_dev);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_ctx.input_buf) {
|
if (g_ctx.input_buf) {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.mp3player
|
||||||
platforms=esp32s3
|
app.version.name=1.0.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.mp3player
|
app.name=MP3 Player
|
||||||
versionName=1.0.0
|
|
||||||
versionCode=1
|
|
||||||
name=MP3 Player
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.mystifydemo
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.mystifydemo
|
app.name=Mystify Demo
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Mystify Demo
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(PocketDungeon)
|
||||||
|
tactility_project(PocketDungeon)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Pocket Dungeon
|
||||||
|
|
||||||
|
Paper Apps-inspired tiny roguelike for Tactility ESP32 devices.
|
||||||
|
|
||||||
|
MVP behavior:
|
||||||
|
- 9x7 board with walls, treasure, stairs, slimes, bats, and the player.
|
||||||
|
- One input = one turn.
|
||||||
|
- Move with keyboard arrows/WASD, swipe gestures, or touch quadrants.
|
||||||
|
- Bump monsters to attack.
|
||||||
|
- Monsters chase/attack after each player turn.
|
||||||
|
- Treasure and defeated monsters add gold.
|
||||||
|
- Stairs generate the next floor.
|
||||||
|
- Best floor and gold persist via preferences.
|
||||||
|
|
||||||
|
Build from repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tactility.py Apps/PocketDungeon build esp32s3 --verbose
|
||||||
|
```
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
|
||||||
|
file(GLOB_RECURSE GAMEKIT_FILES ../../../Libraries/GameKit/Source/*.c*)
|
||||||
|
file(GLOB_RECURSE SFX_ENGINE_FILES ../../../Libraries/SfxEngine/Source/*.c*)
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES} ${GAMEKIT_FILES} ${SFX_ENGINE_FILES}
|
||||||
|
# Library headers must be included directly,
|
||||||
|
# because all regular dependencies get stripped by elf_loader's cmake script
|
||||||
|
INCLUDE_DIRS
|
||||||
|
../../../Libraries/TactilityCpp/Include
|
||||||
|
../../../Libraries/GameKit/Include
|
||||||
|
../../../Libraries/SfxEngine/Include
|
||||||
|
REQUIRES TactilitySDK
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#include "DungeonModel.h"
|
||||||
|
#ifndef LV_ABS
|
||||||
|
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
|
||||||
|
#endif
|
||||||
|
namespace PocketDungeon {
|
||||||
|
|
||||||
|
uint32_t DungeonModel::nextRandom() {
|
||||||
|
seed = seed * 1103515245u + 12345u;
|
||||||
|
return (seed >> 16u) & 0x7FFFu;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DungeonModel::reset(uint32_t seedValue, bool tutorials) {
|
||||||
|
seed = seedValue;
|
||||||
|
state = DungeonState {};
|
||||||
|
state.floor = tutorials ? 0 : 2;
|
||||||
|
state.hp = 5;
|
||||||
|
state.gold = 0;
|
||||||
|
state.gameOver = false;
|
||||||
|
generateFloor();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DungeonModel::clearAllTiles() {
|
||||||
|
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||||
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
|
||||||
|
state.tiles[y][x] = Tile::Floor;
|
||||||
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { state.tiles[0][x] = Tile::Wall; state.tiles[DUNGEON_ROWS-1][x] = Tile::Wall; }
|
||||||
|
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { state.tiles[y][0] = Tile::Wall; state.tiles[y][DUNGEON_COLS-1] = Tile::Wall; }
|
||||||
|
}
|
||||||
|
|
||||||
|
void DungeonModel::addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp) {
|
||||||
|
if (state.entityCount >= MAX_ENTITIES) return;
|
||||||
|
state.entities[state.entityCount++] = Entity { type, pos, hp, true };
|
||||||
|
}
|
||||||
|
|
||||||
|
void DungeonModel::generateTutorialFloor(uint8_t tId) {
|
||||||
|
clearAllTiles();
|
||||||
|
state.entityCount = 0;
|
||||||
|
state.renderRows = TUTORIAL_ROWS;
|
||||||
|
for (uint8_t y = TUTORIAL_ROWS + 1; y < DUNGEON_ROWS; ++y)
|
||||||
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
|
||||||
|
state.tiles[y][x] = Tile::Wall;
|
||||||
|
state.playerPos = {1, 1};
|
||||||
|
if (tId == 0) {
|
||||||
|
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
|
||||||
|
} else {
|
||||||
|
state.tiles[1][3] = Tile::Treasure;
|
||||||
|
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
|
||||||
|
addEnemy(EntityType::Bat, {5, 1}, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DungeonModel::generateFloor() {
|
||||||
|
if (state.floor < 2) { generateTutorialFloor(static_cast<uint8_t>(state.floor)); return; }
|
||||||
|
state.renderRows = DUNGEON_ROWS;
|
||||||
|
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||||
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||||
|
const bool edge = (x == 0 || y == 0 || x == DUNGEON_COLS - 1 || y == DUNGEON_ROWS - 1);
|
||||||
|
state.tiles[y][x] = edge ? Tile::Wall : Tile::Floor;
|
||||||
|
}
|
||||||
|
state.playerPos = {1, 1};
|
||||||
|
state.entityCount = 0;
|
||||||
|
state.tiles[2][4] = Tile::Wall;
|
||||||
|
state.tiles[3][4] = Tile::Wall;
|
||||||
|
state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure;
|
||||||
|
state.tiles[5][7] = Tile::Stairs;
|
||||||
|
if (state.floor > 2) {
|
||||||
|
auto x = static_cast<uint8_t>(2 + (nextRandom() % 5));
|
||||||
|
auto y = static_cast<uint8_t>(1 + (nextRandom() % 5));
|
||||||
|
if (!(x == 1 && y == 1) && !(x == 7 && y == 5)) state.tiles[y][x] = Tile::Wall;
|
||||||
|
}
|
||||||
|
uint16_t realFloor = state.floor - 1;
|
||||||
|
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
|
||||||
|
addEnemy(EntityType::Bat, {3, 5}, 1);
|
||||||
|
if (realFloor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
int DungeonModel::findEnemyAt(GameKit::GridPos pos) const {
|
||||||
|
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||||
|
const auto& e = state.entities[i];
|
||||||
|
if (e.alive && e.pos == pos) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DungeonModel::isWalkable(GameKit::GridPos pos) const {
|
||||||
|
if (pos.x < 0 || pos.y < 0 || pos.x >= DUNGEON_COLS || pos.y >= DUNGEON_ROWS) return false;
|
||||||
|
return state.tiles[pos.y][pos.x] != Tile::Wall;
|
||||||
|
}
|
||||||
|
|
||||||
|
MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
||||||
|
if (state.gameOver) return MoveResult::Dead;
|
||||||
|
GameKit::GridPos target { static_cast<int16_t>(state.playerPos.x + dx), static_cast<int16_t>(state.playerPos.y + dy) };
|
||||||
|
if (!isWalkable(target)) return MoveResult::Blocked;
|
||||||
|
const int enemyIdx = findEnemyAt(target);
|
||||||
|
if (enemyIdx >= 0) {
|
||||||
|
Entity& enemy = state.entities[enemyIdx];
|
||||||
|
enemy.hp -= 1;
|
||||||
|
if (enemy.hp <= 0) { enemy.alive = false; state.gold += enemy.type == EntityType::Bat ? 2 : 1; }
|
||||||
|
if (!isTutorial()) moveMonsters();
|
||||||
|
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||||
|
return MoveResult::Attacked;
|
||||||
|
}
|
||||||
|
state.playerPos = target;
|
||||||
|
MoveResult result = MoveResult::Moved;
|
||||||
|
Tile& tile = state.tiles[target.y][target.x];
|
||||||
|
if (tile == Tile::Treasure) {
|
||||||
|
state.gold += 5;
|
||||||
|
tile = Tile::Floor;
|
||||||
|
result = MoveResult::Treasure;
|
||||||
|
} else if (tile == Tile::Stairs) {
|
||||||
|
state.floor += 1;
|
||||||
|
if (state.floor >= 2) {
|
||||||
|
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||||
|
if (state.gold > bestGold) bestGold = state.gold;
|
||||||
|
}
|
||||||
|
generateFloor();
|
||||||
|
return MoveResult::Stairs;
|
||||||
|
}
|
||||||
|
if (!isTutorial()) moveMonsters();
|
||||||
|
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||||
|
if (state.floor >= 2) {
|
||||||
|
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||||
|
if (state.gold > bestGold) bestGold = state.gold;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DungeonModel::moveMonsters() {
|
||||||
|
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||||
|
Entity& enemy = state.entities[i];
|
||||||
|
if (!enemy.alive) continue;
|
||||||
|
const int16_t dx = state.playerPos.x - enemy.pos.x;
|
||||||
|
const int16_t dy = state.playerPos.y - enemy.pos.y;
|
||||||
|
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) { state.hp -= 1; continue; }
|
||||||
|
GameKit::GridPos target = enemy.pos;
|
||||||
|
if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) {
|
||||||
|
const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
|
||||||
|
const int* dir = dirs[nextRandom() % 4];
|
||||||
|
target.x += dir[0]; target.y += dir[1];
|
||||||
|
} else if (LV_ABS(dx) > LV_ABS(dy)) {
|
||||||
|
target.x += dx > 0 ? 1 : -1;
|
||||||
|
} else if (dy != 0) {
|
||||||
|
target.y += dy > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
if (target == state.playerPos) state.hp -= 1;
|
||||||
|
else if (isWalkable(target) && findEnemyAt(target) < 0) enemy.pos = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace PocketDungeon
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <GameKitGrid.h>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace PocketDungeon {
|
||||||
|
static constexpr uint8_t DUNGEON_COLS = 9;
|
||||||
|
static constexpr uint8_t DUNGEON_ROWS = 7;
|
||||||
|
static constexpr uint8_t TUTORIAL_ROWS = 2;
|
||||||
|
static constexpr uint8_t MAX_ENTITIES = 8;
|
||||||
|
|
||||||
|
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
|
||||||
|
enum class EntityType : uint8_t { Player, Slime, Bat };
|
||||||
|
enum class MoveResult : uint8_t { None, Moved, Blocked, Attacked, Treasure, Stairs, Dead };
|
||||||
|
|
||||||
|
struct Entity {
|
||||||
|
EntityType type = EntityType::Slime;
|
||||||
|
GameKit::GridPos pos {};
|
||||||
|
int8_t hp = 1;
|
||||||
|
bool alive = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DungeonState {
|
||||||
|
Tile tiles[DUNGEON_ROWS][DUNGEON_COLS] {};
|
||||||
|
Entity entities[MAX_ENTITIES] {};
|
||||||
|
uint8_t entityCount = 0;
|
||||||
|
GameKit::GridPos playerPos {1, 1};
|
||||||
|
uint16_t floor = 0; // 0,1 tutorial, 2+ real
|
||||||
|
uint16_t gold = 0;
|
||||||
|
int8_t hp = 5;
|
||||||
|
bool gameOver = false;
|
||||||
|
uint8_t renderRows = DUNGEON_ROWS;
|
||||||
|
};
|
||||||
|
|
||||||
|
class DungeonModel {
|
||||||
|
DungeonState state {};
|
||||||
|
uint32_t seed = 0xC0FFEE;
|
||||||
|
uint16_t bestFloor = 1;
|
||||||
|
uint16_t bestGold = 0;
|
||||||
|
|
||||||
|
uint32_t nextRandom();
|
||||||
|
void addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp);
|
||||||
|
int findEnemyAt(GameKit::GridPos pos) const;
|
||||||
|
bool isWalkable(GameKit::GridPos pos) const;
|
||||||
|
void moveMonsters();
|
||||||
|
void generateTutorialFloor(uint8_t tId);
|
||||||
|
void clearAllTiles();
|
||||||
|
|
||||||
|
public:
|
||||||
|
void reset(uint32_t seedValue = 0xC0FFEE, bool tutorials = true);
|
||||||
|
void generateFloor();
|
||||||
|
MoveResult movePlayer(int dx, int dy);
|
||||||
|
|
||||||
|
const DungeonState& getState() const { return state; }
|
||||||
|
uint16_t getBestFloor() const { return bestFloor; }
|
||||||
|
uint16_t getBestGold() const { return bestGold; }
|
||||||
|
void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; }
|
||||||
|
bool isTutorial() const { return state.floor < 2; }
|
||||||
|
uint8_t tutorialId() const { return static_cast<uint8_t>(state.floor); }
|
||||||
|
uint16_t displayFloor() const { return state.floor < 2 ? 1 : state.floor - 1; }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace PocketDungeon
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#include "DungeonRenderer.h"
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace PocketDungeon {
|
||||||
|
|
||||||
|
static constexpr uint32_t COLOR_BG = 0x10111A;
|
||||||
|
static constexpr uint32_t COLOR_PANEL = 0x1D2030;
|
||||||
|
static constexpr uint32_t COLOR_FLOOR = 0xD8C7A1;
|
||||||
|
static constexpr uint32_t COLOR_WALL = 0x5D574C;
|
||||||
|
static constexpr uint32_t COLOR_STAIRS = 0x6D4CC2;
|
||||||
|
static constexpr uint32_t COLOR_TREASURE = 0xD99B23;
|
||||||
|
static constexpr uint32_t COLOR_PLAYER = 0x365CF5;
|
||||||
|
static constexpr uint32_t COLOR_SLIME = 0x3B9D58;
|
||||||
|
static constexpr uint32_t COLOR_BAT = 0x953B9D;
|
||||||
|
|
||||||
|
void DungeonRenderer::create(lv_obj_t* parent) {
|
||||||
|
root = parent;
|
||||||
|
lv_obj_set_style_bg_color(root, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
status = lv_label_create(root);
|
||||||
|
lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN);
|
||||||
|
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 6);
|
||||||
|
const lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
|
||||||
|
const lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
|
||||||
|
const uint16_t maxCellW = static_cast<uint16_t>((screenW - 22) / DUNGEON_COLS);
|
||||||
|
const uint16_t maxCellH = static_cast<uint16_t>((screenH - 70) / DUNGEON_ROWS);
|
||||||
|
const uint16_t cell = maxCellW < maxCellH ? maxCellW : maxCellH;
|
||||||
|
grid = GameKit::GridSpec { DUNGEON_COLS, DUNGEON_ROWS, static_cast<uint16_t>(cell > 10 ? cell : 10), 0, 0 };
|
||||||
|
board = GameKit::makePanel(root, lv_color_hex(COLOR_PANEL), 10);
|
||||||
|
lv_obj_set_size(board, grid.cols * grid.cellPx, grid.rows * grid.cellPx);
|
||||||
|
lv_obj_align(board, LV_ALIGN_CENTER, 0, 8);
|
||||||
|
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||||
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||||
|
cells[y][x] = GameKit::makeCell(board, grid.cellPx - 2, lv_color_hex(COLOR_FLOOR), 4);
|
||||||
|
GameKit::setCell(cells[y][x], x, y, grid.cellPx);
|
||||||
|
labels[y][x] = lv_label_create(cells[y][x]);
|
||||||
|
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
|
||||||
|
lv_obj_center(labels[y][x]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message = lv_label_create(root);
|
||||||
|
lv_obj_set_style_text_color(message, lv_color_hex(0xB8BCD8), LV_PART_MAIN);
|
||||||
|
lv_obj_align(message, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_color_t DungeonRenderer::tileColor(Tile tile) {
|
||||||
|
switch (tile) {
|
||||||
|
case Tile::Wall: return lv_color_hex(COLOR_WALL);
|
||||||
|
case Tile::Stairs: return lv_color_hex(COLOR_STAIRS);
|
||||||
|
case Tile::Treasure: return lv_color_hex(COLOR_TREASURE);
|
||||||
|
case Tile::Floor: default: return lv_color_hex(COLOR_FLOOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* DungeonRenderer::tileText(Tile tile) {
|
||||||
|
switch (tile) {
|
||||||
|
case Tile::Stairs: return ">";
|
||||||
|
case Tile::Treasure: return "$";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_color_t DungeonRenderer::entityColor(EntityType type) {
|
||||||
|
switch (type) {
|
||||||
|
case EntityType::Player: return lv_color_hex(COLOR_PLAYER);
|
||||||
|
case EntityType::Bat: return lv_color_hex(COLOR_BAT);
|
||||||
|
case EntityType::Slime: default: return lv_color_hex(COLOR_SLIME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DungeonRenderer::clearEntityLabels() {
|
||||||
|
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||||
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||||
|
lv_label_set_text(labels[y][x], "");
|
||||||
|
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) {
|
||||||
|
const DungeonState& state = model.getState();
|
||||||
|
char buf[96];
|
||||||
|
if (model.isTutorial()) {
|
||||||
|
std::snprintf(buf, sizeof(buf), "TUTORIAL %u/2 HP:%d Gold:%u", model.tutorialId()+1, state.hp, state.gold);
|
||||||
|
} else {
|
||||||
|
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", model.displayFloor(), state.hp, state.gold, model.getBestFloor(), model.getBestGold());
|
||||||
|
}
|
||||||
|
lv_label_set_text(status, buf);
|
||||||
|
clearEntityLabels();
|
||||||
|
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||||
|
bool visible = y < state.renderRows;
|
||||||
|
if (!visible) {
|
||||||
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) lv_obj_add_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||||
|
lv_obj_clear_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
|
||||||
|
const Tile tile = state.tiles[y][x];
|
||||||
|
lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN);
|
||||||
|
lv_label_set_text(labels[y][x], tileText(tile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.renderRows == TUTORIAL_ROWS) {
|
||||||
|
lv_obj_set_height(board, grid.cellPx * TUTORIAL_ROWS);
|
||||||
|
lv_obj_align(board, LV_ALIGN_CENTER, 0, -10);
|
||||||
|
} else {
|
||||||
|
lv_obj_set_height(board, grid.rows * grid.cellPx);
|
||||||
|
lv_obj_align(board, LV_ALIGN_CENTER, 0, 8);
|
||||||
|
}
|
||||||
|
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||||
|
const Entity& e = state.entities[i];
|
||||||
|
if (!e.alive) continue;
|
||||||
|
const char* sym = e.type == EntityType::Bat ? "b" : "s";
|
||||||
|
lv_label_set_text(labels[e.pos.y][e.pos.x], sym);
|
||||||
|
lv_obj_set_style_text_color(labels[e.pos.y][e.pos.x], entityColor(e.type), LV_PART_MAIN);
|
||||||
|
}
|
||||||
|
lv_label_set_text(labels[state.playerPos.y][state.playerPos.x], "@");
|
||||||
|
lv_obj_set_style_text_color(labels[state.playerPos.y][state.playerPos.x], lv_color_hex(COLOR_PLAYER), LV_PART_MAIN);
|
||||||
|
|
||||||
|
const char* text = model.isTutorial() ? (model.tutorialId()==0 ? "TUT1: @ to >" : "TUT2: Kill b, $ , >") : "Arrows/Swipe/Tap";
|
||||||
|
switch (lastResult) {
|
||||||
|
case MoveResult::Blocked: text = "Wall blocks"; break;
|
||||||
|
case MoveResult::Attacked: text = "You strike!"; break;
|
||||||
|
case MoveResult::Treasure: text = "Treasure! +5"; break;
|
||||||
|
case MoveResult::Stairs: text = model.isTutorial() ? "Tutorial done!" : "Down..."; break;
|
||||||
|
case MoveResult::Dead: text = "Game over - tap to restart"; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
lv_label_set_text(message, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace PocketDungeon
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "DungeonModel.h"
|
||||||
|
#include <GameKit.h>
|
||||||
|
#include <lvgl.h>
|
||||||
|
|
||||||
|
namespace PocketDungeon {
|
||||||
|
|
||||||
|
class DungeonRenderer {
|
||||||
|
lv_obj_t* root = nullptr;
|
||||||
|
lv_obj_t* board = nullptr;
|
||||||
|
lv_obj_t* cells[DUNGEON_ROWS][DUNGEON_COLS] {};
|
||||||
|
lv_obj_t* labels[DUNGEON_ROWS][DUNGEON_COLS] {};
|
||||||
|
lv_obj_t* status = nullptr;
|
||||||
|
lv_obj_t* message = nullptr;
|
||||||
|
GameKit::GridSpec grid {};
|
||||||
|
|
||||||
|
static lv_color_t tileColor(Tile tile);
|
||||||
|
static const char* tileText(Tile tile);
|
||||||
|
static lv_color_t entityColor(EntityType type);
|
||||||
|
void clearEntityLabels();
|
||||||
|
|
||||||
|
public:
|
||||||
|
void create(lv_obj_t* parent);
|
||||||
|
void render(const DungeonModel& model, MoveResult lastResult);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace PocketDungeon
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
#include "PocketDungeon.h"
|
||||||
|
#include <cstdio>
|
||||||
|
extern "C" {
|
||||||
|
#include <tt_app.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace PocketDungeon {
|
||||||
|
|
||||||
|
static constexpr uint32_t TICK_MS = 200;
|
||||||
|
static constexpr const char* PREF_BEST_FLOOR = "bestFloor";
|
||||||
|
static constexpr const char* PREF_BEST_GOLD = "bestGold";
|
||||||
|
|
||||||
|
static constexpr uint32_t COLOR_BG = 0x10111A;
|
||||||
|
static constexpr uint32_t COLOR_PANEL = 0x1D2030;
|
||||||
|
static constexpr uint32_t COLOR_ACCENT = 0x365CF5;
|
||||||
|
static constexpr uint32_t COLOR_OVERLAY = 0x000000;
|
||||||
|
|
||||||
|
void PocketDungeon::onShow(AppHandle app, lv_obj_t* parent) {
|
||||||
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(parent, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||||
|
root = parent;
|
||||||
|
model.setBests(static_cast<uint16_t>(prefs.getInt(PREF_BEST_FLOOR, 1)), static_cast<uint16_t>(prefs.getInt(PREF_BEST_GOLD, 0)));
|
||||||
|
model.reset(lv_tick_get(), true);
|
||||||
|
if (sfx == nullptr) {
|
||||||
|
sfx = new SfxEngine();
|
||||||
|
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
||||||
|
}
|
||||||
|
phase = AppPhase::Intro;
|
||||||
|
onEnter(parent);
|
||||||
|
loop.start(TICK_MS, this);
|
||||||
|
(void) app;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::onHide(AppHandle app) {
|
||||||
|
(void) app;
|
||||||
|
loop.stop();
|
||||||
|
onExit();
|
||||||
|
saveBests();
|
||||||
|
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; }
|
||||||
|
root = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
|
||||||
|
root = sceneRoot;
|
||||||
|
GameKit::attachInput(root, this);
|
||||||
|
showIntro();
|
||||||
|
lastResult = MoveResult::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::onExit() { clearIntro(); clearGame(); }
|
||||||
|
|
||||||
|
void PocketDungeon::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::clearIntro() { if (introContainer) { lv_obj_delete(introContainer); introContainer = nullptr; } }
|
||||||
|
void PocketDungeon::clearGame() {
|
||||||
|
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
|
||||||
|
if (gameContainer) { lv_obj_delete(gameContainer); gameContainer = nullptr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::showIntro() {
|
||||||
|
clearGame(); clearIntro();
|
||||||
|
phase = AppPhase::Intro;
|
||||||
|
introContainer = lv_obj_create(root);
|
||||||
|
lv_obj_set_size(introContainer, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_style_pad_all(introContainer, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(introContainer, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(introContainer, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(introContainer, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_flow(introContainer, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(introContainer, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_pad_row(introContainer, 6, LV_PART_MAIN);
|
||||||
|
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||||
|
renderIntro();
|
||||||
|
attachInputToCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::showGame() {
|
||||||
|
clearIntro(); clearGame();
|
||||||
|
phase = AppPhase::Playing;
|
||||||
|
gameContainer = lv_obj_create(root);
|
||||||
|
lv_obj_set_size(gameContainer, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_style_pad_all(gameContainer, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(gameContainer, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(gameContainer, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(gameContainer, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||||
|
renderer.create(gameContainer);
|
||||||
|
lv_obj_t* pauseBtn = lv_btn_create(gameContainer);
|
||||||
|
lv_obj_set_size(pauseBtn, 32, 26);
|
||||||
|
lv_obj_set_style_bg_color(pauseBtn, lv_color_hex(0x2A2D40), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_radius(pauseBtn, 6, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_all(pauseBtn, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_align(pauseBtn, LV_ALIGN_TOP_RIGHT, -6, 4);
|
||||||
|
lv_obj_add_event_cb(pauseBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
|
||||||
|
lv_obj_t* pauseLbl = lv_label_create(pauseBtn);
|
||||||
|
lv_label_set_text(pauseLbl, LV_SYMBOL_CLOSE);
|
||||||
|
lv_obj_center(pauseLbl);
|
||||||
|
lastResult = MoveResult::None;
|
||||||
|
attachInputToCurrent();
|
||||||
|
render();
|
||||||
|
if (sfx) sfx->play(SfxId::Warp);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::showPause() {
|
||||||
|
if (pauseOverlay) return;
|
||||||
|
phase = AppPhase::Paused;
|
||||||
|
pauseOverlay = lv_obj_create(root);
|
||||||
|
lv_obj_set_size(pauseOverlay, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_style_pad_all(pauseOverlay, 12, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(pauseOverlay, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(pauseOverlay, lv_color_hex(COLOR_OVERLAY), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(pauseOverlay, LV_OPA_70, LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(pauseOverlay, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_flow(pauseOverlay, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(pauseOverlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_pad_row(pauseOverlay, 10, LV_PART_MAIN);
|
||||||
|
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
lv_obj_t* box = lv_obj_create(pauseOverlay);
|
||||||
|
lv_obj_set_width(box, LV_PCT(80));
|
||||||
|
lv_obj_set_height(box, LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_pad_all(box, 14, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(box, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(box, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_radius(box, 12, LV_PART_MAIN);
|
||||||
|
lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_pad_row(box, 10, LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_t* t = lv_label_create(box);
|
||||||
|
lv_label_set_text(t, "Paused");
|
||||||
|
lv_obj_set_style_text_color(t, lv_color_white(), LV_PART_MAIN);
|
||||||
|
lv_obj_t* resumeBtn = lv_btn_create(box);
|
||||||
|
lv_obj_set_width(resumeBtn, LV_PCT(100));
|
||||||
|
lv_obj_set_height(resumeBtn, 38);
|
||||||
|
lv_obj_set_style_bg_color(resumeBtn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_radius(resumeBtn, 10, LV_PART_MAIN);
|
||||||
|
lv_obj_add_event_cb(resumeBtn, onResumeButtonClicked, LV_EVENT_CLICKED, this);
|
||||||
|
lv_obj_t* rl = lv_label_create(resumeBtn);
|
||||||
|
lv_label_set_text(rl, "Resume");
|
||||||
|
lv_obj_center(rl);
|
||||||
|
lv_obj_t* exitBtn = lv_btn_create(box);
|
||||||
|
lv_obj_set_width(exitBtn, LV_PCT(100));
|
||||||
|
lv_obj_set_height(exitBtn, 36);
|
||||||
|
lv_obj_set_style_bg_color(exitBtn, lv_color_hex(0x2E303F), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_radius(exitBtn, 10, LV_PART_MAIN);
|
||||||
|
lv_obj_add_event_cb(exitBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
|
||||||
|
lv_obj_t* el = lv_label_create(exitBtn);
|
||||||
|
lv_label_set_text(el, "Exit Game");
|
||||||
|
lv_obj_center(el);
|
||||||
|
if (sfx) sfx->play(SfxId::MenuOpen);
|
||||||
|
attachInputToCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::hidePause() {
|
||||||
|
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
|
||||||
|
phase = AppPhase::Playing;
|
||||||
|
if (sfx) sfx->play(SfxId::MenuClose);
|
||||||
|
attachInputToCurrent();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::exitGame() {
|
||||||
|
saveBests();
|
||||||
|
if (sfx) sfx->play(SfxId::Cancel);
|
||||||
|
tt_app_stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::renderIntro() {
|
||||||
|
if (!introContainer) return;
|
||||||
|
lv_obj_t* titleRow = lv_obj_create(introContainer);
|
||||||
|
lv_obj_set_width(titleRow, LV_PCT(100));
|
||||||
|
lv_obj_set_height(titleRow, LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_pad_all(titleRow, 6, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(titleRow, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_flow(titleRow, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(titleRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_pad_row(titleRow, 2, LV_PART_MAIN);
|
||||||
|
lv_obj_t* title = lv_label_create(titleRow);
|
||||||
|
lv_label_set_text(title, "POCKET DUNGEON");
|
||||||
|
lv_obj_set_style_text_color(title, lv_color_hex(0xF8F3E6), LV_PART_MAIN);
|
||||||
|
lv_obj_t* subtitle = lv_label_create(titleRow);
|
||||||
|
lv_label_set_text(subtitle, "Tiny paper roguelike");
|
||||||
|
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x8A9FBF), LV_PART_MAIN);
|
||||||
|
char bestBuf[64];
|
||||||
|
std::snprintf(bestBuf, sizeof(bestBuf), "Best F%u G%u", model.getBestFloor(), model.getBestGold());
|
||||||
|
lv_obj_t* best = lv_label_create(titleRow);
|
||||||
|
lv_label_set_text(best, bestBuf);
|
||||||
|
lv_obj_set_style_text_color(best, lv_color_hex(0x7A8196), LV_PART_MAIN);
|
||||||
|
|
||||||
|
lv_obj_t* mainRow = lv_obj_create(introContainer);
|
||||||
|
lv_obj_set_width(mainRow, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(mainRow, 1);
|
||||||
|
lv_obj_set_style_pad_all(mainRow, 4, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_column(mainRow, 8, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_bottom(mainRow, 8, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(mainRow, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(mainRow, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||||
|
lv_obj_set_flex_flow(mainRow, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(mainRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||||
|
lv_obj_remove_flag(mainRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* leftCol = lv_obj_create(mainRow);
|
||||||
|
lv_obj_set_flex_grow(leftCol, 1);
|
||||||
|
lv_obj_set_height(leftCol, LV_PCT(100));
|
||||||
|
lv_obj_set_style_pad_all(leftCol, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_right(leftCol, 4, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_bottom(leftCol, 6, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(leftCol, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(leftCol, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||||
|
lv_obj_set_flex_flow(leftCol, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_style_pad_row(leftCol, 8, LV_PART_MAIN);
|
||||||
|
lv_obj_add_flag(leftCol, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
auto makeCard = [](lv_obj_t* parent, const char* tTitle, const char* body, uint32_t col) {
|
||||||
|
lv_obj_t* card = lv_obj_create(parent);
|
||||||
|
lv_obj_set_width(card, LV_PCT(100));
|
||||||
|
lv_obj_set_height(card, LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_pad_all(card, 9, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_row(card, 3, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(card, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_radius(card, 10, LV_PART_MAIN);
|
||||||
|
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_t* t = lv_label_create(card);
|
||||||
|
lv_label_set_text(t, tTitle);
|
||||||
|
lv_obj_set_style_text_color(t, lv_color_hex(col), LV_PART_MAIN);
|
||||||
|
lv_obj_t* b = lv_label_create(card);
|
||||||
|
lv_label_set_text(b, body);
|
||||||
|
lv_obj_set_style_text_color(b, lv_color_hex(0xD9DDE8), LV_PART_MAIN);
|
||||||
|
lv_label_set_long_mode(b, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_width(b, LV_PCT(100));
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
makeCard(leftCol, "GOAL", "Reach deepest floor.\nCollect gold, survive.", 0xD99B23);
|
||||||
|
makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit", 0x6D8CFF);
|
||||||
|
|
||||||
|
lv_obj_t* rightRail = lv_obj_create(mainRow);
|
||||||
|
lv_obj_set_width(rightRail, 44);
|
||||||
|
lv_obj_set_height(rightRail, LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_pad_all(rightRail, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_row(rightRail, 10, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(rightRail, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(rightRail, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||||
|
lv_obj_set_flex_flow(rightRail, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(rightRail, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_remove_flag(rightRail, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
auto makeIconBtn = [](lv_obj_t* parent, const char* symbol, uint32_t bg, lv_event_cb_t cb, void* user, int s) -> lv_obj_t* {
|
||||||
|
lv_obj_t* btn = lv_btn_create(parent);
|
||||||
|
lv_obj_set_size(btn, s, s);
|
||||||
|
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_radius(btn, s / 3, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user);
|
||||||
|
lv_obj_t* lbl = lv_label_create(btn);
|
||||||
|
lv_label_set_text(lbl, symbol);
|
||||||
|
lv_obj_center(lbl);
|
||||||
|
lv_obj_set_style_text_color(lbl, lv_color_white(), LV_PART_MAIN);
|
||||||
|
return btn;
|
||||||
|
};
|
||||||
|
|
||||||
|
makeIconBtn(rightRail, LV_SYMBOL_PLAY, COLOR_ACCENT, onStartButtonClicked, this, 38);
|
||||||
|
makeIconBtn(rightRail, LV_SYMBOL_CLOSE, 0x252836, onExitButtonClicked, this, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::onStartButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->showGame(); lv_event_stop_bubbling(e); }
|
||||||
|
void PocketDungeon::onResumeButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->hidePause(); lv_event_stop_bubbling(e); }
|
||||||
|
void PocketDungeon::onExitButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->exitGame(); lv_event_stop_bubbling(e); }
|
||||||
|
void PocketDungeon::onGameContainerLongPress(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s && s->phase==AppPhase::Playing) s->showPause(); }
|
||||||
|
|
||||||
|
void PocketDungeon::onInput(const GameKit::InputEvent& input) {
|
||||||
|
if (input.kind == GameKit::InputKind::Cancel || input.kind == GameKit::InputKind::Menu) { exitGame(); return; }
|
||||||
|
if (phase == AppPhase::Intro) { if (input.kind != GameKit::InputKind::Unknown) showGame(); return; }
|
||||||
|
if (phase == AppPhase::Paused) { if (input.kind != GameKit::InputKind::Unknown) hidePause(); return; }
|
||||||
|
int dx=0, dy=0;
|
||||||
|
switch (input.kind) {
|
||||||
|
case GameKit::InputKind::Up: dy=-1; break;
|
||||||
|
case GameKit::InputKind::Down: dy=1; break;
|
||||||
|
case GameKit::InputKind::Left: dx=-1; break;
|
||||||
|
case GameKit::InputKind::Right: dx=1; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (model.getState().gameOver) {
|
||||||
|
if (dx!=0||dy!=0||input.kind==GameKit::InputKind::Confirm||input.kind==GameKit::InputKind::Touch) {
|
||||||
|
model.reset(lv_tick_get(), true); lastResult=MoveResult::None; render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dx!=0||dy!=0) {
|
||||||
|
lastResult=model.movePlayer(dx,dy);
|
||||||
|
playResultSound(lastResult);
|
||||||
|
if (lastResult==MoveResult::Dead) saveBests();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PocketDungeon::onTick(const GameKit::Tick& tick){ (void)tick; }
|
||||||
|
void PocketDungeon::render(){
|
||||||
|
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return;
|
||||||
|
if (!gameContainer) return;
|
||||||
|
renderer.render(model, lastResult);
|
||||||
|
}
|
||||||
|
void PocketDungeon::saveBests(){
|
||||||
|
prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor());
|
||||||
|
prefs.putInt(PREF_BEST_GOLD, model.getBestGold());
|
||||||
|
}
|
||||||
|
void PocketDungeon::playResultSound(MoveResult r){
|
||||||
|
if (!sfx) return;
|
||||||
|
switch(r){
|
||||||
|
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
|
||||||
|
case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
|
||||||
|
case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
|
||||||
|
case MoveResult::Dead: sfx->play(SfxId::GameOver); break;
|
||||||
|
case MoveResult::Moved: sfx->play(SfxId::Blip); break;
|
||||||
|
case MoveResult::Blocked: sfx->play(SfxId::Error); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace PocketDungeon
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "DungeonModel.h"
|
||||||
|
#include "DungeonRenderer.h"
|
||||||
|
#include <GameKit.h>
|
||||||
|
#include <SfxEngine.h>
|
||||||
|
#include <TactilityCpp/App.h>
|
||||||
|
#include <lvgl.h>
|
||||||
|
|
||||||
|
namespace PocketDungeon {
|
||||||
|
|
||||||
|
enum class AppPhase : uint8_t { Intro, Playing, Paused };
|
||||||
|
|
||||||
|
class PocketDungeon final : public App, public GameKit::Scene {
|
||||||
|
lv_obj_t* root = nullptr;
|
||||||
|
lv_obj_t* introContainer = nullptr;
|
||||||
|
lv_obj_t* gameContainer = nullptr;
|
||||||
|
lv_obj_t* pauseOverlay = nullptr;
|
||||||
|
AppPhase phase = AppPhase::Intro;
|
||||||
|
|
||||||
|
GameKit::Loop loop;
|
||||||
|
GameKit::Prefs prefs {"PocketDungeon"};
|
||||||
|
DungeonModel model;
|
||||||
|
DungeonRenderer renderer;
|
||||||
|
SfxEngine* sfx = nullptr;
|
||||||
|
MoveResult lastResult = MoveResult::None;
|
||||||
|
|
||||||
|
void playResultSound(MoveResult result);
|
||||||
|
void saveBests();
|
||||||
|
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);
|
||||||
|
|
||||||
|
public:
|
||||||
|
void onShow(AppHandle app, lv_obj_t* parent) override;
|
||||||
|
void onHide(AppHandle app) override;
|
||||||
|
void onEnter(lv_obj_t* root) override;
|
||||||
|
void onExit() override;
|
||||||
|
void onInput(const GameKit::InputEvent& input) override;
|
||||||
|
void onTick(const GameKit::Tick& tick) override;
|
||||||
|
void render() override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace PocketDungeon
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#include "PocketDungeon.h"
|
||||||
|
#include <TactilityCpp/App.h>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
(void) argc;
|
||||||
|
(void) argv;
|
||||||
|
registerApp<PocketDungeon::PocketDungeon>();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3,esp32p4
|
||||||
|
app.id=one.tactility.pocketdungeon
|
||||||
|
app.version.name=0.1.2
|
||||||
|
app.version.code=3
|
||||||
|
app.name=Pocket Dungeon
|
||||||
|
app.description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
|
||||||
|
app.flags=HideStatusBar
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.reynabot
|
||||||
platforms=esp32s3
|
app.version.name=1.0.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.reynabot
|
app.name=ReynaBot
|
||||||
versionName=1.0.0
|
|
||||||
versionCode=1
|
|
||||||
name=ReynaBot
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(RobotArm)
|
||||||
|
tactility_project(RobotArm)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES}
|
||||||
|
REQUIRES TactilitySDK lwip
|
||||||
|
)
|
||||||
|
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function)
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <tt_mdns.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <lwip/sockets.h>
|
||||||
|
#include <lwip/inet.h>
|
||||||
|
|
||||||
|
#define TAG "RobotArm"
|
||||||
|
#define ARM_PORT 80
|
||||||
|
#define ARM_PATH "/api/mcp"
|
||||||
|
#define NUM_JOINTS 6
|
||||||
|
#define SEQ_MAX 16
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char* name;
|
||||||
|
const char* cute;
|
||||||
|
uint32_t bg;
|
||||||
|
uint32_t accent;
|
||||||
|
int home, rmin, rmax;
|
||||||
|
int value, last_sent;
|
||||||
|
} Joint;
|
||||||
|
|
||||||
|
static Joint joints[NUM_JOINTS] = {
|
||||||
|
{"base","Base",0xFFD6E0,0xFF8FA8,70,0,180,70,-1},
|
||||||
|
{"shoulder","Shldr",0xD6E8FF,0x8FB6FF,40,0,70,40,-1},
|
||||||
|
{"elbow","Elbow",0xFFE8C5,0xFFB86A,20,0,120,20,-1},
|
||||||
|
{"pitch","Pitch",0xD5F0D5,0x88D488,90,30,150,90,-1},
|
||||||
|
{"roll","Roll",0xE8D5FF,0xB088FF,90,30,150,90,-1},
|
||||||
|
{"gripper","Grip",0xFFF0B3,0xFFD060,90,0,180,90,-1},
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef struct { int v[NUM_JOINTS]; } Frame;
|
||||||
|
static Frame seq[SEQ_MAX];
|
||||||
|
static int seq_len = 0, seq_idx = -1;
|
||||||
|
static bool seq_playing = false;
|
||||||
|
static int seq_play_pos = 0;
|
||||||
|
|
||||||
|
static char arm_host[64] = "";
|
||||||
|
static bool arm_connected = false;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
AppHandle app;
|
||||||
|
lv_obj_t* root;
|
||||||
|
lv_obj_t* content; // scrollable column container
|
||||||
|
lv_obj_t* status;
|
||||||
|
lv_obj_t* sliders[6];
|
||||||
|
lv_obj_t* vals[6];
|
||||||
|
lv_obj_t* seq_label;
|
||||||
|
lv_obj_t* play_label;
|
||||||
|
lv_obj_t* blocks[SEQ_MAX];
|
||||||
|
lv_obj_t* connect_box;
|
||||||
|
lv_obj_t* main_box;
|
||||||
|
lv_obj_t* connect_label;
|
||||||
|
lv_obj_t* connect_spinner;
|
||||||
|
int pend[6];
|
||||||
|
bool has[6];
|
||||||
|
int ticks[6];
|
||||||
|
lv_timer_t* poll;
|
||||||
|
lv_timer_t* seq_timer;
|
||||||
|
lv_timer_t* connect_timer;
|
||||||
|
int connect_attempts;
|
||||||
|
} Ctx;
|
||||||
|
static Ctx* g = NULL;
|
||||||
|
|
||||||
|
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
||||||
|
|
||||||
|
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
|
||||||
|
uint32_t ip=ipaddr_addr(host);
|
||||||
|
if(ip==0 || ip==0xFFFFFFFF) return -2;
|
||||||
|
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||||
|
if(fd<0) return -1;
|
||||||
|
struct sockaddr_in s; memset(&s,0,sizeof(s));
|
||||||
|
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ip;
|
||||||
|
struct timeval tv={2,0};
|
||||||
|
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||||
|
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||||
|
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
|
||||||
|
char hdr[256]; int bl=strlen(body);
|
||||||
|
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
|
||||||
|
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
|
||||||
|
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
|
||||||
|
int tot=0; while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
||||||
|
out[tot]='\0'; close(fd);
|
||||||
|
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
|
||||||
|
return tot>0?0:-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool jget(const char* js,const char* key,int* out){
|
||||||
|
const char* p=strstr(js,key); if(!p) return false;
|
||||||
|
p+=strlen(key); while(*p && *p!=':'){p++; if(!*p) return false;} p++;
|
||||||
|
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
|
||||||
|
int sign=1; if(*p=='-'){sign=-1;p++;}
|
||||||
|
float v=0,frac=0.1f; bool dot=false,got=false;
|
||||||
|
while(*p){
|
||||||
|
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
|
||||||
|
else if(*p=='.'&&!dot) dot=true; else break; p++;
|
||||||
|
}
|
||||||
|
if(!got) return false;
|
||||||
|
*out=(int)(v*sign+0.5f); return true;
|
||||||
|
}
|
||||||
|
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||||
|
int v; bool ok=true;
|
||||||
|
if(jget(r,"base",&v)) *b=v; else ok=false;
|
||||||
|
if(jget(r,"shoulder",&v)) *s=v; else ok=false;
|
||||||
|
if(jget(r,"elbow",&v)) *e=v; else ok=false;
|
||||||
|
if(jget(r,"pitch",&v)) *p=v; else ok=false;
|
||||||
|
if(jget(r,"roll",&v)) *ro=v; else ok=false;
|
||||||
|
if(jget(r,"gripper",&v)) *gr=v; else ok=false;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||||
|
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
|
||||||
|
char resp[2048]; memset(resp,0,sizeof(resp));
|
||||||
|
if(http_post(arm_host,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
||||||
|
return parse_state(resp,b,s,e,p,ro,gr);
|
||||||
|
}
|
||||||
|
static bool rpc_move(const char* j,int a){
|
||||||
|
char body[300]; snprintf(body,sizeof(body),
|
||||||
|
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",j,a);
|
||||||
|
char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||||
|
}
|
||||||
|
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
|
||||||
|
char body[420]; snprintf(body,sizeof(body),
|
||||||
|
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
|
||||||
|
b,s,e,p,ro,gr,dur);
|
||||||
|
char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||||
|
}
|
||||||
|
static bool rpc_home(void){
|
||||||
|
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
|
||||||
|
char r[512]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
||||||
|
}
|
||||||
|
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
|
||||||
|
static void apply_ui(void){
|
||||||
|
if(!g) return;
|
||||||
|
for(int i=0;i<NUM_JOINTS;i++){
|
||||||
|
if(g->sliders[i]) lv_slider_set_value(g->sliders[i],joints[i].value,LV_ANIM_OFF);
|
||||||
|
if(g->vals[i]){char buf[8]; snprintf(buf,sizeof(buf),"%d",joints[i].value); lv_label_set_text(g->vals[i],buf);}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void refresh_arm(void){
|
||||||
|
set_status("Reading...");
|
||||||
|
int b,s,e,p,ro,gr;
|
||||||
|
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
|
||||||
|
joints[0].value=b; joints[1].value=s; joints[2].value=e;
|
||||||
|
joints[3].value=p; joints[4].value=ro; joints[5].value=gr;
|
||||||
|
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
|
||||||
|
apply_ui();
|
||||||
|
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
|
||||||
|
} else set_status("No arm");
|
||||||
|
}
|
||||||
|
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||||
|
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||||
|
static void poll_cb(lv_timer_t* t){
|
||||||
|
(void)t; if(!g || !arm_connected) return;
|
||||||
|
for(int i=0;i<NUM_JOINTS;i++){
|
||||||
|
if(!g->has[i]) continue;
|
||||||
|
g->ticks[i]++; if(g->ticks[i]<3) continue;
|
||||||
|
g->has[i]=false; g->ticks[i]=0;
|
||||||
|
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
|
||||||
|
joints[i].last_sent=tgt; joints[i].value=tgt;
|
||||||
|
char buf[24]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
||||||
|
bool ok=rpc_move(joints[i].name,tgt);
|
||||||
|
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"o":"x"); set_status(buf);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void slider_cb(lv_event_t* e){
|
||||||
|
int idx=(int)(intptr_t)lv_event_get_user_data(e);
|
||||||
|
int v=(int)lv_slider_get_value(lv_event_get_target(e));
|
||||||
|
joints[idx].value=v;
|
||||||
|
if(g){
|
||||||
|
if(g->vals[idx]){char b[8]; snprintf(b,sizeof(b),"%d",v); lv_label_set_text(g->vals[idx],b);}
|
||||||
|
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void update_seq_ui(void){
|
||||||
|
if(!g) return;
|
||||||
|
if(g->seq_label){
|
||||||
|
if(seq_len==0) lv_label_set_text(g->seq_label,"empty");
|
||||||
|
else {char b[16]; snprintf(b,sizeof(b),"%d/%d", (seq_idx>=0?seq_idx+1:seq_len), seq_len); lv_label_set_text(g->seq_label,b);}
|
||||||
|
}
|
||||||
|
for(int i=0;i<SEQ_MAX;i++){
|
||||||
|
if(!g->blocks[i]) continue;
|
||||||
|
if(i<seq_len){
|
||||||
|
lv_obj_set_style_bg_opa(g->blocks[i],LV_OPA_COVER,0);
|
||||||
|
if(i==seq_idx){
|
||||||
|
lv_obj_set_style_border_width(g->blocks[i],3,0);
|
||||||
|
lv_obj_set_style_border_color(g->blocks[i],lv_color_hex(0xFFFFFF),0);
|
||||||
|
} else {
|
||||||
|
lv_obj_set_style_border_width(g->blocks[i],2,0);
|
||||||
|
lv_obj_set_style_border_color(g->blocks[i],lv_color_hex(0x3A3A3A),0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lv_obj_set_style_bg_opa(g->blocks[i],LV_OPA_30,0);
|
||||||
|
lv_obj_set_style_border_width(g->blocks[i],0,0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void load_frame(int fidx){
|
||||||
|
if(fidx<0||fidx>=seq_len) return;
|
||||||
|
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=seq[fidx].v[i]; joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false;}}
|
||||||
|
apply_ui(); seq_idx=fidx; update_seq_ui();
|
||||||
|
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
|
||||||
|
}
|
||||||
|
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
||||||
|
static void seq_rem_cb(void){
|
||||||
|
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
|
||||||
|
int rem=seq_idx; for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
||||||
|
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
|
||||||
|
if(seq_idx>=seq_len) seq_idx=seq_len-1; load_frame(seq_idx);
|
||||||
|
}
|
||||||
|
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
|
||||||
|
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
|
||||||
|
static void seq_timer_cb(lv_timer_t* t){
|
||||||
|
(void)t; if(!seq_playing||seq_len==0) return;
|
||||||
|
int f=seq_play_pos%seq_len; int* v=seq[f].v;
|
||||||
|
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
|
||||||
|
set_status("Seq fail"); seq_playing=false;
|
||||||
|
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play"); return;
|
||||||
|
}
|
||||||
|
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
|
||||||
|
apply_ui(); seq_idx=f; update_seq_ui();
|
||||||
|
char b[20]; snprintf(b,sizeof(b),"Play %d/%d",f+1,seq_len); set_status(b);
|
||||||
|
seq_play_pos++; if(seq_play_pos>=seq_len) seq_play_pos=0;
|
||||||
|
}
|
||||||
|
static void seq_play_cb(lv_event_t* e){
|
||||||
|
(void)e; if(seq_len==0){set_status("No frames"); return;}
|
||||||
|
seq_playing=!seq_playing;
|
||||||
|
if(g&&g->play_label) lv_label_set_text(g->play_label, seq_playing?"Pause":"Play");
|
||||||
|
if(seq_playing){seq_play_pos=(seq_idx>=0?seq_idx:0); set_status("Playing");} else set_status("Paused");
|
||||||
|
}
|
||||||
|
static void seq_add_ev(lv_event_t* e){(void)e; seq_add_cb();}
|
||||||
|
static void seq_rem_ev(lv_event_t* e){(void)e; seq_rem_cb();}
|
||||||
|
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
|
||||||
|
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
|
||||||
|
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
|
||||||
|
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
|
||||||
|
static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
|
||||||
|
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
|
||||||
|
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
|
||||||
|
|
||||||
|
static bool try_host(const char* host){
|
||||||
|
char resp[1024]; memset(resp,0,sizeof(resp));
|
||||||
|
if(http_post(host,ARM_PORT,ARM_PATH,"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}",resp,sizeof(resp))==0){
|
||||||
|
if(strstr(resp,"base")){
|
||||||
|
strncpy(arm_host,host,sizeof(arm_host)-1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static void show_main_ui(void){
|
||||||
|
if(!g) return;
|
||||||
|
arm_connected=true;
|
||||||
|
if(g->connect_box) lv_obj_add_flag(g->connect_box, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if(g->main_box) lv_obj_clear_flag(g->main_box, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if(g->connect_timer){ lv_timer_delete(g->connect_timer); g->connect_timer=NULL; }
|
||||||
|
if(!g->poll) g->poll=lv_timer_create(poll_cb,100,NULL);
|
||||||
|
if(!g->seq_timer) g->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
||||||
|
lv_timer_create(init_cb,500,NULL);
|
||||||
|
char buf[48]; snprintf(buf,sizeof(buf),"Conn %s",arm_host); set_status(buf);
|
||||||
|
}
|
||||||
|
static void connect_timer_cb(lv_timer_t* t){
|
||||||
|
(void)t; if(!g || arm_connected) return;
|
||||||
|
g->connect_attempts++;
|
||||||
|
char lbl[64];
|
||||||
|
if(g->connect_attempts==1) snprintf(lbl,sizeof(lbl),"mDNS browsing _robotarm._tcp...");
|
||||||
|
else snprintf(lbl,sizeof(lbl),"Searching (%d)...",g->connect_attempts);
|
||||||
|
if(g->connect_label) lv_label_set_text(g->connect_label,lbl);
|
||||||
|
|
||||||
|
// ── 1) mDNS browse for _robotarm._tcp (preferred) ──
|
||||||
|
TtMdnsBrowseResult res; memset(&res,0,sizeof(res));
|
||||||
|
if(tt_mdns_browse("_robotarm","_tcp",2500,10,&res)){
|
||||||
|
for(int i=0;i<res.count;i++){
|
||||||
|
if(res.services[i].primaryAddress[0]){
|
||||||
|
if(try_host(res.services[i].primaryAddress)){
|
||||||
|
show_main_ui(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── 2) mDNS resolve robotarm.local hostname ──
|
||||||
|
char ipbuf[64]={0};
|
||||||
|
if(tt_mdns_resolve_hostname("robotarm.local",2000,ipbuf) || tt_mdns_resolve_hostname("robotarm",2000,ipbuf)){
|
||||||
|
if(try_host(ipbuf)){ show_main_ui(); return; }
|
||||||
|
}
|
||||||
|
// ── 3) fallback hardcoded (old behavior) ──
|
||||||
|
const char* fallbacks[]={"192.168.68.148","192.168.68.103","192.168.68.102"};
|
||||||
|
int idx = (g->connect_attempts-1) % (int)(sizeof(fallbacks)/sizeof(fallbacks[0]));
|
||||||
|
if(try_host(fallbacks[idx])){ show_main_ui(); return; }
|
||||||
|
|
||||||
|
if(g->connect_attempts>15){
|
||||||
|
if(g->connect_label) lv_label_set_text(g->connect_label,"No arm found.\nMake sure robotarm\nis powered & on WiFi.\nTap Retry.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void retry_cb(lv_event_t* e){(void)e; if(!g) return; g->connect_attempts=0; if(g->connect_label) lv_label_set_text(g->connect_label,"Retrying mDNS...");}
|
||||||
|
|
||||||
|
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||||
|
(void)data;
|
||||||
|
Ctx* c=calloc(1,sizeof(Ctx)); if(!c) return;
|
||||||
|
c->app=app; g=c;
|
||||||
|
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
|
||||||
|
arm_host[0]='\0'; arm_connected=false; c->connect_attempts=0;
|
||||||
|
|
||||||
|
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
|
||||||
|
|
||||||
|
// Root fills screen below toolbar - NOT scrollable itself, content will be
|
||||||
|
lv_obj_t* root=lv_obj_create(parent);
|
||||||
|
c->root=root;
|
||||||
|
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
||||||
|
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,36,0);
|
||||||
|
lv_obj_set_style_border_width(root,0,0);
|
||||||
|
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
|
||||||
|
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
|
||||||
|
lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
// ── connecting screen (initial visible) ──
|
||||||
|
c->connect_box=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(c->connect_box,LV_PCT(100),LV_PCT(100));
|
||||||
|
lv_obj_set_pos(c->connect_box,0,0);
|
||||||
|
lv_obj_set_style_border_width(c->connect_box,0,0);
|
||||||
|
lv_obj_set_style_bg_opa(c->connect_box,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(c->connect_box,4,0);
|
||||||
|
lv_obj_clear_flag(c->connect_box, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* tl=lv_label_create(c->connect_box);
|
||||||
|
lv_label_set_text(tl,"Finding robotarm...");
|
||||||
|
lv_obj_set_style_text_font(tl,lvgl_get_text_font(FONT_SIZE_LARGE),0);
|
||||||
|
lv_obj_set_style_text_color(tl,lv_color_hex(0x3D2B5A),0);
|
||||||
|
lv_obj_set_pos(tl,4,4);
|
||||||
|
|
||||||
|
c->connect_label=lv_label_create(c->connect_box);
|
||||||
|
lv_label_set_text(c->connect_label,"mDNS: _robotarm._tcp / robotarm.local\nFallback: 192.168.68.148\n\nSearching...");
|
||||||
|
lv_obj_set_style_text_font(c->connect_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(c->connect_label,lv_color_hex(0x6A5A7A),0);
|
||||||
|
lv_obj_set_pos(c->connect_label,4,28); lv_obj_set_width(c->connect_label,260);
|
||||||
|
|
||||||
|
c->connect_spinner=lv_spinner_create(c->connect_box);
|
||||||
|
lv_obj_set_size(c->connect_spinner,40,40);
|
||||||
|
lv_obj_set_pos(c->connect_spinner,120,110);
|
||||||
|
|
||||||
|
lv_obj_t* rb=lv_btn_create(c->connect_box);
|
||||||
|
lv_obj_set_size(rb,80,28); lv_obj_set_pos(rb,80,170);
|
||||||
|
lv_obj_set_style_bg_color(rb,lv_color_hex(0xC5F5C5),0); lv_obj_set_style_radius(rb,8,0);
|
||||||
|
lv_obj_t* rbl=lv_label_create(rb); lv_label_set_text(rbl,"Retry");
|
||||||
|
lv_obj_set_style_text_font(rbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(rbl,lv_color_hex(0x2A2A5A),0); lv_obj_center(rbl);
|
||||||
|
lv_obj_add_event_cb(rb,retry_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
// ── main scrollable content (hidden until connected) ──
|
||||||
|
// content is the ONLY scrollable container - FIX overall app scroller lost
|
||||||
|
c->content=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(c->content,LV_PCT(100),LV_PCT(100));
|
||||||
|
lv_obj_set_pos(c->content,0,0);
|
||||||
|
lv_obj_set_style_border_width(c->content,0,0);
|
||||||
|
lv_obj_set_style_bg_opa(c->content,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(c->content,0,0);
|
||||||
|
lv_obj_set_flex_flow(c->content, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_add_flag(c->content, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_scrollbar_mode(c->content, LV_SCROLLBAR_MODE_AUTO);
|
||||||
|
|
||||||
|
c->main_box=lv_obj_create(c->content);
|
||||||
|
lv_obj_set_size(c->main_box,LV_PCT(100),LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_border_width(c->main_box,0,0);
|
||||||
|
lv_obj_set_style_bg_opa(c->main_box,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(c->main_box,0,0);
|
||||||
|
lv_obj_clear_flag(c->main_box, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_add_flag(c->main_box, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
|
lv_obj_t* hdr=lv_obj_create(c->main_box); lv_obj_set_size(hdr,LV_PCT(100),16);
|
||||||
|
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(hdr,0,0);
|
||||||
|
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
|
||||||
|
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0);
|
||||||
|
lv_obj_set_pos(title,2,0);
|
||||||
|
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn...");
|
||||||
|
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0);
|
||||||
|
lv_obj_set_pos(c->status,90,0);
|
||||||
|
|
||||||
|
lv_obj_t* brow=lv_obj_create(c->main_box); lv_obj_set_size(brow,LV_PCT(100),20);
|
||||||
|
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(brow,0,0);
|
||||||
|
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
|
||||||
|
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
|
||||||
|
for(int i=0;i<4;i++){
|
||||||
|
lv_obj_t* b=lv_btn_create(brow); lv_obj_set_size(b,56,18);
|
||||||
|
lv_obj_set_style_bg_color(b,lv_color_hex(btns[i].col),0); lv_obj_set_style_radius(b,8,0);
|
||||||
|
lv_obj_set_pos(b,i*62+2,0);
|
||||||
|
lv_obj_t* l=lv_label_create(b); lv_label_set_text(l,btns[i].t);
|
||||||
|
lv_obj_set_style_text_font(l,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(l,lv_color_hex(0x4A356A),0); lv_obj_center(l);
|
||||||
|
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* grid=lv_obj_create(c->main_box);
|
||||||
|
lv_obj_set_size(grid,LV_PCT(100),196);
|
||||||
|
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(grid,2,0);
|
||||||
|
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_OFF);
|
||||||
|
|
||||||
|
for(int i=0;i<NUM_JOINTS;i++){
|
||||||
|
int col=i%3, row=i/3;
|
||||||
|
lv_obj_t* card=lv_obj_create(grid);
|
||||||
|
lv_obj_set_size(card,102,96);
|
||||||
|
lv_obj_set_pos(card,col*104+2,row*98);
|
||||||
|
lv_obj_set_style_bg_color(card,lv_color_hex(joints[i].bg),0);
|
||||||
|
lv_obj_set_style_bg_opa(card,LV_OPA_90,0);
|
||||||
|
lv_obj_set_style_radius(card,12,0);
|
||||||
|
lv_obj_set_style_border_width(card,0,0);
|
||||||
|
lv_obj_set_style_pad_all(card,3,0);
|
||||||
|
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_OFF);
|
||||||
|
|
||||||
|
lv_obj_t* name=lv_label_create(card);
|
||||||
|
lv_label_set_text(name,joints[i].cute);
|
||||||
|
lv_obj_set_style_text_font(name,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(name,lv_color_hex(0x4A356A),0); lv_obj_set_pos(name,2,0);
|
||||||
|
|
||||||
|
c->vals[i]=lv_label_create(card);
|
||||||
|
char vb[8]; snprintf(vb,sizeof(vb),"%d",joints[i].value);
|
||||||
|
lv_label_set_text(c->vals[i],vb);
|
||||||
|
lv_obj_set_style_text_font(c->vals[i],lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(c->vals[i],lv_color_hex(0xC04060),0); lv_obj_set_pos(c->vals[i],40,0);
|
||||||
|
|
||||||
|
lv_obj_t* rlbl=lv_label_create(card);
|
||||||
|
char rb[12]; snprintf(rb,sizeof(rb),"%d-%d",joints[i].rmin,joints[i].rmax);
|
||||||
|
lv_label_set_text(rlbl,rb);
|
||||||
|
lv_obj_set_style_text_font(rlbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(rlbl,lv_color_hex(0xA090A0),0); lv_obj_set_pos(rlbl,58,0);
|
||||||
|
|
||||||
|
lv_obj_t* sl=lv_slider_create(card);
|
||||||
|
c->sliders[i]=sl;
|
||||||
|
lv_obj_set_size(sl,22,72);
|
||||||
|
lv_obj_set_pos(sl,40,20);
|
||||||
|
lv_slider_set_range(sl,joints[i].rmin,joints[i].rmax);
|
||||||
|
lv_slider_set_value(sl,joints[i].value,LV_ANIM_OFF);
|
||||||
|
lv_obj_set_style_bg_color(sl,lv_color_hex(0xFFFFFF),LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(sl,LV_OPA_80,LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_radius(sl,10,LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(sl,lv_color_hex(joints[i].accent),LV_PART_INDICATOR);
|
||||||
|
lv_obj_set_style_radius(sl,10,LV_PART_INDICATOR);
|
||||||
|
lv_obj_set_style_bg_color(sl,lv_color_hex(0xFFFFFF),LV_PART_KNOB);
|
||||||
|
lv_obj_set_style_border_color(sl,lv_color_hex(joints[i].accent),LV_PART_KNOB);
|
||||||
|
lv_obj_set_style_border_width(sl,2,LV_PART_KNOB);
|
||||||
|
lv_obj_set_style_radius(sl,12,LV_PART_KNOB);
|
||||||
|
lv_obj_set_style_pad_all(sl,3,LV_PART_KNOB);
|
||||||
|
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* seqbar=lv_obj_create(c->main_box);
|
||||||
|
lv_obj_set_size(seqbar,316,96);
|
||||||
|
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
|
||||||
|
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
|
||||||
|
lv_obj_set_style_radius(seqbar,12,0);
|
||||||
|
lv_obj_set_style_border_width(seqbar,1,0);
|
||||||
|
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
|
||||||
|
lv_obj_set_style_pad_all(seqbar,4,0);
|
||||||
|
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
|
||||||
|
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
|
||||||
|
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
|
||||||
|
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
|
||||||
|
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
|
||||||
|
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
|
||||||
|
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
|
||||||
|
for(int i=0;i<5;i++){
|
||||||
|
lv_obj_t* b=lv_btn_create(seqbar);
|
||||||
|
lv_obj_set_size(b,(i==2)?56:38,32);
|
||||||
|
lv_obj_set_style_bg_color(b,lv_color_hex(sbtns[i].col),0);
|
||||||
|
lv_obj_set_style_radius(b,8,0);
|
||||||
|
int xs[5]={64,110,158,220,260};
|
||||||
|
lv_obj_set_pos(b,xs[i],0);
|
||||||
|
lv_obj_t* l=lv_label_create(b); if(sbtns[i].play) c->play_label=l;
|
||||||
|
lv_label_set_text(l,sbtns[i].t);
|
||||||
|
lv_obj_set_style_text_font(l,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
|
||||||
|
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
}
|
||||||
|
uint32_t blk_cols[16]={
|
||||||
|
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
|
||||||
|
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
|
||||||
|
};
|
||||||
|
for(int i=0;i<SEQ_MAX;i++){
|
||||||
|
lv_obj_t* blk=lv_btn_create(seqbar);
|
||||||
|
lv_obj_set_size(blk,22,22);
|
||||||
|
lv_obj_set_pos(blk,(i%8)*24+2,(i/8)*24+38);
|
||||||
|
lv_obj_set_style_bg_color(blk,lv_color_hex(blk_cols[i]),0);
|
||||||
|
lv_obj_set_style_radius(blk,6,0);
|
||||||
|
lv_obj_set_style_border_width(blk,0,0);
|
||||||
|
lv_obj_set_style_bg_opa(blk,LV_OPA_30,0);
|
||||||
|
c->blocks[i]=blk;
|
||||||
|
lv_obj_add_event_cb(blk,block_click_cb,LV_EVENT_CLICKED,(void*)(intptr_t)i);
|
||||||
|
}
|
||||||
|
update_seq_ui();
|
||||||
|
|
||||||
|
c->connect_timer=lv_timer_create(connect_timer_cb, 1000, NULL);
|
||||||
|
connect_timer_cb(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onHide(AppHandle app,void* data){
|
||||||
|
(void)app;(void)data;
|
||||||
|
if(g){
|
||||||
|
if(g->poll) lv_timer_delete(g->poll);
|
||||||
|
if(g->seq_timer) lv_timer_delete(g->seq_timer);
|
||||||
|
if(g->connect_timer) lv_timer_delete(g->connect_timer);
|
||||||
|
free(g); g=NULL;
|
||||||
|
}
|
||||||
|
seq_playing=false; arm_connected=false;
|
||||||
|
}
|
||||||
|
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[manifest]
|
||||||
|
version=0.1
|
||||||
|
|
||||||
|
[target]
|
||||||
|
sdk=0.8.0-dev
|
||||||
|
platforms=esp32s3
|
||||||
|
|
||||||
|
[app]
|
||||||
|
id=one.tactility.robotarm
|
||||||
|
versionName=0.1.0
|
||||||
|
versionCode=1
|
||||||
|
name=Robot Arm
|
||||||
|
description=Cute controller for MCP robot arm
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.serialconsole
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.7.0
|
||||||
[app]
|
app.version.code=7
|
||||||
id=one.tactility.serialconsole
|
app.name=Serial Console
|
||||||
versionName=0.4.0
|
|
||||||
versionCode=4
|
|
||||||
name=Serial Console
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.snake
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.8.0
|
||||||
[app]
|
app.version.code=8
|
||||||
id=one.tactility.snake
|
app.name=Snake
|
||||||
versionName=0.5.0
|
app.description=Classic Snake game
|
||||||
versionCode=5
|
|
||||||
name=Snake
|
|
||||||
description=Classic Snake game
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
|
|||||||
if (sfxEngine == nullptr) {
|
if (sfxEngine == nullptr) {
|
||||||
sfxEngine = new SfxEngine();
|
sfxEngine = new SfxEngine();
|
||||||
sfxEngine->start();
|
sfxEngine->start();
|
||||||
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
|
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
bool soundEnabled;
|
bool soundEnabled;
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.tamatac
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.4.0
|
||||||
[app]
|
app.version.code=4
|
||||||
id=one.tactility.tamatac
|
app.name=TamaTac
|
||||||
versionName=0.1.0
|
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
|
||||||
versionCode=1
|
|
||||||
name=TamaTac
|
|
||||||
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.todolist
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.5.0
|
||||||
[app]
|
app.version.code=5
|
||||||
id=one.tactility.todolist
|
app.name=Todo List
|
||||||
versionName=0.2.0
|
app.description=Simple task list manager
|
||||||
versionCode=2
|
|
||||||
name=Todo List
|
|
||||||
description=Simple task list manager
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.twoeleven
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.7.0
|
||||||
[app]
|
app.version.code=7
|
||||||
id=one.tactility.twoeleven
|
app.name=2048
|
||||||
versionName=0.4.0
|
app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
|
||||||
versionCode=4
|
|
||||||
name=2048
|
|
||||||
description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(VoiceRecorder)
|
||||||
|
tactility_project(VoiceRecorder)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES}
|
||||||
|
REQUIRES TactilitySDK
|
||||||
|
)
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Voice Recorder App for Tactility OS
|
* Voice Recorder App for Tactility OS
|
||||||
*
|
*
|
||||||
* Records voice memos (16kHz 16-bit Mono WAV format) to the SD card (/sdcard/memos/)
|
* Records voice memos (16kHz 16-bit Mono WAV) to /sdcard/memos/
|
||||||
* and plays them back.
|
* Uses audio-stream API with resampling (native codec 44100 -> app 16000)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <tt_app.h>
|
#include <tt_app.h>
|
||||||
#include <tt_lvgl.h>
|
#include <tt_lvgl.h>
|
||||||
#include <tt_lvgl_toolbar.h>
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
#include <tactility/drivers/audio_stream.h>
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -42,7 +41,9 @@ typedef enum {
|
|||||||
} AudioState;
|
} AudioState;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
struct Device* i2s_dev;
|
struct Device* stream_dev;
|
||||||
|
AudioStreamHandle input_handle;
|
||||||
|
AudioStreamHandle output_handle;
|
||||||
uint8_t* audio_buf;
|
uint8_t* audio_buf;
|
||||||
AudioState state;
|
AudioState state;
|
||||||
int volume;
|
int volume;
|
||||||
@@ -65,19 +66,19 @@ typedef struct {
|
|||||||
} AppCtx;
|
} AppCtx;
|
||||||
|
|
||||||
typedef struct __attribute__((packed)) {
|
typedef struct __attribute__((packed)) {
|
||||||
char riff[4]; // "RIFF"
|
char riff[4];
|
||||||
uint32_t overall_size; // file size - 8
|
uint32_t overall_size;
|
||||||
char wave[4]; // "WAVE"
|
char wave[4];
|
||||||
char fmt_chunk_marker[4]; // "fmt "
|
char fmt_chunk_marker[4];
|
||||||
uint32_t length_of_fmt; // 16 for PCM
|
uint32_t length_of_fmt;
|
||||||
uint16_t format_type; // 1 for PCM
|
uint16_t format_type;
|
||||||
uint16_t channels; // 1 for mono
|
uint16_t channels;
|
||||||
uint32_t sample_rate; // 16000
|
uint32_t sample_rate;
|
||||||
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
|
uint32_t byterate;
|
||||||
uint16_t block_align; // channels * (bits_per_sample / 8)
|
uint16_t block_align;
|
||||||
uint16_t bits_per_sample; // 16
|
uint16_t bits_per_sample;
|
||||||
char data_chunk_header[4]; // "data"
|
char data_chunk_header[4];
|
||||||
uint32_t data_size; // data size in bytes
|
uint32_t data_size;
|
||||||
} WavHeader;
|
} WavHeader;
|
||||||
|
|
||||||
static AppCtx g_ctx;
|
static AppCtx g_ctx;
|
||||||
@@ -87,6 +88,20 @@ static void update_ui(AppCtx* ctx);
|
|||||||
static void refresh_memo_list(AppCtx* ctx);
|
static void refresh_memo_list(AppCtx* ctx);
|
||||||
static void on_memo_selected(lv_event_t* e);
|
static void on_memo_selected(lv_event_t* e);
|
||||||
|
|
||||||
|
static bool find_audio_stream_device(AppCtx* ctx) {
|
||||||
|
struct Device* dev = device_find_by_name("audio-stream");
|
||||||
|
if (dev) {
|
||||||
|
ctx->stream_dev = dev;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||||
|
if (dev) {
|
||||||
|
ctx->stream_dev = dev;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── WAV Header Writer ─── */
|
/* ─── WAV Header Writer ─── */
|
||||||
static void write_wav_header(FILE* f, uint32_t sample_rate, uint16_t bits_per_sample, uint16_t channels, uint32_t data_size) {
|
static void write_wav_header(FILE* f, uint32_t sample_rate, uint16_t bits_per_sample, uint16_t channels, uint32_t data_size) {
|
||||||
WavHeader header;
|
WavHeader header;
|
||||||
@@ -114,10 +129,8 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
|||||||
struct tm timeinfo;
|
struct tm timeinfo;
|
||||||
localtime_r(&rawtime, &timeinfo);
|
localtime_r(&rawtime, &timeinfo);
|
||||||
|
|
||||||
// Format: YYYYMMDD_HHMMSS (e.g., 20260629_232202)
|
|
||||||
strftime(datetime_buf, sizeof(datetime_buf), "%Y%m%d_%H%M%S", &timeinfo);
|
strftime(datetime_buf, sizeof(datetime_buf), "%Y%m%d_%H%M%S", &timeinfo);
|
||||||
|
|
||||||
// Default unique name candidate
|
|
||||||
snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf);
|
snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf);
|
||||||
|
|
||||||
char filepath[256];
|
char filepath[256];
|
||||||
@@ -125,24 +138,21 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
|||||||
|
|
||||||
struct stat st;
|
struct stat st;
|
||||||
if (stat(filepath, &st) != 0) {
|
if (stat(filepath, &st) != 0) {
|
||||||
// File does not exist, safe to use
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// File exists, let's search for a unique name by appending _N
|
|
||||||
int suffix = 1;
|
int suffix = 1;
|
||||||
while (1) {
|
while (1) {
|
||||||
snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix);
|
snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix);
|
||||||
snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename);
|
snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename);
|
||||||
if (stat(filepath, &st) != 0) {
|
if (stat(filepath, &st) != 0) {
|
||||||
// Found a unique name!
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
suffix++;
|
suffix++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Record Task ─── */
|
/* ─── Record Task (audio-stream input, resampled to 16k mono) ─── */
|
||||||
static void record_task(void* arg) {
|
static void record_task(void* arg) {
|
||||||
AppCtx* ctx = (AppCtx*)arg;
|
AppCtx* ctx = (AppCtx*)arg;
|
||||||
char filepath[256];
|
char filepath[256];
|
||||||
@@ -164,29 +174,24 @@ static void record_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write default header
|
|
||||||
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
|
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
|
||||||
|
|
||||||
/* Configure I2S for recording - RLCD fix: ES7210 outputs stereo (2 mics)
|
// Open input stream via audio-stream (resampler provides 16k mono regardless of codec native rate)
|
||||||
* even when we want mono file. Working MicroPython uses I2S.STEREO for RX
|
struct AudioStreamConfig in_cfg = {
|
||||||
* then extracts left channel. If we request MONO, driver uses MONO slot mode
|
|
||||||
* but ES7210 still sends 2 slots, causing channel mismatch / low pitch.
|
|
||||||
* So request STEREO from I2S and downmix to mono in the loop.
|
|
||||||
* See audio_util.py: i2s = I2S(..., format=I2S.STEREO, rate=16000) */
|
|
||||||
struct I2sConfig cfg = {
|
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
|
||||||
.sample_rate = SAMPLE_RATE,
|
.sample_rate = SAMPLE_RATE,
|
||||||
.bits_per_sample = BITS_PER_SAMPLE,
|
.bits_per_sample = BITS_PER_SAMPLE,
|
||||||
.channel_left = 0,
|
.channels = 1
|
||||||
.channel_right = 1 // STEREO for ES7210 dual mic - we downmix to mono below
|
|
||||||
};
|
};
|
||||||
|
|
||||||
device_lock(ctx->i2s_dev);
|
error_t err = audio_stream_open_input(ctx->stream_dev, &in_cfg, &ctx->input_handle);
|
||||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
if (err == ERROR_NONE) {
|
||||||
device_unlock(ctx->i2s_dev);
|
// Ensure mic is unmuted and at max gain (ES8311 0..24dB)
|
||||||
|
audio_stream_set_mute(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, false);
|
||||||
|
audio_stream_set_volume(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
|
||||||
|
ESP_LOGI(TAG, "Mic unmuted, gain 100%%");
|
||||||
|
}
|
||||||
if (err != ERROR_NONE) {
|
if (err != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to set I2S config: %d", err);
|
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
|
||||||
fclose(f);
|
fclose(f);
|
||||||
unlink(filepath);
|
unlink(filepath);
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
@@ -199,68 +204,47 @@ static void record_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Recording started -> %s", filepath);
|
ESP_LOGI(TAG, "Recording started -> %s (via audio-stream %u Hz)", filepath, (unsigned)SAMPLE_RATE);
|
||||||
|
|
||||||
size_t total_written = 0;
|
size_t total_written = 0;
|
||||||
uint32_t start_time = xTaskGetTickCount();
|
uint32_t start_time = xTaskGetTickCount();
|
||||||
|
|
||||||
while (ctx->state == STATE_RECORDING) {
|
while (ctx->state == STATE_RECORDING) {
|
||||||
size_t bytes_read = 0;
|
size_t bytes_read = 0;
|
||||||
err = i2s_controller_read(ctx->i2s_dev, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(100));
|
err = audio_stream_read(ctx->input_handle, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(200));
|
||||||
if (err == ERROR_NONE && bytes_read > 0) {
|
if (err == ERROR_NONE && bytes_read > 0) {
|
||||||
// ES7210 stereo -> mono downmix: extract left channel (every other 16-bit sample)
|
// Data already 16k mono from audio-stream resampler, no downmix needed
|
||||||
// Working MP: for i in 0..bytes_read step 4: mono[j:j+2]=buffer[i:i+2]
|
size_t written = fwrite(ctx->audio_buf, 1, bytes_read, f);
|
||||||
// CHUNK_BYTES=1024 mono, but we read AUDIO_BUF_SIZE stereo (4096) then downmix to half
|
if (written != bytes_read) {
|
||||||
size_t mono_bytes = bytes_read / 2;
|
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)bytes_read);
|
||||||
// In-place downmix using separate temp? Use static tmp buffer via audio_buf + mono offset
|
|
||||||
// Easiest: convert in-place from end to start to avoid overwrite
|
|
||||||
// Stereo layout: L0 R0 L1 R1 ... each 2 bytes, total bytes_read
|
|
||||||
// We want L0 L1 L2 ... -> mono_bytes
|
|
||||||
// Do backward copy: dest index = mono_bytes-2, src left = bytes_read-4
|
|
||||||
// This avoids needing second buffer
|
|
||||||
for (int src = (int)bytes_read - 4, dst = (int)mono_bytes - 2; src >= 0 && dst >= 0; src -= 4, dst -= 2) {
|
|
||||||
ctx->audio_buf[dst] = ctx->audio_buf[src];
|
|
||||||
ctx->audio_buf[dst+1] = ctx->audio_buf[src+1];
|
|
||||||
}
|
|
||||||
size_t written = fwrite(ctx->audio_buf, 1, mono_bytes, f);
|
|
||||||
if (written != mono_bytes) {
|
|
||||||
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)mono_bytes);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
total_written += written;
|
total_written += written;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update elapsed time in UI
|
|
||||||
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
|
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
|
||||||
char status_buf[64];
|
char status_buf[64];
|
||||||
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
|
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
|
||||||
|
|
||||||
// Animate progress bar using modulo
|
|
||||||
int bar_val = (int)(((xTaskGetTickCount() - start_time) * 100) / (configTICK_RATE_HZ * 300)); // 5 min max
|
|
||||||
|
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
lv_label_set_text(ctx->lbl_status, status_buf);
|
lv_label_set_text(ctx->lbl_status, status_buf);
|
||||||
lv_bar_set_value(ctx->bar_progress, bar_val % 100, LV_ANIM_OFF);
|
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
|
||||||
tt_lvgl_unlock();
|
tt_lvgl_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewrite WAV header with actual recorded size
|
audio_stream_close(ctx->input_handle);
|
||||||
|
ctx->input_handle = NULL;
|
||||||
|
|
||||||
fseek(f, 0, SEEK_SET);
|
fseek(f, 0, SEEK_SET);
|
||||||
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
|
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
|
||||||
fclose(f);
|
fclose(f);
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written);
|
ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written);
|
||||||
|
|
||||||
// Reset I2S controller to stop/release DMA channel
|
|
||||||
device_lock(ctx->i2s_dev);
|
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
|
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
ctx->state = STATE_IDLE;
|
ctx->state = STATE_IDLE;
|
||||||
refresh_memo_list(ctx);
|
refresh_memo_list(ctx);
|
||||||
|
|
||||||
// Select the new file automatically
|
|
||||||
ctx->selected_index = -1;
|
ctx->selected_index = -1;
|
||||||
for (int i = 0; i < ctx->memo_count; i++) {
|
for (int i = 0; i < ctx->memo_count; i++) {
|
||||||
if (strcmp(ctx->memos[i], filename) == 0) {
|
if (strcmp(ctx->memos[i], filename) == 0) {
|
||||||
@@ -276,7 +260,7 @@ static void record_task(void* arg) {
|
|||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Playback Task ─── */
|
/* ─── Playback Task (audio-stream output with resampling) ─── */
|
||||||
static void play_task(void* arg) {
|
static void play_task(void* arg) {
|
||||||
AppCtx* ctx = (AppCtx*)arg;
|
AppCtx* ctx = (AppCtx*)arg;
|
||||||
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
|
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
|
||||||
@@ -305,7 +289,6 @@ static void play_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse WAV header
|
|
||||||
WavHeader header;
|
WavHeader header;
|
||||||
if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) {
|
if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) {
|
||||||
ESP_LOGE(TAG, "Failed to read WAV header");
|
ESP_LOGE(TAG, "Failed to read WAV header");
|
||||||
@@ -320,7 +303,6 @@ static void play_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file structure
|
|
||||||
if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) {
|
if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) {
|
||||||
ESP_LOGE(TAG, "Invalid WAV format");
|
ESP_LOGE(TAG, "Invalid WAV format");
|
||||||
fclose(f);
|
fclose(f);
|
||||||
@@ -334,21 +316,16 @@ static void play_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Configure I2S based on file metadata */
|
/* Open output stream via audio-stream (resampler converts file rate -> native 44100) */
|
||||||
struct I2sConfig cfg = {
|
struct AudioStreamConfig out_cfg = {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
|
||||||
.sample_rate = header.sample_rate,
|
.sample_rate = header.sample_rate,
|
||||||
.bits_per_sample = header.bits_per_sample,
|
.bits_per_sample = header.bits_per_sample,
|
||||||
.channel_left = 0,
|
.channels = header.channels
|
||||||
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
|
||||||
};
|
};
|
||||||
|
|
||||||
device_lock(ctx->i2s_dev);
|
error_t err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
|
|
||||||
if (err != ERROR_NONE) {
|
if (err != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
|
ESP_LOGE(TAG, "audio_stream_open_output failed for playback: %d (rate=%u)", err, (unsigned)header.sample_rate);
|
||||||
fclose(f);
|
fclose(f);
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
ctx->state = STATE_IDLE;
|
ctx->state = STATE_IDLE;
|
||||||
@@ -360,7 +337,7 @@ static void play_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Playing: %s, rate=%u Hz, channels=%u, size=%u bytes",
|
ESP_LOGI(TAG, "Playing via audio-stream: %s, rate=%u Hz, ch=%u, size=%u",
|
||||||
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
|
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
|
||||||
|
|
||||||
size_t total_played = 0;
|
size_t total_played = 0;
|
||||||
@@ -371,40 +348,35 @@ static void play_task(void* arg) {
|
|||||||
fseek(f, sizeof(WavHeader), SEEK_SET);
|
fseek(f, sizeof(WavHeader), SEEK_SET);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool i2s_configured = true;
|
bool out_open = true;
|
||||||
|
|
||||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
if (i2s_configured) {
|
if (out_open) {
|
||||||
// Reset I2S controller to stop DMA immediately when pausing
|
audio_stream_close(ctx->output_handle);
|
||||||
device_lock(ctx->i2s_dev);
|
ctx->output_handle = NULL;
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
out_open = false;
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
i2s_configured = false;
|
|
||||||
}
|
}
|
||||||
vTaskDelay(pdMS_TO_TICKS(50));
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!i2s_configured) {
|
if (!out_open) {
|
||||||
// Reconfigure I2S when resuming
|
err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||||
device_lock(ctx->i2s_dev);
|
|
||||||
err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
if (err != ERROR_NONE) {
|
if (err != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to reconfigure I2S on resume: %d", err);
|
ESP_LOGE(TAG, "Failed to reopen audio_stream on resume: %d", err);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
i2s_configured = true;
|
out_open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES;
|
size_t to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES;
|
||||||
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f);
|
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f);
|
||||||
if (read_bytes == 0) {
|
if (read_bytes == 0) {
|
||||||
break; // EOF
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scaling Volume
|
// Volume scaling
|
||||||
int vol = ctx->volume;
|
int vol = ctx->volume;
|
||||||
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
|
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
|
||||||
size_t sample_count = read_bytes / sizeof(int16_t);
|
size_t sample_count = read_bytes / sizeof(int16_t);
|
||||||
@@ -417,22 +389,19 @@ static void play_task(void* arg) {
|
|||||||
bool write_err = false;
|
bool write_err = false;
|
||||||
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
|
err = audio_stream_write(ctx->output_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||||
if (err != ERROR_NONE || written == 0) {
|
if (err != ERROR_NONE || written == 0) {
|
||||||
ESP_LOGE(TAG, "I2S playback write error: %d", err);
|
ESP_LOGE(TAG, "audio_stream write error: %d", err);
|
||||||
write_err = true;
|
write_err = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
offset += written;
|
offset += written;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (write_err) {
|
if (write_err) break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
total_played += read_bytes;
|
total_played += read_bytes;
|
||||||
|
|
||||||
// UI progress update
|
|
||||||
int pct = (int)((total_played * 100) / data_size);
|
int pct = (int)((total_played * 100) / data_size);
|
||||||
uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8);
|
uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8);
|
||||||
uint32_t elapsed_sec = total_played / bytes_per_sec;
|
uint32_t elapsed_sec = total_played / bytes_per_sec;
|
||||||
@@ -452,10 +421,10 @@ static void play_task(void* arg) {
|
|||||||
fclose(f);
|
fclose(f);
|
||||||
ESP_LOGI(TAG, "Playback task complete");
|
ESP_LOGI(TAG, "Playback task complete");
|
||||||
|
|
||||||
// Reset I2S controller to stop/release DMA channel and prevent looping noise
|
if (out_open && ctx->output_handle) {
|
||||||
device_lock(ctx->i2s_dev);
|
audio_stream_close(ctx->output_handle);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
ctx->output_handle = NULL;
|
||||||
device_unlock(ctx->i2s_dev);
|
}
|
||||||
|
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
ctx->state = STATE_IDLE;
|
ctx->state = STATE_IDLE;
|
||||||
@@ -470,12 +439,12 @@ static void play_task(void* arg) {
|
|||||||
static void on_record_click(lv_event_t* e) {
|
static void on_record_click(lv_event_t* e) {
|
||||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
if (ctx->state != STATE_IDLE) return;
|
if (ctx->state != STATE_IDLE) return;
|
||||||
if (ctx->i2s_dev == NULL) return;
|
if (ctx->stream_dev == NULL) return;
|
||||||
|
|
||||||
ctx->state = STATE_RECORDING;
|
ctx->state = STATE_RECORDING;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
|
|
||||||
xTaskCreate(record_task, "voice_rec", 4096, ctx, 5, &ctx->task_handle);
|
xTaskCreate(record_task, "voice_rec", 8192, ctx, 5, &ctx->task_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void on_play_pause_click(lv_event_t* e) {
|
static void on_play_pause_click(lv_event_t* e) {
|
||||||
@@ -485,7 +454,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
|||||||
if (ctx->state == STATE_IDLE) {
|
if (ctx->state == STATE_IDLE) {
|
||||||
ctx->state = STATE_PLAYING;
|
ctx->state = STATE_PLAYING;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle);
|
xTaskCreate(play_task, "voice_play", 8192, ctx, 5, &ctx->task_handle);
|
||||||
} else if (ctx->state == STATE_PLAYING) {
|
} else if (ctx->state == STATE_PLAYING) {
|
||||||
ctx->state = STATE_PAUSED;
|
ctx->state = STATE_PAUSED;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
@@ -523,14 +492,12 @@ static void on_memo_selected(lv_event_t* e) {
|
|||||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
lv_obj_t* btn = lv_event_get_target(e);
|
lv_obj_t* btn = lv_event_get_target(e);
|
||||||
|
|
||||||
// Clear check states from other entries
|
|
||||||
uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
|
uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
|
||||||
for (uint32_t i = 0; i < child_cnt; i++) {
|
for (uint32_t i = 0; i < child_cnt; i++) {
|
||||||
lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i);
|
lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i);
|
||||||
lv_obj_clear_state(child, LV_STATE_CHECKED);
|
lv_obj_clear_state(child, LV_STATE_CHECKED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply check state to the clicked memo
|
|
||||||
lv_obj_add_state(btn, LV_STATE_CHECKED);
|
lv_obj_add_state(btn, LV_STATE_CHECKED);
|
||||||
|
|
||||||
const char* text = lv_list_get_btn_text(ctx->lst_memos, btn);
|
const char* text = lv_list_get_btn_text(ctx->lst_memos, btn);
|
||||||
@@ -568,7 +535,6 @@ static void refresh_memo_list(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
closedir(dir);
|
closedir(dir);
|
||||||
|
|
||||||
// Simple bubble sort
|
|
||||||
for (int i = 0; i < ctx->memo_count - 1; i++) {
|
for (int i = 0; i < ctx->memo_count - 1; i++) {
|
||||||
for (int j = 0; j < ctx->memo_count - i - 1; j++) {
|
for (int j = 0; j < ctx->memo_count - i - 1; j++) {
|
||||||
if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) {
|
if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) {
|
||||||
@@ -580,7 +546,6 @@ static void refresh_memo_list(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate UI List
|
|
||||||
for (int i = 0; i < ctx->memo_count; i++) {
|
for (int i = 0; i < ctx->memo_count; i++) {
|
||||||
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
|
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
|
||||||
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
|
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
|
||||||
@@ -647,115 +612,100 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
g_ctx.volume = 80;
|
g_ctx.volume = 80;
|
||||||
g_ctx.selected_index = -1;
|
g_ctx.selected_index = -1;
|
||||||
|
|
||||||
// Create the memos storage directory if missing
|
|
||||||
mkdir("/sdcard/memos", 0755);
|
mkdir("/sdcard/memos", 0755);
|
||||||
|
|
||||||
// Allocate audio buffer
|
|
||||||
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
|
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
|
||||||
if (g_ctx.audio_buf == NULL) {
|
if (g_ctx.audio_buf == NULL) {
|
||||||
ESP_LOGE(TAG, "Failed to allocate audio buffer");
|
ESP_LOGE(TAG, "Failed to allocate audio buffer");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locate I2S hardware channel
|
if (!find_audio_stream_device(&g_ctx)) {
|
||||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
ESP_LOGE(TAG, "audio-stream device not found!");
|
||||||
if (g_ctx.i2s_dev == NULL) {
|
|
||||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── UI Setup ───
|
|
||||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
|
||||||
// Premium Player Card
|
|
||||||
lv_obj_t* card = lv_obj_create(parent);
|
lv_obj_t* card = lv_obj_create(parent);
|
||||||
lv_obj_set_size(card, lv_pct(95), lv_pct(88));
|
lv_obj_set_size(card, lv_pct(95), lv_pct(88));
|
||||||
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
|
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
|
||||||
lv_obj_set_style_radius(card, 15, 0);
|
lv_obj_set_style_radius(card, 15, 0);
|
||||||
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); // mocha base
|
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
|
||||||
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); // mocha surface0
|
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
|
||||||
lv_obj_set_style_border_width(card, 2, 0);
|
lv_obj_set_style_border_width(card, 2, 0);
|
||||||
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||||
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
lv_obj_set_style_pad_all(card, 10, 0);
|
lv_obj_set_style_pad_all(card, 10, 0);
|
||||||
lv_obj_set_style_pad_gap(card, 8, 0);
|
lv_obj_set_style_pad_gap(card, 8, 0);
|
||||||
|
|
||||||
// Status Area
|
|
||||||
g_ctx.lbl_status = lv_label_create(card);
|
g_ctx.lbl_status = lv_label_create(card);
|
||||||
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
|
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
|
||||||
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
|
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); // mocha text
|
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
|
||||||
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
|
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
|
||||||
|
|
||||||
// Progress Bar
|
|
||||||
g_ctx.bar_progress = lv_bar_create(card);
|
g_ctx.bar_progress = lv_bar_create(card);
|
||||||
lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8);
|
lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8);
|
||||||
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
|
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
|
||||||
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
|
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
|
||||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); // mocha surface1
|
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
|
||||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); // mocha blue
|
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||||
|
|
||||||
// Memos List
|
|
||||||
g_ctx.lst_memos = lv_list_create(card);
|
g_ctx.lst_memos = lv_list_create(card);
|
||||||
lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85);
|
lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85);
|
||||||
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0); // mocha mantle
|
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0);
|
||||||
lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0);
|
lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0);
|
||||||
lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0);
|
lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0);
|
||||||
lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0);
|
lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0);
|
||||||
|
|
||||||
// Controls Box
|
|
||||||
lv_obj_t* ctrl_box = lv_obj_create(card);
|
lv_obj_t* ctrl_box = lv_obj_create(card);
|
||||||
lv_obj_remove_style_all(ctrl_box);
|
lv_obj_remove_style_all(ctrl_box);
|
||||||
lv_obj_set_size(ctrl_box, lv_pct(95), 40);
|
lv_obj_set_size(ctrl_box, lv_pct(95), 40);
|
||||||
lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW);
|
lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW);
|
||||||
lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
|
||||||
// Rec Button
|
|
||||||
g_ctx.btn_record = lv_btn_create(ctrl_box);
|
g_ctx.btn_record = lv_btn_create(ctrl_box);
|
||||||
lv_obj_set_size(g_ctx.btn_record, 45, 32);
|
lv_obj_set_size(g_ctx.btn_record, 45, 32);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_record, 8, 0);
|
lv_obj_set_style_radius(g_ctx.btn_record, 8, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); // mocha red
|
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0);
|
lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0);
|
||||||
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
|
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
|
||||||
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
|
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
|
||||||
lv_obj_center(lbl_rec);
|
lv_obj_center(lbl_rec);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Play/Pause Button
|
|
||||||
g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
|
g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
|
||||||
lv_obj_set_size(g_ctx.btn_play_pause, 45, 32);
|
lv_obj_set_size(g_ctx.btn_play_pause, 45, 32);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0);
|
lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); // mocha blue
|
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
|
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
|
||||||
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
|
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
|
||||||
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
|
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
|
||||||
lv_obj_center(lbl_play);
|
lv_obj_center(lbl_play);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Stop Button
|
|
||||||
g_ctx.btn_stop = lv_btn_create(ctrl_box);
|
g_ctx.btn_stop = lv_btn_create(ctrl_box);
|
||||||
lv_obj_set_size(g_ctx.btn_stop, 45, 32);
|
lv_obj_set_size(g_ctx.btn_stop, 45, 32);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0);
|
lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0); // mocha subtext1
|
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
|
lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
|
||||||
lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
|
lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
|
||||||
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
|
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
|
||||||
lv_obj_center(lbl_stop);
|
lv_obj_center(lbl_stop);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Delete Button
|
|
||||||
g_ctx.btn_delete = lv_btn_create(ctrl_box);
|
g_ctx.btn_delete = lv_btn_create(ctrl_box);
|
||||||
lv_obj_set_size(g_ctx.btn_delete, 45, 32);
|
lv_obj_set_size(g_ctx.btn_delete, 45, 32);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0);
|
lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); // mocha sapphire
|
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0);
|
lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0);
|
||||||
lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete);
|
lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete);
|
||||||
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
|
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
|
||||||
lv_obj_center(lbl_del);
|
lv_obj_center(lbl_del);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Initialize list and control state
|
if (g_ctx.stream_dev == NULL) {
|
||||||
if (g_ctx.i2s_dev == NULL) {
|
lv_label_set_text(g_ctx.lbl_status, "ERROR: audio-stream missing");
|
||||||
lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S 'i2s0' missing");
|
|
||||||
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
|
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
|
||||||
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
|
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
|
||||||
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
||||||
@@ -778,16 +728,17 @@ static void onHideApp(AppHandle app, void* data) {
|
|||||||
g_ctx.state = STATE_IDLE;
|
g_ctx.state = STATE_IDLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for task completion
|
|
||||||
while (g_ctx.task_handle != NULL) {
|
while (g_ctx.task_handle != NULL) {
|
||||||
vTaskDelay(pdMS_TO_TICKS(10));
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset I2S to ensure DMA channel is stopped
|
if (g_ctx.input_handle) {
|
||||||
if (g_ctx.i2s_dev != NULL) {
|
audio_stream_close(g_ctx.input_handle);
|
||||||
device_lock(g_ctx.i2s_dev);
|
g_ctx.input_handle = NULL;
|
||||||
i2s_controller_reset(g_ctx.i2s_dev);
|
}
|
||||||
device_unlock(g_ctx.i2s_dev);
|
if (g_ctx.output_handle) {
|
||||||
|
audio_stream_close(g_ctx.output_handle);
|
||||||
|
g_ctx.output_handle = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_ctx.audio_buf != NULL) {
|
if (g_ctx.audio_buf != NULL) {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3
|
||||||
|
app.id=one.tactility.voicerecorder
|
||||||
|
app.version.name=1.0.0
|
||||||
|
app.version.code=1
|
||||||
|
app.name=Voice Recorder
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
SHELL_COLOR_RED = "\033[91m"
|
||||||
|
SHELL_COLOR_ORANGE = "\033[93m"
|
||||||
|
SHELL_COLOR_RESET = "\033[m"
|
||||||
|
|
||||||
|
def print_warning(message):
|
||||||
|
print(f"{SHELL_COLOR_ORANGE}WARNING: {message}{SHELL_COLOR_RESET}")
|
||||||
|
|
||||||
|
def print_error(message):
|
||||||
|
print(f"{SHELL_COLOR_RED}ERROR: {message}{SHELL_COLOR_RESET}")
|
||||||
|
|
||||||
|
def print_help():
|
||||||
|
print("Usage: python upload-app-files.py [path] [sdkVersion] [cloudflareAccountId] [cloudflareTokenName] [cloudflareTokenValue]")
|
||||||
|
print("")
|
||||||
|
print("Options:")
|
||||||
|
print(" --index-only Upload only apps.json")
|
||||||
|
|
||||||
|
def exit_with_error(message):
|
||||||
|
print_error(message)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def main(path: str, sdk_version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
exit_with_error(f"Path not found: {path}")
|
||||||
|
s3 = boto3.client(
|
||||||
|
service_name="s3",
|
||||||
|
endpoint_url=f"https://{cloudflare_account_id}.r2.cloudflarestorage.com",
|
||||||
|
aws_access_key_id=cloudflare_token_name,
|
||||||
|
aws_secret_access_key=cloudflare_token_value,
|
||||||
|
region_name="auto"
|
||||||
|
)
|
||||||
|
files_to_upload = os.listdir(path)
|
||||||
|
if index_only:
|
||||||
|
files_to_upload = [f for f in files_to_upload if f == 'apps.json']
|
||||||
|
else:
|
||||||
|
# Ensure apps.json is uploaded last so it never references files that
|
||||||
|
# haven't finished uploading yet.
|
||||||
|
files_to_upload.sort(key=lambda f: f == 'apps.json')
|
||||||
|
counter = 1
|
||||||
|
total = len(files_to_upload)
|
||||||
|
for file_name in files_to_upload:
|
||||||
|
object_path = f"apps/{sdk_version}/{file_name}"
|
||||||
|
print(f"[{counter}/{total}] Uploading {file_name} to {object_path}")
|
||||||
|
file_path = os.path.join(path, file_name)
|
||||||
|
try:
|
||||||
|
s3.upload_file(file_path, "tactility", object_path)
|
||||||
|
except Exception as e:
|
||||||
|
exit_with_error(f"Failed to upload {file_name}: {str(e)}")
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Tactility CDN Apps Uploader")
|
||||||
|
if "--help" in sys.argv:
|
||||||
|
print_help()
|
||||||
|
sys.exit()
|
||||||
|
# Argument validation
|
||||||
|
if len(sys.argv) < 6:
|
||||||
|
print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
main(
|
||||||
|
path=sys.argv[1],
|
||||||
|
sdk_version=sys.argv[2],
|
||||||
|
cloudflare_account_id=sys.argv[3],
|
||||||
|
cloudflare_token_name=sys.argv[4],
|
||||||
|
cloudflare_token_value=sys.argv[5],
|
||||||
|
index_only="--index-only" in sys.argv
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "GameKitScene.h"
|
||||||
|
#include "GameKitLoop.h"
|
||||||
|
#include "GameKitGrid.h"
|
||||||
|
#include "GameKitInput.h"
|
||||||
|
#include "GameKitDraw.h"
|
||||||
|
#include "GameKitPrefs.h"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <lvgl.h>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
lv_obj_t* makePanel(lv_obj_t* parent, lv_color_t color, uint8_t radius = 8);
|
||||||
|
lv_obj_t* makeCell(lv_obj_t* parent, uint16_t size, lv_color_t color, uint8_t radius = 4);
|
||||||
|
lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, lv_color_t color);
|
||||||
|
void setCell(lv_obj_t* obj, int x, int y, uint16_t cellPx);
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <lvgl.h>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
struct GridPos {
|
||||||
|
int16_t x = 0;
|
||||||
|
int16_t y = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline bool operator==(GridPos a, GridPos b) { return a.x == b.x && a.y == b.y; }
|
||||||
|
inline bool operator!=(GridPos a, GridPos b) { return !(a == b); }
|
||||||
|
|
||||||
|
struct GridSpec {
|
||||||
|
uint8_t cols = 0;
|
||||||
|
uint8_t rows = 0;
|
||||||
|
uint16_t cellPx = 0;
|
||||||
|
uint16_t originX = 0;
|
||||||
|
uint16_t originY = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
lv_point_t gridToPx(const GridSpec& grid, GridPos pos);
|
||||||
|
bool gridContains(const GridSpec& grid, GridPos pos);
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "GameKitScene.h"
|
||||||
|
#include <lvgl.h>
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
InputKind keyToInput(uint32_t key);
|
||||||
|
InputKind gestureToInput(lv_dir_t dir);
|
||||||
|
InputKind pointToQuadrant(lv_obj_t* obj, lv_point_t point);
|
||||||
|
void attachInput(lv_obj_t* target, Scene* scene);
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "GameKitScene.h"
|
||||||
|
#include <lvgl.h>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
class Loop {
|
||||||
|
lv_timer_t* timer = nullptr;
|
||||||
|
Scene* scene = nullptr;
|
||||||
|
uint32_t periodMs = 0;
|
||||||
|
uint32_t lastTickMs = 0;
|
||||||
|
|
||||||
|
static void onTimer(lv_timer_t* timer);
|
||||||
|
|
||||||
|
public:
|
||||||
|
~Loop();
|
||||||
|
bool start(uint32_t tickMs, Scene* targetScene);
|
||||||
|
void stop();
|
||||||
|
bool isRunning() const { return timer != nullptr; }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <TactilityCpp/Preferences.h>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
class Prefs {
|
||||||
|
Preferences prefs;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Prefs(const char* ns) : prefs(ns) {}
|
||||||
|
int32_t getInt(const char* key, int32_t fallback = 0) const { return prefs.getInt32(key, fallback); }
|
||||||
|
void putInt(const char* key, int32_t value) const { prefs.putInt32(key, value); }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <lvgl.h>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
struct Tick {
|
||||||
|
uint32_t dtMs = 0;
|
||||||
|
uint32_t nowMs = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class InputKind : uint8_t {
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Confirm,
|
||||||
|
Cancel,
|
||||||
|
Menu,
|
||||||
|
Touch,
|
||||||
|
Swipe,
|
||||||
|
Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct InputEvent {
|
||||||
|
InputKind kind = InputKind::Unknown;
|
||||||
|
lv_point_t point {0, 0};
|
||||||
|
};
|
||||||
|
|
||||||
|
class Scene {
|
||||||
|
public:
|
||||||
|
virtual ~Scene() = default;
|
||||||
|
virtual void onEnter(lv_obj_t* root) = 0;
|
||||||
|
virtual void onExit() = 0;
|
||||||
|
virtual void onInput(const InputEvent& input) = 0;
|
||||||
|
virtual void onTick(const Tick& tick) = 0;
|
||||||
|
virtual void render() = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# GameKit
|
||||||
|
|
||||||
|
Small LVGL-first helper library for Tactility games.
|
||||||
|
|
||||||
|
Current scope:
|
||||||
|
- `Loop`: `lv_timer` lifecycle wrapper.
|
||||||
|
- `Scene`: common enter/exit/input/tick/render interface.
|
||||||
|
- `Input`: normalize keyboard arrows/WASD, gestures, and touch-quadrant clicks.
|
||||||
|
- `Grid`: tiny tile-grid coordinate helpers.
|
||||||
|
- `Draw`: lightweight panel/cell/label helpers.
|
||||||
|
- `Prefs`: small wrapper around Tactility preferences.
|
||||||
|
|
||||||
|
This intentionally avoids a heavy sprite engine. First consumers should use LVGL rectangles/labels, then add canvas/sprite helpers only when a game proves it needs them.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#include "GameKitDraw.h"
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
lv_obj_t* makePanel(lv_obj_t* parent, lv_color_t color, uint8_t radius) {
|
||||||
|
lv_obj_t* obj = lv_obj_create(parent);
|
||||||
|
lv_obj_set_style_bg_color(obj, color, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_radius(obj, radius, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* makeCell(lv_obj_t* parent, uint16_t size, lv_color_t color, uint8_t radius) {
|
||||||
|
lv_obj_t* obj = makePanel(parent, color, radius);
|
||||||
|
lv_obj_set_size(obj, size, size);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, lv_color_t color) {
|
||||||
|
lv_obj_t* label = lv_label_create(parent);
|
||||||
|
lv_label_set_text(label, text);
|
||||||
|
lv_obj_set_style_text_color(label, color, LV_PART_MAIN);
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCell(lv_obj_t* obj, int x, int y, uint16_t cellPx) {
|
||||||
|
lv_obj_set_pos(obj, x * cellPx, y * cellPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#include "GameKitGrid.h"
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
lv_point_t gridToPx(const GridSpec& grid, GridPos pos) {
|
||||||
|
return lv_point_t {
|
||||||
|
static_cast<lv_coord_t>(grid.originX + pos.x * grid.cellPx),
|
||||||
|
static_cast<lv_coord_t>(grid.originY + pos.y * grid.cellPx)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool gridContains(const GridSpec& grid, GridPos pos) {
|
||||||
|
return pos.x >= 0 && pos.y >= 0 && pos.x < grid.cols && pos.y < grid.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#include "GameKitInput.h"
|
||||||
|
#include <tt_lvgl_keyboard.h>
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
InputKind keyToInput(uint32_t key) {
|
||||||
|
switch (key) {
|
||||||
|
case LV_KEY_UP: return InputKind::Up;
|
||||||
|
case LV_KEY_DOWN: return InputKind::Down;
|
||||||
|
case LV_KEY_LEFT: return InputKind::Left;
|
||||||
|
case LV_KEY_RIGHT: return InputKind::Right;
|
||||||
|
case LV_KEY_ENTER: return InputKind::Confirm;
|
||||||
|
case LV_KEY_ESC: return InputKind::Cancel;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (key == 'w' || key == 'W') return InputKind::Up;
|
||||||
|
if (key == 's' || key == 'S') return InputKind::Down;
|
||||||
|
if (key == 'a' || key == 'A') return InputKind::Left;
|
||||||
|
if (key == 'd' || key == 'D') return InputKind::Right;
|
||||||
|
if (key == 'q' || key == 'Q') return InputKind::Cancel;
|
||||||
|
return InputKind::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputKind gestureToInput(lv_dir_t dir) {
|
||||||
|
if (dir & LV_DIR_TOP) return InputKind::Up;
|
||||||
|
if (dir & LV_DIR_BOTTOM) return InputKind::Down;
|
||||||
|
if (dir & LV_DIR_LEFT) return InputKind::Left;
|
||||||
|
if (dir & LV_DIR_RIGHT) return InputKind::Right;
|
||||||
|
return InputKind::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputKind pointToQuadrant(lv_obj_t* obj, lv_point_t point) {
|
||||||
|
const lv_coord_t w = lv_obj_get_width(obj);
|
||||||
|
const lv_coord_t h = lv_obj_get_height(obj);
|
||||||
|
const lv_coord_t cx = w / 2;
|
||||||
|
const lv_coord_t cy = h / 2;
|
||||||
|
const lv_coord_t dx = point.x - cx;
|
||||||
|
const lv_coord_t dy = point.y - cy;
|
||||||
|
if (LV_ABS(dx) > LV_ABS(dy)) return dx < 0 ? InputKind::Left : InputKind::Right;
|
||||||
|
return dy < 0 ? InputKind::Up : InputKind::Down;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void inputEvent(lv_event_t* e) {
|
||||||
|
auto* scene = static_cast<Scene*>(lv_event_get_user_data(e));
|
||||||
|
if (scene == nullptr) return;
|
||||||
|
InputEvent input;
|
||||||
|
const lv_event_code_t code = lv_event_get_code(e);
|
||||||
|
lv_obj_t* target = lv_event_get_target_obj(e);
|
||||||
|
if (code == LV_EVENT_KEY) {
|
||||||
|
input.kind = keyToInput(lv_event_get_key(e));
|
||||||
|
} else if (code == LV_EVENT_GESTURE) {
|
||||||
|
input.kind = gestureToInput(lv_indev_get_gesture_dir(lv_indev_active()));
|
||||||
|
} else if (code == LV_EVENT_CLICKED) {
|
||||||
|
lv_indev_t* indev = lv_indev_active();
|
||||||
|
if (indev) {
|
||||||
|
lv_indev_get_point(indev, &input.point);
|
||||||
|
lv_area_t coords;
|
||||||
|
lv_obj_get_coords(target, &coords);
|
||||||
|
input.point.x -= coords.x1;
|
||||||
|
input.point.y -= coords.y1;
|
||||||
|
input.kind = pointToQuadrant(target, input.point);
|
||||||
|
} else {
|
||||||
|
input.kind = InputKind::Confirm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.kind != InputKind::Unknown) scene->onInput(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
void attachInput(lv_obj_t* target, Scene* scene) {
|
||||||
|
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 (tt_lvgl_hardware_keyboard_is_available()) {
|
||||||
|
lv_group_t* group = lv_group_get_default();
|
||||||
|
if (group) {
|
||||||
|
lv_group_add_obj(group, target);
|
||||||
|
lv_group_focus_obj(target);
|
||||||
|
lv_group_set_editing(group, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#include "GameKitLoop.h"
|
||||||
|
|
||||||
|
namespace GameKit {
|
||||||
|
|
||||||
|
Loop::~Loop() { stop(); }
|
||||||
|
|
||||||
|
bool Loop::start(uint32_t tickMs, Scene* targetScene) {
|
||||||
|
stop();
|
||||||
|
if (targetScene == nullptr || tickMs == 0) return false;
|
||||||
|
scene = targetScene;
|
||||||
|
periodMs = tickMs;
|
||||||
|
lastTickMs = lv_tick_get();
|
||||||
|
timer = lv_timer_create(onTimer, tickMs, this);
|
||||||
|
return timer != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Loop::stop() {
|
||||||
|
if (timer != nullptr) {
|
||||||
|
lv_timer_delete(timer);
|
||||||
|
timer = nullptr;
|
||||||
|
}
|
||||||
|
scene = nullptr;
|
||||||
|
periodMs = 0;
|
||||||
|
lastTickMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Loop::onTimer(lv_timer_t* timer) {
|
||||||
|
auto* loop = static_cast<Loop*>(lv_timer_get_user_data(timer));
|
||||||
|
if (loop == nullptr || loop->scene == nullptr) return;
|
||||||
|
const uint32_t now = lv_tick_get();
|
||||||
|
uint32_t dt = loop->periodMs;
|
||||||
|
if (loop->lastTickMs != 0) dt = now - loop->lastTickMs;
|
||||||
|
loop->lastTickMs = now;
|
||||||
|
loop->scene->onTick(Tick { dt, now });
|
||||||
|
loop->scene->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace GameKit
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Peanut-GB vendored library
|
||||||
|
|
||||||
|
Source: https://github.com/deltabeard/Peanut-GB
|
||||||
|
File: peanut_gb.h (single-header emulator)
|
||||||
|
License: MIT License - Copyright (c) 2018-2023 Mahyar Koshkouei
|
||||||
|
Date Vendored: 2026-07-17 UTC
|
||||||
|
Upstream: master branch latest as fetched 2026-07-17
|
||||||
|
Commit URL: https://github.com/deltabeard/Peanut-GB/tree/master
|
||||||
|
Size: ~4044 lines
|
||||||
|
|
||||||
|
MIT license text is preserved intact at top of peanut_gb.h header.
|
||||||
|
SameBoy-derived portions: Copyright (c) 2015-2019 Lior Halphon, also MIT.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
```c
|
||||||
|
#define ENABLE_SOUND 0
|
||||||
|
#define ENABLE_LCD 1
|
||||||
|
#include "peanut_gb.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
No modifications applied — used as-is.
|
||||||
|
|
||||||
|
For Tactility GameBoy app, audio is disabled (ENABLE_SOUND 0) per prototype spec.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/audio_stream.h>
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "freertos/queue.h"
|
#include "freertos/queue.h"
|
||||||
@@ -277,7 +278,8 @@ private:
|
|||||||
// State
|
// State
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
Device* i2sDevice_ = nullptr;
|
Device* streamDevice_ = nullptr;
|
||||||
|
AudioStreamHandle streamHandle_ = nullptr;
|
||||||
TaskHandle_t task_ = nullptr;
|
TaskHandle_t task_ = nullptr;
|
||||||
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
|
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
|
||||||
QueueHandle_t msgQueue_ = nullptr;
|
QueueHandle_t msgQueue_ = nullptr;
|
||||||
|
|||||||
@@ -35,11 +35,8 @@ if (!engine->start()) {
|
|||||||
ESP_LOGE(TAG, "Failed to start SfxEngine");
|
ESP_LOGE(TAG, "Failed to start SfxEngine");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
|
|
||||||
|
|
||||||
engine->play(SfxId::Coin); // Predefined SFX
|
engine->play(SfxId::Coin); // Predefined SFX
|
||||||
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
|
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
|
||||||
engine->setVolume(0.7f); // Volume control
|
|
||||||
|
|
||||||
engine->stop();
|
engine->stop();
|
||||||
delete engine;
|
delete engine;
|
||||||
@@ -87,9 +84,11 @@ idf_component_register(
|
|||||||
- `void stopVoice(voice)` - Stop specific voice
|
- `void stopVoice(voice)` - Stop specific voice
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
|
|
||||||
- `void setEnabled(bool)` - Mute/unmute
|
- `void setEnabled(bool)` - Mute/unmute
|
||||||
- `void applyVolumePreset(VolumePreset)` - Apply Quiet/Normal/Loud preset (configures volume, gate, normalization)
|
|
||||||
|
Loudness is controlled by the system output volume (set via the audio_stream device / Settings UI),
|
||||||
|
not by SfxEngine itself -- a fixed app-side gain on top of hardware attenuation gets swamped at low
|
||||||
|
system volumes, so there's no separate volume control here.
|
||||||
|
|
||||||
### Mixing (consistent with SoundEngine)
|
### Mixing (consistent with SoundEngine)
|
||||||
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
|
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
#include "SfxEngine.h"
|
#include "SfxEngine.h"
|
||||||
#include "SfxDefinitions.h"
|
#include "SfxDefinitions.h"
|
||||||
|
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/audio_stream.h>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
@@ -424,7 +425,7 @@ void SfxEngine::audioTaskFunc(void* param) {
|
|||||||
size_t written;
|
size_t written;
|
||||||
QueueMsg msg;
|
QueueMsg msg;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Audio task started");
|
ESP_LOGI(TAG, "Audio task started (audio-stream)");
|
||||||
|
|
||||||
while (self->running_) {
|
while (self->running_) {
|
||||||
// Process queued messages (non-blocking)
|
// Process queued messages (non-blocking)
|
||||||
@@ -463,19 +464,26 @@ void SfxEngine::audioTaskFunc(void* param) {
|
|||||||
// Fill audio buffer (member buffer to avoid stack pressure)
|
// Fill audio buffer (member buffer to avoid stack pressure)
|
||||||
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
|
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
|
||||||
|
|
||||||
// Write to I2S
|
// Write via audio-stream (resampled to native codec rate, e.g. 44100)
|
||||||
error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
|
if (self->streamHandle_ == nullptr) {
|
||||||
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
error_t error = audio_stream_write(self->streamHandle_, self->audioBuffer_,
|
||||||
|
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(200));
|
||||||
if (error != ERROR_NONE) {
|
if (error != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "I2S write error");
|
ESP_LOGE(TAG, "audio_stream_write error %d", error);
|
||||||
self->running_ = false;
|
self->running_ = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush silence
|
// Flush silence to avoid pop
|
||||||
|
if (self->streamHandle_ != nullptr) {
|
||||||
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
||||||
i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
|
size_t dummy = 0;
|
||||||
|
audio_stream_write(self->streamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &dummy, pdMS_TO_TICKS(100));
|
||||||
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Audio task exiting");
|
ESP_LOGI(TAG, "Audio task exiting");
|
||||||
|
|
||||||
@@ -494,33 +502,38 @@ void SfxEngine::audioTaskFunc(void* param) {
|
|||||||
bool SfxEngine::start() {
|
bool SfxEngine::start() {
|
||||||
if (running_) return true;
|
if (running_) return true;
|
||||||
|
|
||||||
// Find I2S device
|
// Find audio-stream device (new audio provision)
|
||||||
i2sDevice_ = nullptr;
|
streamDevice_ = nullptr;
|
||||||
device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) {
|
streamHandle_ = nullptr;
|
||||||
|
|
||||||
|
streamDevice_ = device_find_by_name("audio-stream");
|
||||||
|
if (streamDevice_ == nullptr) {
|
||||||
|
// Fallback: find first device of AUDIO_STREAM_TYPE
|
||||||
|
device_for_each_of_type(&AUDIO_STREAM_TYPE, &streamDevice_, [](Device* device, void* context) {
|
||||||
if (!device_is_ready(device)) return true;
|
if (!device_is_ready(device)) return true;
|
||||||
Device** devicePtr = static_cast<Device**>(context);
|
Device** devPtr = static_cast<Device**>(context);
|
||||||
*devicePtr = device;
|
*devPtr = device;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (i2sDevice_ == nullptr) {
|
if (streamDevice_ == nullptr) {
|
||||||
ESP_LOGW(TAG, "No I2S device found");
|
ESP_LOGW(TAG, "No audio-stream device found - audio provision missing");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure I2S
|
// Open output stream with resampling (app wants 16k stereo, codec native is 44100)
|
||||||
I2sConfig config = {
|
struct AudioStreamConfig cfg = {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
|
||||||
.sample_rate = SAMPLE_RATE,
|
.sample_rate = SAMPLE_RATE,
|
||||||
.bits_per_sample = 16,
|
.bits_per_sample = 16,
|
||||||
.channel_left = 0,
|
.channels = 2
|
||||||
.channel_right = 0
|
|
||||||
};
|
};
|
||||||
|
|
||||||
error_t error = i2s_controller_set_config(i2sDevice_, &config);
|
error_t error = audio_stream_open_output(streamDevice_, &cfg, &streamHandle_);
|
||||||
if (error != ERROR_NONE) {
|
if (error != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
|
ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error);
|
||||||
i2sDevice_ = nullptr;
|
streamDevice_ = nullptr;
|
||||||
|
streamHandle_ = nullptr;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,12 +541,13 @@ bool SfxEngine::start() {
|
|||||||
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
|
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
|
||||||
if (msgQueue_ == nullptr) {
|
if (msgQueue_ == nullptr) {
|
||||||
ESP_LOGE(TAG, "Failed to create message queue");
|
ESP_LOGE(TAG, "Failed to create message queue");
|
||||||
i2s_controller_reset(i2sDevice_);
|
audio_stream_close(streamHandle_);
|
||||||
i2sDevice_ = nullptr;
|
streamHandle_ = nullptr;
|
||||||
|
streamDevice_ = nullptr;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start audio task
|
// Start audio task (needs slightly larger stack for audio_stream write path)
|
||||||
running_ = true;
|
running_ = true;
|
||||||
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
|
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
|
||||||
if (result != pdPASS) {
|
if (result != pdPASS) {
|
||||||
@@ -541,12 +555,13 @@ bool SfxEngine::start() {
|
|||||||
running_ = false;
|
running_ = false;
|
||||||
vQueueDelete(msgQueue_);
|
vQueueDelete(msgQueue_);
|
||||||
msgQueue_ = nullptr;
|
msgQueue_ = nullptr;
|
||||||
i2s_controller_reset(i2sDevice_);
|
audio_stream_close(streamHandle_);
|
||||||
i2sDevice_ = nullptr;
|
streamHandle_ = nullptr;
|
||||||
|
streamDevice_ = nullptr;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "SfxEngine started (voices=%d, sampleRate=%d)", NUM_VOICES, SAMPLE_RATE);
|
ESP_LOGI(TAG, "SfxEngine started via audio-stream (voices=%d, sampleRate=%d -> resampled to native)", NUM_VOICES, SAMPLE_RATE);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,11 +590,13 @@ void SfxEngine::stop() {
|
|||||||
msgQueue_ = nullptr;
|
msgQueue_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i2sDevice_ != nullptr) {
|
if (streamHandle_ != nullptr) {
|
||||||
i2s_controller_reset(i2sDevice_);
|
audio_stream_close(streamHandle_);
|
||||||
i2sDevice_ = nullptr;
|
streamHandle_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamDevice_ = nullptr;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "SfxEngine stopped");
|
ESP_LOGI(TAG, "SfxEngine stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-40
@@ -1,14 +1,23 @@
|
|||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import configparser
|
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
|
||||||
def read_properties_file(path):
|
def read_properties_file(path):
|
||||||
config = configparser.RawConfigParser()
|
properties = {}
|
||||||
config.read(path)
|
with open(path, "r") as file:
|
||||||
return config
|
for line in file:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
key, sep, value = line.partition("=")
|
||||||
|
if not sep:
|
||||||
|
continue
|
||||||
|
properties[key.strip()] = value.strip()
|
||||||
|
return properties
|
||||||
|
|
||||||
def get_manifest(appPath):
|
def get_manifest(appPath):
|
||||||
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz
|
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz
|
||||||
@@ -62,51 +71,49 @@ def get_manifest(appPath):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_versioned_file_name(manifest):
|
def get_versioned_file_name(manifest):
|
||||||
app_id = manifest["app"]["id"]
|
app_id = manifest["app.id"]
|
||||||
version_code = manifest["app"]["versionCode"]
|
version_code = manifest["app.version.code"]
|
||||||
return f"{app_id}-{version_code}.app"
|
return f"{app_id}-{version_code}.app"
|
||||||
|
|
||||||
def get_os_version(manifest):
|
def get_os_version(manifest):
|
||||||
sdk = manifest["target"]["sdk"]
|
sdk = manifest["target.sdk"]
|
||||||
# Remove trailing hyphen suffix if present
|
# Remove trailing hyphen suffix if present
|
||||||
if "-" in sdk:
|
if "-" in sdk:
|
||||||
return sdk.rsplit("-", 1)[0].strip()
|
return sdk.rsplit("-", 1)[0].strip()
|
||||||
else:
|
else:
|
||||||
return sdk
|
return sdk
|
||||||
|
|
||||||
def manifest_config_to_flat_json(manifest):
|
def check_and_get_sdk_version(manifest_map):
|
||||||
"""Convert a ConfigParser manifest into a flat JSON-like dict.
|
"""Ensure all apps target the same (simplified) SDK version and return it."""
|
||||||
|
versions = {get_os_version(manifest) for manifest in manifest_map.values()}
|
||||||
|
if len(versions) != 1:
|
||||||
|
print(f"ERROR: Apps target multiple SDK versions: {sorted(versions)}. All apps must target the same SDK version.")
|
||||||
|
sys.exit(1)
|
||||||
|
return next(iter(versions))
|
||||||
|
|
||||||
Expected sections/keys (case-insensitive for keys):
|
def get_git_commit_hash():
|
||||||
- [app]
|
return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
|
||||||
id -> appId
|
|
||||||
versionName -> appVersionName
|
def manifest_config_to_flat_json(manifest):
|
||||||
versionCode -> appVersionCode (int)
|
"""Convert a flat (V2) manifest dict into a flat JSON-like dict.
|
||||||
name -> appName
|
|
||||||
description -> appDescription (optional; default "")
|
Expected keys:
|
||||||
- [target]
|
app.id -> appId
|
||||||
sdk -> targetSdk
|
app.version.name -> appVersionName
|
||||||
platforms -> targetPlatforms (comma-separated list)
|
app.version.code -> appVersionCode (int)
|
||||||
|
app.name -> appName
|
||||||
|
app.description -> appDescription (optional; default "")
|
||||||
|
target.sdk -> targetSdk
|
||||||
|
target.platforms -> targetPlatforms (comma-separated list)
|
||||||
|
|
||||||
Unknown/missing values fall back to sensible defaults per requirements.
|
Unknown/missing values fall back to sensible defaults per requirements.
|
||||||
"""
|
"""
|
||||||
def get_opt(section, option, default=None):
|
|
||||||
if not manifest.has_section(section):
|
|
||||||
return default
|
|
||||||
# try exact option then lowercase (RawConfigParser lowercases by default)
|
|
||||||
if manifest.has_option(section, option):
|
|
||||||
return manifest.get(section, option)
|
|
||||||
low = option.lower()
|
|
||||||
if manifest.has_option(section, low):
|
|
||||||
return manifest.get(section, low)
|
|
||||||
return default
|
|
||||||
|
|
||||||
# Map values
|
# Map values
|
||||||
app_id = get_opt("app", "id", "")
|
app_id = manifest.get("app.id", "")
|
||||||
app_version_name = get_opt("app", "versionName", "")
|
app_version_name = manifest.get("app.version.name", "")
|
||||||
app_version_code_raw = get_opt("app", "versionCode", "0")
|
app_version_code_raw = manifest.get("app.version.code", "0")
|
||||||
app_name = get_opt("app", "name", "")
|
app_name = manifest.get("app.name", "")
|
||||||
app_description = get_opt("app", "description", "") or ""
|
app_description = manifest.get("app.description", "") or ""
|
||||||
|
|
||||||
# Coerce version code to int safely
|
# Coerce version code to int safely
|
||||||
try:
|
try:
|
||||||
@@ -114,8 +121,8 @@ def manifest_config_to_flat_json(manifest):
|
|||||||
except Exception:
|
except Exception:
|
||||||
app_version_code = 0
|
app_version_code = 0
|
||||||
|
|
||||||
target_sdk = get_opt("target", "sdk", "")
|
target_sdk = manifest.get("target.sdk", "")
|
||||||
platforms_raw = get_opt("target", "platforms", "")
|
platforms_raw = manifest.get("target.platforms", "")
|
||||||
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
|
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
|
||||||
|
|
||||||
filename = get_versioned_file_name(manifest)
|
filename = get_versioned_file_name(manifest)
|
||||||
@@ -142,15 +149,23 @@ if __name__ == "__main__":
|
|||||||
sys.exit()
|
sys.exit()
|
||||||
app_directory = sys.argv[1]
|
app_directory = sys.argv[1]
|
||||||
manifest_map = {}
|
manifest_map = {}
|
||||||
output_json = {
|
|
||||||
"apps": []
|
|
||||||
}
|
|
||||||
any_manifest = None
|
any_manifest = None
|
||||||
if os.path.exists(app_directory):
|
if os.path.exists(app_directory):
|
||||||
for file in os.listdir(app_directory):
|
for file in os.listdir(app_directory):
|
||||||
if file.endswith(".app"):
|
if file.endswith(".app"):
|
||||||
file_path = os.path.join(app_directory, file)
|
file_path = os.path.join(app_directory, file)
|
||||||
manifest_map[file_path] = get_manifest(file_path)
|
manifest_map[file_path] = get_manifest(file_path)
|
||||||
|
# All bundled apps must target the same SDK version; this becomes the CDN path segment
|
||||||
|
sdk_version = check_and_get_sdk_version(manifest_map)
|
||||||
|
with open("sdk_version.txt", "w") as f:
|
||||||
|
f.write(sdk_version)
|
||||||
|
print(f"SDK version: {sdk_version}")
|
||||||
|
output_json = {
|
||||||
|
"sdkVersion": sdk_version,
|
||||||
|
"created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||||
|
"gitCommit": get_git_commit_hash(),
|
||||||
|
"apps": []
|
||||||
|
}
|
||||||
# Rename files and collect manifest data into output json object
|
# Rename files and collect manifest data into output json object
|
||||||
for file_path in manifest_map.keys():
|
for file_path in manifest_map.keys():
|
||||||
print(f"Processing {file_path}: {manifest_map[file_path]}")
|
print(f"Processing {file_path}: {manifest_map[file_path]}")
|
||||||
|
|||||||
+23
-36
@@ -1,4 +1,3 @@
|
|||||||
import configparser
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -13,7 +12,7 @@ import tarfile
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
ttbuild_path = ".tactility"
|
ttbuild_path = ".tactility"
|
||||||
ttbuild_version = "3.5.1"
|
ttbuild_version = "4.1.0"
|
||||||
ttbuild_cdn = "https://cdn.tactilityproject.org"
|
ttbuild_cdn = "https://cdn.tactilityproject.org"
|
||||||
ttbuild_sdk_json_validity = 3600 # seconds
|
ttbuild_sdk_json_validity = 3600 # seconds
|
||||||
ttport = 6666
|
ttport = 6666
|
||||||
@@ -106,9 +105,17 @@ def get_url(ip, path):
|
|||||||
return f"http://{ip}:{ttport}{path}"
|
return f"http://{ip}:{ttport}{path}"
|
||||||
|
|
||||||
def read_properties_file(path):
|
def read_properties_file(path):
|
||||||
config = configparser.RawConfigParser()
|
properties = {}
|
||||||
config.read(path)
|
with open(path, "r") as file:
|
||||||
return config
|
for line in file:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
key, sep, value = line.partition("=")
|
||||||
|
if not sep:
|
||||||
|
continue
|
||||||
|
properties[key.strip()] = value.strip()
|
||||||
|
return properties
|
||||||
|
|
||||||
#endregion Core
|
#endregion Core
|
||||||
|
|
||||||
@@ -185,7 +192,7 @@ def fetch_sdkconfig_files(platform_targets):
|
|||||||
for platform in platform_targets:
|
for platform in platform_targets:
|
||||||
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
||||||
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
|
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
|
||||||
if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path):
|
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
|
||||||
exit_with_error(f"Failed to download sdkconfig file for {platform}")
|
exit_with_error(f"Failed to download sdkconfig file for {platform}")
|
||||||
|
|
||||||
#endregion SDK helpers
|
#endregion SDK helpers
|
||||||
@@ -231,32 +238,12 @@ def read_manifest():
|
|||||||
return read_properties_file("manifest.properties")
|
return read_properties_file("manifest.properties")
|
||||||
|
|
||||||
def validate_manifest(manifest):
|
def validate_manifest(manifest):
|
||||||
# [manifest]
|
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"):
|
||||||
if not "manifest" in manifest:
|
if key not in manifest:
|
||||||
exit_with_error("Invalid manifest format: [manifest] not found")
|
exit_with_error(f"Invalid manifest format: {key} not found")
|
||||||
if not "version" in manifest["manifest"]:
|
|
||||||
exit_with_error("Invalid manifest format: [manifest] version not found")
|
|
||||||
# [target]
|
|
||||||
if not "target" in manifest:
|
|
||||||
exit_with_error("Invalid manifest format: [target] not found")
|
|
||||||
if not "sdk" in manifest["target"]:
|
|
||||||
exit_with_error("Invalid manifest format: [target] sdk not found")
|
|
||||||
if not "platforms" in manifest["target"]:
|
|
||||||
exit_with_error("Invalid manifest format: [target] platforms not found")
|
|
||||||
# [app]
|
|
||||||
if not "app" in manifest:
|
|
||||||
exit_with_error("Invalid manifest format: [app] not found")
|
|
||||||
if not "id" in manifest["app"]:
|
|
||||||
exit_with_error("Invalid manifest format: [app] id not found")
|
|
||||||
if not "versionName" in manifest["app"]:
|
|
||||||
exit_with_error("Invalid manifest format: [app] versionName not found")
|
|
||||||
if not "versionCode" in manifest["app"]:
|
|
||||||
exit_with_error("Invalid manifest format: [app] versionCode not found")
|
|
||||||
if not "name" in manifest["app"]:
|
|
||||||
exit_with_error("Invalid manifest format: [app] name not found")
|
|
||||||
|
|
||||||
def is_valid_manifest_platform(manifest, platform):
|
def is_valid_manifest_platform(manifest, platform):
|
||||||
manifest_platforms = manifest["target"]["platforms"].split(",")
|
manifest_platforms = manifest["target.platforms"].split(",")
|
||||||
return platform in manifest_platforms
|
return platform in manifest_platforms
|
||||||
|
|
||||||
def validate_manifest_platform(manifest, platform):
|
def validate_manifest_platform(manifest, platform):
|
||||||
@@ -265,7 +252,7 @@ def validate_manifest_platform(manifest, platform):
|
|||||||
|
|
||||||
def get_manifest_target_platforms(manifest, requested_platform):
|
def get_manifest_target_platforms(manifest, requested_platform):
|
||||||
if requested_platform == "" or requested_platform is None:
|
if requested_platform == "" or requested_platform is None:
|
||||||
return manifest["target"]["platforms"].split(",")
|
return manifest["target.platforms"].split(",")
|
||||||
else:
|
else:
|
||||||
validate_manifest_platform(manifest, requested_platform)
|
validate_manifest_platform(manifest, requested_platform)
|
||||||
return [requested_platform]
|
return [requested_platform]
|
||||||
@@ -512,7 +499,7 @@ def build_action(manifest, platform_arg, skip_build):
|
|||||||
if use_local_sdk:
|
if use_local_sdk:
|
||||||
global local_base_path
|
global local_base_path
|
||||||
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
|
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
|
||||||
validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
|
validate_local_sdks(platforms_to_build, manifest["target.sdk"])
|
||||||
|
|
||||||
if should_fetch_sdkconfig_files(platforms_to_build):
|
if should_fetch_sdkconfig_files(platforms_to_build):
|
||||||
fetch_sdkconfig_files(platforms_to_build)
|
fetch_sdkconfig_files(platforms_to_build)
|
||||||
@@ -521,7 +508,7 @@ def build_action(manifest, platform_arg, skip_build):
|
|||||||
sdk_json = read_sdk_json()
|
sdk_json = read_sdk_json()
|
||||||
validate_self(sdk_json)
|
validate_self(sdk_json)
|
||||||
# Build
|
# Build
|
||||||
sdk_version = manifest["target"]["sdk"]
|
sdk_version = manifest["target.sdk"]
|
||||||
if not use_local_sdk:
|
if not use_local_sdk:
|
||||||
if not sdk_download_all(sdk_version, platforms_to_build):
|
if not sdk_download_all(sdk_version, platforms_to_build):
|
||||||
exit_with_error("Failed to download one or more SDKs")
|
exit_with_error("Failed to download one or more SDKs")
|
||||||
@@ -570,7 +557,7 @@ def get_device_info(ip):
|
|||||||
print_status_error(f"Device info request failed: {e}")
|
print_status_error(f"Device info request failed: {e}")
|
||||||
|
|
||||||
def run_action(manifest, ip):
|
def run_action(manifest, ip):
|
||||||
app_id = manifest["app"]["id"]
|
app_id = manifest["app.id"]
|
||||||
print_status_busy("Running")
|
print_status_busy("Running")
|
||||||
url = get_url(ip, "/app/run")
|
url = get_url(ip, "/app/run")
|
||||||
params = {'id': app_id}
|
params = {'id': app_id}
|
||||||
@@ -614,7 +601,7 @@ def install_action(ip, platforms):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def uninstall_action(manifest, ip):
|
def uninstall_action(manifest, ip):
|
||||||
app_id = manifest["app"]["id"]
|
app_id = manifest["app.id"]
|
||||||
print_status_busy("Uninstalling")
|
print_status_busy("Uninstalling")
|
||||||
url = get_url(ip, "/app/uninstall")
|
url = get_url(ip, "/app/uninstall")
|
||||||
params = {'id': app_id}
|
params = {'id': app_id}
|
||||||
@@ -670,7 +657,7 @@ if __name__ == "__main__":
|
|||||||
exit_with_error("manifest.properties not found")
|
exit_with_error("manifest.properties not found")
|
||||||
manifest = read_manifest()
|
manifest = read_manifest()
|
||||||
validate_manifest(manifest)
|
validate_manifest(manifest)
|
||||||
all_platform_targets = manifest["target"]["platforms"].split(",")
|
all_platform_targets = manifest["target.platforms"].split(",")
|
||||||
# Update SDK cache (tool.json)
|
# Update SDK cache (tool.json)
|
||||||
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
|
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
|
||||||
exit_with_error("Failed to retrieve SDK info")
|
exit_with_error("Failed to retrieve SDK info")
|
||||||
|
|||||||
Reference in New Issue
Block a user