Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be9229b80d | |||
| 6bfe514ff1 | |||
| be07675d00 | |||
| 343da892bc | |||
| 80ab2880d9 | |||
| 9def43b4ed | |||
| c258aebca1 | |||
| 4a153099f9 | |||
| aebee50d25 | |||
| 6d9001fcd7 | |||
| 653ad8902f | |||
| e4d1d35da4 | |||
| 7c3c4ad2fe | |||
| 25b966a340 | |||
| 6eb032cb53 | |||
| 07749d86a1 | |||
| 8721a653e7 | |||
| 694b68de1a | |||
| 71bf2631f4 | |||
| 4ab2377970 | |||
| 8a0f9ef4e4 |
@@ -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 }}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
project(DiscoveryMountain)
|
||||||
|
tactility_project(DiscoveryMountain)
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# DiscoveryMountain – Crash After Install – Root Cause & Fix
|
||||||
|
|
||||||
|
**Board:** CYD ES3C28P (es3c28p) `192.168.68.132`
|
||||||
|
**Firmware:** 0.8.0-dev / IDF 5.5.2
|
||||||
|
**App ID:** `one.tactility.discoverymountain`
|
||||||
|
**Branch:** `personal/discovery-mountain-player` → merged into `main`
|
||||||
|
**Date:** 2026-07-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- After `tactility.py install 192.168.68.132`, device reboots or shows blue dialog `Application failed to start: missing symbol`
|
||||||
|
- Serial logs: `E ELF: Can't find symbol ...`, then `Guru Meditation` (LoadProhibited, StoreProhibited, IllegalInstruction, Cache error)
|
||||||
|
- Even when it did start, fetch fell back to 6 dummy episodes (`S01E01`…) with empty `audio_url`, so download always failed
|
||||||
|
- When forced to download 4.3 MB MP3 from `http://192.168.68.110:8098/.../esp32_24k.mp3`, device hard-faulted during `play_task`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Missing Symbol Loader Failures
|
||||||
|
|
||||||
|
### Detected via `xtensa-esp32s3-elf-nm -D …elf | grep " U "`
|
||||||
|
|
||||||
|
```
|
||||||
|
U lv_font_montserrat_14
|
||||||
|
U lv_roller_create
|
||||||
|
U lv_roller_set_options
|
||||||
|
U lv_roller_get_selected
|
||||||
|
U lv_roller_set_selected
|
||||||
|
U qsort
|
||||||
|
U cJSON_* (via json component)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Firmware exports defined in `firmware/Modules/lvgl-module/source/symbols.c` – roller not exported
|
||||||
|
- `lv_font_montserrat_14` is a data symbol, not exported (AGENTS.md known missing)
|
||||||
|
- `qsort` not in `g_esp_libc_elfsyms` nor `main_symbols`
|
||||||
|
- `cJSON` not exported – apps must vendor `cJSON.c` (see `BookPlayer`, `McpScreen`)
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `manifest.properties` V1 INI → V2 flat:
|
||||||
|
```
|
||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3
|
||||||
|
app.id=one.tactility.discoverymountain
|
||||||
|
app.version.name=0.1.0
|
||||||
|
app.version.code=1
|
||||||
|
```
|
||||||
|
- `main/CMakeLists.txt`:
|
||||||
|
```cmake
|
||||||
|
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
||||||
|
idf_component_register(SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||||
|
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||||
|
REQUIRES TactilitySDK esp_http_client lwip)
|
||||||
|
```
|
||||||
|
- Replace roller with `lv_dropdown` (exported, see `symbols.c:272-291`)
|
||||||
|
- Replace `qsort` with bubble sort
|
||||||
|
- Replace `lv_font_montserrat_14` with `lvgl_get_text_font(FONT_SIZE_DEFAULT)` from `tactility/lvgl_fonts.h`
|
||||||
|
|
||||||
|
After fix: `nm -D` shows no missing roller/qsort/font, verify script passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fatal `memset` Bug
|
||||||
|
|
||||||
|
```c
|
||||||
|
memset(&G,0,0); // size 0!
|
||||||
|
```
|
||||||
|
Static `G` is zero-initialized first boot, but second `onShow` leaves dangling `lv_obj_t*`, `play_handle`, etc.
|
||||||
|
|
||||||
|
**Fix:** `memset(&G,0,sizeof(G))`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Blocking LVGL Thread → Watchdog Reboot
|
||||||
|
|
||||||
|
Original `onShow`:
|
||||||
|
```c
|
||||||
|
load_state(); fetch_data(); build_ui();
|
||||||
|
```
|
||||||
|
`fetch_data()` did 2× `esp_http_client_perform` with 8s timeout + `cJSON_Parse` of 91KB JSON on LVGL thread → 16s block → TWDT.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Build UI immediately with "Fetching..."
|
||||||
|
- `xTaskCreate(fetch_task_fn,"dm_fetch",16384, ...)`
|
||||||
|
- `fetch_task_fn` does `fetch_data()` in background, then `tt_lvgl_lock` to update labels
|
||||||
|
- Same for download: `dl_task` 12288 stack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `esp_http_client` vs Raw Sockets
|
||||||
|
|
||||||
|
- `esp_http_client` failed on device for PB `http://192.168.68.110:8095` (returned chunked `1634\r\n{...}`), while `curl --http1.0` returned plain JSON.
|
||||||
|
|
||||||
|
Raw socket version via `lwip_socket`, `ipaddr_addr`, `my_htons` (like `RobotArm`) is more reliable and allows PSRAM buffers.
|
||||||
|
|
||||||
|
PocketBase:
|
||||||
|
- `HTTP/1.1` → `Transfer-Encoding: chunked` → `1634\r\n{...}\r\n0\r\n\r\n`
|
||||||
|
- `HTTP/1.0` → `Content-Length` absent, body until close → 5684 bytes seasons, 91293 bytes episodes
|
||||||
|
|
||||||
|
**Fix:** Implement `http_get_raw` with:
|
||||||
|
- PSRAM `heap_caps_malloc(65536, MALLOC_CAP_SPIRAM|8BIT)` growing to 600KB
|
||||||
|
- Manual realloc via malloc+memcpy+free (avoid `heap_caps_realloc` not exported)
|
||||||
|
- `strstr(buf,"\r\n\r\n")` to find header end
|
||||||
|
- Detect `chunked`, decode hex chunk sizes via `strtol(hex, NULL, 16)`
|
||||||
|
- Return de-chunked body
|
||||||
|
|
||||||
|
Result: `DM: seasons items count 37`, `episodes items 232` (was fallback 37/6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Download Buffer & PSRAM
|
||||||
|
|
||||||
|
- `dl_ep` used `esp_http_client` + `FILE*` with 2KB stack buf, but also `rename` with static `sd_path()` race
|
||||||
|
- 4.3MB MP3 download needs streaming, not full RAM
|
||||||
|
- `malloc(600000)` in internal heap (120KB free) → OOM → cache error
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `make_sd_path(out,len,slug)` caller-provided buffer
|
||||||
|
- `ensure_dir()` does NOT `mkdir("/sdcard")`
|
||||||
|
- `dl_ep_raw` uses PSRAM `hdr 8192` + `recv_buf 4096`, streams directly to `FILE*`
|
||||||
|
- Progress via `lv_label_set_text` under `tt_lvgl_lock`
|
||||||
|
- LRU keeps 8 files, skips current playing
|
||||||
|
|
||||||
|
Result: `DL done total 4329701 expected 4329701` – file correctly saved to `/sdcard/dm/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Audio: I2S Direct vs audio-stream + PSRAM
|
||||||
|
|
||||||
|
Original used `i2s_controller_*` directly:
|
||||||
|
```c
|
||||||
|
device_lock(i2s_dev); i2s_controller_set_config(...); i2s_controller_write(...);
|
||||||
|
```
|
||||||
|
Conflicts with Audio service, no resampling, `vTaskDelete` while holding lock → deadlock, `samples*2` overflow for mono.
|
||||||
|
|
||||||
|
**Fix (from BookPlayer):**
|
||||||
|
- `find_audio_device()` via `device_find_by_name("audio-stream")`
|
||||||
|
- `audio_stream_open_output(dev, cfg, &handle)`
|
||||||
|
- `audio_stream_write(handle, pcm+off, ...)`
|
||||||
|
- `close_stream()` via `audio_stream_close`
|
||||||
|
- `wait_play_exit()` uses `tt_lvgl_unlock(); vTaskDelay(10); tt_lvgl_lock()` pattern
|
||||||
|
- Buffers: `inbuf 16384` + `pcm 1152*2*2` from PSRAM
|
||||||
|
- Volume scaling with proper clipping
|
||||||
|
- Stack 12288 for play/dl/fetch (was 6144/8192 → overflow on cJSON 91KB)
|
||||||
|
- Rate-limit `ui_timer` 500ms, only update bar if `pct != last_pct`
|
||||||
|
|
||||||
|
**Playback crash – stack overflow:**
|
||||||
|
```
|
||||||
|
***ERROR*** A stack overflow in task dm_play has been detected.
|
||||||
|
...
|
||||||
|
Guru Meditation: BREAK instr at vTaskGenericNotifyGiveFromISR
|
||||||
|
...
|
||||||
|
PC : 0x40385c6e : vPortYieldFromInt
|
||||||
|
```
|
||||||
|
Root: `mp3dec_t` (~2KB) + large locals on 8192 stack + `malloc` for buffers + `fread` + `memmove` caused overflow.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Increase `dm_play` stack 8192 → 16384
|
||||||
|
- Allocate `mp3dec_t* dec` from PSRAM via `heap_caps_malloc(..., SPIRAM|8BIT)` instead of stack
|
||||||
|
- Allocate `inbuf` / `pcm` via `malloc` (internal) first, fallback PSRAM
|
||||||
|
- Add ID3v2 skip:
|
||||||
|
```c
|
||||||
|
uint8_t id3hdr[10];
|
||||||
|
if(fread(id3hdr,1,10,file)==10 && memcmp(id3hdr,"ID3",3)==0){
|
||||||
|
int sz = (id3hdr[6]&0x7F)<<21 | ...;
|
||||||
|
fseek(file, sz+10, SEEK_SET);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Add logs `open_stream sr=%d ch=%d`, `open_stream ok`, `decoding frame buffered=%d`
|
||||||
|
|
||||||
|
Result after fix:
|
||||||
|
```
|
||||||
|
DM: play task start /sdcard/dm/463_...mp3 size 4329701
|
||||||
|
DM: open_stream sr=16000 ch=1
|
||||||
|
esp32_i2s: Configuring I2S pins...
|
||||||
|
Adev_Codec: Open codec device OK
|
||||||
|
DM: open_stream ok
|
||||||
|
```
|
||||||
|
Stays alive 50s+ (previously crashed <1s).
|
||||||
|
|
||||||
|
### 7. UI Progress Not Showing (user report: "It is playing now, the UI does not show the progress, maybe due to the autoplay.")
|
||||||
|
|
||||||
|
Root:
|
||||||
|
```c
|
||||||
|
G.pos_sec += samples / info.hz; // integer division 1152/16000 = 0!
|
||||||
|
```
|
||||||
|
`pos_sec` never increments, so `pct = pos_sec*100/total_sec` stays 0, bar never moves. Also `last_pct` not reset on new episode.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Track `total_decoded_samples += samples; G.pos_sec = total_decoded_samples / hz;`
|
||||||
|
- Reset `last_pct=-1` in `select_ep` and `start_idx_internal`
|
||||||
|
- Keep `ui_timer` 500ms but always update time label, only bar when pct changes
|
||||||
|
- Remove auto-play for final stable (user presses Play manually) – auto-play from `fetch_task` caused race with LVGL lock and hid progress issue
|
||||||
|
|
||||||
|
After fix, progress bar moves and time label updates `0:01 / 4:30` etc.
|
||||||
|
No immediate crash, stays alive 50s+ (previously crashed <1s).
|
||||||
|
|
||||||
|
Remaining TODO: test actual audio output via speaker, try 44.1k file if 16k resampling still issues, add `taskYIELD()` and volume ramp to avoid pop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Deploy (correct env)
|
||||||
|
|
||||||
|
Per `AGENTS.md`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/adolforeyna/Projects/Tactility/apps
|
||||||
|
unset PYTHONPATH; unset PYTHONHOME
|
||||||
|
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.5_py3.10_env
|
||||||
|
source /Users/adolforeyna/esp/esp-idf-v5.5.2/export.sh
|
||||||
|
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
|
||||||
|
|
||||||
|
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/DiscoveryMountain build esp32s3 --local-sdk
|
||||||
|
curl -X PUT http://192.168.68.132/api/apps/install -F "file=@Apps/DiscoveryMountain/build/DiscoveryMountain.app"
|
||||||
|
curl -X POST "http://192.168.68.132/api/apps/run?id=one.tactility.discoverymountain"
|
||||||
|
# serial
|
||||||
|
python3 -c "import serial; s=serial.Serial('/dev/cu.usbmodem101',115200); ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify symbols:
|
||||||
|
```bash
|
||||||
|
xtensa-esp32s3-elf-nm -D Apps/DiscoveryMountain/build/cmake-build-esp32s3/DiscoveryMountain.app.elf | grep " U " | grep -E "roller|qsort|montserrat"
|
||||||
|
# should be empty
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final State (2026-07-21 post-fix)
|
||||||
|
|
||||||
|
- **Stable install:** No `missing symbol`, no `memset` garbage, no watchdog. Boots to launcher, shows UI, stays alive.
|
||||||
|
- **Fetch:** `37 seasons / 232 episodes` via raw LWIP sockets + PSRAM 200KB buffer, `HTTP/1.0` to avoid chunked. Previously fallback 6 dummy.
|
||||||
|
- **Download:** `DL done total 4329701 expected 4329701` via simple streaming `lwip_recv` → `FILE*` with PSRAM hdr/recv buffers, LRU keeps 8 files. Previously OOM and crash.
|
||||||
|
- **Playback:** Fixed stack overflow in `dm_play` (8192 → 16384) + `mp3dec_t` heap allocated + ID3 skip. Now:
|
||||||
|
```
|
||||||
|
DM: play task start ... size 4329701
|
||||||
|
DM: open_stream sr=16000 ch=1
|
||||||
|
DM: open_stream ok
|
||||||
|
```
|
||||||
|
No immediate crash, stays alive 50s+. Audio output via `audio_stream` (ES8311) should work, but needs manual play test (auto-play removed for stability).
|
||||||
|
- **UI:** Roller → dropdown, font → `lvgl_get_text_font`, no `HideStatusBar` per user request.
|
||||||
|
- **Build:** Verified `nm -D` clean, `tactility.py build esp32s3 --local-sdk` ok.
|
||||||
|
|
||||||
|
## Verified Logs (final)
|
||||||
|
|
||||||
|
```
|
||||||
|
DM: fetch task start
|
||||||
|
DM: GET .../dm_seasons... → GET ok 5684
|
||||||
|
DM: seasons items count 37
|
||||||
|
DM: GET .../dm_episodes... → GET ok 91293
|
||||||
|
DM: episodes items 232
|
||||||
|
DM: fetch done seasons=37 eps=232
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] Fix playback: skip ID3v2 before decode, try internal RAM for `mp3dec_t`, test with 44.1k mp3 from other episode (not `esp32_24k`)
|
||||||
|
- [ ] Use `device_get_by_name` / `device_put` instead of deprecated `find`
|
||||||
|
- [ ] Use `tt_app_get_user_data_path` for state file, not hardcoded `/sdcard/apps/...`
|
||||||
|
- [ ] Rate-limit screenshot API (2s gap) to avoid heap spike
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||||
|
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||||
|
REQUIRES TactilitySDK esp_http_client lwip
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#include "app_context.h"
|
||||||
|
|
||||||
|
AppCtx G = {0};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
#include <lvgl.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/audio_stream.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define TAG "DM"
|
||||||
|
#define PB "http://192.168.68.110:8095"
|
||||||
|
#define DM_DIR "/sdcard/dm"
|
||||||
|
#define STATE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/state.json"
|
||||||
|
#define MAX_SEASONS 40
|
||||||
|
#define MAX_EPS 400
|
||||||
|
#define MP3_BUF 16384
|
||||||
|
|
||||||
|
typedef struct { int num; } SeasonInfo;
|
||||||
|
typedef struct {
|
||||||
|
char title[128];
|
||||||
|
char slug[96];
|
||||||
|
int season;
|
||||||
|
int ep_num;
|
||||||
|
char audio_url[256];
|
||||||
|
bool seen;
|
||||||
|
} EpInfo;
|
||||||
|
|
||||||
|
typedef struct { char slug[96]; int pos; } SavedPos;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
SeasonInfo seasons[MAX_SEASONS];
|
||||||
|
int season_cnt;
|
||||||
|
EpInfo eps[MAX_EPS];
|
||||||
|
int ep_cnt;
|
||||||
|
int cur_season;
|
||||||
|
int cur_ep_idx;
|
||||||
|
struct Device* stream_dev;
|
||||||
|
AudioStreamHandle stream_handle;
|
||||||
|
bool is_playing;
|
||||||
|
bool is_paused;
|
||||||
|
bool need_stop;
|
||||||
|
TaskHandle_t play_handle;
|
||||||
|
TaskHandle_t fetch_handle;
|
||||||
|
TaskHandle_t dl_handle;
|
||||||
|
int pos_sec;
|
||||||
|
int total_sec;
|
||||||
|
int volume;
|
||||||
|
int last_pct;
|
||||||
|
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
|
||||||
|
lv_obj_t *overlay, *dropdown;
|
||||||
|
// Download UI
|
||||||
|
lv_obj_t *dl_overlay;
|
||||||
|
lv_obj_t *dl_bar;
|
||||||
|
lv_obj_t *dl_lbl_pct;
|
||||||
|
lv_obj_t *dl_lbl_detail;
|
||||||
|
lv_obj_t *dl_lbl_title;
|
||||||
|
lv_obj_t *dl_lbl_sub;
|
||||||
|
char cur_title[128];
|
||||||
|
char last_slug[96];
|
||||||
|
bool fetching;
|
||||||
|
bool downloading;
|
||||||
|
int dl_total;
|
||||||
|
int dl_expected;
|
||||||
|
int dl_last_ui_update;
|
||||||
|
bool dl_cancel_req;
|
||||||
|
char dl_ep_title[128];
|
||||||
|
char dl_slug[96];
|
||||||
|
int dl_season;
|
||||||
|
int dl_ep_num;
|
||||||
|
int dl_last_pct;
|
||||||
|
// Per-episode resume
|
||||||
|
SavedPos saved_pos[MAX_EPS];
|
||||||
|
int saved_pos_cnt;
|
||||||
|
int last_save_tick;
|
||||||
|
} AppCtx;
|
||||||
|
|
||||||
|
extern AppCtx G;
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
#include "download.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "network.h"
|
||||||
|
#include "player.h"
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <lwip/sockets.h>
|
||||||
|
#include <lwip/inet.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <errno.h>
|
||||||
|
|
||||||
|
static void dl_cancel_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
ESP_LOGI(TAG,"DL cancel requested");
|
||||||
|
G.dl_cancel_req=true;
|
||||||
|
if(G.dl_lbl_detail) lv_label_set_text(G.dl_lbl_detail,"Canceling...");
|
||||||
|
}
|
||||||
|
|
||||||
|
void hide_dl_overlay(){
|
||||||
|
if(G.dl_overlay){
|
||||||
|
lv_obj_delete(G.dl_overlay);
|
||||||
|
G.dl_overlay=NULL;
|
||||||
|
G.dl_bar=NULL;
|
||||||
|
G.dl_lbl_pct=NULL;
|
||||||
|
G.dl_lbl_detail=NULL;
|
||||||
|
G.dl_lbl_title=NULL;
|
||||||
|
G.dl_lbl_sub=NULL;
|
||||||
|
}
|
||||||
|
G.downloading=false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
|
||||||
|
strncpy(G.dl_ep_title, ep_title ? ep_title : "Episode", sizeof(G.dl_ep_title)-1);
|
||||||
|
strncpy(G.dl_slug, slug ? slug : "", sizeof(G.dl_slug)-1);
|
||||||
|
G.dl_season=season;
|
||||||
|
G.dl_ep_num=ep_num;
|
||||||
|
G.dl_total=0;
|
||||||
|
G.dl_expected=-1;
|
||||||
|
G.dl_last_pct=-1;
|
||||||
|
G.dl_cancel_req=false;
|
||||||
|
G.downloading=true;
|
||||||
|
|
||||||
|
G.dl_overlay = lv_obj_create(lv_scr_act());
|
||||||
|
lv_obj_set_size(G.dl_overlay, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_style_bg_color(G.dl_overlay, lv_color_hex(0x000000), 0);
|
||||||
|
lv_obj_set_style_bg_opa(G.dl_overlay, LV_OPA_70, 0);
|
||||||
|
lv_obj_set_style_border_width(G.dl_overlay, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(G.dl_overlay, 0, 0);
|
||||||
|
lv_obj_set_style_radius(G.dl_overlay, 0, 0);
|
||||||
|
lv_obj_remove_flag(G.dl_overlay, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* card = lv_obj_create(G.dl_overlay);
|
||||||
|
lv_obj_set_size(card, 220, 210);
|
||||||
|
lv_obj_center(card);
|
||||||
|
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
|
||||||
|
lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0);
|
||||||
|
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
|
||||||
|
lv_obj_set_style_border_width(card, 1, 0);
|
||||||
|
lv_obj_set_style_radius(card, 12, 0);
|
||||||
|
lv_obj_set_style_pad_all(card, 12, 0);
|
||||||
|
lv_obj_set_style_pad_row(card, 6, 0);
|
||||||
|
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_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* lbl_head = lv_label_create(card);
|
||||||
|
lv_label_set_text(lbl_head, "Downloading");
|
||||||
|
lv_obj_set_style_text_color(lbl_head, lv_color_hex(0xCDD6F4), 0);
|
||||||
|
lv_obj_set_style_text_font(lbl_head, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
|
||||||
|
|
||||||
|
G.dl_lbl_title = lv_label_create(card);
|
||||||
|
lv_label_set_text(G.dl_lbl_title, G.dl_ep_title);
|
||||||
|
lv_obj_set_width(G.dl_lbl_title, 190);
|
||||||
|
lv_obj_set_style_text_color(G.dl_lbl_title, lv_color_hex(0xFFFFFF), 0);
|
||||||
|
lv_obj_set_style_text_align(G.dl_lbl_title, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_label_set_long_mode(G.dl_lbl_title, LV_LABEL_LONG_WRAP);
|
||||||
|
|
||||||
|
G.dl_lbl_sub = lv_label_create(card);
|
||||||
|
char sub[64];
|
||||||
|
snprintf(sub, sizeof(sub), "S%02d E%02d • %s", season, ep_num, slug);
|
||||||
|
lv_label_set_text(G.dl_lbl_sub, sub);
|
||||||
|
lv_obj_set_style_text_color(G.dl_lbl_sub, lv_color_hex(0xA6ADC8), 0);
|
||||||
|
lv_obj_set_style_text_font(G.dl_lbl_sub, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
|
||||||
|
lv_obj_set_style_text_align(G.dl_lbl_sub, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
|
||||||
|
G.dl_bar = lv_bar_create(card);
|
||||||
|
lv_obj_set_size(G.dl_bar, 190, 14);
|
||||||
|
lv_bar_set_range(G.dl_bar, 0, 100);
|
||||||
|
lv_bar_set_value(G.dl_bar, 0, LV_ANIM_OFF);
|
||||||
|
lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x45475A), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||||
|
lv_obj_set_style_radius(G.dl_bar, 7, 0);
|
||||||
|
|
||||||
|
G.dl_lbl_pct = lv_label_create(card);
|
||||||
|
lv_label_set_text(G.dl_lbl_pct, "0%");
|
||||||
|
lv_obj_set_style_text_color(G.dl_lbl_pct, lv_color_hex(0xCDD6F4), 0);
|
||||||
|
lv_obj_set_style_text_font(G.dl_lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
|
||||||
|
|
||||||
|
G.dl_lbl_detail = lv_label_create(card);
|
||||||
|
lv_label_set_text(G.dl_lbl_detail, "Starting…");
|
||||||
|
lv_obj_set_style_text_color(G.dl_lbl_detail, lv_color_hex(0xA6ADC8), 0);
|
||||||
|
lv_obj_set_style_text_font(G.dl_lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
|
||||||
|
lv_obj_set_style_text_align(G.dl_lbl_detail, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_obj_set_width(G.dl_lbl_detail, 190);
|
||||||
|
lv_label_set_long_mode(G.dl_lbl_detail, LV_LABEL_LONG_WRAP);
|
||||||
|
|
||||||
|
lv_obj_t* btn_cancel = lv_button_create(card);
|
||||||
|
lv_obj_set_size(btn_cancel, 90, 32);
|
||||||
|
lv_obj_set_style_radius(btn_cancel, 8, 0);
|
||||||
|
lv_obj_set_style_bg_color(btn_cancel, lv_color_hex(0x313244), 0);
|
||||||
|
lv_obj_t* lbl_c = lv_label_create(btn_cancel);
|
||||||
|
lv_label_set_text(lbl_c, "Cancel");
|
||||||
|
lv_obj_center(lbl_c);
|
||||||
|
lv_obj_add_event_cb(btn_cancel, dl_cancel_cb, LV_EVENT_CLICKED, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void update_dl_overlay_ui(){
|
||||||
|
if(!G.downloading || !G.dl_overlay) return;
|
||||||
|
if(G.dl_bar){
|
||||||
|
int pct=0;
|
||||||
|
if(G.dl_expected>0){
|
||||||
|
pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected);
|
||||||
|
if(pct<0) pct=0;
|
||||||
|
if(pct>100) pct=100;
|
||||||
|
} else {
|
||||||
|
pct = G.dl_last_pct;
|
||||||
|
if(pct<0) pct=0;
|
||||||
|
}
|
||||||
|
if(pct!=G.dl_last_pct){
|
||||||
|
G.dl_last_pct=pct;
|
||||||
|
lv_bar_set_value(G.dl_bar, pct, LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(G.dl_lbl_pct){
|
||||||
|
char b[16];
|
||||||
|
if(G.dl_expected>0){
|
||||||
|
int pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected);
|
||||||
|
if(pct<0) pct=0;
|
||||||
|
if(pct>100) pct=100;
|
||||||
|
snprintf(b,sizeof(b),"%d%%",pct);
|
||||||
|
} else {
|
||||||
|
snprintf(b,sizeof(b),"%d KB",G.dl_total/1024);
|
||||||
|
}
|
||||||
|
lv_label_set_text(G.dl_lbl_pct,b);
|
||||||
|
}
|
||||||
|
if(G.dl_lbl_detail){
|
||||||
|
char d[64];
|
||||||
|
if(G.dl_expected>0){
|
||||||
|
int total_kb = G.dl_total/1024;
|
||||||
|
int exp_kb = G.dl_expected/1024;
|
||||||
|
if(exp_kb>1024){
|
||||||
|
snprintf(d,sizeof(d),"%d KB / %d KB (%d.%01d MB)", total_kb, exp_kb, exp_kb/1024, (exp_kb%1024)*10/1024);
|
||||||
|
} else {
|
||||||
|
snprintf(d,sizeof(d),"%d / %d KB", total_kb, exp_kb);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
snprintf(d,sizeof(d),"%d KB downloaded", G.dl_total/1024);
|
||||||
|
}
|
||||||
|
lv_label_set_text(G.dl_lbl_detail,d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool dl_ep_raw(EpInfo* ep){
|
||||||
|
if(!ep) return false;
|
||||||
|
if(strlen(ep->audio_url)==0){ ESP_LOGE(TAG,"dl no url for %s",ep->slug); return false; }
|
||||||
|
|
||||||
|
char url_norm[512];
|
||||||
|
normalize_audio_url(ep->audio_url, url_norm, sizeof(url_norm));
|
||||||
|
ESP_LOGI(TAG,"dl url normalized: %s -> %s", ep->audio_url, url_norm);
|
||||||
|
|
||||||
|
const char* url=url_norm;
|
||||||
|
if(strncmp(url,"http://",7)!=0){
|
||||||
|
ESP_LOGE(TAG,"dl url not http %s",url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const char* p=url+7;
|
||||||
|
const char* slash=strchr(p,'/');
|
||||||
|
if(!slash){ ESP_LOGE(TAG,"dl no slash"); return false; }
|
||||||
|
char hostport[128]={0};
|
||||||
|
size_t hlen=slash-p;
|
||||||
|
if(hlen>=sizeof(hostport)){ ESP_LOGE(TAG,"dl hostport too long"); return false; }
|
||||||
|
memcpy(hostport,p,hlen);
|
||||||
|
hostport[hlen]=0;
|
||||||
|
const char* path=slash;
|
||||||
|
char host[96]={0};
|
||||||
|
int port=80;
|
||||||
|
char* colon=strchr(hostport,':');
|
||||||
|
if(colon){
|
||||||
|
*colon=0;
|
||||||
|
strncpy(host,hostport,sizeof(host)-1);
|
||||||
|
port=atoi(colon+1);
|
||||||
|
if(port<=0) port=80;
|
||||||
|
} else {
|
||||||
|
strncpy(host,hostport,sizeof(host)-1);
|
||||||
|
}
|
||||||
|
uint32_t ip=0;
|
||||||
|
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"dl ip fail %s",host); return false; }
|
||||||
|
|
||||||
|
char final_path[300];
|
||||||
|
char tmp_path[320];
|
||||||
|
make_sd_path(final_path,sizeof(final_path),ep->slug);
|
||||||
|
snprintf(tmp_path,sizeof(tmp_path),"%s.tmp",final_path);
|
||||||
|
unlink(tmp_path);
|
||||||
|
FILE* f=fopen(tmp_path,"wb");
|
||||||
|
if(!f){ ESP_LOGE(TAG,"dl fopen fail %s",tmp_path); return false; }
|
||||||
|
|
||||||
|
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||||
|
if(fd<0){ ESP_LOGE(TAG,"dl socket fail"); fclose(f); unlink(tmp_path); return false; }
|
||||||
|
struct sockaddr_in sa;
|
||||||
|
memset(&sa,0,sizeof(sa));
|
||||||
|
sa.sin_family=AF_INET;
|
||||||
|
sa.sin_port=my_htons(port);
|
||||||
|
sa.sin_addr.s_addr=ip;
|
||||||
|
struct timeval tv={15,0};
|
||||||
|
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||||
|
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||||
|
ESP_LOGI(TAG,"dl connecting %s:%d",host,port);
|
||||||
|
if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){
|
||||||
|
ESP_LOGE(TAG,"dl connect fail %s:%d errno=%d",host,port,errno);
|
||||||
|
fclose(f); close(fd); unlink(tmp_path); return false;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"dl connected");
|
||||||
|
|
||||||
|
char req[512];
|
||||||
|
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
|
||||||
|
if(lwip_send(fd,req,reqlen,0)<0){
|
||||||
|
ESP_LOGE(TAG,"dl send fail errno=%d",errno);
|
||||||
|
fclose(f); close(fd); unlink(tmp_path); return false;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"dl request sent");
|
||||||
|
|
||||||
|
char* hdr=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!hdr){ fclose(f); close(fd); unlink(tmp_path); return false; }
|
||||||
|
int hdr_len=0;
|
||||||
|
bool header_done=false;
|
||||||
|
int content_len=-1;
|
||||||
|
int total=0;
|
||||||
|
uint8_t* recv_buf=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!recv_buf){ heap_caps_free(hdr); fclose(f); close(fd); unlink(tmp_path); return false; }
|
||||||
|
|
||||||
|
G.dl_total=0;
|
||||||
|
G.dl_expected=-1;
|
||||||
|
G.dl_last_pct=-1;
|
||||||
|
|
||||||
|
while(!header_done){
|
||||||
|
if(G.dl_cancel_req){ ESP_LOGI(TAG,"dl canceled during header"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; }
|
||||||
|
int r=lwip_recv(fd,(char*)recv_buf,4096,0);
|
||||||
|
if(r<=0){
|
||||||
|
ESP_LOGE(TAG,"dl recv header fail r=%d errno=%d",r,errno);
|
||||||
|
heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false;
|
||||||
|
}
|
||||||
|
if(hdr_len+r >= 8191){ ESP_LOGE(TAG,"dl hdr overflow"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; }
|
||||||
|
memcpy(hdr+hdr_len, recv_buf, r);
|
||||||
|
hdr_len+=r;
|
||||||
|
hdr[hdr_len]=0;
|
||||||
|
char* he=strstr(hdr,"\r\n\r\n");
|
||||||
|
if(he){
|
||||||
|
header_done=true;
|
||||||
|
if(strncmp(hdr,"HTTP/1.1 200",12)!=0 && strncmp(hdr,"HTTP/1.0 200",12)!=0){
|
||||||
|
ESP_LOGE(TAG,"dl not 200: %.120s",hdr);
|
||||||
|
heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false;
|
||||||
|
}
|
||||||
|
char* cl=strstr(hdr,"Content-Length:");
|
||||||
|
if(!cl) cl=strstr(hdr,"content-length:");
|
||||||
|
if(cl){
|
||||||
|
cl=strchr(cl,':');
|
||||||
|
if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); }
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"dl header done cl=%d",content_len);
|
||||||
|
G.dl_expected=content_len;
|
||||||
|
int body_start = (he - hdr) + 4;
|
||||||
|
int body_in_buf = hdr_len - body_start;
|
||||||
|
if(body_in_buf>0){
|
||||||
|
fwrite(hdr+body_start,1,body_in_buf,f);
|
||||||
|
total+=body_in_buf;
|
||||||
|
G.dl_total=total;
|
||||||
|
ESP_LOGI(TAG,"dl wrote initial %d",body_in_buf);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG,"dl streaming body cl=%d",content_len);
|
||||||
|
int consecutive_timeouts=0;
|
||||||
|
const int max_timeouts=12;
|
||||||
|
while(1){
|
||||||
|
if(G.dl_cancel_req){
|
||||||
|
ESP_LOGI(TAG,"dl canceled during body total=%d",total);
|
||||||
|
heap_caps_free(hdr);
|
||||||
|
heap_caps_free(recv_buf);
|
||||||
|
close(fd);
|
||||||
|
fclose(f);
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if(content_len>=0 && total>=content_len) break;
|
||||||
|
int r=lwip_recv(fd,(char*)recv_buf,8192,0);
|
||||||
|
if(r<0){
|
||||||
|
if(errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT || errno==0){
|
||||||
|
consecutive_timeouts++;
|
||||||
|
ESP_LOGW(TAG,"dl timeout %d/%d total=%d/%d",consecutive_timeouts,max_timeouts,total,content_len);
|
||||||
|
if(consecutive_timeouts>=max_timeouts){
|
||||||
|
ESP_LOGE(TAG,"dl too many timeouts, aborting");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(200));
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
ESP_LOGE(TAG,"dl recv error r=%d errno=%d",r,errno);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(r==0){
|
||||||
|
ESP_LOGI(TAG,"dl EOF total=%d expected=%d",total,content_len);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
consecutive_timeouts=0;
|
||||||
|
fwrite(recv_buf,1,r,f);
|
||||||
|
total+=r;
|
||||||
|
G.dl_total=total;
|
||||||
|
}
|
||||||
|
|
||||||
|
heap_caps_free(hdr);
|
||||||
|
heap_caps_free(recv_buf);
|
||||||
|
close(fd);
|
||||||
|
fclose(f);
|
||||||
|
ESP_LOGI(TAG,"DL done total %d expected %d",total,content_len);
|
||||||
|
if(G.dl_cancel_req){
|
||||||
|
ESP_LOGI(TAG,"DL canceled, removing tmp");
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if(total<1024){ ESP_LOGE(TAG,"dl too small %d",total); unlink(tmp_path); return false; }
|
||||||
|
if(content_len>=0 && total < content_len){
|
||||||
|
if(total < content_len * 95 / 100){
|
||||||
|
ESP_LOGE(TAG,"dl incomplete %d < %d (95%% threshold)",total,content_len);
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"dl size slightly short %d vs %d, accepting",total,content_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(rename(tmp_path,final_path)!=0){
|
||||||
|
ESP_LOGE(TAG,"rename failed %s -> %s errno=%d",tmp_path,final_path,errno);
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ep->seen=true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool dl_ep(EpInfo* ep){
|
||||||
|
if(!ep) return false;
|
||||||
|
if(has_slug(ep->slug)) return true;
|
||||||
|
ensure_dir();
|
||||||
|
lru_check();
|
||||||
|
return dl_ep_raw(ep);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dl_task(void* arg){
|
||||||
|
int idx = (int)(intptr_t)arg;
|
||||||
|
G.downloading=true;
|
||||||
|
G.dl_cancel_req=false;
|
||||||
|
if(idx<0||idx>=G.ep_cnt){ G.downloading=false; G.dl_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
ESP_LOGI(TAG,"DL start %s url %s",ep->slug,ep->audio_url);
|
||||||
|
bool ok=dl_ep(ep);
|
||||||
|
ESP_LOGI(TAG,"DL end %s ok %d",ep->slug,ok);
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
hide_dl_overlay();
|
||||||
|
if(G.lbl_status){
|
||||||
|
if(ok) lv_label_set_text(G.lbl_status,"DL done, playing...");
|
||||||
|
else {
|
||||||
|
if(G.dl_cancel_req) lv_label_set_text(G.lbl_status,"DL canceled");
|
||||||
|
else lv_label_set_text(G.lbl_status,"DL failed – tap Play to retry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
G.downloading=false;
|
||||||
|
G.dl_handle=NULL;
|
||||||
|
if(ok){
|
||||||
|
start_idx_internal(idx);
|
||||||
|
}
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Raw download to file with progress tracking
|
||||||
|
bool dl_ep_raw(EpInfo* ep);
|
||||||
|
bool dl_ep(EpInfo* ep);
|
||||||
|
|
||||||
|
// Download task
|
||||||
|
void dl_task(void* arg);
|
||||||
|
|
||||||
|
// Download overlay UI
|
||||||
|
void hide_dl_overlay(void);
|
||||||
|
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num);
|
||||||
|
void update_dl_overlay_ui(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include "app_context.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "network.h"
|
||||||
|
#include "download.h"
|
||||||
|
#include "player.h"
|
||||||
|
#include "ui.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
static void fetch_task_fn(void* arg){
|
||||||
|
(void)arg;
|
||||||
|
G.fetching=true;
|
||||||
|
ESP_LOGI(TAG,"fetch task start");
|
||||||
|
fetch_data();
|
||||||
|
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d",G.season_cnt,G.ep_cnt);
|
||||||
|
if(strlen(G.last_slug)>0){
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
|
||||||
|
}
|
||||||
|
if(G.cur_ep_idx<0){
|
||||||
|
int s1=-1;
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; }
|
||||||
|
if(s1==-1) s1=0;
|
||||||
|
G.cur_ep_idx=s1;
|
||||||
|
}
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.ep_cnt>0 && G.cur_ep_idx>=0){
|
||||||
|
EpInfo* ep=&G.eps[G.cur_ep_idx];
|
||||||
|
int saved = get_saved_pos_for_slug(ep->slug);
|
||||||
|
if(saved==0) saved = G.pos_sec;
|
||||||
|
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
|
||||||
|
if(G.lbl_meta){
|
||||||
|
char b[64];
|
||||||
|
if(saved>5){
|
||||||
|
char tbuf[16];
|
||||||
|
fmt_time(tbuf,saved);
|
||||||
|
snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf);
|
||||||
|
} else {
|
||||||
|
snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":"");
|
||||||
|
}
|
||||||
|
lv_label_set_text(G.lbl_meta,b);
|
||||||
|
}
|
||||||
|
if(G.lbl_status){
|
||||||
|
if(saved>5) lv_label_set_text(G.lbl_status,"Resume – tap Play");
|
||||||
|
else lv_label_set_text(G.lbl_status,ep->seen?"On SD – tap Play":"Tap Play to DL");
|
||||||
|
}
|
||||||
|
if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
||||||
|
if(G.lbl_time){
|
||||||
|
if(saved>5){
|
||||||
|
char a[16];
|
||||||
|
fmt_time(a,saved);
|
||||||
|
char buf[40];
|
||||||
|
snprintf(buf,sizeof(buf),"%s / --:--",a);
|
||||||
|
lv_label_set_text(G.lbl_time,buf);
|
||||||
|
} else {
|
||||||
|
lv_label_set_text(G.lbl_time,"0:00 / --:--");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(saved>0) G.pos_sec=saved;
|
||||||
|
} else {
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"No data");
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
G.fetching=false;
|
||||||
|
G.fetch_handle=NULL;
|
||||||
|
save_state();
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
|
||||||
|
(void)app; (void)data; (void)parent;
|
||||||
|
memset(&G,0,sizeof(G));
|
||||||
|
G.pos_sec=0;
|
||||||
|
G.total_sec=0;
|
||||||
|
G.cur_season=1;
|
||||||
|
G.cur_ep_idx=-1;
|
||||||
|
G.volume=80;
|
||||||
|
G.last_pct=-1;
|
||||||
|
G.dl_last_pct=-1;
|
||||||
|
G.dl_expected=-1;
|
||||||
|
ensure_dir();
|
||||||
|
load_state();
|
||||||
|
build_ui();
|
||||||
|
xTaskCreate(fetch_task_fn,"dm_fetch",16384,NULL,5,&G.fetch_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onHide(AppHandle app, void* data){
|
||||||
|
(void)app; (void)data;
|
||||||
|
save_current_ep_pos();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.fetch_handle){
|
||||||
|
vTaskDelete(G.fetch_handle);
|
||||||
|
G.fetch_handle=NULL;
|
||||||
|
G.fetching=false;
|
||||||
|
}
|
||||||
|
if(G.dl_handle){
|
||||||
|
G.dl_cancel_req=true;
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
if(G.dl_handle){
|
||||||
|
vTaskDelete(G.dl_handle);
|
||||||
|
G.dl_handle=NULL;
|
||||||
|
G.downloading=false;
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait_play_exit();
|
||||||
|
close_overlay();
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]){
|
||||||
|
(void)argc; (void)argv;
|
||||||
|
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
|||||||
|
#include "network.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <lwip/sockets.h>
|
||||||
|
#include <lwip/inet.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
||||||
|
|
||||||
|
bool resolve_host(const char* host, uint32_t* out_ip){
|
||||||
|
if(!host || !out_ip) return false;
|
||||||
|
uint32_t ip = ipaddr_addr(host);
|
||||||
|
if(ip!=0 && ip!=0xFFFFFFFF){
|
||||||
|
*out_ip=ip;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ESP_LOGE(TAG,"resolve fail (DNS not supported) %s",host);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void normalize_audio_url(const char* in, char* out, size_t out_len){
|
||||||
|
if(!in || !out) return;
|
||||||
|
if(strncmp(in,"http://",7)==0){
|
||||||
|
strncpy(out,in,out_len-1);
|
||||||
|
out[out_len-1]=0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(in[0]=='/'){
|
||||||
|
snprintf(out,out_len,"%s%s",PB,in);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(strstr(in,"://")==NULL){
|
||||||
|
if(in[0]=='/') snprintf(out,out_len,"%s%s",PB,in);
|
||||||
|
else snprintf(out,out_len,"%s/%s",PB,in);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
strncpy(out,in,out_len-1);
|
||||||
|
out[out_len-1]=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int http_get_raw(const char* url, char** out_body){
|
||||||
|
if(!url || strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"http_get url not http %s",url); return -1; }
|
||||||
|
const char* p = url+7;
|
||||||
|
const char* slash = strchr(p,'/');
|
||||||
|
if(!slash){ ESP_LOGE(TAG,"no slash %s",url); return -1; }
|
||||||
|
char hostport[128]={0};
|
||||||
|
size_t hlen=slash-p;
|
||||||
|
if(hlen>=sizeof(hostport)) return -1;
|
||||||
|
memcpy(hostport,p,hlen);
|
||||||
|
hostport[hlen]=0;
|
||||||
|
const char* path=slash;
|
||||||
|
char host[96]={0};
|
||||||
|
int port=80;
|
||||||
|
char* colon=strchr(hostport,':');
|
||||||
|
if(colon){ *colon=0; strncpy(host,hostport,sizeof(host)-1); port=atoi(colon+1); if(port<=0) port=80; } else { strncpy(host,hostport,sizeof(host)-1); }
|
||||||
|
ESP_LOGI(TAG,"GET %s host=%s port=%d",url,host,port);
|
||||||
|
uint32_t ip=0;
|
||||||
|
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; }
|
||||||
|
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||||
|
if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; }
|
||||||
|
struct sockaddr_in sa; memset(&sa,0,sizeof(sa));
|
||||||
|
sa.sin_family=AF_INET; sa.sin_port=my_htons(port); sa.sin_addr.s_addr=ip;
|
||||||
|
struct timeval tv={8,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*)&sa,sizeof(sa))<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
|
||||||
|
char req[512];
|
||||||
|
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
|
||||||
|
if(lwip_send(fd,req,reqlen,0)<0){ ESP_LOGE(TAG,"send fail"); close(fd); return -1; }
|
||||||
|
|
||||||
|
int capacity=65536;
|
||||||
|
char* buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!buf){ ESP_LOGE(TAG,"PSRAM 64k fail"); close(fd); return -1; }
|
||||||
|
int total=0;
|
||||||
|
char* header_end=NULL;
|
||||||
|
int body_start=0;
|
||||||
|
int content_len=-1;
|
||||||
|
bool is_chunked=false;
|
||||||
|
|
||||||
|
while(1){
|
||||||
|
if(total>=capacity-1){
|
||||||
|
int newcap=capacity*2;
|
||||||
|
if(newcap>600000){ ESP_LOGE(TAG,"too large %d",newcap); heap_caps_free(buf); close(fd); return -1; }
|
||||||
|
char* nb=heap_caps_malloc(newcap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!nb){ heap_caps_free(buf); close(fd); return -1; }
|
||||||
|
memcpy(nb,buf,total);
|
||||||
|
heap_caps_free(buf);
|
||||||
|
buf=nb;
|
||||||
|
capacity=newcap;
|
||||||
|
}
|
||||||
|
int r=lwip_recv(fd,buf+total,capacity-total-1,0);
|
||||||
|
if(r<=0) break;
|
||||||
|
total+=r;
|
||||||
|
buf[total]=0;
|
||||||
|
if(!header_end){
|
||||||
|
header_end=strstr(buf,"\r\n\r\n");
|
||||||
|
if(header_end){
|
||||||
|
body_start=(header_end-buf)+4;
|
||||||
|
if(strstr(buf,"chunked")) is_chunked=true;
|
||||||
|
char* cl=strstr(buf,"Content-Length:");
|
||||||
|
if(!cl) cl=strstr(buf,"content-length:");
|
||||||
|
if(cl){ cl=strchr(cl,':'); if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); } }
|
||||||
|
ESP_LOGI(TAG,"header_end chunked=%d cl=%d total=%d",is_chunked,content_len,total);
|
||||||
|
if(content_len>=0 && !is_chunked){
|
||||||
|
if(total-body_start >= content_len) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if(content_len>=0 && !is_chunked){
|
||||||
|
if(total-body_start >= content_len) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
if(!header_end){ ESP_LOGE(TAG,"no header end total=%d",total); heap_caps_free(buf); return -1; }
|
||||||
|
if(strncmp(buf,"HTTP/1.1 200",12)!=0 && strncmp(buf,"HTTP/1.0 200",12)!=0){ ESP_LOGE(TAG,"not 200: %.60s",buf); heap_caps_free(buf); return -1; }
|
||||||
|
|
||||||
|
int raw_len=total-body_start;
|
||||||
|
char* raw=buf+body_start;
|
||||||
|
char* final_body=NULL;
|
||||||
|
int final_len=0;
|
||||||
|
|
||||||
|
if(is_chunked){
|
||||||
|
final_body=heap_caps_malloc(200000, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!final_body){ heap_caps_free(buf); return -1; }
|
||||||
|
int pos=0, out=0;
|
||||||
|
while(pos<raw_len){
|
||||||
|
char* crlf=strstr(raw+pos,"\r\n");
|
||||||
|
if(!crlf) break;
|
||||||
|
int off=crlf-(raw+pos);
|
||||||
|
if(off<=0 || off>16){ pos+=off+2; continue; }
|
||||||
|
char hex[16]={0};
|
||||||
|
memcpy(hex,raw+pos,off);
|
||||||
|
char* semi=strchr(hex,';');
|
||||||
|
if(semi) *semi=0;
|
||||||
|
long sz=strtol(hex,NULL,16);
|
||||||
|
if(sz==0) break;
|
||||||
|
pos+=off+2;
|
||||||
|
if(pos+sz>raw_len) sz=raw_len-pos;
|
||||||
|
if(out+sz>=200000) break;
|
||||||
|
memcpy(final_body+out,raw+pos,sz);
|
||||||
|
out+=sz;
|
||||||
|
pos+=sz;
|
||||||
|
if(pos+1<raw_len && raw[pos]=='\r' && raw[pos+1]=='\n') pos+=2;
|
||||||
|
}
|
||||||
|
final_body[out]=0;
|
||||||
|
final_len=out;
|
||||||
|
} else {
|
||||||
|
int bl=raw_len;
|
||||||
|
if(content_len>=0 && bl>content_len) bl=content_len;
|
||||||
|
final_body=heap_caps_malloc(bl+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!final_body){ heap_caps_free(buf); return -1; }
|
||||||
|
memcpy(final_body,raw,bl);
|
||||||
|
final_body[bl]=0;
|
||||||
|
final_len=bl;
|
||||||
|
}
|
||||||
|
heap_caps_free(buf);
|
||||||
|
*out_body=final_body;
|
||||||
|
ESP_LOGI(TAG,"GET ok %d",final_len);
|
||||||
|
return final_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetch_data(){
|
||||||
|
char* js=NULL;
|
||||||
|
char url[256];
|
||||||
|
ESP_LOGI(TAG,"fetch seasons");
|
||||||
|
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB);
|
||||||
|
int len=http_get_raw(url,&js);
|
||||||
|
if(len>0 && js){
|
||||||
|
cJSON* root=cJSON_Parse(js);
|
||||||
|
free(js); js=NULL;
|
||||||
|
if(root){
|
||||||
|
cJSON* items=cJSON_GetObjectItem(root,"items");
|
||||||
|
if(cJSON_IsArray(items)){
|
||||||
|
G.season_cnt=0;
|
||||||
|
int n=cJSON_GetArraySize(items);
|
||||||
|
for(int i=0;i<n&&G.season_cnt<MAX_SEASONS;i++){
|
||||||
|
cJSON* it=cJSON_GetArrayItem(items,i);
|
||||||
|
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
|
||||||
|
if(sn&&cJSON_IsNumber(sn)){ G.seasons[G.season_cnt].num=sn->valueint; G.season_cnt++; }
|
||||||
|
}
|
||||||
|
sort_seasons();
|
||||||
|
}
|
||||||
|
cJSON_Delete(root);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"seasons fetch failed");
|
||||||
|
}
|
||||||
|
if(G.season_cnt==0){
|
||||||
|
ESP_LOGW(TAG,"seasons fallback");
|
||||||
|
for(int i=1;i<=37;i++) G.seasons[i-1].num=i;
|
||||||
|
G.season_cnt=37;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG,"fetch episodes");
|
||||||
|
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB);
|
||||||
|
len=http_get_raw(url,&js);
|
||||||
|
if(len>0 && js){
|
||||||
|
cJSON* root=cJSON_Parse(js);
|
||||||
|
free(js); js=NULL;
|
||||||
|
if(root){
|
||||||
|
cJSON* items=cJSON_GetObjectItem(root,"items");
|
||||||
|
if(cJSON_IsArray(items)){
|
||||||
|
G.ep_cnt=0;
|
||||||
|
int n=cJSON_GetArraySize(items);
|
||||||
|
for(int i=0;i<n&&G.ep_cnt<MAX_EPS;i++){
|
||||||
|
cJSON* it=cJSON_GetArrayItem(items,i);
|
||||||
|
cJSON* t=cJSON_GetObjectItem(it,"title");
|
||||||
|
cJSON* slug=cJSON_GetObjectItem(it,"slug");
|
||||||
|
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
|
||||||
|
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
|
||||||
|
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
|
||||||
|
if(!sn||!en) continue;
|
||||||
|
EpInfo* e=&G.eps[G.ep_cnt];
|
||||||
|
memset(e,0,sizeof(*e));
|
||||||
|
if(t&&cJSON_IsString(t)) strncpy(e->title,t->valuestring,sizeof(e->title)-1);
|
||||||
|
if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1);
|
||||||
|
else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1);
|
||||||
|
e->season=sn->valueint;
|
||||||
|
e->ep_num=en->valueint;
|
||||||
|
if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1);
|
||||||
|
e->seen=has_slug(e->slug);
|
||||||
|
G.ep_cnt++;
|
||||||
|
}
|
||||||
|
sort_eps();
|
||||||
|
}
|
||||||
|
cJSON_Delete(root);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"episodes fetch failed");
|
||||||
|
}
|
||||||
|
if(G.ep_cnt==0){
|
||||||
|
ESP_LOGW(TAG,"episodes fallback");
|
||||||
|
for(int i=0;i<6;i++){
|
||||||
|
EpInfo* e=&G.eps[i];
|
||||||
|
snprintf(e->slug,sizeof(e->slug),"S01E%02d",i+1);
|
||||||
|
snprintf(e->title,sizeof(e->title),"Episode %d",i+1);
|
||||||
|
e->season=1; e->ep_num=i+1;
|
||||||
|
}
|
||||||
|
G.ep_cnt=6;
|
||||||
|
}
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
uint16_t my_htons(uint16_t v);
|
||||||
|
bool resolve_host(const char* host, uint32_t* out_ip);
|
||||||
|
void normalize_audio_url(const char* in, char* out, size_t out_len);
|
||||||
|
|
||||||
|
// Robust HTTP GET with PSRAM buffer, returns length and allocates *out_body (must be free'd via free/heap_caps_free)
|
||||||
|
int http_get_raw(const char* url, char** out_body);
|
||||||
|
|
||||||
|
// Fetch seasons and episodes from PB
|
||||||
|
void fetch_data(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
#include "player.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "download.h"
|
||||||
|
#include "ui.h"
|
||||||
|
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <lwip/sockets.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#define MINIMP3_IMPLEMENTATION
|
||||||
|
#define MINIMP3_NO_SIMD
|
||||||
|
#include "minimp3.h"
|
||||||
|
|
||||||
|
bool find_audio_device(){
|
||||||
|
struct Device* dev=device_find_by_name("audio-stream");
|
||||||
|
if(dev){ G.stream_dev=dev; return true; }
|
||||||
|
dev=device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||||
|
if(dev){ G.stream_dev=dev; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void close_stream(){
|
||||||
|
if(G.stream_handle){
|
||||||
|
audio_stream_close(G.stream_handle);
|
||||||
|
G.stream_handle=NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool open_stream(uint32_t sr, uint8_t ch){
|
||||||
|
close_stream();
|
||||||
|
if(!G.stream_dev && !find_audio_device()) return false;
|
||||||
|
struct AudioStreamConfig cfg={ .sample_rate=sr, .bits_per_sample=16, .channels=ch };
|
||||||
|
error_t err=audio_stream_open_output(G.stream_dev,&cfg,&G.stream_handle);
|
||||||
|
return err==ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void wait_play_exit(){
|
||||||
|
if(G.play_handle){
|
||||||
|
G.need_stop=true;
|
||||||
|
while(G.play_handle!=NULL){
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close_stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop_playback_sync(){
|
||||||
|
if(G.play_handle){
|
||||||
|
G.need_stop=true;
|
||||||
|
int tries=200;
|
||||||
|
while(G.play_handle!=NULL && tries-- >0){
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close_stream();
|
||||||
|
G.is_playing=false;
|
||||||
|
G.is_paused=false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void play_task(void* arg){
|
||||||
|
(void)arg;
|
||||||
|
int idx=G.cur_ep_idx;
|
||||||
|
if(idx<0||idx>=G.ep_cnt){ G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
char path[300];
|
||||||
|
make_sd_path(path,sizeof(path),ep->slug);
|
||||||
|
FILE* file=fopen(path,"rb");
|
||||||
|
if(!file){ ESP_LOGE(TAG,"open fail %s",path); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
fseek(file,0,SEEK_END);
|
||||||
|
long fsize=ftell(file);
|
||||||
|
fseek(file,0,SEEK_SET);
|
||||||
|
if(fsize>0) G.total_sec=(int)(fsize/16000);
|
||||||
|
if(!find_audio_device()){ ESP_LOGE(TAG,"no audio-stream dev"); fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
|
||||||
|
uint8_t id3hdr[10];
|
||||||
|
long id3_skip=0;
|
||||||
|
if(fread(id3hdr,1,10,file)==10 && memcmp(id3hdr,"ID3",3)==0){
|
||||||
|
int id3size = (id3hdr[6]&0x7F)<<21 | (id3hdr[7]&0x7F)<<14 | (id3hdr[8]&0x7F)<<7 | (id3hdr[9]&0x7F);
|
||||||
|
ESP_LOGI(TAG,"ID3 found %d, skip",id3size);
|
||||||
|
id3_skip=id3size+10;
|
||||||
|
fseek(file, id3_skip, SEEK_SET);
|
||||||
|
fsize -= id3_skip;
|
||||||
|
if(fsize>0) G.total_sec=(int)(fsize/16000);
|
||||||
|
} else {
|
||||||
|
fseek(file,0,SEEK_SET);
|
||||||
|
}
|
||||||
|
|
||||||
|
int resume_sec = G.pos_sec;
|
||||||
|
if(resume_sec>5){
|
||||||
|
long seek_bytes = (long)resume_sec * 16000;
|
||||||
|
if(seek_bytes < fsize){
|
||||||
|
fseek(file, id3_skip + seek_bytes, SEEK_SET);
|
||||||
|
ESP_LOGI(TAG,"Resuming %s from %d sec (~%ld bytes)", ep->slug, resume_sec, seek_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mp3dec_t* dec = heap_caps_malloc(sizeof(mp3dec_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!dec){ ESP_LOGE(TAG,"dec malloc fail"); fclose(file); G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
mp3dec_init(dec);
|
||||||
|
uint8_t* inbuf=malloc(MP3_BUF);
|
||||||
|
int16_t* pcm=malloc(1152*2*2);
|
||||||
|
if(!inbuf||!pcm){ if(inbuf) free(inbuf); if(pcm) free(pcm); fclose(file); heap_caps_free(dec); G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
size_t buffered=0;
|
||||||
|
mp3dec_frame_info_t info;
|
||||||
|
memset(&info,0,sizeof(info));
|
||||||
|
bool configured=false;
|
||||||
|
int sr=0, ch=0;
|
||||||
|
size_t r=fread(inbuf,1,MP3_BUF,file);
|
||||||
|
buffered=r;
|
||||||
|
ESP_LOGI(TAG,"play task start %s size %ld resume=%d",path,fsize,resume_sec);
|
||||||
|
long total_decoded_samples = 0;
|
||||||
|
if(resume_sec>5){
|
||||||
|
total_decoded_samples = (long)resume_sec * 16000;
|
||||||
|
}
|
||||||
|
|
||||||
|
while(!G.need_stop){
|
||||||
|
if(G.is_paused){
|
||||||
|
if(configured){
|
||||||
|
close_stream();
|
||||||
|
configured=false;
|
||||||
|
sr=0; ch=0;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int samples=mp3dec_decode_frame(dec,inbuf,(int)buffered,pcm,&info);
|
||||||
|
if(samples>0){
|
||||||
|
total_decoded_samples += samples;
|
||||||
|
if(info.hz>0) G.pos_sec = total_decoded_samples / info.hz;
|
||||||
|
else G.pos_sec = total_decoded_samples / 16000;
|
||||||
|
if(!configured || info.hz!=sr || info.channels!=ch){
|
||||||
|
ESP_LOGI(TAG,"open_stream sr=%d ch=%d",info.hz,info.channels);
|
||||||
|
if(!open_stream((uint32_t)info.hz,(uint8_t)info.channels)){
|
||||||
|
ESP_LOGE(TAG,"open_stream fail %d %d",info.hz,info.channels);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"open_stream ok");
|
||||||
|
sr=info.hz; ch=info.channels;
|
||||||
|
configured=true;
|
||||||
|
if(resume_sec>5 && total_decoded_samples < (long)resume_sec * info.hz + info.hz){
|
||||||
|
total_decoded_samples = (long)resume_sec * info.hz;
|
||||||
|
G.pos_sec = resume_sec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float vol=G.volume/100.0f;
|
||||||
|
if(vol<0) vol=0;
|
||||||
|
if(vol>1) vol=1;
|
||||||
|
int total_samples = samples * (info.channels>0?info.channels:1);
|
||||||
|
for(int i=0;i<total_samples;i++){
|
||||||
|
int32_t s=pcm[i];
|
||||||
|
s=(int32_t)(s*vol);
|
||||||
|
if(s>32767) s=32767;
|
||||||
|
if(s<-32768) s=-32768;
|
||||||
|
pcm[i]=(int16_t)s;
|
||||||
|
}
|
||||||
|
size_t to_write = total_samples * sizeof(int16_t);
|
||||||
|
size_t off=0;
|
||||||
|
while(off<to_write && !G.need_stop){
|
||||||
|
if(G.is_paused){
|
||||||
|
if(configured){
|
||||||
|
close_stream();
|
||||||
|
configured=false;
|
||||||
|
sr=0; ch=0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
size_t w=0;
|
||||||
|
error_t e=audio_stream_write(G.stream_handle,(uint8_t*)pcm+off,to_write-off,&w,pdMS_TO_TICKS(500));
|
||||||
|
if(e==ERROR_NONE){
|
||||||
|
off+=w;
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"audio_write err %d",e);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(G.is_paused) continue;
|
||||||
|
}
|
||||||
|
if(info.frame_bytes>0 && info.frame_bytes <= (int)buffered){
|
||||||
|
memmove(inbuf,inbuf+info.frame_bytes,buffered-info.frame_bytes);
|
||||||
|
buffered-=info.frame_bytes;
|
||||||
|
} else {
|
||||||
|
buffered=0;
|
||||||
|
}
|
||||||
|
if(buffered < MP3_BUF/2){
|
||||||
|
size_t nr=fread(inbuf+buffered,1,MP3_BUF-buffered,file);
|
||||||
|
if(nr>0) buffered+=nr;
|
||||||
|
else { if(samples==0) break; }
|
||||||
|
}
|
||||||
|
if(buffered==0) break;
|
||||||
|
}
|
||||||
|
fclose(file);
|
||||||
|
free(inbuf);
|
||||||
|
free(pcm);
|
||||||
|
heap_caps_free(dec);
|
||||||
|
close_stream();
|
||||||
|
bool finished_naturally = !G.need_stop;
|
||||||
|
if(finished_naturally){
|
||||||
|
ESP_LOGI(TAG,"playback finished naturally for %s, clearing saved pos", ep->slug);
|
||||||
|
set_saved_pos_for_slug(ep->slug, 0);
|
||||||
|
G.pos_sec=0;
|
||||||
|
save_state();
|
||||||
|
} else {
|
||||||
|
set_saved_pos_for_slug(ep->slug, G.pos_sec);
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"play task end finished_naturally=%d", finished_naturally);
|
||||||
|
G.is_playing=false;
|
||||||
|
G.play_handle=NULL;
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void start_idx_internal(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
if(G.cur_ep_idx!=idx){
|
||||||
|
int saved = get_saved_pos_for_slug(ep->slug);
|
||||||
|
if(saved>5){
|
||||||
|
G.pos_sec=saved;
|
||||||
|
ESP_LOGI(TAG,"Restoring saved pos %d for %s", saved, ep->slug);
|
||||||
|
}
|
||||||
|
G.last_pct=-1;
|
||||||
|
}
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
wait_play_exit();
|
||||||
|
G.cur_ep_idx=idx; G.cur_season=ep->season;
|
||||||
|
strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
|
||||||
|
strncpy(G.last_slug,ep->slug,sizeof(G.last_slug)-1);
|
||||||
|
G.need_stop=false; G.is_paused=false; G.is_playing=true;
|
||||||
|
save_state();
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
xTaskCreate(play_task,"dm_play",16384,NULL,5,&G.play_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void start_idx(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
if(!has_slug(ep->slug)){
|
||||||
|
stop_playback_sync();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
show_dl_overlay_locked(ep->title, ep->slug, ep->season, ep->ep_num);
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading…");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
if(G.dl_handle){ return; }
|
||||||
|
xTaskCreate(dl_task,"dm_dl",16384,(void*)(intptr_t)idx,5,&G.dl_handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
start_idx_internal(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void select_ep(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt && G.cur_ep_idx!=idx){
|
||||||
|
save_current_ep_pos();
|
||||||
|
}
|
||||||
|
G.cur_ep_idx=idx;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
G.cur_season=ep->season;
|
||||||
|
strncpy(G.last_slug, ep->slug, sizeof(G.last_slug)-1);
|
||||||
|
|
||||||
|
int saved = get_saved_pos_for_slug(ep->slug);
|
||||||
|
if(saved>5){
|
||||||
|
G.pos_sec=saved;
|
||||||
|
ESP_LOGI(TAG,"select_ep restore %s pos=%d", ep->slug, saved);
|
||||||
|
} else {
|
||||||
|
G.pos_sec=saved;
|
||||||
|
}
|
||||||
|
G.last_pct=-1;
|
||||||
|
G.total_sec=0;
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
|
||||||
|
if(G.lbl_meta){
|
||||||
|
char b[64];
|
||||||
|
if(saved>5){
|
||||||
|
char tbuf[16];
|
||||||
|
fmt_time(tbuf,saved);
|
||||||
|
snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf);
|
||||||
|
} else {
|
||||||
|
snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":"");
|
||||||
|
}
|
||||||
|
lv_label_set_text(G.lbl_meta,b);
|
||||||
|
}
|
||||||
|
if(G.lbl_status){
|
||||||
|
if(saved>5) lv_label_set_text(G.lbl_status,"Resume – tap Play");
|
||||||
|
else lv_label_set_text(G.lbl_status,ep->seen?"On SD – tap Play":"Tap Play to download");
|
||||||
|
}
|
||||||
|
if(G.bar){
|
||||||
|
lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
if(G.lbl_time){
|
||||||
|
if(saved>5){
|
||||||
|
char a[16];
|
||||||
|
fmt_time(a,saved);
|
||||||
|
char buf[40];
|
||||||
|
snprintf(buf,sizeof(buf),"%s / --:--",a);
|
||||||
|
lv_label_set_text(G.lbl_time,buf);
|
||||||
|
} else {
|
||||||
|
lv_label_set_text(G.lbl_time,"0:00 / --:--");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
void go_next(){
|
||||||
|
if(G.ep_cnt==0) return;
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
stop_playback_sync();
|
||||||
|
int n=G.cur_ep_idx+1;
|
||||||
|
if(n>=G.ep_cnt) n=0;
|
||||||
|
select_ep(n);
|
||||||
|
start_idx(n);
|
||||||
|
}
|
||||||
|
void go_prev(){
|
||||||
|
if(G.ep_cnt==0) return;
|
||||||
|
if(G.pos_sec>5){
|
||||||
|
ESP_LOGI(TAG,"go_prev restart current %d sec",G.pos_sec);
|
||||||
|
if(G.cur_ep_idx>=0){
|
||||||
|
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, 0);
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
stop_playback_sync();
|
||||||
|
G.pos_sec=0;
|
||||||
|
int cur=G.cur_ep_idx;
|
||||||
|
if(cur<0) cur=0;
|
||||||
|
select_ep(cur);
|
||||||
|
start_idx(cur);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
stop_playback_sync();
|
||||||
|
int p=G.cur_ep_idx-1;
|
||||||
|
if(p<0) p=G.ep_cnt-1;
|
||||||
|
select_ep(p);
|
||||||
|
start_idx(p);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
bool find_audio_device(void);
|
||||||
|
void close_stream(void);
|
||||||
|
bool open_stream(uint32_t sr, uint8_t ch);
|
||||||
|
void wait_play_exit(void);
|
||||||
|
void stop_playback_sync(void);
|
||||||
|
|
||||||
|
void play_task(void* arg);
|
||||||
|
void start_idx_internal(int idx);
|
||||||
|
void start_idx(int idx);
|
||||||
|
void select_ep(int idx);
|
||||||
|
void go_next(void);
|
||||||
|
void go_prev(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
#include "storage.h"
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
void make_sd_path(char* out, size_t out_len, const char* slug){
|
||||||
|
if(!out || !slug) return;
|
||||||
|
snprintf(out, out_len, "%s/%s.mp3", DM_DIR, slug);
|
||||||
|
}
|
||||||
|
void ensure_dir(){
|
||||||
|
mkdir("/sdcard/apps",0777);
|
||||||
|
mkdir("/sdcard/apps/one.tactility.discoverymountain",0777);
|
||||||
|
mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777);
|
||||||
|
mkdir(DM_DIR,0777);
|
||||||
|
}
|
||||||
|
bool has_slug(const char* slug){
|
||||||
|
char p[300];
|
||||||
|
make_sd_path(p, sizeof(p), slug);
|
||||||
|
struct stat st;
|
||||||
|
return stat(p,&st)==0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
|
||||||
|
|
||||||
|
void sort_seasons(){
|
||||||
|
for(int i=0;i<G.season_cnt;i++){
|
||||||
|
for(int j=i+1;j<G.season_cnt;j++){
|
||||||
|
if(G.seasons[j].num < G.seasons[i].num){
|
||||||
|
SeasonInfo t=G.seasons[i]; G.seasons[i]=G.seasons[j]; G.seasons[j]=t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void sort_eps(){
|
||||||
|
for(int i=0;i<G.ep_cnt;i++){
|
||||||
|
for(int j=i+1;j<G.ep_cnt;j++){
|
||||||
|
EpInfo *a=&G.eps[i], *b=&G.eps[j];
|
||||||
|
bool should_swap=false;
|
||||||
|
if(a->season != b->season) should_swap = (b->season < a->season);
|
||||||
|
else should_swap = (b->ep_num < a->ep_num);
|
||||||
|
if(should_swap){
|
||||||
|
EpInfo t=*a; *a=*b; *b=t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-episode position helpers ──
|
||||||
|
int find_saved_pos_idx(const char* slug){
|
||||||
|
if(!slug) return -1;
|
||||||
|
for(int i=0;i<G.saved_pos_cnt;i++){
|
||||||
|
if(strcmp(G.saved_pos[i].slug, slug)==0) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int get_saved_pos_for_slug(const char* slug){
|
||||||
|
int idx=find_saved_pos_idx(slug);
|
||||||
|
if(idx>=0) return G.saved_pos[idx].pos;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
void set_saved_pos_for_slug(const char* slug, int pos){
|
||||||
|
if(!slug || strlen(slug)==0) return;
|
||||||
|
if(pos<0) pos=0;
|
||||||
|
if(G.total_sec>0 && pos>0 && pos >= G.total_sec-10){
|
||||||
|
pos=0;
|
||||||
|
}
|
||||||
|
int idx=find_saved_pos_idx(slug);
|
||||||
|
if(idx>=0){
|
||||||
|
G.saved_pos[idx].pos=pos;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(G.saved_pos_cnt < MAX_EPS){
|
||||||
|
strncpy(G.saved_pos[G.saved_pos_cnt].slug, slug, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
|
||||||
|
G.saved_pos[G.saved_pos_cnt].slug[sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1]=0;
|
||||||
|
G.saved_pos[G.saved_pos_cnt].pos=pos;
|
||||||
|
G.saved_pos_cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void save_current_ep_pos(){
|
||||||
|
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt){
|
||||||
|
const char* slug=G.eps[G.cur_ep_idx].slug;
|
||||||
|
set_saved_pos_for_slug(slug, G.pos_sec);
|
||||||
|
} else if(strlen(G.last_slug)>0){
|
||||||
|
set_saved_pos_for_slug(G.last_slug, G.pos_sec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void save_state(){
|
||||||
|
if(G.cur_ep_idx>=0 && G.ep_cnt>0){
|
||||||
|
if(G.pos_sec>=0){
|
||||||
|
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, G.pos_sec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cJSON* r=cJSON_CreateObject();
|
||||||
|
if(G.ep_cnt>0 && G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
|
||||||
|
else if(strlen(G.last_slug)>0) cJSON_AddStringToObject(r,"last_slug",G.last_slug);
|
||||||
|
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
|
||||||
|
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
|
||||||
|
if(G.saved_pos_cnt>0){
|
||||||
|
cJSON* pos_obj=cJSON_CreateObject();
|
||||||
|
for(int i=0;i<G.saved_pos_cnt;i++){
|
||||||
|
if(G.saved_pos[i].slug[0]==0) continue;
|
||||||
|
if(G.saved_pos[i].pos>0){
|
||||||
|
cJSON_AddNumberToObject(pos_obj, G.saved_pos[i].slug, G.saved_pos[i].pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cJSON_AddItemToObject(r,"positions",pos_obj);
|
||||||
|
}
|
||||||
|
char* s=cJSON_PrintUnformatted(r);
|
||||||
|
if(s){
|
||||||
|
FILE* f=fopen(STATE_PATH,"w");
|
||||||
|
if(f){ fwrite(s,1,strlen(s),f); fclose(f);}
|
||||||
|
free(s);
|
||||||
|
}
|
||||||
|
cJSON_Delete(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
void load_state(){
|
||||||
|
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
|
||||||
|
G.saved_pos_cnt=0;
|
||||||
|
G.last_save_tick=0;
|
||||||
|
memset(G.last_slug,0,sizeof(G.last_slug));
|
||||||
|
memset(G.saved_pos,0,sizeof(G.saved_pos));
|
||||||
|
FILE* f=fopen(STATE_PATH,"r");
|
||||||
|
if(!f) return;
|
||||||
|
fseek(f,0,SEEK_END);
|
||||||
|
long sz=ftell(f);
|
||||||
|
fseek(f,0,SEEK_SET);
|
||||||
|
if(sz<=0||sz>16384){ fclose(f); return;}
|
||||||
|
char* buf=malloc(sz+1);
|
||||||
|
if(!buf){ fclose(f); return; }
|
||||||
|
size_t got=fread(buf,1,sz,f);
|
||||||
|
buf[got]=0;
|
||||||
|
fclose(f);
|
||||||
|
cJSON* r=cJSON_Parse(buf);
|
||||||
|
free(buf);
|
||||||
|
if(!r) return;
|
||||||
|
cJSON* ls=cJSON_GetObjectItem(r,"last_slug");
|
||||||
|
cJSON* lsn=cJSON_GetObjectItem(r,"last_season");
|
||||||
|
cJSON* ps=cJSON_GetObjectItem(r,"pos_sec");
|
||||||
|
if(ps&&cJSON_IsNumber(ps)) G.pos_sec=ps->valueint;
|
||||||
|
if(lsn&&cJSON_IsNumber(lsn)) G.cur_season=lsn->valueint;
|
||||||
|
if(ls&&cJSON_IsString(ls)){
|
||||||
|
strncpy(G.last_slug, ls->valuestring, sizeof(G.last_slug)-1);
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,ls->valuestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
|
||||||
|
}
|
||||||
|
cJSON* poss=cJSON_GetObjectItem(r,"positions");
|
||||||
|
if(poss && cJSON_IsObject(poss)){
|
||||||
|
cJSON* child=NULL;
|
||||||
|
cJSON_ArrayForEach(child, poss){
|
||||||
|
if(cJSON_IsNumber(child) && child->string && G.saved_pos_cnt < MAX_EPS){
|
||||||
|
strncpy(G.saved_pos[G.saved_pos_cnt].slug, child->string, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
|
||||||
|
G.saved_pos[G.saved_pos_cnt].pos=child->valueint;
|
||||||
|
G.saved_pos_cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(strlen(G.last_slug)>0){
|
||||||
|
int sp=get_saved_pos_for_slug(G.last_slug);
|
||||||
|
if(sp>0) G.pos_sec=sp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cJSON_Delete(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
void lru_check(){
|
||||||
|
typedef struct{ char path[300]; time_t mt; } FE;
|
||||||
|
FE files[200];
|
||||||
|
int fc=0;
|
||||||
|
DIR* d=opendir(DM_DIR);
|
||||||
|
if(!d) return;
|
||||||
|
struct dirent* de;
|
||||||
|
char cur_path[300]={0};
|
||||||
|
if(G.cur_ep_idx>=0 && G.cur_ep_idx<G.ep_cnt){
|
||||||
|
make_sd_path(cur_path, sizeof(cur_path), G.eps[G.cur_ep_idx].slug);
|
||||||
|
}
|
||||||
|
while((de=readdir(d))!=NULL && fc<200){
|
||||||
|
if(de->d_name[0]=='.') continue;
|
||||||
|
size_t l=strlen(de->d_name);
|
||||||
|
if(l<5) continue;
|
||||||
|
if(strcmp(de->d_name+l-4,".mp3")!=0) continue;
|
||||||
|
char full[300];
|
||||||
|
snprintf(full,sizeof(full),"%s/%.200s",DM_DIR,de->d_name);
|
||||||
|
struct stat st;
|
||||||
|
if(stat(full,&st)!=0) continue;
|
||||||
|
if(cur_path[0] && strcmp(full,cur_path)==0) continue;
|
||||||
|
strncpy(files[fc].path,full,sizeof(files[fc].path)-1);
|
||||||
|
files[fc].mt=st.st_mtime;
|
||||||
|
fc++;
|
||||||
|
}
|
||||||
|
closedir(d);
|
||||||
|
if(fc<12) return;
|
||||||
|
for(int i=0;i<fc;i++){
|
||||||
|
for(int j=i+1;j<fc;j++){
|
||||||
|
if(files[j].mt<files[i].mt){ FE t=files[i]; files[i]=files[j]; files[j]=t; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int todel=fc-8;
|
||||||
|
for(int i=0;i<todel;i++) unlink(files[i].path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Path helpers
|
||||||
|
void make_sd_path(char* out, size_t out_len, const char* slug);
|
||||||
|
void ensure_dir(void);
|
||||||
|
bool has_slug(const char* slug);
|
||||||
|
|
||||||
|
// LRU cache cleanup
|
||||||
|
void lru_check(void);
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
void sort_seasons(void);
|
||||||
|
void sort_eps(void);
|
||||||
|
|
||||||
|
// Time formatting
|
||||||
|
void fmt_time(char* o, int s);
|
||||||
|
|
||||||
|
// Per-episode resume helpers
|
||||||
|
int find_saved_pos_idx(const char* slug);
|
||||||
|
int get_saved_pos_for_slug(const char* slug);
|
||||||
|
void set_saved_pos_for_slug(const char* slug, int pos);
|
||||||
|
void save_current_ep_pos(void);
|
||||||
|
|
||||||
|
// Persistence
|
||||||
|
void save_state(void);
|
||||||
|
void load_state(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#include "ui.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "player.h"
|
||||||
|
#include "download.h"
|
||||||
|
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
void close_overlay(){
|
||||||
|
if(G.overlay){
|
||||||
|
lv_obj_delete(G.overlay);
|
||||||
|
G.overlay=NULL;
|
||||||
|
G.dropdown=NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dropdown_cb(lv_event_t* e){
|
||||||
|
if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return;
|
||||||
|
lv_obj_t* dd = (lv_obj_t*)lv_event_get_target(e);
|
||||||
|
int sel=lv_dropdown_get_selected(dd);
|
||||||
|
if(sel<0||sel>=G.season_cnt) return;
|
||||||
|
int sn=G.seasons[sel].num;
|
||||||
|
close_overlay();
|
||||||
|
int first=-1, first_unseen=-1;
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==sn){
|
||||||
|
if(first==-1) first=i;
|
||||||
|
if(!G.eps[i].seen && first_unseen==-1) first_unseen=i;
|
||||||
|
}
|
||||||
|
int tgt=first_unseen!=-1?first_unseen:first;
|
||||||
|
if(tgt!=-1){
|
||||||
|
G.cur_season=sn;
|
||||||
|
select_ep(tgt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void btn_seasons_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
if(G.downloading) return;
|
||||||
|
if(G.overlay){ close_overlay(); return; }
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
G.overlay=lv_obj_create(lv_scr_act());
|
||||||
|
lv_obj_set_size(G.overlay,240,280);
|
||||||
|
lv_obj_center(G.overlay);
|
||||||
|
lv_obj_set_style_bg_color(G.overlay,lv_color_hex(0x222222),0);
|
||||||
|
lv_obj_set_style_radius(G.overlay,12,0);
|
||||||
|
lv_obj_t* title=lv_label_create(G.overlay);
|
||||||
|
lv_label_set_text(title,"Select Season");
|
||||||
|
lv_obj_align(title,LV_ALIGN_TOP_MID,0,8);
|
||||||
|
|
||||||
|
static char opts[1024];
|
||||||
|
opts[0]=0;
|
||||||
|
for(int i=0;i<G.season_cnt;i++){
|
||||||
|
char line[16];
|
||||||
|
if(i==0) snprintf(opts,sizeof(opts),"S%d",G.seasons[i].num);
|
||||||
|
else { snprintf(line,sizeof(line),"\nS%d",G.seasons[i].num); strncat(opts,line,sizeof(opts)-strlen(opts)-1); }
|
||||||
|
}
|
||||||
|
G.dropdown=lv_dropdown_create(G.overlay);
|
||||||
|
lv_dropdown_set_options(G.dropdown, opts);
|
||||||
|
lv_obj_set_size(G.dropdown,160,40);
|
||||||
|
lv_obj_align(G.dropdown,LV_ALIGN_CENTER,0,10);
|
||||||
|
int sel=0;
|
||||||
|
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==G.cur_season) sel=i;
|
||||||
|
lv_dropdown_set_selected(G.dropdown, sel);
|
||||||
|
lv_obj_add_event_cb(G.dropdown,dropdown_cb,LV_EVENT_VALUE_CHANGED,NULL);
|
||||||
|
|
||||||
|
lv_obj_t* bc=lv_btn_create(G.overlay);
|
||||||
|
lv_obj_set_size(bc,80,32);
|
||||||
|
lv_obj_align(bc,LV_ALIGN_BOTTOM_MID,0,-8);
|
||||||
|
lv_obj_t* lc=lv_label_create(bc);
|
||||||
|
lv_label_set_text(lc,"Close");
|
||||||
|
lv_obj_center(lc);
|
||||||
|
lv_obj_add_event_cb(bc,(lv_event_cb_t)close_overlay,LV_EVENT_CLICKED,NULL);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
void build_ui(){
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
lv_obj_t* root=lv_obj_create(lv_scr_act());
|
||||||
|
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
||||||
|
lv_obj_set_style_bg_color(root,lv_color_hex(0x111111),0);
|
||||||
|
lv_obj_set_style_pad_all(root,8,0);
|
||||||
|
lv_obj_set_style_border_width(root,0,0);
|
||||||
|
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
G.lbl_title=lv_label_create(root);
|
||||||
|
lv_label_set_text(G.lbl_title,"Discovery Mountain");
|
||||||
|
lv_obj_set_style_text_font(G.lbl_title, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
|
||||||
|
lv_obj_set_width(G.lbl_title,LV_PCT(100));
|
||||||
|
lv_label_set_long_mode(G.lbl_title,LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_align(G.lbl_title,LV_ALIGN_TOP_LEFT,0,0);
|
||||||
|
|
||||||
|
G.lbl_meta=lv_label_create(root);
|
||||||
|
lv_label_set_text(G.lbl_meta,"Loading...");
|
||||||
|
lv_obj_set_style_text_color(G.lbl_meta,lv_color_hex(0xAAAAAA),0);
|
||||||
|
lv_obj_align(G.lbl_meta,LV_ALIGN_TOP_LEFT,0,28);
|
||||||
|
|
||||||
|
G.bar=lv_bar_create(root);
|
||||||
|
lv_obj_set_size(G.bar,LV_PCT(100),12);
|
||||||
|
lv_obj_align(G.bar,LV_ALIGN_TOP_LEFT,0,50);
|
||||||
|
lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
||||||
|
|
||||||
|
G.lbl_time=lv_label_create(root);
|
||||||
|
lv_label_set_text(G.lbl_time,"0:00 / --:--");
|
||||||
|
lv_obj_align(G.lbl_time,LV_ALIGN_TOP_LEFT,0,66);
|
||||||
|
|
||||||
|
G.lbl_status=lv_label_create(root);
|
||||||
|
lv_label_set_text(G.lbl_status,"Fetching data...");
|
||||||
|
lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0);
|
||||||
|
lv_obj_align(G.lbl_status,LV_ALIGN_TOP_LEFT,0,82);
|
||||||
|
|
||||||
|
lv_obj_t* row=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(row,LV_PCT(100),56);
|
||||||
|
lv_obj_align(row,LV_ALIGN_TOP_LEFT,0,104);
|
||||||
|
lv_obj_set_flex_flow(row,LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(row,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_bg_opa(row,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_border_width(row,0,0);
|
||||||
|
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* b1=lv_btn_create(row);
|
||||||
|
lv_obj_set_size(b1,56,48);
|
||||||
|
lv_obj_t* l1=lv_label_create(b1);
|
||||||
|
lv_label_set_text(l1,LV_SYMBOL_PREV);
|
||||||
|
lv_obj_center(l1);
|
||||||
|
lv_obj_add_event_cb(b1,btn_prev_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
G.btn_play=lv_btn_create(row);
|
||||||
|
lv_obj_set_size(G.btn_play,72,48);
|
||||||
|
lv_obj_set_style_bg_color(G.btn_play,lv_color_hex(0x2E7D32),0);
|
||||||
|
lv_obj_t* l2=lv_label_create(G.btn_play);
|
||||||
|
lv_label_set_text(l2,LV_SYMBOL_PLAY);
|
||||||
|
lv_obj_center(l2);
|
||||||
|
lv_obj_add_event_cb(G.btn_play,btn_play_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
lv_obj_t* b3=lv_btn_create(row);
|
||||||
|
lv_obj_set_size(b3,56,48);
|
||||||
|
lv_obj_t* l3=lv_label_create(b3);
|
||||||
|
lv_label_set_text(l3,LV_SYMBOL_NEXT);
|
||||||
|
lv_obj_center(l3);
|
||||||
|
lv_obj_add_event_cb(b3,btn_next_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
lv_obj_t* row2=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(row2,LV_PCT(100),44);
|
||||||
|
lv_obj_align(row2,LV_ALIGN_BOTTOM_LEFT,0,0);
|
||||||
|
lv_obj_set_flex_flow(row2,LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(row2,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_bg_opa(row2,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_border_width(row2,0,0);
|
||||||
|
lv_obj_remove_flag(row2, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* bs=lv_btn_create(row2);
|
||||||
|
lv_obj_set_size(bs,LV_PCT(100),36);
|
||||||
|
lv_obj_t* ls=lv_label_create(bs);
|
||||||
|
lv_label_set_text(ls,"Seasons");
|
||||||
|
lv_obj_center(ls);
|
||||||
|
lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
lv_timer_create(ui_timer,200,NULL);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ui_timer(lv_timer_t* t){
|
||||||
|
(void)t;
|
||||||
|
if(G.downloading){
|
||||||
|
update_dl_overlay_ui();
|
||||||
|
}
|
||||||
|
if(G.bar){
|
||||||
|
if(G.total_sec>0){
|
||||||
|
int pct=G.pos_sec*100/G.total_sec;
|
||||||
|
if(pct>100) pct=100;
|
||||||
|
if(pct!=G.last_pct){
|
||||||
|
G.last_pct=pct;
|
||||||
|
lv_bar_set_value(G.bar,pct,LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
} else if(G.is_playing){
|
||||||
|
int pct=(G.pos_sec%10)*10;
|
||||||
|
if(pct!=G.last_pct){
|
||||||
|
G.last_pct=pct;
|
||||||
|
lv_bar_set_value(G.bar,pct,LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(G.lbl_time){
|
||||||
|
char a[16],b[16];
|
||||||
|
fmt_time(a,G.pos_sec);
|
||||||
|
if(G.total_sec>0) fmt_time(b,G.total_sec);
|
||||||
|
else snprintf(b,sizeof(b),"--:--");
|
||||||
|
char buf[40];
|
||||||
|
snprintf(buf,sizeof(buf),"%s / %s",a,b);
|
||||||
|
lv_label_set_text(G.lbl_time,buf);
|
||||||
|
}
|
||||||
|
if(G.is_playing && G.lbl_status && !G.downloading){
|
||||||
|
const char* cur = lv_label_get_text(G.lbl_status);
|
||||||
|
if(!cur || strcmp(cur,"Playing")!=0) lv_label_set_text(G.lbl_status,"Playing");
|
||||||
|
}
|
||||||
|
if(G.is_playing && !G.is_paused && !G.downloading){
|
||||||
|
G.last_save_tick++;
|
||||||
|
if(G.last_save_tick >= 25){
|
||||||
|
G.last_save_tick=0;
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void btn_play_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
if(G.downloading){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(G.is_playing && !G.is_paused){
|
||||||
|
G.is_paused=true;
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Paused");
|
||||||
|
if(G.btn_play){
|
||||||
|
lv_obj_t* ch=lv_obj_get_child(G.btn_play,0);
|
||||||
|
if(ch) lv_label_set_text(ch,LV_SYMBOL_PLAY);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
} else if(G.is_paused){
|
||||||
|
G.is_paused=false;
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.btn_play){
|
||||||
|
lv_obj_t* ch=lv_obj_get_child(G.btn_play,0);
|
||||||
|
if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
} else {
|
||||||
|
start_idx(G.cur_ep_idx);
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.btn_play){
|
||||||
|
lv_obj_t* ch=lv_obj_get_child(G.btn_play,0);
|
||||||
|
if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void btn_prev_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_prev(); }
|
||||||
|
void btn_next_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_next(); }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void build_ui(void);
|
||||||
|
void ui_timer(lv_timer_t* t);
|
||||||
|
|
||||||
|
void btn_play_cb(lv_event_t* e);
|
||||||
|
void btn_prev_cb(lv_event_t* e);
|
||||||
|
void btn_next_cb(lv_event_t* e);
|
||||||
|
void btn_seasons_cb(lv_event_t* e);
|
||||||
|
void close_overlay(void);
|
||||||
|
void dropdown_cb(lv_event_t* e);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3
|
||||||
|
app.id=one.tactility.discoverymountain
|
||||||
|
app.name=Discovery Mountain
|
||||||
|
app.version.name=0.1.0
|
||||||
|
app.version.code=1
|
||||||
@@ -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>
|
||||||
@@ -23,9 +24,10 @@ class MediaKeys final : public App {
|
|||||||
struct Device* _hidDevice = nullptr;
|
struct Device* _hidDevice = nullptr;
|
||||||
|
|
||||||
// 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.
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.8.0-dev
|
app.id=one.tactility.mp3player
|
||||||
platforms=esp32s3
|
app.version.name=1.0.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.mp3player
|
app.name=MP3 Player
|
||||||
versionName=1.0.0
|
|
||||||
versionCode=1
|
|
||||||
name=MP3 Player
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.8.0-dev
|
app.id=one.tactility.pocketdungeon
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.1.2
|
||||||
[app]
|
app.version.code=3
|
||||||
id=one.tactility.pocketdungeon
|
app.name=Pocket Dungeon
|
||||||
versionName=0.1.1
|
app.description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
|
||||||
versionCode=2
|
app.flags=HideStatusBar
|
||||||
name=Pocket Dungeon
|
|
||||||
description=Tiny paper-inspired dungeon crawler - audio-stream migrated
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <tt_app.h>
|
#include <tt_app.h>
|
||||||
#include <tt_lvgl_toolbar.h>
|
#include <tt_lvgl_toolbar.h>
|
||||||
#include <tactility/lvgl_fonts.h>
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <tt_mdns.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
@@ -8,10 +9,8 @@
|
|||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <lwip/sockets.h>
|
#include <lwip/sockets.h>
|
||||||
#include <lwip/inet.h>
|
#include <lwip/inet.h>
|
||||||
#include "esp_log.h"
|
|
||||||
|
|
||||||
#define TAG "RobotArm"
|
#define TAG "RobotArm"
|
||||||
#define ARM_HOST "192.168.68.103"
|
|
||||||
#define ARM_PORT 80
|
#define ARM_PORT 80
|
||||||
#define ARM_PATH "/api/mcp"
|
#define ARM_PATH "/api/mcp"
|
||||||
#define NUM_JOINTS 6
|
#define NUM_JOINTS 6
|
||||||
@@ -41,68 +40,68 @@ static int seq_len = 0, seq_idx = -1;
|
|||||||
static bool seq_playing = false;
|
static bool seq_playing = false;
|
||||||
static int seq_play_pos = 0;
|
static int seq_play_pos = 0;
|
||||||
|
|
||||||
|
static char arm_host[64] = "";
|
||||||
|
static bool arm_connected = false;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
AppHandle app;
|
AppHandle app;
|
||||||
|
lv_obj_t* root;
|
||||||
|
lv_obj_t* content; // scrollable column container
|
||||||
lv_obj_t* status;
|
lv_obj_t* status;
|
||||||
lv_obj_t* sliders[6];
|
lv_obj_t* sliders[6];
|
||||||
lv_obj_t* vals[6];
|
lv_obj_t* vals[6];
|
||||||
lv_obj_t* seq_label;
|
lv_obj_t* seq_label;
|
||||||
lv_obj_t* play_label;
|
lv_obj_t* play_label;
|
||||||
lv_obj_t* blocks[SEQ_MAX];
|
lv_obj_t* blocks[SEQ_MAX];
|
||||||
|
lv_obj_t* connect_box;
|
||||||
|
lv_obj_t* main_box;
|
||||||
|
lv_obj_t* connect_label;
|
||||||
|
lv_obj_t* connect_spinner;
|
||||||
int pend[6];
|
int pend[6];
|
||||||
bool has[6];
|
bool has[6];
|
||||||
int ticks[6];
|
int ticks[6];
|
||||||
lv_timer_t* poll;
|
lv_timer_t* poll;
|
||||||
lv_timer_t* seq_timer;
|
lv_timer_t* seq_timer;
|
||||||
|
lv_timer_t* connect_timer;
|
||||||
|
int connect_attempts;
|
||||||
} Ctx;
|
} Ctx;
|
||||||
static Ctx* g = NULL;
|
static Ctx* g = NULL;
|
||||||
|
|
||||||
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
||||||
|
|
||||||
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
|
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
|
||||||
|
uint32_t ip=ipaddr_addr(host);
|
||||||
|
if(ip==0 || ip==0xFFFFFFFF) return -2;
|
||||||
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||||
if(fd<0) return -1;
|
if(fd<0) return -1;
|
||||||
struct sockaddr_in s; memset(&s,0,sizeof(s));
|
struct sockaddr_in s; memset(&s,0,sizeof(s));
|
||||||
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ipaddr_addr(host);
|
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ip;
|
||||||
struct timeval tv={5,0};
|
struct timeval tv={2,0};
|
||||||
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||||
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||||
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
|
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
|
||||||
char hdr[256];
|
char hdr[256]; int bl=strlen(body);
|
||||||
int bl=strlen(body);
|
|
||||||
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
|
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
|
||||||
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
|
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
|
||||||
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
|
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
|
||||||
int tot=0;
|
int tot=0; while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
||||||
while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
|
||||||
out[tot]='\0'; close(fd);
|
out[tot]='\0'; close(fd);
|
||||||
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
|
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
|
||||||
return tot>0?0:-4;
|
return tot>0?0:-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool jget(const char* js,const char* key,int* out){
|
static bool jget(const char* js,const char* key,int* out){
|
||||||
const char* p=strstr(js,key);
|
const char* p=strstr(js,key); if(!p) return false;
|
||||||
if(!p) return false;
|
p+=strlen(key); while(*p && *p!=':'){p++; if(!*p) return false;} p++;
|
||||||
p+=strlen(key);
|
|
||||||
while(*p && *p!=':'){
|
|
||||||
p++;
|
|
||||||
if(!*p) return false;
|
|
||||||
}
|
|
||||||
p++;
|
|
||||||
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
|
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
|
||||||
int sign=1;
|
int sign=1; if(*p=='-'){sign=-1;p++;}
|
||||||
if(*p=='-'){sign=-1;p++;}
|
float v=0,frac=0.1f; bool dot=false,got=false;
|
||||||
float v=0,frac=0.1f;
|
|
||||||
bool dot=false,got=false;
|
|
||||||
while(*p){
|
while(*p){
|
||||||
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
|
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
|
||||||
else if(*p=='.'&&!dot) dot=true;
|
else if(*p=='.'&&!dot) dot=true; else break; p++;
|
||||||
else break;
|
|
||||||
p++;
|
|
||||||
}
|
}
|
||||||
if(!got) return false;
|
if(!got) return false;
|
||||||
*out=(int)(v*sign+0.5f);
|
*out=(int)(v*sign+0.5f); return true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
|
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||||
int v; bool ok=true;
|
int v; bool ok=true;
|
||||||
@@ -117,30 +116,24 @@ static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* g
|
|||||||
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
|
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||||
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
|
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
|
||||||
char resp[2048]; memset(resp,0,sizeof(resp));
|
char resp[2048]; memset(resp,0,sizeof(resp));
|
||||||
if(http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
if(http_post(arm_host,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
||||||
return parse_state(resp,b,s,e,p,ro,gr);
|
return parse_state(resp,b,s,e,p,ro,gr);
|
||||||
}
|
}
|
||||||
static bool rpc_move(const char* j,int a){
|
static bool rpc_move(const char* j,int a){
|
||||||
char body[300]; snprintf(body,sizeof(body),
|
char body[300]; snprintf(body,sizeof(body),
|
||||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",
|
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",j,a);
|
||||||
j,a);
|
char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||||
char r[1024]; memset(r,0,sizeof(r));
|
|
||||||
return http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
|
||||||
}
|
}
|
||||||
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
|
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
|
||||||
char body[420]; snprintf(body,sizeof(body),
|
char body[420]; snprintf(body,sizeof(body),
|
||||||
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
|
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
|
||||||
b,s,e,p,ro,gr,dur);
|
b,s,e,p,ro,gr,dur);
|
||||||
char r[1024]; memset(r,0,sizeof(r));
|
char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||||
int rc=http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r));
|
|
||||||
ESP_LOGI(TAG,"move_all %d %d %d rc=%d",b,s,e,rc);
|
|
||||||
return rc==0;
|
|
||||||
}
|
}
|
||||||
static bool rpc_home(void){
|
static bool rpc_home(void){
|
||||||
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
|
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
|
||||||
char r[512]; memset(r,0,sizeof(r)); return http_post(ARM_HOST,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
char r[512]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
|
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
|
||||||
static void apply_ui(void){
|
static void apply_ui(void){
|
||||||
if(!g) return;
|
if(!g) return;
|
||||||
@@ -150,7 +143,7 @@ static void apply_ui(void){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
static void refresh_arm(void){
|
static void refresh_arm(void){
|
||||||
set_status("Reading .103...");
|
set_status("Reading...");
|
||||||
int b,s,e,p,ro,gr;
|
int b,s,e,p,ro,gr;
|
||||||
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
|
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
|
||||||
joints[0].value=b; joints[1].value=s; joints[2].value=e;
|
joints[0].value=b; joints[1].value=s; joints[2].value=e;
|
||||||
@@ -158,21 +151,21 @@ static void refresh_arm(void){
|
|||||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
|
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
|
||||||
apply_ui();
|
apply_ui();
|
||||||
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
|
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
|
||||||
} else set_status("No .103");
|
} else set_status("No arm");
|
||||||
}
|
}
|
||||||
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||||
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||||
static void poll_cb(lv_timer_t* t){
|
static void poll_cb(lv_timer_t* t){
|
||||||
(void)t; if(!g) return;
|
(void)t; if(!g || !arm_connected) return;
|
||||||
for(int i=0;i<NUM_JOINTS;i++){
|
for(int i=0;i<NUM_JOINTS;i++){
|
||||||
if(!g->has[i]) continue;
|
if(!g->has[i]) continue;
|
||||||
g->ticks[i]++; if(g->ticks[i]<3) continue;
|
g->ticks[i]++; if(g->ticks[i]<3) continue;
|
||||||
g->has[i]=false; g->ticks[i]=0;
|
g->has[i]=false; g->ticks[i]=0;
|
||||||
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
|
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
|
||||||
joints[i].last_sent=tgt; joints[i].value=tgt;
|
joints[i].last_sent=tgt; joints[i].value=tgt;
|
||||||
char buf[20]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
char buf[24]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
||||||
bool ok=rpc_move(joints[i].name,tgt);
|
bool ok=rpc_move(joints[i].name,tgt);
|
||||||
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"ok":"fail"); set_status(buf);
|
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"o":"x"); set_status(buf);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,8 +178,6 @@ static void slider_cb(lv_event_t* e){
|
|||||||
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
|
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── sequencer ── */
|
|
||||||
static void update_seq_ui(void){
|
static void update_seq_ui(void){
|
||||||
if(!g) return;
|
if(!g) return;
|
||||||
if(g->seq_label){
|
if(g->seq_label){
|
||||||
@@ -216,26 +207,21 @@ static void load_frame(int fidx){
|
|||||||
apply_ui(); seq_idx=fidx; update_seq_ui();
|
apply_ui(); seq_idx=fidx; update_seq_ui();
|
||||||
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
|
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
|
||||||
}
|
}
|
||||||
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Seq full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
||||||
static void seq_rem_cb(void){
|
static void seq_rem_cb(void){
|
||||||
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
|
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
|
||||||
int rem=seq_idx;
|
int rem=seq_idx; for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
||||||
for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
|
||||||
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
|
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
|
||||||
if(seq_idx>=seq_len) seq_idx=seq_len-1;
|
if(seq_idx>=seq_len) seq_idx=seq_len-1; load_frame(seq_idx);
|
||||||
load_frame(seq_idx);
|
|
||||||
}
|
}
|
||||||
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
|
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
|
||||||
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
|
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
|
||||||
|
|
||||||
static void seq_timer_cb(lv_timer_t* t){
|
static void seq_timer_cb(lv_timer_t* t){
|
||||||
(void)t; if(!seq_playing||seq_len==0) return;
|
(void)t; if(!seq_playing||seq_len==0) return;
|
||||||
int f=seq_play_pos%seq_len;
|
int f=seq_play_pos%seq_len; int* v=seq[f].v;
|
||||||
int* v=seq[f].v;
|
|
||||||
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
|
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
|
||||||
set_status("Seq fail"); seq_playing=false;
|
set_status("Seq fail"); seq_playing=false;
|
||||||
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play");
|
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play"); return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
|
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
|
||||||
apply_ui(); seq_idx=f; update_seq_ui();
|
apply_ui(); seq_idx=f; update_seq_ui();
|
||||||
@@ -253,42 +239,155 @@ static void seq_rem_ev(lv_event_t* e){(void)e; seq_rem_cb();}
|
|||||||
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
|
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
|
||||||
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
|
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
|
||||||
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
|
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
|
||||||
|
|
||||||
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
|
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
|
||||||
static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
|
static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
|
||||||
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
|
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
|
||||||
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
|
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
|
||||||
|
|
||||||
|
static bool try_host(const char* host){
|
||||||
|
char resp[1024]; memset(resp,0,sizeof(resp));
|
||||||
|
if(http_post(host,ARM_PORT,ARM_PATH,"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}",resp,sizeof(resp))==0){
|
||||||
|
if(strstr(resp,"base")){
|
||||||
|
strncpy(arm_host,host,sizeof(arm_host)-1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static void show_main_ui(void){
|
||||||
|
if(!g) return;
|
||||||
|
arm_connected=true;
|
||||||
|
if(g->connect_box) lv_obj_add_flag(g->connect_box, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if(g->main_box) lv_obj_clear_flag(g->main_box, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if(g->connect_timer){ lv_timer_delete(g->connect_timer); g->connect_timer=NULL; }
|
||||||
|
if(!g->poll) g->poll=lv_timer_create(poll_cb,100,NULL);
|
||||||
|
if(!g->seq_timer) g->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
||||||
|
lv_timer_create(init_cb,500,NULL);
|
||||||
|
char buf[48]; snprintf(buf,sizeof(buf),"Conn %s",arm_host); set_status(buf);
|
||||||
|
}
|
||||||
|
static void connect_timer_cb(lv_timer_t* t){
|
||||||
|
(void)t; if(!g || arm_connected) return;
|
||||||
|
g->connect_attempts++;
|
||||||
|
char lbl[64];
|
||||||
|
if(g->connect_attempts==1) snprintf(lbl,sizeof(lbl),"mDNS browsing _robotarm._tcp...");
|
||||||
|
else snprintf(lbl,sizeof(lbl),"Searching (%d)...",g->connect_attempts);
|
||||||
|
if(g->connect_label) lv_label_set_text(g->connect_label,lbl);
|
||||||
|
|
||||||
|
// ── 1) mDNS browse for _robotarm._tcp (preferred) ──
|
||||||
|
TtMdnsBrowseResult res; memset(&res,0,sizeof(res));
|
||||||
|
if(tt_mdns_browse("_robotarm","_tcp",2500,10,&res)){
|
||||||
|
for(int i=0;i<res.count;i++){
|
||||||
|
if(res.services[i].primaryAddress[0]){
|
||||||
|
if(try_host(res.services[i].primaryAddress)){
|
||||||
|
show_main_ui(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── 2) mDNS resolve robotarm.local hostname ──
|
||||||
|
char ipbuf[64]={0};
|
||||||
|
if(tt_mdns_resolve_hostname("robotarm.local",2000,ipbuf) || tt_mdns_resolve_hostname("robotarm",2000,ipbuf)){
|
||||||
|
if(try_host(ipbuf)){ show_main_ui(); return; }
|
||||||
|
}
|
||||||
|
// ── 3) fallback hardcoded (old behavior) ──
|
||||||
|
const char* fallbacks[]={"192.168.68.148","192.168.68.103","192.168.68.102"};
|
||||||
|
int idx = (g->connect_attempts-1) % (int)(sizeof(fallbacks)/sizeof(fallbacks[0]));
|
||||||
|
if(try_host(fallbacks[idx])){ show_main_ui(); return; }
|
||||||
|
|
||||||
|
if(g->connect_attempts>15){
|
||||||
|
if(g->connect_label) lv_label_set_text(g->connect_label,"No arm found.\nMake sure robotarm\nis powered & on WiFi.\nTap Retry.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void retry_cb(lv_event_t* e){(void)e; if(!g) return; g->connect_attempts=0; if(g->connect_label) lv_label_set_text(g->connect_label,"Retrying mDNS...");}
|
||||||
|
|
||||||
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||||
(void)data;
|
(void)data;
|
||||||
Ctx* c=(Ctx*)calloc(1,sizeof(Ctx)); if(!c) return;
|
Ctx* c=calloc(1,sizeof(Ctx)); if(!c) return;
|
||||||
c->app=app; g=c;
|
c->app=app; g=c;
|
||||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
|
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
|
||||||
|
arm_host[0]='\0'; arm_connected=false; c->connect_attempts=0;
|
||||||
|
|
||||||
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
|
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
|
||||||
|
|
||||||
|
// Root fills screen below toolbar - NOT scrollable itself, content will be
|
||||||
lv_obj_t* root=lv_obj_create(parent);
|
lv_obj_t* root=lv_obj_create(parent);
|
||||||
|
c->root=root;
|
||||||
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
||||||
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,34,0);
|
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,36,0);
|
||||||
lv_obj_set_style_border_width(root,0,0);
|
lv_obj_set_style_border_width(root,0,0);
|
||||||
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
|
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
|
||||||
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
|
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
|
||||||
lv_obj_set_scroll_dir(root, LV_DIR_VER);
|
lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
lv_obj_t* hdr=lv_obj_create(root); lv_obj_set_size(hdr,LV_PCT(100),16);
|
// ── connecting screen (initial visible) ──
|
||||||
|
c->connect_box=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(c->connect_box,LV_PCT(100),LV_PCT(100));
|
||||||
|
lv_obj_set_pos(c->connect_box,0,0);
|
||||||
|
lv_obj_set_style_border_width(c->connect_box,0,0);
|
||||||
|
lv_obj_set_style_bg_opa(c->connect_box,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(c->connect_box,4,0);
|
||||||
|
lv_obj_clear_flag(c->connect_box, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* tl=lv_label_create(c->connect_box);
|
||||||
|
lv_label_set_text(tl,"Finding robotarm...");
|
||||||
|
lv_obj_set_style_text_font(tl,lvgl_get_text_font(FONT_SIZE_LARGE),0);
|
||||||
|
lv_obj_set_style_text_color(tl,lv_color_hex(0x3D2B5A),0);
|
||||||
|
lv_obj_set_pos(tl,4,4);
|
||||||
|
|
||||||
|
c->connect_label=lv_label_create(c->connect_box);
|
||||||
|
lv_label_set_text(c->connect_label,"mDNS: _robotarm._tcp / robotarm.local\nFallback: 192.168.68.148\n\nSearching...");
|
||||||
|
lv_obj_set_style_text_font(c->connect_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(c->connect_label,lv_color_hex(0x6A5A7A),0);
|
||||||
|
lv_obj_set_pos(c->connect_label,4,28); lv_obj_set_width(c->connect_label,260);
|
||||||
|
|
||||||
|
c->connect_spinner=lv_spinner_create(c->connect_box);
|
||||||
|
lv_obj_set_size(c->connect_spinner,40,40);
|
||||||
|
lv_obj_set_pos(c->connect_spinner,120,110);
|
||||||
|
|
||||||
|
lv_obj_t* rb=lv_btn_create(c->connect_box);
|
||||||
|
lv_obj_set_size(rb,80,28); lv_obj_set_pos(rb,80,170);
|
||||||
|
lv_obj_set_style_bg_color(rb,lv_color_hex(0xC5F5C5),0); lv_obj_set_style_radius(rb,8,0);
|
||||||
|
lv_obj_t* rbl=lv_label_create(rb); lv_label_set_text(rbl,"Retry");
|
||||||
|
lv_obj_set_style_text_font(rbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(rbl,lv_color_hex(0x2A2A5A),0); lv_obj_center(rbl);
|
||||||
|
lv_obj_add_event_cb(rb,retry_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
// ── main scrollable content (hidden until connected) ──
|
||||||
|
// content is the ONLY scrollable container - FIX overall app scroller lost
|
||||||
|
c->content=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(c->content,LV_PCT(100),LV_PCT(100));
|
||||||
|
lv_obj_set_pos(c->content,0,0);
|
||||||
|
lv_obj_set_style_border_width(c->content,0,0);
|
||||||
|
lv_obj_set_style_bg_opa(c->content,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(c->content,0,0);
|
||||||
|
lv_obj_set_flex_flow(c->content, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_add_flag(c->content, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_scrollbar_mode(c->content, LV_SCROLLBAR_MODE_AUTO);
|
||||||
|
|
||||||
|
c->main_box=lv_obj_create(c->content);
|
||||||
|
lv_obj_set_size(c->main_box,LV_PCT(100),LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_border_width(c->main_box,0,0);
|
||||||
|
lv_obj_set_style_bg_opa(c->main_box,LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_pad_all(c->main_box,0,0);
|
||||||
|
lv_obj_clear_flag(c->main_box, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_add_flag(c->main_box, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
|
lv_obj_t* hdr=lv_obj_create(c->main_box); lv_obj_set_size(hdr,LV_PCT(100),16);
|
||||||
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
|
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
|
||||||
lv_obj_set_style_pad_all(hdr,0,0); lv_obj_set_pos(hdr,0,0);
|
lv_obj_set_style_pad_all(hdr,0,0);
|
||||||
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
|
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
|
||||||
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(title,2,0);
|
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0);
|
||||||
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn .103...");
|
lv_obj_set_pos(title,2,0);
|
||||||
|
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn...");
|
||||||
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0); lv_obj_set_pos(c->status,90,0);
|
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0);
|
||||||
|
lv_obj_set_pos(c->status,90,0);
|
||||||
|
|
||||||
lv_obj_t* brow=lv_obj_create(root); lv_obj_set_size(brow,LV_PCT(100),20);
|
lv_obj_t* brow=lv_obj_create(c->main_box); lv_obj_set_size(brow,LV_PCT(100),20);
|
||||||
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
|
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
|
||||||
lv_obj_set_style_pad_all(brow,0,0); lv_obj_set_pos(brow,0,16);
|
lv_obj_set_style_pad_all(brow,0,0);
|
||||||
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
|
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
|
||||||
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
|
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
|
||||||
@@ -302,12 +401,12 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
|||||||
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
|
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── large nice vertical sliders: 3 cols, 2x height 22x72 per request ── */
|
lv_obj_t* grid=lv_obj_create(c->main_box);
|
||||||
lv_obj_t* grid=lv_obj_create(root);
|
|
||||||
lv_obj_set_size(grid,LV_PCT(100),196);
|
lv_obj_set_size(grid,LV_PCT(100),196);
|
||||||
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
|
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
|
||||||
lv_obj_set_style_pad_all(grid,2,0); lv_obj_set_pos(grid,0,36);
|
lv_obj_set_style_pad_all(grid,2,0);
|
||||||
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_OFF);
|
||||||
|
|
||||||
for(int i=0;i<NUM_JOINTS;i++){
|
for(int i=0;i<NUM_JOINTS;i++){
|
||||||
int col=i%3, row=i/3;
|
int col=i%3, row=i/3;
|
||||||
@@ -320,6 +419,7 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
|||||||
lv_obj_set_style_border_width(card,0,0);
|
lv_obj_set_style_border_width(card,0,0);
|
||||||
lv_obj_set_style_pad_all(card,3,0);
|
lv_obj_set_style_pad_all(card,3,0);
|
||||||
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_OFF);
|
||||||
|
|
||||||
lv_obj_t* name=lv_label_create(card);
|
lv_obj_t* name=lv_label_create(card);
|
||||||
lv_label_set_text(name,joints[i].cute);
|
lv_label_set_text(name,joints[i].cute);
|
||||||
@@ -357,27 +457,22 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
|||||||
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
|
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── sequencer at bottom, NOT fixed — scrollable with content ──
|
lv_obj_t* seqbar=lv_obj_create(c->main_box);
|
||||||
visual: colored blocks, no info, current highlighted, larger buttons */
|
|
||||||
lv_obj_t* seqbar=lv_obj_create(root);
|
|
||||||
lv_obj_set_size(seqbar,316,96);
|
lv_obj_set_size(seqbar,316,96);
|
||||||
lv_obj_set_pos(seqbar,2,232);
|
|
||||||
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
|
||||||
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
|
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
|
||||||
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
|
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
|
||||||
lv_obj_set_style_radius(seqbar,12,0);
|
lv_obj_set_style_radius(seqbar,12,0);
|
||||||
lv_obj_set_style_border_width(seqbar,1,0);
|
lv_obj_set_style_border_width(seqbar,1,0);
|
||||||
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
|
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
|
||||||
lv_obj_set_style_pad_all(seqbar,4,0);
|
lv_obj_set_style_pad_all(seqbar,4,0);
|
||||||
|
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
|
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
|
||||||
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
|
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
|
||||||
|
|
||||||
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
|
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
|
||||||
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
|
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
|
||||||
|
|
||||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
|
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
|
||||||
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
|
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
|
||||||
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
|
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
|
||||||
@@ -394,7 +489,6 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
|||||||
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
|
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
|
||||||
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
|
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t blk_cols[16]={
|
uint32_t blk_cols[16]={
|
||||||
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
|
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
|
||||||
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
|
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
|
||||||
@@ -412,9 +506,8 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
|||||||
}
|
}
|
||||||
update_seq_ui();
|
update_seq_ui();
|
||||||
|
|
||||||
c->poll=lv_timer_create(poll_cb,100,NULL);
|
c->connect_timer=lv_timer_create(connect_timer_cb, 1000, NULL);
|
||||||
c->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
connect_timer_cb(NULL);
|
||||||
lv_timer_create(init_cb,600,NULL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void onHide(AppHandle app,void* data){
|
static void onHide(AppHandle app,void* data){
|
||||||
@@ -422,8 +515,9 @@ static void onHide(AppHandle app,void* data){
|
|||||||
if(g){
|
if(g){
|
||||||
if(g->poll) lv_timer_delete(g->poll);
|
if(g->poll) lv_timer_delete(g->poll);
|
||||||
if(g->seq_timer) lv_timer_delete(g->seq_timer);
|
if(g->seq_timer) lv_timer_delete(g->seq_timer);
|
||||||
|
if(g->connect_timer) lv_timer_delete(g->connect_timer);
|
||||||
free(g); g=NULL;
|
free(g); g=NULL;
|
||||||
}
|
}
|
||||||
seq_playing=false;
|
seq_playing=false; arm_connected=false;
|
||||||
}
|
}
|
||||||
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
|
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
|
||||||
|
|||||||
@@ -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).
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.8.0-dev
|
app.id=one.tactility.voicerecorder
|
||||||
platforms=esp32s3
|
app.version.name=1.0.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.voicerecorder
|
app.name=Voice Recorder
|
||||||
name=Voice Recorder
|
|
||||||
versionName=1.0.0
|
|
||||||
versionCode=1
|
|
||||||
|
|||||||
@@ -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,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
@@ -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)
|
||||||
|
|||||||
+56
-41
@@ -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]}")
|
||||||
@@ -164,4 +179,4 @@ if __name__ == "__main__":
|
|||||||
any_manifest = manifest
|
any_manifest = manifest
|
||||||
# Write JSON
|
# Write JSON
|
||||||
output_json_path = os.path.join(app_directory, "apps.json")
|
output_json_path = os.path.join(app_directory, "apps.json")
|
||||||
write_json(output_json_path, output_json)
|
write_json(output_json_path, output_json)
|
||||||
|
|||||||
+23
-36
@@ -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