2 Commits

Author SHA1 Message Date
Hermes Reyna Bot 30de6beee7 feat(DiscoveryMountain): 2-screen podcast player S01 default resume prev/next season roller (Bible dial style) - backend PB+nginx ready mp3 DL LRU 2026-07-21 21:12:14 -04:00
Adolfo 4ff787ff1d fix(GameBoy): I2 2bpp 5.6KB internal canvas fixes black screen after reboot (L:144 FPS:40)
Root cause: RGB565 46KB PSRAM required flush_cache handler which is NULL on ESP32S3 LVGL port -> dirty cache never reached PSRAM -> black. Frame writes L:144 prove core OK, FPS:40 proves timer OK, but PSRAM stale.

Fix: I2 indexed 160*144/4=5760 bytes internal RAM, no cache coherency, no WiFi/SD starvation (previous 46KB internal -> 7KB free -> sdmmc_read_sectors fail + wifi m f null). Uses lv_canvas_set_buffer I2 + lv_canvas_set_palette white/light/dark/black. Preserves Roms/GB capitalized probe + .gb file browser tap via tt_app_get_parameters bundle file.

Installs to 192.168.68.132, survives reboot.
2026-07-20 14:06:08 -04:00
84 changed files with 1491 additions and 7576 deletions
-21
View File
@@ -1,21 +0,0 @@
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 -9
View File
@@ -1,10 +1,5 @@
name: Release Apps
outputs:
sdk_version:
description: 'Common SDK version shared by all bundled apps'
value: ${{ steps.release.outputs.sdk_version }}
runs:
using: 'composite'
steps:
@@ -16,11 +11,8 @@ runs:
run: rsync -av downloaded_apps/*/*.app cdn_files/
shell: bash
- name: 'Create CDN release files'
id: release
run: python release.py cdn_files/
shell: bash
run: |
python release.py cdn_files/
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
with:
+1 -22
View File
@@ -12,12 +12,10 @@ jobs:
Build:
strategy:
matrix:
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]
app_name: [Brainfuck, Breakout, BookPlayer, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Build"
uses: ./.github/actions/build-app
with:
@@ -25,26 +23,7 @@ jobs:
Bundle:
runs-on: ubuntu-latest
needs: [Build]
outputs:
sdk_version: ${{ steps.release.outputs.sdk_version }}
steps:
- uses: actions/checkout@v4
- name: "Build"
id: release
uses: ./.github/actions/release-apps
PublishApps:
runs-on: ubuntu-latest
needs: [Bundle]
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Publish Apps"
env:
CDN_ID: ${{ secrets.CDN_ID }}
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
uses: ./.github/actions/publish-apps
with:
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.audiotest
app.version.name=0.1.0
app.version.code=1
app.name=Audio Test
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.audiotest
versionName=0.1.0
versionCode=1
name=Audio Test
+14 -15
View File
@@ -33,7 +33,7 @@ LV_FONT_DECLARE(georgia_regular_24)
#define MAX_BIBLE_BOOKS 66
#define MAX_BOOK_NAME 40
#define MAX_VERSE_TEXT 2048
#define MAX_PATH 128
#define MAX_PATH 320
typedef struct {
uint32_t offset;
@@ -239,7 +239,7 @@ static bool parse_books_json(AppCtx* ctx, const char* json_str) {
const char* obj_end = strchr(obj_start, '}');
if (!obj_end) break;
// parse fields within obj_start..obj_end
char temp[256];
char temp[512];
size_t len = (size_t)(obj_end - obj_start + 1);
if (len >= sizeof(temp)) len = sizeof(temp)-1;
memcpy(temp, obj_start, len);
@@ -672,27 +672,27 @@ static void show_current_verse(AppCtx* ctx) {
const char* text_ptr = "(empty)";
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
// Fix stack overflow: verse_buf was 2048 bytes on stack, causing gui task (4096) overflow
// Use heap allocation
char* verse_buf = (char*)malloc(MAX_VERSE_TEXT);
if (!verse_buf) {
ESP_LOGE(TAG, "Failed to malloc verse_buf");
return;
}
strncpy(verse_buf, text_ptr, MAX_VERSE_TEXT-1);
verse_buf[MAX_VERSE_TEXT-1]='\0';
char verse_buf[MAX_VERSE_TEXT];
strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1);
verse_buf[sizeof(verse_buf)-1]='\0';
char ref_buf[64];
char ref_buf[96];
snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
char ref_pretty[80];
char bname_upper[40];
// Editorial reference style like your image: "PSALMS 23:1" with tracking
char ref_pretty[108];
// Use uppercase for book? Keep title for now but uppercase style for editorial match
// We'll format as "— PSALMS 23:1 —" feel? For dark mode keep minimal like ref image
// Reference image shows lines flanking citation - we do with letterspacing + dim
// For now: "PSALMS 23:1" style - uppercase via runtime? We'll uppercase book name
char bname_upper[48];
strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
bname_upper[sizeof(bname_upper)-1]='\0';
for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p);
snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum);
if (ctx->lbl_verse) {
// start faded for transition
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
lv_label_set_text(ctx->lbl_verse, verse_buf);
apply_verse_scaling(ctx, verse_buf);
@@ -741,7 +741,6 @@ static void show_current_verse(AppCtx* ctx) {
if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
}
free(verse_buf);
save_progress(ctx);
verse_fade_in(ctx);
}
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.bibleverse
app.version.name=0.1.0
app.version.code=2
app.name=Bible Verse
app.description=Single verse at a time, advances each minute. Tap to show controls.
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.bibleverse
versionName=0.1.0
versionCode=1
name=Bible Verse
description=Single verse at a time, advances each minute. Tap to show controls.
+179 -154
View File
@@ -4,7 +4,7 @@
#include <tt_app_alertdialog.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
@@ -45,15 +45,14 @@ typedef struct {
} BookMetadata;
typedef struct {
struct Device* stream_dev;
AudioStreamHandle stream_handle;
struct Device* i2s_dev;
PlaybackState state;
int volume;
// Book picker list data
BookMetadata books[MAX_BOOKS];
int book_count;
// Currently loaded book details
char current_book_slug[MAX_PATH];
cJSON* manifest_root;
@@ -61,12 +60,12 @@ typedef struct {
int page_count;
int current_page;
int last_pct;
// UI elements
AppHandle app;
lv_obj_t* picker_wrapper;
lv_obj_t* lst_books;
lv_obj_t* player_wrapper;
lv_obj_t* header_bar;
lv_obj_t* ctrl_bar;
@@ -77,29 +76,29 @@ typedef struct {
lv_obj_t* btn_play_pause;
lv_obj_t* btn_prev;
lv_obj_t* btn_next;
// Audio State
char current_audio_path[512];
uint8_t* audio_buf; // Shared MP3 input and WAV buffer
mp3d_sample_t* pcm_buf; // MP3 decoded pcm buffer
TaskHandle_t playback_task_handle;
} AppCtx;
typedef struct __attribute__((packed)) {
char riff[4];
uint32_t overall_size;
char wave[4];
char fmt_chunk_marker[4];
uint32_t length_of_fmt;
uint16_t format_type;
uint16_t channels;
uint32_t sample_rate;
uint32_t byterate;
uint16_t block_align;
uint16_t bits_per_sample;
char data_chunk_header[4];
uint32_t data_size;
char riff[4]; // "RIFF"
uint32_t overall_size; // file size - 8
char wave[4]; // "WAVE"
char fmt_chunk_marker[4]; // "fmt "
uint32_t length_of_fmt; // 16 for PCM
uint16_t format_type; // 1 for PCM
uint16_t channels; // 1 for mono
uint32_t sample_rate; // 16000
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
uint16_t block_align; // channels * (bits_per_sample / 8)
uint16_t bits_per_sample; // 16
char data_chunk_header[4]; // "data"
uint32_t data_size; // data size in bytes
} WavHeader;
static AppCtx g_ctx;
@@ -114,60 +113,19 @@ static void play_mp3(AppCtx* ctx);
static void play_wav(AppCtx* ctx);
static void scan_books(AppCtx* ctx);
/* ─── Audio-stream helpers ─── */
static bool find_audio_stream_device(AppCtx* ctx) {
// Prefer device_find_first_by_type but keep compatibility with name lookup
struct Device* dev = device_find_by_name("audio-stream");
if (dev) {
ctx->stream_dev = dev;
return true;
}
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
if (dev) {
ctx->stream_dev = dev;
return true;
}
return false;
}
static void close_stream_if_open(AppCtx* ctx) {
if (ctx->stream_handle) {
audio_stream_close(ctx->stream_handle);
ctx->stream_handle = NULL;
}
}
static bool open_output_stream(AppCtx* ctx, uint32_t sample_rate, uint8_t channels, uint8_t bits) {
close_stream_if_open(ctx);
if (!ctx->stream_dev) return false;
struct AudioStreamConfig cfg = {
.sample_rate = sample_rate,
.bits_per_sample = bits,
.channels = channels
};
error_t err = audio_stream_open_output(ctx->stream_dev, &cfg, &ctx->stream_handle);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_open_output failed: %d (rate=%u ch=%u)", err, (unsigned)sample_rate, channels);
ctx->stream_handle = NULL;
return false;
}
ESP_LOGI(TAG, "audio_stream output opened: %u Hz %u ch %u-bit", (unsigned)sample_rate, channels, bits);
return true;
}
/* ─── UI Helper to update status labels & button states ─── */
static void update_ui(AppCtx* ctx) {
if (!ctx->btn_play_pause) return;
lv_obj_t* lbl_play = lv_obj_get_child(ctx->btn_play_pause, 0);
// Prev button state
if (ctx->current_page <= 0) {
lv_obj_add_state(ctx->btn_prev, LV_STATE_DISABLED);
} else {
lv_obj_clear_state(ctx->btn_prev, LV_STATE_DISABLED);
}
// Next button state
if (ctx->current_page >= ctx->page_count - 1) {
lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
@@ -204,13 +162,13 @@ static char* read_full_file(const char* filepath) {
return NULL;
}
fseek(file, 0, SEEK_SET);
char* buf = malloc(size + 1);
if (!buf) {
fclose(file);
return NULL;
}
size_t read_bytes = fread(buf, 1, size, file);
buf[read_bytes] = '\0';
fclose(file);
@@ -227,6 +185,7 @@ static void on_auto_advance_timer(lv_timer_t* timer) {
static void handle_audio_finished(AppCtx* ctx) {
if (ctx->current_page + 1 < ctx->page_count) {
// Create a one-shot timer with repeat count 1 to run on the GUI thread
lv_timer_t* timer = lv_timer_create(on_auto_advance_timer, 0, ctx);
lv_timer_set_repeat_count(timer, 1);
} else {
@@ -249,6 +208,7 @@ static void wait_for_playback_task_to_exit(AppCtx* ctx) {
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
// 1. Stop current audio if running and wait for thread to finish
wait_for_playback_task_to_exit(ctx);
if (page_index < 0 || page_index >= ctx->page_count) return;
@@ -265,7 +225,7 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
if (img_item && img_item->valuestring) {
char img_path[512];
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
FILE* img_file = fopen(img_path, "rb");
if (img_file) {
fclose(img_file);
@@ -278,13 +238,15 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
lv_image_set_src(ctx->img_page, lv_img_path);
} else {
ESP_LOGW(TAG, "Image file not found: %s", img_path);
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); // Show default fallback icon
}
} else {
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
}
// Update Page index indicator
// Update Page index indicator (e.g. "1 / 12")
char ind_buf[32];
snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count);
lv_label_set_text(ctx->lbl_indicator, ind_buf);
@@ -296,16 +258,18 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
ctx->current_audio_path[0] = '\0';
}
// Update UI states (like Prev/Next buttons)
update_ui(ctx);
// Start playback if requested and valid
if (start_audio && ctx->current_audio_path[0] != '\0') {
// Verify audio file exists
FILE* audio_file = fopen(ctx->current_audio_path, "rb");
if (audio_file) {
fclose(audio_file);
ctx->state = STATE_PLAYING;
update_ui(ctx);
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
} else {
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
const char* buttons[] = {"OK"};
@@ -336,7 +300,7 @@ static void return_to_picker(AppCtx* ctx) {
/* ─── Task Selection (MP3 or WAV) ─── */
static void audio_playback_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
size_t len = strlen(ctx->current_audio_path);
bool is_wav = false;
if (len > 4 && strcasecmp(ctx->current_audio_path + len - 4, ".wav") == 0) {
@@ -350,8 +314,9 @@ static void audio_playback_task(void* arg) {
}
}
/* ─── MP3 Decoder Routine (audio-stream) ─── */
/* ─── MP3 Decoder Routine ─── */
static void play_mp3(AppCtx* ctx) {
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
vTaskDelay(pdMS_TO_TICKS(400));
FILE* file = fopen(ctx->current_audio_path, "rb");
@@ -389,12 +354,14 @@ static void play_mp3(AppCtx* ctx) {
int sample_rate = 0;
int channels = 0;
ESP_LOGI(TAG, "Starting MP3 playback via audio-stream: %s (%d bytes)", ctx->current_audio_path, file_size);
ESP_LOGI(TAG, "Starting MP3 playback: %s (%d bytes)", ctx->current_audio_path, file_size);
while (ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) {
if (ctx->stream_handle) {
close_stream_if_open(ctx);
if (sample_rate != 0) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
sample_rate = 0;
channels = 0;
}
@@ -423,6 +390,7 @@ static void play_mp3(AppCtx* ctx) {
if (info.frame_bytes <= 0) {
if (eof) break;
// Resync
memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes);
continue;
}
@@ -432,10 +400,22 @@ static void play_mp3(AppCtx* ctx) {
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
if (samples > 0) {
// Open/reopen stream on format change
// Configure I2S
if (sample_rate != info.hz || channels != info.channels) {
if (!open_output_stream(ctx, (uint32_t)info.hz, (uint8_t)info.channels, 16)) {
ESP_LOGE(TAG, "Failed to open audio stream for MP3: %d Hz %d ch", info.hz, info.channels);
struct I2sConfig config = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = (uint32_t)info.hz,
.bits_per_sample = 16,
.channel_left = 0,
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to configure I2S: %d", err);
break;
}
sample_rate = info.hz;
@@ -451,15 +431,15 @@ static void play_mp3(AppCtx* ctx) {
samples_ptr[i] = (int16_t)scaled;
}
// Write via audio_stream (resampled to native 44100 internally)
// Write PCM to I2S
size_t offset = 0;
size_t data_size = sample_count * sizeof(int16_t);
bool write_err = false;
while (offset < data_size && ctx->state == STATE_PLAYING) {
size_t written = 0;
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(1000));
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "audio_stream_write error: %d", err);
ESP_LOGE(TAG, "I2S write error: %d", err);
write_err = true;
break;
}
@@ -485,7 +465,12 @@ static void play_mp3(AppCtx* ctx) {
}
fclose(file);
close_stream_if_open(ctx);
if (ctx->i2s_dev) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
bool stopped_externally = (ctx->state == STATE_IDLE);
@@ -504,8 +489,9 @@ static void play_mp3(AppCtx* ctx) {
vTaskDelete(NULL);
}
/* ─── WAV Decoder Routine (audio-stream) ─── */
/* ─── WAV Decoder Routine ─── */
static void play_wav(AppCtx* ctx) {
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
vTaskDelay(pdMS_TO_TICKS(400));
FILE* file = fopen(ctx->current_audio_path, "rb");
@@ -552,36 +538,49 @@ static void play_wav(AppCtx* ctx) {
fseek(file, sizeof(WavHeader), SEEK_SET);
}
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) {
ESP_LOGE(TAG, "Failed to open audio stream for WAV: %u Hz %u ch", (unsigned)header.sample_rate, header.channels);
struct I2sConfig config = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = header.sample_rate,
.bits_per_sample = header.bits_per_sample,
.channel_left = 0,
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to configure WAV I2S: %d", err);
fclose(file);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
ctx->playback_task_handle = NULL;
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Starting WAV via audio-stream: %s (%u Hz, %u ch)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
ESP_LOGI(TAG, "Starting WAV playback: %s (%u Hz, %u channels)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
size_t total_played = 0;
bool stream_open = true;
bool i2s_configured = true;
while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) {
if (stream_open) {
close_stream_if_open(ctx);
stream_open = false;
if (i2s_configured) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
i2s_configured = false;
}
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
if (!stream_open) {
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) break;
stream_open = true;
if (!i2s_configured) {
device_lock(ctx->i2s_dev);
err = i2s_controller_set_config(ctx->i2s_dev, &config);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) break;
i2s_configured = true;
}
size_t to_read = (data_size - total_played < MP3_INPUT_BUFFER_SIZE) ? (data_size - total_played) : MP3_INPUT_BUFFER_SIZE;
@@ -597,14 +596,14 @@ static void play_wav(AppCtx* ctx) {
samples_ptr[i] = (int16_t)scaled;
}
// Write via audio_stream
// Play to I2S
size_t offset = 0;
bool write_err = false;
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
size_t written = 0;
error_t err = audio_stream_write(ctx->stream_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "audio_stream WAV write error: %d", err);
ESP_LOGE(TAG, "I2S WAV write error: %d", err);
write_err = true;
break;
}
@@ -615,6 +614,7 @@ static void play_wav(AppCtx* ctx) {
total_played += read_bytes;
// Update progress bar
int pct = (int)((total_played * 100) / data_size);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
@@ -629,7 +629,12 @@ static void play_wav(AppCtx* ctx) {
}
fclose(file);
close_stream_if_open(ctx);
if (ctx->i2s_dev) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
bool stopped_externally = (ctx->state == STATE_IDLE);
@@ -647,29 +652,29 @@ static void play_wav(AppCtx* ctx) {
static void on_book_selected(lv_event_t* e) {
int index = (int)(intptr_t)lv_event_get_user_data(e);
AppCtx* ctx = &g_ctx;
BookMetadata* book = &ctx->books[index];
strncpy(ctx->current_book_slug, book->slug, sizeof(ctx->current_book_slug) - 1);
char manifest_path[512];
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", book->slug);
char* json_str = read_full_file(manifest_path);
if (!json_str) {
const char* buttons[] = {"OK"};
tt_app_alertdialog_start("Read Error", "Failed to open the book manifest.", buttons, 1);
return;
}
ctx->manifest_root = cJSON_Parse(json_str);
free(json_str);
if (!ctx->manifest_root) {
const char* buttons[] = {"OK"};
tt_app_alertdialog_start("JSON Error", "The book manifest is not formatted correctly.", buttons, 1);
return;
}
ctx->pages_array = cJSON_GetObjectItem(ctx->manifest_root, "pages");
if (!ctx->pages_array || !cJSON_IsArray(ctx->pages_array)) {
const char* buttons[] = {"OK"};
@@ -679,7 +684,7 @@ static void on_book_selected(lv_event_t* e) {
ctx->pages_array = NULL;
return;
}
ctx->page_count = cJSON_GetArraySize(ctx->pages_array);
if (ctx->page_count <= 0) {
const char* buttons[] = {"OK"};
@@ -689,16 +694,17 @@ static void on_book_selected(lv_event_t* e) {
ctx->pages_array = NULL;
return;
}
lv_label_set_text(ctx->lbl_book_title, book->title);
// Switch views
lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN);
// Load first page (no auto-play initially)
load_page(ctx, 0, false);
}
@@ -706,57 +712,57 @@ static void on_book_selected(lv_event_t* e) {
static void scan_books(AppCtx* ctx) {
ctx->book_count = 0;
lv_obj_clean(ctx->lst_books);
DIR* dir = opendir("/sdcard/books");
if (!dir) {
ESP_LOGE(TAG, "Failed to scan books: /sdcard/books folder missing.");
lv_list_add_text(ctx->lst_books, "No SD card or books directory found.");
return;
}
struct dirent* entry;
while ((entry = readdir(dir)) != NULL && ctx->book_count < MAX_BOOKS) {
if (entry->d_name[0] == '.') continue;
char manifest_path[512];
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", entry->d_name);
char* json_str = read_full_file(manifest_path);
if (json_str) {
cJSON* root = cJSON_Parse(json_str);
free(json_str);
if (root) {
cJSON* title_item = cJSON_GetObjectItem(root, "title");
cJSON* author_item = cJSON_GetObjectItem(root, "author");
BookMetadata* book = &ctx->books[ctx->book_count];
strncpy(book->slug, entry->d_name, sizeof(book->slug) - 1);
if (title_item && title_item->valuestring) {
strncpy(book->title, title_item->valuestring, sizeof(book->title) - 1);
} else {
strncpy(book->title, entry->d_name, sizeof(book->title) - 1);
}
if (author_item && author_item->valuestring) {
strncpy(book->author, author_item->valuestring, sizeof(book->author) - 1);
} else {
book->author[0] = '\0';
}
cJSON_Delete(root);
ctx->book_count++;
}
}
}
closedir(dir);
if (ctx->book_count == 0) {
lv_list_add_text(ctx->lst_books, "No book manifest.json files found.");
return;
}
// Bubble sort by title
for (int i = 0; i < ctx->book_count - 1; i++) {
for (int j = 0; j < ctx->book_count - i - 1; j++) {
@@ -767,7 +773,7 @@ static void scan_books(AppCtx* ctx) {
}
}
}
// Add items to screen list
for (int i = 0; i < ctx->book_count; ++i) {
char label_text[512];
@@ -789,7 +795,7 @@ static void on_play_pause_click(lv_event_t* e) {
if (ctx->state == STATE_IDLE) {
ctx->state = STATE_PLAYING;
update_ui(ctx);
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
} else if (ctx->state == STATE_PLAYING) {
ctx->state = STATE_PAUSED;
update_ui(ctx);
@@ -818,7 +824,7 @@ static void on_next_click(lv_event_t* e) {
static void on_image_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx) return;
bool is_hidden = lv_obj_has_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
if (is_hidden) {
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
@@ -841,7 +847,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
memset(&g_ctx, 0, sizeof(g_ctx));
g_ctx.app = app;
g_ctx.volume = 80;
// Allocate shared audio buffers
#ifdef ESP_PLATFORM
g_ctx.audio_buf = (uint8_t*)heap_caps_malloc(MP3_INPUT_BUFFER_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
@@ -850,12 +856,13 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
g_ctx.audio_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
#endif
// Find audio-stream device (provides resampling to native 44100)
if (!find_audio_stream_device(&g_ctx)) {
ESP_LOGE(TAG, "audio-stream device not found! Tried 'audio-stream' name and AUDIO_STREAM_TYPE");
// Find I2S sound device
g_ctx.i2s_dev = device_find_by_name("i2s0");
if (!g_ctx.i2s_dev) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
}
// Create dual layouts
// 1. Picker Screen wrapper
g_ctx.picker_wrapper = lv_obj_create(parent);
@@ -863,11 +870,13 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_style_border_width(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0); // Dark background
// Toolbar in picker
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Picker list
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
@@ -876,7 +885,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_height(g_ctx.lst_books, parent_height - toolbar_height);
lv_obj_set_style_bg_color(g_ctx.lst_books, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_border_color(g_ctx.lst_books, lv_color_hex(0x313244), 0);
// 2. Player Screen wrapper (hidden on start)
g_ctx.player_wrapper = lv_obj_create(parent);
lv_obj_set_size(g_ctx.player_wrapper, LV_PCT(100), LV_PCT(100));
@@ -884,9 +893,10 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_style_pad_all(g_ctx.player_wrapper, 0, 0);
lv_obj_set_style_pad_gap(g_ctx.player_wrapper, 0, 0);
lv_obj_set_style_bg_color(g_ctx.player_wrapper, lv_color_hex(0x1E1E2E), 0);
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE); // Lock page scrolling
lv_obj_add_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_HIDDEN);
// Page Image (covers the whole screen as background)
g_ctx.img_page = lv_image_create(g_ctx.player_wrapper);
lv_obj_align(g_ctx.img_page, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0);
@@ -895,18 +905,20 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_remove_flag(g_ctx.img_page, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(g_ctx.img_page, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(g_ctx.img_page, on_image_click, LV_EVENT_CLICKED, &g_ctx);
// Custom Player header (Back button, Book Title, Page Indicator)
g_ctx.header_bar = lv_obj_create(g_ctx.player_wrapper);
lv_obj_t* header_bar = g_ctx.header_bar;
lv_obj_set_size(header_bar, LV_PCT(100), 36);
lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_radius(header_bar, 0, 0);
lv_obj_set_style_bg_color(header_bar, lv_color_hex(0x11111B), 0);
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0);
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0); // Solid header bar
lv_obj_set_style_border_color(header_bar, lv_color_hex(0x313244), 0);
lv_obj_set_style_border_width(header_bar, 1, LV_PART_MAIN);
lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE);
// Back button
lv_obj_t* btn_back = lv_button_create(header_bar);
lv_obj_set_size(btn_back, 44, 26);
lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0);
@@ -916,33 +928,37 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_label_set_text(lbl_back, LV_SYMBOL_LEFT);
lv_obj_center(lbl_back);
lv_obj_add_event_cb(btn_back, on_back_click, LV_EVENT_CLICKED, &g_ctx);
// Title
g_ctx.lbl_book_title = lv_label_create(header_bar);
lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_width(g_ctx.lbl_book_title, 180);
lv_obj_set_style_text_align(g_ctx.lbl_book_title, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xCDD6F4), 0);
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT);
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT); // Static text with dots to prevent dynamic redraw overhead
// Page Indicator
g_ctx.lbl_indicator = lv_label_create(header_bar);
lv_obj_align(g_ctx.lbl_indicator, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_style_text_color(g_ctx.lbl_indicator, lv_color_hex(0xA6ADC8), 0);
lv_label_set_text(g_ctx.lbl_indicator, "1 / 1");
// Bottom Controls container (overlaying background image)
g_ctx.ctrl_bar = lv_obj_create(g_ctx.player_wrapper);
lv_obj_t* ctrl_bar = g_ctx.ctrl_bar;
lv_obj_set_size(ctrl_bar, LV_PCT(100), 40);
lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_radius(ctrl_bar, 0, 0);
lv_obj_set_style_bg_color(ctrl_bar, lv_color_hex(0x11111B), 0);
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0);
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0); // Solid control bar
lv_obj_set_style_border_width(ctrl_bar, 0, 0);
lv_obj_set_style_pad_all(ctrl_bar, 0, 0);
lv_obj_set_style_pad_gap(ctrl_bar, 0, 0);
lv_obj_set_flex_flow(ctrl_bar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctrl_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
// Progress Bar (thin bar running along the top of control bar)
g_ctx.bar_progress = lv_bar_create(g_ctx.player_wrapper);
lv_obj_set_size(g_ctx.bar_progress, LV_PCT(100), 4);
lv_obj_align_to(g_ctx.bar_progress, ctrl_bar, LV_ALIGN_OUT_TOP_MID, 0, 0);
@@ -950,7 +966,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
// Prev Button
g_ctx.btn_prev = lv_button_create(ctrl_bar);
lv_obj_set_size(g_ctx.btn_prev, 54, 30);
lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0);
@@ -959,7 +976,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_label_set_text(lbl_prev, LV_SYMBOL_PREV);
lv_obj_center(lbl_prev);
lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx);
// Play/Pause Button
g_ctx.btn_play_pause = lv_button_create(ctrl_bar);
lv_obj_set_size(g_ctx.btn_play_pause, 94, 30);
lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0);
@@ -969,7 +987,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_label_set_text(lbl_play_btn, LV_SYMBOL_PLAY " Play");
lv_obj_center(lbl_play_btn);
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
// Next Button
g_ctx.btn_next = lv_button_create(ctrl_bar);
lv_obj_set_size(g_ctx.btn_next, 54, 30);
lv_obj_set_style_radius(g_ctx.btn_next, 15, 0);
@@ -982,20 +1001,26 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
// Initial load
g_ctx.state = STATE_IDLE;
update_ui(&g_ctx);
// Scan Books
scan_books(&g_ctx);
}
static void onHideApp(AppHandle app, void* data) {
wait_for_playback_task_to_exit(&g_ctx);
close_stream_if_open(&g_ctx);
if (g_ctx.i2s_dev) {
device_lock(g_ctx.i2s_dev);
i2s_controller_reset(g_ctx.i2s_dev);
device_unlock(g_ctx.i2s_dev);
}
if (g_ctx.manifest_root) {
cJSON_Delete(g_ctx.manifest_root);
g_ctx.manifest_root = NULL;
g_ctx.pages_array = NULL;
}
#ifdef ESP_PLATFORM
if (g_ctx.audio_buf) {
heap_caps_free(g_ctx.audio_buf);
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.bookplayer
app.version.name=1.0.0
app.version.code=1
app.name=Book Player
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3
[app]
id=one.tactility.bookplayer
versionName=1.0.0
versionCode=1
name=Book Player
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.brainfuck
app.version.name=0.5.0
app.version.code=5
app.name=Brainfuck interpreter
app.description=Brainfuck esoteric language interpreter
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.brainfuck
versionName=0.2.0
versionCode=2
name=Brainfuck interpreter
description=Brainfuck esoteric language interpreter
+1
View File
@@ -124,6 +124,7 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
if (!sfxEngine) {
sfxEngine = new SfxEngine();
sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
sfxEngine->setEnabled(soundEnabled);
}
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.breakout
app.version.name=0.5.0
app.version.code=5
app.name=Breakout
app.description=Classic brick-breaking arcade game
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.breakout
versionName=0.2.0
versionCode=2
name=Breakout
description=Classic brick-breaking arcade game
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.calculator
app.version.name=0.6.0
app.version.code=6
app.name=Calculator
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.calculator
versionName=0.3.0
versionCode=3
name=Calculator
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.diceware
app.version.name=0.6.0
app.version.code=6
app.name=Diceware
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.diceware
versionName=0.3.0
versionCode=3
name=Diceware
-263
View File
@@ -1,263 +0,0 @@
# DiscoveryMountain Crash After Install Root Cause & Fix
**Board:** CYD ES3C28P (es3c28p) `192.168.68.132`
**Firmware:** 0.8.0-dev / IDF 5.5.2
**App ID:** `one.tactility.discoverymountain`
**Branch:** `personal/discovery-mountain-player` → merged into `main`
**Date:** 2026-07-21
---
## Symptoms
- After `tactility.py install 192.168.68.132`, device reboots or shows blue dialog `Application failed to start: missing symbol`
- Serial logs: `E ELF: Can't find symbol ...`, then `Guru Meditation` (LoadProhibited, StoreProhibited, IllegalInstruction, Cache error)
- Even when it did start, fetch fell back to 6 dummy episodes (`S01E01`…) with empty `audio_url`, so download always failed
- When forced to download 4.3 MB MP3 from `http://192.168.68.110:8098/.../esp32_24k.mp3`, device hard-faulted during `play_task`
---
## 1. Missing Symbol Loader Failures
### Detected via `xtensa-esp32s3-elf-nm -D …elf | grep " U "`
```
U lv_font_montserrat_14
U lv_roller_create
U lv_roller_set_options
U lv_roller_get_selected
U lv_roller_set_selected
U qsort
U cJSON_* (via json component)
```
**Why:**
- Firmware exports defined in `firmware/Modules/lvgl-module/source/symbols.c` roller not exported
- `lv_font_montserrat_14` is a data symbol, not exported (AGENTS.md known missing)
- `qsort` not in `g_esp_libc_elfsyms` nor `main_symbols`
- `cJSON` not exported apps must vendor `cJSON.c` (see `BookPlayer`, `McpScreen`)
**Fix:**
- `manifest.properties` V1 INI → V2 flat:
```
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.discoverymountain
app.version.name=0.1.0
app.version.code=1
```
- `main/CMakeLists.txt`:
```cmake
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
idf_component_register(SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
REQUIRES TactilitySDK esp_http_client lwip)
```
- Replace roller with `lv_dropdown` (exported, see `symbols.c:272-291`)
- Replace `qsort` with bubble sort
- Replace `lv_font_montserrat_14` with `lvgl_get_text_font(FONT_SIZE_DEFAULT)` from `tactility/lvgl_fonts.h`
After fix: `nm -D` shows no missing roller/qsort/font, verify script passes.
---
## 2. Fatal `memset` Bug
```c
memset(&G,0,0); // size 0!
```
Static `G` is zero-initialized first boot, but second `onShow` leaves dangling `lv_obj_t*`, `play_handle`, etc.
**Fix:** `memset(&G,0,sizeof(G))`
---
## 3. Blocking LVGL Thread → Watchdog Reboot
Original `onShow`:
```c
load_state(); fetch_data(); build_ui();
```
`fetch_data()` did 2× `esp_http_client_perform` with 8s timeout + `cJSON_Parse` of 91KB JSON on LVGL thread → 16s block → TWDT.
**Fix:**
- Build UI immediately with "Fetching..."
- `xTaskCreate(fetch_task_fn,"dm_fetch",16384, ...)`
- `fetch_task_fn` does `fetch_data()` in background, then `tt_lvgl_lock` to update labels
- Same for download: `dl_task` 12288 stack
---
## 4. `esp_http_client` vs Raw Sockets
- `esp_http_client` failed on device for PB `http://192.168.68.110:8095` (returned chunked `1634\r\n{...}`), while `curl --http1.0` returned plain JSON.
Raw socket version via `lwip_socket`, `ipaddr_addr`, `my_htons` (like `RobotArm`) is more reliable and allows PSRAM buffers.
PocketBase:
- `HTTP/1.1` → `Transfer-Encoding: chunked` → `1634\r\n{...}\r\n0\r\n\r\n`
- `HTTP/1.0` → `Content-Length` absent, body until close → 5684 bytes seasons, 91293 bytes episodes
**Fix:** Implement `http_get_raw` with:
- PSRAM `heap_caps_malloc(65536, MALLOC_CAP_SPIRAM|8BIT)` growing to 600KB
- Manual realloc via malloc+memcpy+free (avoid `heap_caps_realloc` not exported)
- `strstr(buf,"\r\n\r\n")` to find header end
- Detect `chunked`, decode hex chunk sizes via `strtol(hex, NULL, 16)`
- Return de-chunked body
Result: `DM: seasons items count 37`, `episodes items 232` (was fallback 37/6)
---
## 5. Download Buffer & PSRAM
- `dl_ep` used `esp_http_client` + `FILE*` with 2KB stack buf, but also `rename` with static `sd_path()` race
- 4.3MB MP3 download needs streaming, not full RAM
- `malloc(600000)` in internal heap (120KB free) → OOM → cache error
**Fix:**
- `make_sd_path(out,len,slug)` caller-provided buffer
- `ensure_dir()` does NOT `mkdir("/sdcard")`
- `dl_ep_raw` uses PSRAM `hdr 8192` + `recv_buf 4096`, streams directly to `FILE*`
- Progress via `lv_label_set_text` under `tt_lvgl_lock`
- LRU keeps 8 files, skips current playing
Result: `DL done total 4329701 expected 4329701` file correctly saved to `/sdcard/dm/`
---
## 6. Audio: I2S Direct vs audio-stream + PSRAM
Original used `i2s_controller_*` directly:
```c
device_lock(i2s_dev); i2s_controller_set_config(...); i2s_controller_write(...);
```
Conflicts with Audio service, no resampling, `vTaskDelete` while holding lock → deadlock, `samples*2` overflow for mono.
**Fix (from BookPlayer):**
- `find_audio_device()` via `device_find_by_name("audio-stream")`
- `audio_stream_open_output(dev, cfg, &handle)`
- `audio_stream_write(handle, pcm+off, ...)`
- `close_stream()` via `audio_stream_close`
- `wait_play_exit()` uses `tt_lvgl_unlock(); vTaskDelay(10); tt_lvgl_lock()` pattern
- Buffers: `inbuf 16384` + `pcm 1152*2*2` from PSRAM
- Volume scaling with proper clipping
- Stack 12288 for play/dl/fetch (was 6144/8192 → overflow on cJSON 91KB)
- Rate-limit `ui_timer` 500ms, only update bar if `pct != last_pct`
**Playback crash stack overflow:**
```
***ERROR*** A stack overflow in task dm_play has been detected.
...
Guru Meditation: BREAK instr at vTaskGenericNotifyGiveFromISR
...
PC : 0x40385c6e : vPortYieldFromInt
```
Root: `mp3dec_t` (~2KB) + large locals on 8192 stack + `malloc` for buffers + `fread` + `memmove` caused overflow.
**Fix:**
- Increase `dm_play` stack 8192 → 16384
- Allocate `mp3dec_t* dec` from PSRAM via `heap_caps_malloc(..., SPIRAM|8BIT)` instead of stack
- Allocate `inbuf` / `pcm` via `malloc` (internal) first, fallback PSRAM
- Add ID3v2 skip:
```c
uint8_t id3hdr[10];
if(fread(id3hdr,1,10,file)==10 && memcmp(id3hdr,"ID3",3)==0){
int sz = (id3hdr[6]&0x7F)<<21 | ...;
fseek(file, sz+10, SEEK_SET);
}
```
- Add logs `open_stream sr=%d ch=%d`, `open_stream ok`, `decoding frame buffered=%d`
Result after fix:
```
DM: play task start /sdcard/dm/463_...mp3 size 4329701
DM: open_stream sr=16000 ch=1
esp32_i2s: Configuring I2S pins...
Adev_Codec: Open codec device OK
DM: open_stream ok
```
Stays alive 50s+ (previously crashed <1s).
### 7. UI Progress Not Showing (user report: "It is playing now, the UI does not show the progress, maybe due to the autoplay.")
Root:
```c
G.pos_sec += samples / info.hz; // integer division 1152/16000 = 0!
```
`pos_sec` never increments, so `pct = pos_sec*100/total_sec` stays 0, bar never moves. Also `last_pct` not reset on new episode.
**Fix:**
- Track `total_decoded_samples += samples; G.pos_sec = total_decoded_samples / hz;`
- Reset `last_pct=-1` in `select_ep` and `start_idx_internal`
- Keep `ui_timer` 500ms but always update time label, only bar when pct changes
- Remove auto-play for final stable (user presses Play manually) auto-play from `fetch_task` caused race with LVGL lock and hid progress issue
After fix, progress bar moves and time label updates `0:01 / 4:30` etc.
No immediate crash, stays alive 50s+ (previously crashed <1s).
Remaining TODO: test actual audio output via speaker, try 44.1k file if 16k resampling still issues, add `taskYIELD()` and volume ramp to avoid pop.
---
## Build & Deploy (correct env)
Per `AGENTS.md`:
```bash
cd /Users/adolforeyna/Projects/Tactility/apps
unset PYTHONPATH; unset PYTHONHOME
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.5_py3.10_env
source /Users/adolforeyna/esp/esp-idf-v5.5.2/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/DiscoveryMountain build esp32s3 --local-sdk
curl -X PUT http://192.168.68.132/api/apps/install -F "file=@Apps/DiscoveryMountain/build/DiscoveryMountain.app"
curl -X POST "http://192.168.68.132/api/apps/run?id=one.tactility.discoverymountain"
# serial
python3 -c "import serial; s=serial.Serial('/dev/cu.usbmodem101',115200); ..."
```
Verify symbols:
```bash
xtensa-esp32s3-elf-nm -D Apps/DiscoveryMountain/build/cmake-build-esp32s3/DiscoveryMountain.app.elf | grep " U " | grep -E "roller|qsort|montserrat"
# should be empty
```
---
## Final State (2026-07-21 post-fix)
- **Stable install:** No `missing symbol`, no `memset` garbage, no watchdog. Boots to launcher, shows UI, stays alive.
- **Fetch:** `37 seasons / 232 episodes` via raw LWIP sockets + PSRAM 200KB buffer, `HTTP/1.0` to avoid chunked. Previously fallback 6 dummy.
- **Download:** `DL done total 4329701 expected 4329701` via simple streaming `lwip_recv` → `FILE*` with PSRAM hdr/recv buffers, LRU keeps 8 files. Previously OOM and crash.
- **Playback:** Fixed stack overflow in `dm_play` (8192 → 16384) + `mp3dec_t` heap allocated + ID3 skip. Now:
```
DM: play task start ... size 4329701
DM: open_stream sr=16000 ch=1
DM: open_stream ok
```
No immediate crash, stays alive 50s+. Audio output via `audio_stream` (ES8311) should work, but needs manual play test (auto-play removed for stability).
- **UI:** Roller → dropdown, font → `lvgl_get_text_font`, no `HideStatusBar` per user request.
- **Build:** Verified `nm -D` clean, `tactility.py build esp32s3 --local-sdk` ok.
## Verified Logs (final)
```
DM: fetch task start
DM: GET .../dm_seasons... → GET ok 5684
DM: seasons items count 37
DM: GET .../dm_episodes... → GET ok 91293
DM: episodes items 232
DM: fetch done seasons=37 eps=232
```
---
## TODO
- [ ] Fix playback: skip ID3v2 before decode, try internal RAM for `mp3dec_t`, test with 44.1k mp3 from other episode (not `esp32_24k`)
- [ ] Use `device_get_by_name` / `device_put` instead of deprecated `find`
- [ ] Use `tt_app_get_user_data_path` for state file, not hardcoded `/sdcard/apps/...`
- [ ] Rate-limit screenshot API (2s gap) to avoid heap spike
-259
View File
@@ -1,259 +0,0 @@
# Discovery Mountain Download & Playback Crash Report
**Date:** 2026-07-25
**Device:** 192.168.68.133 (es3c35p, /dev/cu.usbmodem1101)
**App ID:** `one.tactility.discoverymountain` (DM) + `one.tactility.sddownloader` (SDDL) + `one.tactility.mp3player` (MP3)
**Firmware:** 0.8.0-dev / IDF 5.5.2 / Tactility SDK 0.8.0-dev
---
## Summary What is failing: DM or Downloader?
**Both, same root cause: low internal heap (37 KB) + unsafe task handling.**
- **DM fetch:** 1st launch OK (37/232), 2nd launch crashes with StoreProhibited.
- **Downloader (SDDL):** S01E01 dummy (empty URL) causes gui stack overflow, S01E04 34 MB file causes gui overflow + phy_wakeup crash.
---
## 1. Original Download Crash (from your logs)
**Log:** `2026-07-24/cu.usbmodem101_21-42-13_s002.log`
```
[21:43:39] DM: DL start 396_Wherever_You_Go_S07E01 url ... 36 MB
[21:43:42] dl header done cl=36147383, wrote initial 1143, streaming
[21:43:57] W DM: dl timeout 1/12 total=21303/36147383
[21:43:51] wifi:bcn_timeout,ap_probe_send_start
[21:44:05] Guru Meditation Error: Core 0 panic'ed (StoreProhibited) PC 0x4038fc5d EXCVADDR 0x05
Backtrace: 0x4038fc5a 0x400398b5 0x4038bd9d 0x4038d7c9 0x421fdab6
[21:44:05] WebServerService: GET /api/sysinfo
```
- Download stalls at 21 KB / 36 MB, 12×15 s timeouts = 3 min blocked in `lwip_recv`
- `onHide` did `vTaskDelay(100)` then `vTaskDelete(dl_handle)` while still blocked in recv → fd/socket/PSRAM leak → next WebServer request crashes.
- Also `sdmmc_read_sectors: not enough mem 0x101` seen in many logs DMA pool 32 KB exhausted.
**Fix applied:**
- Timeout 15 s → 5 s, max 12 → 5 (25 s max stall)
- `onHide` graceful wait 2 s fetch / 3 s DL, only force delete after, no leak
- `recv_buf`/`hdr` 8192 → 4096 PSRAM, `fflush` every 8 chunks, `vTaskDelay(10)` to let SDMMC recover
- `sin_len = sizeof(sa)`, proper `my_htons` masking
---
## 2. S01E01 Empty URL + GUI Stack Overflow
**Log:** `cu.usbmodem1101_20-33-06_s008.log`
```
[20:33:14] DM: connect fail 192.168.68.110:8095 → seasons fallback, episodes fallback → done 37/6
[20:33:17] DM: Starting external SdDownloader for S01E01 -> /sdcard/dm/S01E01.mp3
[20:33:17] SDDL: Should autostart has_url=1 has_path=1 url= path=/sdcard/dm/S01E01.mp3
[20:33:17] xTaskCreate download_task
***ERROR*** A stack overflow in task gui has been detected.
Backtrace: 0x40385b29:0x3fcdb9d0 ...
```
- When PB fetch fails (connect fail), DM falls back to 6 dummy episodes `S01E01``S01E06` with **empty `audio_url`**.
- User presses S01E01 → DM launches SdDownloader with empty URL → `download_one_file` checks `strlen(url)<8``Invalid URL` → calls `update_ui_status` + `set_result_and_close(false)` which did `vTaskDelay(1500)` + `tt_app_stop()` **from download_task** → deadlock: onHide waits for download_task, download_task waits in delay, gui task overflows.
- Also `S01E01.mp3` path exists? No, so it tries download.
**Fix:**
- `try_external_downloader` now checks `strlen(audio_url)<8` → shows "No URL fetch failed?" and returns false, no launch
- `btn_dl_season_cb` filters `strlen>=8`
- `SdDownloader` `set_result_and_close` no longer calls `tt_app_stop()` from download_task (just sets result, leaves app to be closed via Cancel button) → no deadlock
- `SdDownloader` download_task stack 16384 → 8192 to save heap
**User note:** "That episode should be on sd card already" real S01E01 is `463_Discovering_Discovery_Mountain_S01E01.mp3` (4.3 MB), not dummy `S01E01.mp3`. Real file exists, dummy does not.
---
## 3. S01E04 34 MB File, GUI Stack Overflow on DL Start
**Log:** `cu.usbmodem1101_20-26-11_s006.log`
```
[20:28:43] DM: Starting external SdDownloader for 460_The_Lookout...S01E04 -> /sdcard/dm/460_...mp3
[20:28:43] SDDL: Should autostart url=http://.../460_...mp3 path=/sdcard/dm/460_...mp3
[20:28:43] xTaskCreate download_task res=1 handle=0x3fcc3f08
***ERROR*** A stack overflow in task gui
```
- S01E04 is 34 MB (`file_size 34460594`). Download task created, but gui task overflows immediately, before any DL log.
- Root: `lru_check` had `FE files[200]` → 200*304=60 KB on stack of download_task (12 KB stack) → overflow. Also `sort_eps` had `EpInfo t` 500 bytes on stack inside double loop.
- `ensure_parent_dir` had 512+512 stack, `req[512]`, etc.
**Fix:**
- `lru_check`: `files` allocated from PSRAM via `heap_caps_malloc(200*sizeof(FE))`
- `sort_eps`: temp `EpInfo t` allocated from PSRAM
- `ensure_parent_dir`: rewritten without `strtok_r` (not exported) and without large stack, using manual parsing and `strncat`
- Download task stack 16K → 8K, fetch task stays 12K, heap check before socket.
---
## 4. DM Second Launch StoreProhibited 0x8208775e
**Log:** `..._17-37-00_s015.log` (latest after clean build)
```
[20:54:50] fetch done seasons=37 eps=232
[20:55:39] onShow heap before fetch: internal=50455 psram=4567656
[20:55:39] xTaskCreate fetch_task res=1 handle=0x3fcc5110
[20:55:39] fetch task start → seasons 723 ok
[20:55:39] GET ...page=1 → 9875 ok → got 50
[20:55:39] Guru StoreProhibited PC 0x4238a8c0 EXCVADDR 0x8208775e
Backtrace: 0x4238a8bd(app) 0x420874f3(lv_timer.c:107) 0x42069754(esp_lvgl_port.c:242) 0x421fdaba(port.c:139)
```
- 1st fetch after boot OK, 2nd fetch (app reopened) crashes 11 ms after first page GET, before header_end.
- Backtrace shows crash in **LVGL timer task**, not fetch task: `lv_timer.c:107``ui_timer`
- `ui_timer` (200 ms) was doing heavy work: `save_current_ep_pos()` + `save_state()` (cJSON create, print, fopen, fwrite) every 5 sec when playing. Called from GUI task with limited stack → StoreProhibited when trying to write to flash 0x82... (string literal in flash?).
- Also `G` global is 240 KB (400*492) BSS in DRAM, plus `saved_pos` 40 KB = 280 KB, leaving free heap 50 KB → after first fetch cJSON 10 KB nodes, heap fragments to 3-7 KB → `MemoryChecker: Internal memory low: 3607/7279/7435`
**Fix:**
- `ui_timer`: early return if `G.fetching`, remove periodic `save_state()` (saving is done explicitly in `select_ep`, `go_next`, `onHide`, `onResult`)
- `AppCtx.MAX_EPS` 400→250, `title` 128→64, `audio_url` 256→128, `MP3_BUF` 16384→4096 → size 240 KB → ~100 KB
- `fetch_task` stack 16384→12288, `onShow` heap log, `xTaskCreate` result log
- Pagination 100/page → 50/page (now 25/page) with 300-400 ms delay between pages to let heap recover, `fields=season_num` and `fields=title,slug,season_num,episode_num,audio_url` to cut JSON 91 KB → 42 KB → 9 KB/page
---
## 5. DL Season Button IntegerDivideByZero in Bundle
**Log:** `cu.usbmodem1101_16-57-59_s014.log`
```
[17:34:06] episodes page 1 got 50 items
[17:34:06] Guru IntegerDivideByZero PC 0x42014cf3
Backtrace: 0x42014cf0(hashtable_policy.h:534) 0x42014dac(unordered_map.h:988) 0x42014345(tt_bundle.cpp:46 putInt32) 0x4237a4d3(app)
```
- Pressing **DL Season** during fetch → `G.ep_cnt` only 50 (partial), `G.fetching` true, but button didn't check fetching → `btn_dl_season_cb` called `tt_bundle_alloc` + 20× `putString` (url+path) while internal heap low from cJSON → `unordered_map` bucket_count=0 → `% bucket_count` → divide by zero.
**Fix:**
- `btn_seasons_cb` and `btn_dl_season_cb` now check `if(G.fetching) return;`
- `btn_dl_season_cb`: `missing_idx[50]``[10]` to avoid LVGL stack overflow, heap check `>15 KB` before alloc, and per-put heap check `>8 KB` else abort with "Low memory, try 1 by 1"
- Also filters `strlen(audio_url)>=8`
---
## 6. Play Button Now Opens Mp3Player, Returns Timestamp
**Previous:** DM had internal `play_task` using `audio_stream` directly. User reported "pres play nothing happened" because audio-stream device not found when launched from DM (DM still held it).
**Fix in `player.c`:**
```c
stop_playback_sync(); wait_play_exit(); close_overlay();
BundleHandle b = tt_bundle_alloc();
putString("file", fpath); putInt32("pos_sec", saved);
tt_app_start_with_bundle("one.tactility.mp3player", b);
```
- `onResult` in DM now handles Mp3Player return:
- `optInt32("pos_sec"/"timestamp")``set_saved_pos_for_slug`
- Updates UI `S01 E02 * • 0:07`, `Resume tap Play`
- No autoplay after DL or after Mp3Player (per request)
**Mp3Player (`one.tactility.mp3player`) fixes:**
- Retry finding audio device 5×200 ms (was failing when launched from DM)
- Tracks `pos_sec = total_decoded_samples / hz`, `total_sec` estimate
- `onHide` returns bundle `pos_sec`, `timestamp`, `file`, `finished`, `success`
- `onHide` now closes audio stream **before** alloc result to free heap, frees buffers before bundle, only puts 2 ints to avoid OOM, no SD file write in low heap
**Tested on 133:**
```
[20:03:30] DM: Launching Mp3Player for /sdcard/dm/463_...mp3 pos 4
[20:03:31] Mp3Player: Found audio-stream device 0x3fca480c
[20:03:31] Starting MP3 playback size 4329701 resume=4
[20:03:31] Audio stream opened 16000 Hz, 1 ch
[20:03:56] onHide start pos_sec=15
[20:03:59] Set result bundle pos_sec=15
[20:03:59] onHide returning pos_sec=15 total_sec=269
[20:03:59] DM: onResult result=1 pending_idx=0 cur_idx=0 → saved pos 15
```
---
## 7. SdDownloader Now Batch + Headless (per your latest spec)
**Manifest:** `one.tactility.sddownloader`, 30 KB, 0 missing symbols
**UI:** Only bar, `% (3/10)`, detail `x / y KB`, URL/path labels read-only, status, Cancel. No input fields.
**Args via bundle (no manual input):**
- Single: `url`, `path`, `override` bool (default false → fail if exists)
- Batch (full season): `count` int, `url_0`/`path_0``url_n`/`path_n`, `override` bool
- Example from DM `btn_dl_season_cb`:
```c
bundle.putInt32("count", 7);
putString("url_0", "http://.../S02E01.mp3"); putString("path_0", "/sdcard/dm/...");
...
tt_app_start_with_bundle("one.tactility.sddownloader", b);
```
**Returns bool:**
```c
bundle.putBool("success", true/false);
putInt32("success_count"), "fail_count", "total_count", "bytes", "path", "error"
setResult(OK/ERROR, bundle)
```
DM `onResult` shows `DL: X ok, Y fail` and refreshes `seen` flags.
**Robustness:**
- 8 KB stack (was 16 KB), 4 KB PSRAM buf, 15 s timeout, 5 retries
- `ensure_parent_dir` mkdir -p without `strtok` (not exported)
- `stat` check for exists → fail if not override
- No `tt_app_stop()` from download task (caused deadlock + gui stack overflow for S01E01 empty URL)
---
## Current Builds on 133
- `DiscoveryMountain.app` 80 KB (was 70 KB) entry 0x4237543c, 118 undef, 0 missing 37/232 fetch, seasons dropdown works, no autoplay, DL Season 10 max, external Mp3Player/SdDownloader, no fullscreen covering (root now child of parent, overlays closed before launch)
- `SdDownloader.app` 30 KB 74 undef, 0 missing batch 10, file-exists check
- `Mp3Player.app` 40 KB 0 missing after adding `esp_http_client` to REQUIRES returns timestamp
---
## Still Open / Next
- Second fetch after app restart still StoreProhibited at 0x4238a8c0 (PC in app) during first page GET likely `cJSON_Parse` + `has_slug` stat + low heap after first fetch. Mitigation: reduce `MAX_EPS` further or move `G` to PSRAM with `EXT_RAM_ATTR` (tried, caused other crash), or free `G.eps` between fetches.
- S01E01 dummy fallback (empty URL) now shows "No URL fetch failed?" instead of crash, but ideally should never appear if fetch succeeds. Ensure PB at `192.168.68.110:8095` is reachable (seen `connect fail` when WiFi bcn_timeout).
- Mp3Player close takes 2.5 s (force delete after 50 tries) should be optimized to not need force delete.
---
## Reproduction Steps for S01E04 Crash (you reported)
1. Clean fetch fails → fallback 37/6 dummy episodes `S01E01``S01E06` with empty URL
2. Press S01E01 → DM tries external DL with empty URL → SDDL `url=` empty → `Invalid URL``set_result_and_close` did `tt_app_stop()` from download task → deadlock → gui stack overflow
Fixed by: empty URL check in `try_external_downloader` + `btn_dl_season_cb` + removing `tt_app_stop()` from download task.
For real S01E04 (34 MB, `460_...`), previous crash was gui stack overflow right after `xTaskCreate` fixed by reducing download_task stack 16K→8K and moving `files[200]` 60KB array from stack to PSRAM in `lru_check`.
---
## Files Changed
- `Apps/DiscoveryMountain/main/Source/app_context.c` added `pending_dl_idx`, `root`
- `app_context.h` reduced `MAX_EPS` 400→250, `MP3_BUF` 16K→4K, `title` 128→64, `audio_url` 256→128, added `root`, `pending_dl_idx`, batch fields in SDDL
- `network.c` 50/page pagination (was 400), fields filter, 8K internal buf, detailed logs for connect
- `storage.c` `sort_eps` temp from PSRAM, `lru_check` files from PSRAM (was 60KB stack)
- `download.c` stubbed to only overlays (internal DL removed)
- `player.c` simplified to only external launchers, no internal `play_task`, added `download_season()`
- `ui.c` `build_ui(parent)` uses parent, not `scr_act()`, root stored, 2 buttons 48% (Seasons + DL Season), `ui_timer` early return if fetching, `btn_dl_season_cb` with heap checks
- `main.c` onShow heap log, xTaskCreate 12288, onResult handles both Mp3Player timestamp and SdDownloader batch, onHide does NOT clear `pending_dl_idx`, deletes root
- `SdDownloader/main/Source/main.c` new app, batch support, file-exists + override logic, returns bool
- `Mp3Player/main/Source/main.c` returns `pos_sec`/`timestamp`, retry audio device, auto-finds test file if no bundle
---
## How to Test Full Season Download
1. Open DM → wait `fetch done 37/232`
2. Tap `Seasons` → S01 → tap `DL Season`
3. Should show `Starting DL 6 eps S01` → launches SDDL → bar shows `3/6: 45%` etc.
4. On finish, DM shows `DL: 6 ok, 0 fail` and refreshes `*` markers
5. Tap episode with `*` → Play → launches Mp3Player → plays from saved pos → close → DM shows `Resume • 0:12`
+2 -5
View File
@@ -1,8 +1,5 @@
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
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK esp_http_client json
)
@@ -1,3 +0,0 @@
#include "app_context.h"
AppCtx G = {0};
@@ -1,106 +0,0 @@
#pragma once
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <lvgl.h>
#include <tactility/lvgl_fonts.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#ifdef __cplusplus
extern "C" {
#endif
#define TAG "DM"
#define PB "http://192.168.68.110:8095"
#define DM_DIR "/sdcard/dm"
#define STATE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/state.json"
#define CACHE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/cache.json"
#define MAX_SEASONS 40
#define MAX_EPS 250
#define MP3_BUF 4096
typedef struct { int num; } SeasonInfo;
typedef struct {
char title[64];
char slug[96];
int season;
int ep_num;
char audio_url[128];
bool seen;
} EpInfo;
typedef struct { char slug[96]; int pos; } SavedPos;
typedef struct {
SeasonInfo seasons[MAX_SEASONS];
int season_cnt;
EpInfo eps[MAX_EPS];
int ep_cnt;
int cur_season;
int cur_ep_idx;
struct Device* stream_dev;
AudioStreamHandle stream_handle;
bool is_playing;
bool is_paused;
bool need_stop;
TaskHandle_t play_handle;
TaskHandle_t fetch_handle;
TaskHandle_t dl_handle;
int pos_sec;
int total_sec;
int volume;
int last_pct;
lv_obj_t *root;
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
lv_obj_t *lbl_season;
lv_obj_t *ep_list_cont;
lv_obj_t *btn_seasons;
lv_obj_t *btn_dl_missing;
lv_obj_t *overlay, *dropdown;
lv_obj_t *season_list_cont;
lv_timer_t *ui_timer_handle;
// Download UI
lv_obj_t *dl_overlay;
lv_obj_t *dl_bar;
lv_obj_t *dl_lbl_pct;
lv_obj_t *dl_lbl_detail;
lv_obj_t *dl_lbl_title;
lv_obj_t *dl_lbl_sub;
char cur_title[128];
char last_slug[96];
bool fetching;
bool downloading;
volatile int dl_total;
int dl_expected;
int dl_last_ui_update;
volatile bool dl_cancel_req;
volatile bool fetch_cancel_req;
char dl_ep_title[128];
char dl_slug[96];
int dl_season;
int dl_ep_num;
int dl_last_pct;
// Per-episode resume
SavedPos saved_pos[MAX_EPS];
int saved_pos_cnt;
int last_save_tick;
// External downloader integration (SdDownloader)
int pending_dl_idx;
// Full season download queue (sequential single-file to avoid batch crash)
bool dl_missing_active;
int dl_missing_season;
int dl_missing_total;
int dl_missing_done;
int dl_missing_failed;
} AppCtx;
extern AppCtx G;
#ifdef __cplusplus
}
#endif
@@ -1,49 +0,0 @@
#include "download.h"
#include "storage.h"
#include <tt_lvgl.h>
#include <string.h>
#include "esp_log.h"
// Simplified: internal download removed, we use external SdDownloader app
// Keep overlay functions as stubs for compatibility, but they do minimal
void hide_dl_overlay(){
if(G.dl_overlay){
lv_obj_delete(G.dl_overlay);
G.dl_overlay=NULL;
G.dl_bar=NULL;
G.dl_lbl_pct=NULL;
G.dl_lbl_detail=NULL;
G.dl_lbl_title=NULL;
G.dl_lbl_sub=NULL;
}
G.downloading=false;
}
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){
// No longer used - external downloader has its own UI
// Keep as stub that shows simple status
(void)ep_title; (void)slug; (void)season; (void)ep_num;
G.downloading=true;
}
void update_dl_overlay_ui(){
// No internal download progress anymore
}
bool dl_ep_raw(EpInfo* ep){
(void)ep;
return false;
}
bool dl_ep(EpInfo* ep){
(void)ep;
return false;
}
void dl_task(void* arg){
(void)arg;
G.downloading=false;
G.dl_handle=NULL;
vTaskDelete(NULL);
}
@@ -1,24 +0,0 @@
#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
+362 -405
View File
@@ -1,433 +1,390 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_bundle.h>
#include "app_context.h"
#include "storage.h"
#include "network.h"
#include "download.h"
#include "player.h"
#include "ui.h"
#include <tactility/device.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_http_client.h"
#include "cJSON.h"
#define MINIMP3_IMPLEMENTATION
#define MINIMP3_NO_SIMD
#include "minimp3.h"
static void fetch_task_fn(void* arg){
#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;
static struct {
SeasonInfo seasons[MAX_SEASONS]; int season_cnt;
EpInfo eps[MAX_EPS]; int ep_cnt;
int cur_season;
int cur_ep_idx;
struct Device* i2s_dev;
bool is_playing; bool is_paused; bool need_stop;
TaskHandle_t play_handle;
int pos_sec; int total_sec;
int volume;
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
lv_obj_t *overlay, *roller;
char cur_title[128];
} G;
static const char* sd_path(const char* slug){ static char p[256]; snprintf(p,sizeof(p),"%s/%s.mp3",DM_DIR,slug); return p; }
static void ensure_dir(){ mkdir("/sdcard",0777); mkdir("/sdcard/apps",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777); mkdir(DM_DIR,0777); }
static bool has_slug(const char* slug){ struct stat st; return stat(sd_path(slug),&st)==0; }
static void save_state(){
cJSON* r=cJSON_CreateObject();
if(G.ep_cnt>0 && G.cur_ep_idx>=0) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
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);
}
static void load_state(){
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
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>4096){ fclose(f); return;}
char* buf=malloc(sz+1); fread(buf,1,sz,f); buf[sz]=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)){
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_Delete(r);
}
static int cmp_season(const void*a,const void*b){ return ((SeasonInfo*)a)->num - ((SeasonInfo*)b)->num; }
static int cmp_ep(const void*a,const void*b){ EpInfo* ea=(EpInfo*)a; EpInfo* eb=(EpInfo*)b; if(ea->season!=eb->season) return ea->season-eb->season; return ea->ep_num-eb->ep_num; }
static int http_get(const char* url, char** out){
esp_http_client_config_t cfg={ .url=url, .timeout_ms=8000 };
esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c) return -1;
esp_http_client_set_method(c,HTTP_METHOD_GET);
if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); return -1; }
if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); return -1; }
int len=esp_http_client_get_content_length(c); if(len<=0) len=65536;
char* buf=malloc(len+1); int rlen=esp_http_client_read_response(c,buf,len); buf[rlen]=0;
esp_http_client_cleanup(c); *out=buf; return rlen;
}
static void fetch_data(){
char* js=NULL; char url[256];
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB);
if(http_get(url,&js)>0){
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++; }
}
qsort(G.seasons,G.season_cnt,sizeof(SeasonInfo),cmp_season);
}
cJSON_Delete(root);
}
}
if(G.season_cnt==0){ for(int i=1;i<=37;i++) G.seasons[i-1].num=i; G.season_cnt=37; }
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB);
if(http_get(url,&js)>0){
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++;
}
qsort(G.eps,G.ep_cnt,sizeof(EpInfo),cmp_ep);
}
cJSON_Delete(root);
}
}
if(G.ep_cnt==0){
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);
}
static 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;
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(G.cur_ep_idx>=0){
const char* curp=sd_path(G.eps[G.cur_ep_idx].slug);
if(strcmp(full,curp)==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);
}
static bool dl_ep(EpInfo* ep){
if(!ep) return false;
if(has_slug(ep->slug)) return true;
ensure_dir(); lru_check();
if(strlen(ep->audio_url)==0) return false;
char tmp[300]; snprintf(tmp,sizeof(tmp),"%s.tmp",sd_path(ep->slug));
FILE* f=fopen(tmp,"wb"); if(!f) return false;
esp_http_client_config_t cfg={ .url=ep->audio_url, .timeout_ms=30000 };
esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c){ fclose(f); return false; }
if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; }
if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; }
char buf[2048]; int total=0;
while(1){
int r=esp_http_client_read(c,buf,sizeof(buf));
if(r<=0) break;
fwrite(buf,1,r,f);
total+=r;
if(G.lbl_status){ char m[32]; snprintf(m,sizeof(m),"DL %dKB",total/1024); lv_label_set_text(G.lbl_status,m);}
}
esp_http_client_cleanup(c); fclose(f);
if(total<1024){ unlink(tmp); return false; }
rename(tmp,sd_path(ep->slug));
ep->seen=true;
return true;
}
static void stop_play(){
if(G.play_handle){ G.need_stop=true; vTaskDelay(pdMS_TO_TICKS(100)); if(G.play_handle){ vTaskDelete(G.play_handle); G.play_handle=NULL; } }
if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; }
G.is_playing=false; G.is_paused=false;
}
static void play_task(void* arg){
(void)arg;
G.fetching=true;
G.fetch_cancel_req=false;
ESP_LOGI(TAG,"fetch task start");
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];
FILE* file=fopen(sd_path(ep->slug),"rb");
if(!file){ 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);
G.i2s_dev=device_find_by_name("i2s0");
if(!G.i2s_dev){ fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
if(fsize>0) G.total_sec=(int)(fsize/16000);
// Try load cached seasons/episodes instant offline support
bool cache_loaded = load_cache();
if(cache_loaded){
ESP_LOGI(TAG,"Cache loaded: %d seasons, %d eps", G.season_cnt, G.ep_cnt);
// Restore cur idx from last_slug
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
if(G.cur_ep_idx<0){
int s1=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; }
if(s1==-1 && G.ep_cnt>0){
s1=0;
G.cur_season=G.eps[0].season;
mp3dec_t dec; mp3dec_init(&dec);
uint8_t* inbuf=malloc(MP3_BUF);
int16_t* pcm=malloc(1152*2*2);
size_t buffered=0;
mp3dec_frame_info_t info; memset(&info,0,sizeof(info));
bool configured=false;
size_t r=fread(inbuf,1,MP3_BUF,file); buffered=r;
while(!G.need_stop){
if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; }
int samples=mp3dec_decode_frame(&dec,inbuf,(int)buffered,pcm,&info);
if(samples>0){
if(!configured){
struct I2sConfig cfg={ .communication_format=I2S_FORMAT_STAND_I2S, .sample_rate=(uint32_t)info.hz, .bits_per_sample=16, .channel_left=0, .channel_right=(info.channels==2)?1:I2S_CHANNEL_NONE };
device_lock(G.i2s_dev); i2s_controller_set_config(G.i2s_dev,&cfg); device_unlock(G.i2s_dev);
if(G.pos_sec>5){ long seek=(long)G.pos_sec*16000; if(seek<fsize){ fseek(file,seek,SEEK_SET); buffered=0; } }
configured=true;
}
G.cur_ep_idx=s1;
}
// Show cached data immediately
G.fetching=false;
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Loaded from cache, refreshing...");
tt_lvgl_unlock();
G.fetching=true;
vTaskDelay(pdMS_TO_TICKS(200));
} else {
ESP_LOGI(TAG,"No cache found, will fetch from network");
}
// Network fetch updates if possible, otherwise keeps cache
bool fetch_ok = fetch_data();
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"fetch task canceled, exiting");
G.fetching=false;
G.fetch_handle=NULL;
vTaskDelete(NULL);
return;
}
if(fetch_ok){
ESP_LOGI(TAG,"Network fetch ok: %d seasons, %d eps saving cache", G.season_cnt, G.ep_cnt);
save_cache();
} else {
if(cache_loaded){
ESP_LOGW(TAG,"Network fetch failed, restoring cache");
// fetch_data overwrote with fallback, restore cache
load_cache();
} else {
ESP_LOGW(TAG,"Network fetch failed and no cache using fallback");
}
}
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d cache=%d fetch_ok=%d",G.season_cnt,G.ep_cnt,cache_loaded,fetch_ok);
vTaskDelay(pdMS_TO_TICKS(300));
// Resolve current episode index preserve user season selection if cache was already shown
if(cache_loaded){
// User may have changed season while network fetch was in progress keep their selection if it still exists
int cur_s = G.cur_season;
bool season_exists=false;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==cur_s) { season_exists=true; break; }
if(!season_exists){
// Fallback to last_slug or first season
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; season_exists=true; break; }
float vol=G.volume/100.0f; if(vol<0) vol=0; if(vol>1) vol=1;
for(int i=0;i<samples*2;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=samples*2*2; size_t off=0;
while(off<to_write && !G.need_stop){
if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; }
size_t w=0;
device_lock(G.i2s_dev);
esp_err_t e=i2s_controller_write(G.i2s_dev,(uint8_t*)pcm+off,to_write-off,&w,pdMS_TO_TICKS(500));
device_unlock(G.i2s_dev);
if(e==ESP_OK) off+=w; else vTaskDelay(pdMS_TO_TICKS(10));
}
if(info.hz>0) G.pos_sec += samples / info.hz;
}
if(season_exists){
// Keep cur_season, find best idx within it
int best=-1;
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==cur_s && strcmp(G.eps[i].slug,G.last_slug)==0){ best=i; break; }
}
if(best==-1){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==cur_s){ best=i; break; }
}
if(best!=-1) G.cur_ep_idx=best;
} else {
// Season no longer exists, fallback to first
if(G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
}
} else {
// First launch use last_slug to restore position
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
if(G.cur_ep_idx<0){
int s1=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; }
if(s1==-1 && G.ep_cnt>0){
s1=0;
G.cur_season=G.eps[0].season;
}
G.cur_ep_idx=s1;
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);
if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; }
G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL);
}
G.fetching=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.fetch_cancel_req){
tt_lvgl_unlock();
G.fetch_handle=NULL;
vTaskDelete(NULL);
return;
static void start_idx(int idx){
if(idx<0||idx>=G.ep_cnt) return;
if(G.cur_ep_idx!=idx) G.pos_sec=0;
EpInfo* ep=&G.eps[idx];
if(!has_slug(ep->slug)){
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading...");
if(!dl_ep(ep)){ if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL failed"); return; }
}
tt_lvgl_unlock();
ui_rebuild_episode_list();
if(G.ep_cnt>0 && G.cur_ep_idx>=0){
EpInfo* ep=&G.eps[G.cur_ep_idx];
int saved = get_saved_pos_for_slug(ep->slug);
if(saved==0) saved = G.pos_sec;
if(saved>0) G.pos_sec=saved;
}
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
if(G.ep_cnt>0){
if(fetch_ok) lv_label_set_text(G.lbl_status, "Select episode to play / download");
else if(cache_loaded) lv_label_set_text(G.lbl_status, "Offline, using cached data");
else lv_label_set_text(G.lbl_status,"No network limited data");
} else {
lv_label_set_text(G.lbl_status,"No data check network");
}
}
tt_lvgl_unlock();
G.fetch_handle=NULL;
stop_play();
G.cur_ep_idx=idx; G.cur_season=ep->season; strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
G.need_stop=false; G.is_paused=false; G.is_playing=true;
save_state();
vTaskDelete(NULL);
xTaskCreate(play_task,"dm_play",6144,NULL,5,&G.play_handle);
}
static void select_ep(int idx){
if(idx<0||idx>=G.ep_cnt) return;
G.cur_ep_idx=idx; EpInfo* ep=&G.eps[idx]; G.cur_season=ep->season;
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
if(G.lbl_meta){ char b[48]; 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) lv_label_set_text(G.lbl_status,ep->seen?"On SD":"Tap Play to DL");
if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
if(G.lbl_time) lv_label_set_text(G.lbl_time,"0:00 / --:--");
G.pos_sec=0; save_state();
}
static void go_next(){ if(G.ep_cnt==0) return; int n=G.cur_ep_idx+1; if(n>=G.ep_cnt) n=0; select_ep(n); start_idx(n); }
static void go_prev(){ if(G.ep_cnt==0) return; if(G.pos_sec>5){ G.pos_sec=0; return; } int p=G.cur_ep_idx-1; if(p<0) p=G.ep_cnt-1; select_ep(p); start_idx(p); }
static void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
static void ui_timer(lv_timer_t* t){
(void)t;
if(G.bar){
if(G.total_sec>0){ int pct=G.pos_sec*100/G.total_sec; if(pct>100) pct=100; lv_bar_set_value(G.bar,pct,LV_ANIM_OFF); }
else if(G.is_playing){ lv_bar_set_value(G.bar,(G.pos_sec%10)*10,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) lv_label_set_text(G.lbl_status,"Playing");
}
static void btn_play_cb(lv_event_t* e){
(void)e;
if(G.is_playing && !G.is_paused){
G.is_paused=true;
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);}
} else if(G.is_paused){
G.is_paused=false;
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);}
} else {
start_idx(G.cur_ep_idx);
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);}
}
}
static void btn_prev_cb(lv_event_t* e){ (void)e; go_prev(); }
static void btn_next_cb(lv_event_t* e){ (void)e; go_next(); }
static void close_overlay(){ if(G.overlay){ lv_obj_del(G.overlay); G.overlay=NULL; G.roller=NULL; } }
static void roller_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return;
int sel=lv_roller_get_selected(G.roller); 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); }
}
static void btn_seasons_cb(lv_event_t* e){
(void)e; if(G.overlay){ close_overlay(); return; }
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++){ if(i==0) snprintf(opts,sizeof(opts),"S%d",G.seasons[i].num); else { char c[16]; snprintf(c,sizeof(c),"\nS%d",G.seasons[i].num); strncat(opts,c,sizeof(opts)-strlen(opts)-1);} }
G.roller=lv_roller_create(G.overlay); lv_roller_set_options(G.roller,opts,LV_ROLLER_MODE_NORMAL);
lv_obj_set_size(G.roller,120,180); lv_obj_align(G.roller,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_roller_set_selected(G.roller,sel,LV_ANIM_OFF); lv_obj_add_event_cb(G.roller,roller_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);
}
static void build_ui(){
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);
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,&lv_font_montserrat_14,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,"S01 E01"); 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,22);
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,44); 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,60);
G.lbl_status=lv_label_create(root); lv_label_set_text(G.lbl_status,"Ready"); 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,76);
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,98); 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_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_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,500,NULL);
}
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
(void)app; (void)data; (void)parent;
memset(&G,0,sizeof(G));
G.pos_sec=0;
G.total_sec=0;
G.cur_season=1;
G.cur_ep_idx=-1;
G.pending_dl_idx=-1;
G.volume=80;
G.last_pct=-1;
G.dl_last_pct=-1;
G.dl_expected=-1;
G.fetch_cancel_req=false;
G.dl_cancel_req=false;
ensure_dir();
load_state();
build_ui(parent);
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
ESP_LOGI(TAG, "onShow heap before fetch task: internal=%d psram=%d", free_internal, free_psram);
BaseType_t res = xTaskCreate(fetch_task_fn,"dm_fetch",12288,NULL,5,&G.fetch_handle);
ESP_LOGI(TAG, "xTaskCreate fetch_task res=%d handle=%p", res, G.fetch_handle);
if (res != pdPASS) {
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Failed to create fetch task");
tt_lvgl_unlock();
}
}
static void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData){
(void)app; (void)data; (void)launchId;
ESP_LOGI(TAG, "onResult: result=%d pending_idx=%d cur_idx=%d", result, G.pending_dl_idx, G.cur_ep_idx);
// Check if result contains timestamp from Mp3Player
if (resultData) {
int32_t pos = 0;
bool has_pos = false;
if (tt_bundle_opt_int32(resultData, "pos_sec", &pos) ||
tt_bundle_opt_int32(resultData, "timestamp", &pos) ||
tt_bundle_opt_int32(resultData, "position", &pos)) {
has_pos = true;
}
char file_path[512]={0};
bool has_file = tt_bundle_opt_string(resultData, "file", file_path, sizeof(file_path)) ||
tt_bundle_opt_string(resultData, "path", file_path, sizeof(file_path));
if (has_pos) {
ESP_LOGI(TAG, "Mp3Player returned pos %d sec for file %s", pos, has_file?file_path:"unknown");
int target_idx = -1;
if (has_file && strlen(file_path)>0) {
for(int i=0;i<G.ep_cnt;i++){
char ep_path[300];
make_sd_path(ep_path, sizeof(ep_path), G.eps[i].slug);
if (strcmp(ep_path, file_path)==0) { target_idx = i; break; }
}
}
if (target_idx<0 && G.pending_dl_idx>=0 && G.pending_dl_idx < G.ep_cnt) target_idx = G.pending_dl_idx;
if (target_idx<0 && G.cur_ep_idx>=0) target_idx = G.cur_ep_idx;
if (target_idx>=0) {
bool finished = false;
tt_bundle_opt_bool(resultData, "finished", &finished);
int32_t total_sec = 0;
tt_bundle_opt_int32(resultData, "total_sec", &total_sec);
if (finished || (total_sec>0 && pos >= total_sec-10)) {
set_saved_pos_for_slug(G.eps[target_idx].slug, 0);
G.pos_sec = 0;
ESP_LOGI(TAG, "Episode %s finished, clearing pos", G.eps[target_idx].slug);
} else {
set_saved_pos_for_slug(G.eps[target_idx].slug, pos);
G.pos_sec = pos;
ESP_LOGI(TAG, "Saved pos %d for %s", pos, G.eps[target_idx].slug);
}
save_state();
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
if (pos>5) lv_label_set_text(G.lbl_status,"Progress saved tap episode to resume");
else lv_label_set_text(G.lbl_status,"Select episode to play / download");
}
tt_lvgl_unlock();
}
G.pending_dl_idx = -1;
return;
}
}
// SdDownloader batch result handling
if (G.pending_dl_idx <0 || G.pending_dl_idx >= G.ep_cnt) {
if (resultData) {
int32_t succ=0, fail=0, total=0;
if (tt_bundle_opt_int32(resultData, "success_count", &succ) ||
tt_bundle_opt_int32(resultData, "total_count", &total)) {
tt_bundle_opt_int32(resultData, "fail_count", &fail);
ESP_LOGI(TAG, "Batch DL result: success %ld fail %ld total %ld", (long)succ, (long)fail, (long)total);
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
save_state();
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
char msg[64];
snprintf(msg,sizeof(msg),"DL: %ld ok, %ld fail", (long)succ, (long)fail);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
G.pending_dl_idx = -1;
return;
}
}
G.pending_dl_idx = -1;
// Still refresh list in case files appeared
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
ui_rebuild_episode_list();
return;
}
// Single download result
bool success = false;
if (resultData) {
tt_bundle_opt_bool(resultData, "success", &success);
}
if (result == APP_RESULT_OK) {
if (resultData) {
bool b=false;
if (tt_bundle_opt_bool(resultData, "success", &b)) success = b;
else success = true;
} else {
success = true;
}
}
int idx = G.pending_dl_idx;
G.pending_dl_idx = -1;
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
save_state();
ui_rebuild_episode_list();
// Handle sequential full-season download queue (safe single-file mode)
if(G.dl_missing_active){
if(success) G.dl_missing_done++;
else G.dl_missing_failed++;
int next = find_next_missing_in_season(G.dl_missing_season);
if(next!=-1){
ESP_LOGI(TAG, "Batch queue continuing: %d/%d done, next idx %d", G.dl_missing_done, G.dl_missing_total, next);
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL %d/%d S%02d...", G.dl_missing_done+1, G.dl_missing_total, G.dl_missing_season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
// Small delay to let UI update before launching next downloader
vTaskDelay(pdMS_TO_TICKS(300));
if(download_episode(next)){
// started next
return;
} else {
// failed to start next, abort queue
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL stopped %d ok %d fail", G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return;
}
} else {
// No more missing queue finished
ESP_LOGI(TAG, "Batch queue finished: %d ok %d fail", G.dl_missing_done, G.dl_missing_failed);
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL S%02d done %d ok %d fail", G.dl_missing_season, G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
if(success && idx>=0) ESP_LOGI(TAG, "Single DL success idx %d", idx);
else ESP_LOGW(TAG, "External DL failed for idx %d", idx);
return;
}
}
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
if (success) lv_label_set_text(G.lbl_status, "DL done tap to play");
else lv_label_set_text(G.lbl_status, "DL failed tap to retry");
}
tt_lvgl_unlock();
if(success && idx>=0){
ESP_LOGI(TAG, "Single DL success idx %d", idx);
} else {
ESP_LOGW(TAG, "External DL failed for idx %d", idx);
}
}
static void onHide(AppHandle app, void* data){
(void)app; (void)data;
save_current_ep_pos();
G.fetch_cancel_req=true;
G.dl_cancel_req=true;
G.dl_missing_active=false;
if(G.fetch_handle){
ESP_LOGI(TAG,"onHide: waiting fetch to exit");
tt_lvgl_lock(portMAX_DELAY);
for(int i=0;i<20 && G.fetch_handle!=NULL; i++){
tt_lvgl_unlock();
vTaskDelay(pdMS_TO_TICKS(100));
tt_lvgl_lock(portMAX_DELAY);
}
if(G.fetch_handle){
ESP_LOGW(TAG,"onHide: force deleting fetch task (leak risk)");
vTaskDelete(G.fetch_handle);
G.fetch_handle=NULL;
}
G.fetching=false;
tt_lvgl_unlock();
}
if(G.dl_handle){
ESP_LOGI(TAG,"onHide: waiting dl to exit");
tt_lvgl_lock(portMAX_DELAY);
for(int i=0;i<30 && G.dl_handle!=NULL; i++){
tt_lvgl_unlock();
vTaskDelay(pdMS_TO_TICKS(100));
tt_lvgl_lock(portMAX_DELAY);
}
if(G.dl_handle){
ESP_LOGW(TAG,"onHide: force deleting dl task after timeout");
vTaskDelete(G.dl_handle);
G.dl_handle=NULL;
G.downloading=false;
if(G.dl_overlay) hide_dl_overlay();
}
tt_lvgl_unlock();
}
tt_lvgl_lock(portMAX_DELAY);
wait_play_exit();
ui_deinit();
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
if(G.root){
lv_obj_delete(G.root);
G.root = NULL;
G.lbl_title = NULL;
G.lbl_meta = NULL;
G.lbl_season = NULL;
G.ep_list_cont = NULL;
G.lbl_time = NULL;
G.lbl_status = NULL;
G.btn_play = NULL;
G.btn_seasons = NULL;
G.btn_dl_missing = NULL;
G.season_list_cont = NULL;
G.bar = NULL;
}
tt_lvgl_unlock();
save_state();
memset(&G,0,0); G.pos_sec=0; G.total_sec=0; G.cur_season=1; G.cur_ep_idx=-1; G.volume=80; ensure_dir();
load_state(); fetch_data(); build_ui();
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; }
if(G.ep_cnt>0 && G.cur_ep_idx>=0) select_ep(G.cur_ep_idx);
}
static void onHide(AppHandle app, void* data){ (void)app; (void)data; stop_play(); save_state(); }
int main(int argc, char* argv[]){
(void)argc; (void)argv;
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide, .onResult=onResult });
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide });
return 0;
}
@@ -1,330 +0,0 @@
#include "network.h"
#include "storage.h"
#include "cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include <unistd.h>
uint16_t my_htons(uint16_t v){
// Proper byte swap with masking to avoid overflow
return (uint16_t)(((v & 0x00FF) << 8) | ((v & 0xFF00) >> 8));
}
bool resolve_host(const char* host, uint32_t* out_ip){
if(!host || !out_ip) return false;
uint32_t ip = ipaddr_addr(host);
if(ip!=0 && ip!=0xFFFFFFFF){
*out_ip=ip;
return true;
}
ESP_LOGE(TAG,"resolve fail (DNS not supported) %s",host);
return false;
}
void normalize_audio_url(const char* in, char* out, size_t out_len){
if(!in || !out) return;
if(strncmp(in,"http://",7)==0){
strncpy(out,in,out_len-1);
out[out_len-1]=0;
return;
}
if(in[0]=='/'){
snprintf(out,out_len,"%s%s",PB,in);
return;
}
if(strstr(in,"://")==NULL){
if(in[0]=='/') snprintf(out,out_len,"%s%s",PB,in);
else snprintf(out,out_len,"%s/%s",PB,in);
return;
}
strncpy(out,in,out_len-1);
out[out_len-1]=0;
}
int http_get_raw(const char* url, char** out_body){
if(!url || strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"http_get url not http %s",url); return -1; }
const char* p = url+7;
const char* slash = strchr(p,'/');
if(!slash){ ESP_LOGE(TAG,"no slash %s",url); return -1; }
char hostport[128]={0};
size_t hlen=slash-p;
if(hlen>=sizeof(hostport)) return -1;
memcpy(hostport,p,hlen);
hostport[hlen]=0;
const char* path=slash;
char host[96]={0};
int port=80;
char* colon=strchr(hostport,':');
if(colon){ *colon=0; strncpy(host,hostport,sizeof(host)-1); port=atoi(colon+1); if(port<=0) port=80; } else { strncpy(host,hostport,sizeof(host)-1); }
ESP_LOGI(TAG,"GET %s host=%s port=%d",url,host,port);
uint32_t ip=0;
ESP_LOGI(TAG,"Resolving host %s",host);
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; }
ESP_LOGI(TAG,"Resolved %s -> ip 0x%08x", host, ip);
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
ESP_LOGI(TAG,"Socket fd=%d", fd);
if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; }
struct sockaddr_in sa;
ESP_LOGI(TAG,"Memset sa");
memset(&sa,0,sizeof(sa));
ESP_LOGI(TAG,"Set sa len/family/port/addr");
sa.sin_len = sizeof(sa);
sa.sin_family=AF_INET;
sa.sin_port=my_htons(port);
sa.sin_addr.s_addr=ip;
ESP_LOGI(TAG,"SetSockOpt RCV");
struct timeval tv={5,0};
int opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"SetSockOpt RCV res=%d", opt_res);
ESP_LOGI(TAG,"SetSockOpt SND");
opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"SetSockOpt SND res=%d", opt_res);
ESP_LOGI(TAG,"Connecting to %s:%d",host,port);
int conn_res = lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa));
ESP_LOGI(TAG,"Connect res=%d", conn_res);
if(conn_res<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
ESP_LOGI(TAG,"Connected, sending request");
char req[512];
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
if(lwip_send(fd,req,reqlen,0)<0){ ESP_LOGE(TAG,"send fail"); close(fd); return -1; }
ESP_LOGI(TAG,"Request sent, waiting for response");
int capacity=8192; // further reduced to 8K internal to avoid PSRAM StoreProhibited crash (seen 0x8208775e)
char* buf=malloc(capacity);
if(!buf){
buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!buf){ ESP_LOGE(TAG,"buf 8k fail"); close(fd); return -1; }
}
int total=0;
char* header_end=NULL;
int body_start=0;
int content_len=-1;
bool is_chunked=false;
while(1){
// Graceful cancel support to avoid force vTaskDelete leaking PSRAM/SD resources
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"http_get_raw canceled by user");
heap_caps_free(buf);
close(fd);
return -1;
}
if(total>=capacity-1){
int newcap=capacity*2;
if(newcap>600000){ ESP_LOGE(TAG,"too large %d",newcap); heap_caps_free(buf); close(fd); return -1; }
char* nb=heap_caps_malloc(newcap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!nb){ heap_caps_free(buf); close(fd); return -1; }
memcpy(nb,buf,total);
heap_caps_free(buf);
buf=nb;
capacity=newcap;
}
int r=lwip_recv(fd,buf+total,capacity-total-1,0);
if(r<=0){
if(r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT)){
ESP_LOGW(TAG,"http_get_raw timeout total=%d",total);
// On timeout, if fetching canceled, abort
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"http_get_raw timeout + canceled");
heap_caps_free(buf);
close(fd);
return -1;
}
// Continue loop LWIP will retry until EOF
// But if we already have header and content_len satisfied, break
if(header_end && content_len>=0 && !is_chunked && total-body_start >= content_len) break;
// If no header yet and timeout, treat as failure to avoid 15s hang
if(!header_end && total==0){
ESP_LOGE(TAG,"http_get_raw timeout before header");
heap_caps_free(buf);
close(fd);
return -1;
}
// Otherwise continue to try again
continue;
}
break;
}
total+=r;
buf[total]=0;
if(!header_end){
header_end=strstr(buf,"\r\n\r\n");
if(header_end){
body_start=(header_end-buf)+4;
if(strstr(buf,"chunked")) is_chunked=true;
char* cl=strstr(buf,"Content-Length:");
if(!cl) cl=strstr(buf,"content-length:");
if(cl){ cl=strchr(cl,':'); if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); } }
ESP_LOGI(TAG,"header_end chunked=%d cl=%d total=%d",is_chunked,content_len,total);
if(content_len>=0 && !is_chunked){
if(total-body_start >= content_len) break;
}
}
} else {
if(content_len>=0 && !is_chunked){
if(total-body_start >= content_len) break;
}
}
}
close(fd);
if(!header_end){ ESP_LOGE(TAG,"no header end total=%d",total); heap_caps_free(buf); return -1; }
if(strncmp(buf,"HTTP/1.1 200",12)!=0 && strncmp(buf,"HTTP/1.0 200",12)!=0){ ESP_LOGE(TAG,"not 200: %.60s",buf); heap_caps_free(buf); return -1; }
int raw_len=total-body_start;
char* raw=buf+body_start;
char* final_body=NULL;
int final_len=0;
if(is_chunked){
final_body=heap_caps_malloc(200000, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!final_body){ heap_caps_free(buf); return -1; }
int pos=0, out=0;
while(pos<raw_len){
char* crlf=strstr(raw+pos,"\r\n");
if(!crlf) break;
int off=crlf-(raw+pos);
if(off<=0 || off>16){ pos+=off+2; continue; }
char hex[16]={0};
memcpy(hex,raw+pos,off);
char* semi=strchr(hex,';');
if(semi) *semi=0;
long sz=strtol(hex,NULL,16);
if(sz==0) break;
pos+=off+2;
if(pos+sz>raw_len) sz=raw_len-pos;
if(out+sz>=200000) break;
memcpy(final_body+out,raw+pos,sz);
out+=sz;
pos+=sz;
if(pos+1<raw_len && raw[pos]=='\r' && raw[pos+1]=='\n') pos+=2;
}
final_body[out]=0;
final_len=out;
} else {
int bl=raw_len;
if(content_len>=0 && bl>content_len) bl=content_len;
final_body=heap_caps_malloc(bl+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!final_body){ heap_caps_free(buf); return -1; }
memcpy(final_body,raw,bl);
final_body[bl]=0;
final_len=bl;
}
heap_caps_free(buf);
*out_body=final_body;
ESP_LOGI(TAG,"GET ok %d",final_len);
return final_len;
}
bool fetch_data(){
char* js=NULL;
char url[320];
bool seasons_ok=false;
bool episodes_ok=false;
ESP_LOGI(TAG,"fetch seasons");
// Reduce payload via fields filter drastically lowers cJSON malloc pressure (internal heap low warning)
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num&fields=season_num",PB);
int len=http_get_raw(url,&js);
if(len>0 && js){
cJSON* root=cJSON_Parse(js);
free(js); js=NULL;
if(root){
cJSON* items=cJSON_GetObjectItem(root,"items");
if(cJSON_IsArray(items)){
G.season_cnt=0;
int n=cJSON_GetArraySize(items);
for(int i=0;i<n&&G.season_cnt<MAX_SEASONS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
if(sn&&cJSON_IsNumber(sn)){ G.seasons[G.season_cnt].num=sn->valueint; G.season_cnt++; }
}
sort_seasons();
if(G.season_cnt>0) seasons_ok=true;
}
cJSON_Delete(root);
}
} else {
ESP_LOGW(TAG,"seasons fetch failed");
}
if(G.season_cnt==0){
ESP_LOGW(TAG,"seasons fallback");
for(int i=1;i<=37;i++) G.seasons[i-1].num=i;
G.season_cnt=37;
}
ESP_LOGI(TAG,"fetch episodes paginated to reduce internal heap (prev low 7KB)");
G.ep_cnt=0;
for(int page=1; page<=10; page++){
if(G.fetch_cancel_req) break;
if(G.ep_cnt>=MAX_EPS) break;
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=25&page=%d&sort=season_num,episode_num&fields=title,slug,season_num,episode_num,audio_url",PB,page);
len=http_get_raw(url,&js);
if(len<=0 || !js){
ESP_LOGW(TAG,"episodes page %d fetch failed",page);
if(js){ free(js); js=NULL; }
break;
}
cJSON* root=cJSON_Parse(js);
free(js); js=NULL;
if(!root){
ESP_LOGW(TAG,"episodes page %d JSON parse fail",page);
break;
}
cJSON* items=cJSON_GetObjectItem(root,"items");
if(!cJSON_IsArray(items)){
cJSON_Delete(root);
break;
}
int n=cJSON_GetArraySize(items);
if(n==0){
cJSON_Delete(root);
break;
}
ESP_LOGI(TAG,"episodes page %d got %d items (total so far %d)",page,n,G.ep_cnt);
for(int i=0;i<n&&G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* t=cJSON_GetObjectItem(it,"title");
cJSON* slug=cJSON_GetObjectItem(it,"slug");
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
if(!sn||!en) continue;
EpInfo* e=&G.eps[G.ep_cnt];
memset(e,0,sizeof(*e));
if(t&&cJSON_IsString(t)) strncpy(e->title,t->valuestring,sizeof(e->title)-1);
if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1);
else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1);
e->season=sn->valueint;
e->ep_num=en->valueint;
if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1);
e->seen=has_slug(e->slug);
G.ep_cnt++;
}
episodes_ok=true;
cJSON_Delete(root);
// Yield between pages to let MemoryChecker recover
vTaskDelay(pdMS_TO_TICKS(400));
if(n<25) break; // last page
}
// Final sort after all pages
if(G.ep_cnt>0) sort_eps();
if(G.ep_cnt==0){
ESP_LOGW(TAG,"episodes fallback");
for(int i=0;i<6;i++){
EpInfo* e=&G.eps[i];
snprintf(e->slug,sizeof(e->slug),"S01E%02d",i+1);
snprintf(e->title,sizeof(e->title),"Episode %d",i+1);
e->season=1; e->ep_num=i+1;
}
G.ep_cnt=6;
}
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
return (seasons_ok && episodes_ok);
}
@@ -1,23 +0,0 @@
#pragma once
#include "app_context.h"
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
uint16_t my_htons(uint16_t v);
bool resolve_host(const char* host, uint32_t* out_ip);
void normalize_audio_url(const char* in, char* out, size_t out_len);
// Robust HTTP GET with PSRAM buffer, returns length and allocates *out_body (must be free'd via free/heap_caps_free)
int http_get_raw(const char* url, char** out_body);
// Fetch seasons and episodes from PB returns true if network fetch succeeded with real data
bool fetch_data(void);
#ifdef __cplusplus
}
#endif
-292
View File
@@ -1,292 +0,0 @@
#include "player.h"
#include "storage.h"
#include "ui.h"
#include "download.h"
#include <tt_lvgl.h>
#include <tt_app.h>
#include <tt_bundle.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
void wait_play_exit(void) {
// No internal playback anymore, nothing to wait
}
void stop_playback_sync(void) {
// No internal playback, nothing to stop
G.is_playing = false;
G.is_paused = false;
}
static bool try_external_downloader(int idx) {
if(idx<0||idx>=G.ep_cnt) return false;
EpInfo* ep=&G.eps[idx];
if (strlen(ep->audio_url) < 8) {
ESP_LOGW(TAG, "Skipping DL for %s: empty URL", ep->slug);
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "No URL fetch failed?");
tt_lvgl_unlock();
return false;
}
char fpath[300];
make_sd_path(fpath, sizeof(fpath), ep->slug);
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
tt_lvgl_unlock();
BundleHandle b = tt_bundle_alloc();
if (!b) {
ESP_LOGW(TAG, "Failed to alloc bundle for external DL");
return false;
}
tt_bundle_put_string(b, "url", ep->audio_url);
tt_bundle_put_string(b, "path", fpath);
tt_bundle_put_bool(b, "override", false);
ESP_LOGI(TAG, "Starting external SdDownloader for %s -> %s", ep->slug, fpath);
G.pending_dl_idx = idx;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Starting downloader...");
tt_lvgl_unlock();
tt_app_start_with_bundle("one.tactility.sddownloader", b);
return true;
}
int count_missing_in_season(int season){
int cnt=0;
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season==season && !G.eps[i].seen && strlen(G.eps[i].audio_url)>=8) cnt++;
}
return cnt;
}
int find_next_missing_in_season(int season){
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season==season && !G.eps[i].seen && strlen(G.eps[i].audio_url)>=8) return i;
}
return -1;
}
bool download_episode(int idx){
return try_external_downloader(idx);
}
bool download_missing_next(void){
if(!G.dl_missing_active) return false;
int next = find_next_missing_in_season(G.dl_missing_season);
if(next==-1){
// No more missing done
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL S%02d done %d ok %d fail", G.dl_missing_season, G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return false;
}
// Update UI for next file
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL %d/%d S%02d...", G.dl_missing_done+1, G.dl_missing_total, G.dl_missing_season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return try_external_downloader(next);
}
bool download_missing_season_start(int season){
int missing = count_missing_in_season(season);
if(missing==0){
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Season already cached");
tt_lvgl_unlock();
G.dl_missing_active=false;
return true;
}
ESP_LOGI(TAG, "Starting sequential DL for S%02d, %d missing", season, missing);
G.dl_missing_active=true;
G.dl_missing_season=season;
G.dl_missing_total=missing;
G.dl_missing_done=0;
G.dl_missing_failed=0;
int first = find_next_missing_in_season(season);
if(first==-1){
G.dl_missing_active=false;
return true;
}
// Update status
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL 1/%d S%02d...", missing, season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return try_external_downloader(first);
}
static bool try_external_downloader_batch(int season) {
// Sequential queue safe single-file mode
return download_missing_season_start(season);
}
void start_idx_internal(int idx){
if(idx<0||idx>=G.ep_cnt) return;
EpInfo* ep=&G.eps[idx];
char fpath[300];
make_sd_path(fpath, sizeof(fpath), ep->slug);
if(!has_slug(ep->slug)){
ESP_LOGW(TAG, "start_idx_internal called but file missing %s", ep->slug);
start_idx(idx);
return;
}
int saved = 0;
if(G.cur_ep_idx!=idx){
saved = get_saved_pos_for_slug(ep->slug);
if(saved>5){
ESP_LOGI(TAG,"Restoring saved pos %d for %s", saved, ep->slug);
}
} else {
saved = G.pos_sec;
}
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
tt_lvgl_unlock();
BundleHandle b = tt_bundle_alloc();
if(b){
tt_bundle_put_string(b, "file", fpath);
tt_bundle_put_string(b, "path", fpath);
tt_bundle_put_int32(b, "pos_sec", saved);
tt_bundle_put_int32(b, "position", saved);
tt_bundle_put_int32(b, "volume", G.volume);
ESP_LOGI(TAG, "Launching Mp3Player for %s pos %d", fpath, saved);
G.pending_dl_idx = idx;
G.cur_ep_idx=idx; G.cur_season=ep->season;
strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
strncpy(G.last_slug,ep->slug,sizeof(G.last_slug)-1);
save_state();
tt_app_start_with_bundle("one.tactility.mp3player", b);
return;
}
ESP_LOGW(TAG, "Failed to alloc bundle for Mp3Player");
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Failed to launch player");
tt_lvgl_unlock();
}
void start_idx(int idx){
if(idx<0||idx>=G.ep_cnt) return;
EpInfo* ep=&G.eps[idx];
if(!has_slug(ep->slug)){
// File not on SD, download via external downloader
if (try_external_downloader(idx)) {
return;
}
// Fallback: show message, cannot download internally anymore
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Install SdDownloader");
tt_lvgl_unlock();
return;
}
start_idx_internal(idx);
}
void select_ep(int idx){
if(idx<0||idx>=G.ep_cnt) return;
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt && G.cur_ep_idx!=idx){
save_current_ep_pos();
}
G.cur_ep_idx=idx;
EpInfo* ep=&G.eps[idx];
G.cur_season=ep->season;
strncpy(G.last_slug, ep->slug, sizeof(G.last_slug)-1);
int saved = get_saved_pos_for_slug(ep->slug);
G.pos_sec=saved;
G.last_pct=-1;
G.total_sec=0;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
if(G.lbl_meta){
char b[64];
if(saved>5){
char tbuf[16];
fmt_time(tbuf,saved);
snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf);
} else {
snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":"");
}
lv_label_set_text(G.lbl_meta,b);
}
if(G.lbl_status){
if(saved>5) lv_label_set_text(G.lbl_status,"Resume tap Play");
else lv_label_set_text(G.lbl_status,ep->seen?"On SD tap Play":"Tap Play to download");
}
if(G.bar){
lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
}
if(G.lbl_time){
if(saved>5){
char a[16];
fmt_time(a,saved);
char buf[40];
snprintf(buf,sizeof(buf),"%s / --:--",a);
lv_label_set_text(G.lbl_time,buf);
} else {
lv_label_set_text(G.lbl_time,"0:00 / --:--");
}
}
tt_lvgl_unlock();
save_state();
}
void go_next(){
if(G.ep_cnt==0) return;
save_current_ep_pos();
save_state();
int n=G.cur_ep_idx+1;
if(n>=G.ep_cnt) n=0;
select_ep(n);
// Do not auto-play next, per user request: no autoplay
}
void go_prev(){
if(G.ep_cnt==0) return;
if(G.pos_sec>5){
if(G.cur_ep_idx>=0){
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, 0);
save_state();
}
G.pos_sec=0;
int cur=G.cur_ep_idx;
if(cur<0) cur=0;
select_ep(cur);
return;
}
save_current_ep_pos();
save_state();
int p=G.cur_ep_idx-1;
if(p<0) p=G.ep_cnt-1;
select_ep(p);
}
// Called from UI for season batch download
bool download_season(int season) {
return try_external_downloader_batch(season);
}
@@ -1,30 +0,0 @@
#pragma once
#include "app_context.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Simplified: no internal audio, only external apps
void wait_play_exit(void);
void stop_playback_sync(void);
void start_idx_internal(int idx);
void start_idx(int idx);
void select_ep(int idx);
void go_next(void);
void go_prev(void);
bool download_season(int season);
// New single-file download API
bool download_episode(int idx);
bool download_missing_season_start(int season);
bool download_missing_next(void); // continue queue, returns false if done
int count_missing_in_season(int season);
int find_next_missing_in_season(int season);
#ifdef __cplusplus
}
#endif
@@ -1,321 +0,0 @@
#include "storage.h"
#include "cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include "esp_heap_caps.h"
void make_sd_path(char* out, size_t out_len, const char* slug){
if(!out || !slug) return;
snprintf(out, out_len, "%s/%s.mp3", DM_DIR, slug);
}
void ensure_dir(){
mkdir("/sdcard/apps",0777);
mkdir("/sdcard/apps/one.tactility.discoverymountain",0777);
mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777);
mkdir(DM_DIR,0777);
}
bool has_slug(const char* slug){
char p[300];
make_sd_path(p, sizeof(p), slug);
struct stat st;
return stat(p,&st)==0;
}
void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
void sort_seasons(){
for(int i=0;i<G.season_cnt;i++){
for(int j=i+1;j<G.season_cnt;j++){
if(G.seasons[j].num < G.seasons[i].num){
SeasonInfo t=G.seasons[i]; G.seasons[i]=G.seasons[j]; G.seasons[j]=t;
}
}
}
}
void sort_eps(){
// Use PSRAM for temp to avoid stack overflow (EpInfo ~300 bytes, was on stack)
EpInfo *t = heap_caps_malloc(sizeof(EpInfo), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!t) return;
for(int i=0;i<G.ep_cnt;i++){
for(int j=i+1;j<G.ep_cnt;j++){
EpInfo *a=&G.eps[i], *b=&G.eps[j];
bool should_swap=false;
if(a->season != b->season) should_swap = (b->season < a->season);
else should_swap = (b->ep_num < a->ep_num);
if(should_swap){
*t=*a; *a=*b; *b=*t;
}
}
}
heap_caps_free(t);
}
// ── Per-episode position helpers ──
int find_saved_pos_idx(const char* slug){
if(!slug) return -1;
for(int i=0;i<G.saved_pos_cnt;i++){
if(strcmp(G.saved_pos[i].slug, slug)==0) return i;
}
return -1;
}
int get_saved_pos_for_slug(const char* slug){
int idx=find_saved_pos_idx(slug);
if(idx>=0) return G.saved_pos[idx].pos;
return 0;
}
void set_saved_pos_for_slug(const char* slug, int pos){
if(!slug || strlen(slug)==0) return;
if(pos<0) pos=0;
if(G.total_sec>0 && pos>0 && pos >= G.total_sec-10){
pos=0;
}
int idx=find_saved_pos_idx(slug);
if(idx>=0){
G.saved_pos[idx].pos=pos;
return;
}
if(G.saved_pos_cnt < MAX_EPS){
strncpy(G.saved_pos[G.saved_pos_cnt].slug, slug, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
G.saved_pos[G.saved_pos_cnt].slug[sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1]=0;
G.saved_pos[G.saved_pos_cnt].pos=pos;
G.saved_pos_cnt++;
}
}
void save_current_ep_pos(){
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt){
const char* slug=G.eps[G.cur_ep_idx].slug;
set_saved_pos_for_slug(slug, G.pos_sec);
} else if(strlen(G.last_slug)>0){
set_saved_pos_for_slug(G.last_slug, G.pos_sec);
}
}
void save_state(){
if(G.cur_ep_idx>=0 && G.ep_cnt>0){
if(G.pos_sec>=0){
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, G.pos_sec);
}
}
cJSON* r=cJSON_CreateObject();
if(G.ep_cnt>0 && G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
else if(strlen(G.last_slug)>0) cJSON_AddStringToObject(r,"last_slug",G.last_slug);
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
if(G.saved_pos_cnt>0){
cJSON* pos_obj=cJSON_CreateObject();
for(int i=0;i<G.saved_pos_cnt;i++){
if(G.saved_pos[i].slug[0]==0) continue;
if(G.saved_pos[i].pos>0){
cJSON_AddNumberToObject(pos_obj, G.saved_pos[i].slug, G.saved_pos[i].pos);
}
}
cJSON_AddItemToObject(r,"positions",pos_obj);
}
char* s=cJSON_PrintUnformatted(r);
if(s){
FILE* f=fopen(STATE_PATH,"w");
if(f){ fwrite(s,1,strlen(s),f); fclose(f);}
free(s);
}
cJSON_Delete(r);
}
void load_state(){
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
G.saved_pos_cnt=0;
G.last_save_tick=0;
memset(G.last_slug,0,sizeof(G.last_slug));
memset(G.saved_pos,0,sizeof(G.saved_pos));
FILE* f=fopen(STATE_PATH,"r");
if(!f) return;
fseek(f,0,SEEK_END);
long sz=ftell(f);
fseek(f,0,SEEK_SET);
if(sz<=0||sz>16384){ fclose(f); return;}
char* buf=malloc(sz+1);
if(!buf){ fclose(f); return; }
size_t got=fread(buf,1,sz,f);
buf[got]=0;
fclose(f);
cJSON* r=cJSON_Parse(buf);
free(buf);
if(!r) return;
cJSON* ls=cJSON_GetObjectItem(r,"last_slug");
cJSON* lsn=cJSON_GetObjectItem(r,"last_season");
cJSON* ps=cJSON_GetObjectItem(r,"pos_sec");
if(ps&&cJSON_IsNumber(ps)) G.pos_sec=ps->valueint;
if(lsn&&cJSON_IsNumber(lsn)) G.cur_season=lsn->valueint;
if(ls&&cJSON_IsString(ls)){
strncpy(G.last_slug, ls->valuestring, sizeof(G.last_slug)-1);
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,ls->valuestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
cJSON* poss=cJSON_GetObjectItem(r,"positions");
if(poss && cJSON_IsObject(poss)){
cJSON* child=NULL;
cJSON_ArrayForEach(child, poss){
if(cJSON_IsNumber(child) && child->string && G.saved_pos_cnt < MAX_EPS){
strncpy(G.saved_pos[G.saved_pos_cnt].slug, child->string, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
G.saved_pos[G.saved_pos_cnt].pos=child->valueint;
G.saved_pos_cnt++;
}
}
if(strlen(G.last_slug)>0){
int sp=get_saved_pos_for_slug(G.last_slug);
if(sp>0) G.pos_sec=sp;
}
}
cJSON_Delete(r);
}
bool save_cache(void){
if(G.ep_cnt==0 || G.season_cnt==0) return false;
cJSON* root=cJSON_CreateObject();
if(!root) return false;
cJSON* seasons_arr=cJSON_CreateArray();
if(!seasons_arr){ cJSON_Delete(root); return false; }
for(int i=0;i<G.season_cnt && i<MAX_SEASONS;i++){
cJSON_AddItemToArray(seasons_arr, cJSON_CreateNumber(G.seasons[i].num));
}
cJSON_AddItemToObject(root,"seasons",seasons_arr);
cJSON* eps_arr=cJSON_CreateArray();
if(!eps_arr){ cJSON_Delete(root); return false; }
for(int i=0;i<G.ep_cnt && i<MAX_EPS;i++){
EpInfo* e=&G.eps[i];
if(e->slug[0]==0) continue;
cJSON* obj=cJSON_CreateObject();
if(!obj) continue;
cJSON_AddStringToObject(obj,"title", e->title);
cJSON_AddStringToObject(obj,"slug", e->slug);
cJSON_AddNumberToObject(obj,"season_num", e->season);
cJSON_AddNumberToObject(obj,"episode_num", e->ep_num);
cJSON_AddStringToObject(obj,"audio_url", e->audio_url);
cJSON_AddItemToArray(eps_arr, obj);
}
cJSON_AddItemToObject(root,"episodes",eps_arr);
char* s=cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if(!s) return false;
FILE* f=fopen(CACHE_PATH,"w");
bool ok=false;
if(f){
size_t len=strlen(s);
size_t wrote=fwrite(s,1,len,f);
fclose(f);
ok = (wrote==len);
}
free(s);
return ok;
}
bool load_cache(void){
FILE* f=fopen(CACHE_PATH,"r");
if(!f) return false;
fseek(f,0,SEEK_END);
long sz=ftell(f);
fseek(f,0,SEEK_SET);
if(sz<=0 || sz>200000){ fclose(f); return false; }
// Use PSRAM for large json to avoid internal heap pressure
char* buf=heap_caps_malloc(sz+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!buf){
buf=malloc(sz+1);
if(!buf){ fclose(f); return false; }
}
size_t got=fread(buf,1,sz,f);
buf[got]=0;
fclose(f);
cJSON* root=cJSON_Parse(buf);
heap_caps_free(buf);
if(!root) return false;
cJSON* seasons_arr=cJSON_GetObjectItem(root,"seasons");
cJSON* eps_arr=cJSON_GetObjectItem(root,"episodes");
if(!cJSON_IsArray(seasons_arr) || !cJSON_IsArray(eps_arr)){
cJSON_Delete(root);
return false;
}
int scnt=cJSON_GetArraySize(seasons_arr);
G.season_cnt=0;
for(int i=0;i<scnt && G.season_cnt<MAX_SEASONS;i++){
cJSON* it=cJSON_GetArrayItem(seasons_arr,i);
if(cJSON_IsNumber(it)){
G.seasons[G.season_cnt].num=it->valueint;
G.season_cnt++;
}
}
int ecnt=cJSON_GetArraySize(eps_arr);
G.ep_cnt=0;
for(int i=0;i<ecnt && G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(eps_arr,i);
if(!cJSON_IsObject(it)) continue;
cJSON* t=cJSON_GetObjectItem(it,"title");
cJSON* slug=cJSON_GetObjectItem(it,"slug");
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
if(!sn || !en) continue;
EpInfo* e=&G.eps[G.ep_cnt];
memset(e,0,sizeof(*e));
if(t && cJSON_IsString(t)) strncpy(e->title, t->valuestring, sizeof(e->title)-1);
if(slug && cJSON_IsString(slug)) strncpy(e->slug, slug->valuestring, sizeof(e->slug)-1);
else if(t && cJSON_IsString(t)) strncpy(e->slug, t->valuestring, sizeof(e->slug)-1);
e->season=sn->valueint;
e->ep_num=en->valueint;
if(au && cJSON_IsString(au)) strncpy(e->audio_url, au->valuestring, sizeof(e->audio_url)-1);
e->seen=has_slug(e->slug);
G.ep_cnt++;
}
cJSON_Delete(root);
if(G.season_cnt>0) sort_seasons();
if(G.ep_cnt>0) sort_eps();
// Refresh seen in case SD changed
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
return (G.season_cnt>0 && G.ep_cnt>0);
}
void lru_check(){
typedef struct{ char path[300]; time_t mt; } FE;
// Allocate from PSRAM to avoid stack overflow (200*~304=60KB on stack would overflow 12KB task)
FE *files = heap_caps_malloc(200 * sizeof(FE), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!files) return;
int fc=0;
DIR* d=opendir(DM_DIR);
if(!d){ heap_caps_free(files); return; }
struct dirent* de;
char cur_path[300]={0};
if(G.cur_ep_idx>=0 && G.cur_ep_idx<G.ep_cnt){
make_sd_path(cur_path, sizeof(cur_path), G.eps[G.cur_ep_idx].slug);
}
while((de=readdir(d))!=NULL && fc<200){
if(de->d_name[0]=='.') continue;
size_t l=strlen(de->d_name);
if(l<5) continue;
if(strcmp(de->d_name+l-4,".mp3")!=0) continue;
char full[300];
snprintf(full,sizeof(full),"%s/%.200s",DM_DIR,de->d_name);
struct stat st;
if(stat(full,&st)!=0) continue;
if(cur_path[0] && strcmp(full,cur_path)==0) continue;
strncpy(files[fc].path,full,sizeof(files[fc].path)-1);
files[fc].mt=st.st_mtime;
fc++;
}
closedir(d);
if(fc<12){ heap_caps_free(files); return; }
for(int i=0;i<fc;i++){
for(int j=i+1;j<fc;j++){
if(files[j].mt<files[i].mt){ FE t=files[i]; files[i]=files[j]; files[j]=t; }
}
}
int todel=fc-8;
for(int i=0;i<todel;i++) unlink(files[i].path);
heap_caps_free(files);
}
@@ -1,42 +0,0 @@
#pragma once
#include "app_context.h"
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Path helpers
void make_sd_path(char* out, size_t out_len, const char* slug);
void ensure_dir(void);
bool has_slug(const char* slug);
// LRU cache cleanup
void lru_check(void);
// Sorting
void sort_seasons(void);
void sort_eps(void);
// Time formatting
void fmt_time(char* o, int s);
// Per-episode resume helpers
int find_saved_pos_idx(const char* slug);
int get_saved_pos_for_slug(const char* slug);
void set_saved_pos_for_slug(const char* slug, int pos);
void save_current_ep_pos(void);
// Persistence
void save_state(void);
void load_state(void);
// Seasons / episodes cache (PB data, rarely changes)
bool save_cache(void);
bool load_cache(void);
#ifdef __cplusplus
}
#endif
-554
View File
@@ -1,554 +0,0 @@
#include "ui.h"
#include "storage.h"
#include "player.h"
#include "download.h"
#include <tt_lvgl.h>
#include <tt_app.h>
#include <tt_bundle.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include "esp_log.h"
#include "esp_heap_caps.h"
void close_overlay(){
if(G.overlay){
lv_obj_delete(G.overlay);
G.overlay=NULL;
G.dropdown=NULL;
G.season_list_cont=NULL;
}
}
static void overlay_close_timer_cb(lv_timer_t* t){
close_overlay();
lv_timer_delete(t);
}
static void close_overlay_safe(void){
// Defer deletion to avoid deleting ancestor of current event target (season buttons inside overlay)
lv_timer_create(overlay_close_timer_cb, 20, NULL);
}
// Forward decls
static void ep_action_cb(lv_event_t* e);
static void season_select_cb(lv_event_t* e);
static void rebuild_episode_list_locked(void);
static void clear_container(lv_obj_t* cont){
if(!cont) return;
uint32_t child_cnt = lv_obj_get_child_cnt(cont);
while(child_cnt>0){
lv_obj_t* child = lv_obj_get_child(cont, 0);
if(child) lv_obj_delete(child);
else break;
child_cnt = lv_obj_get_child_cnt(cont);
}
}
static void btn_close_cb(lv_event_t* e){
(void)e;
if(G.overlay){
close_overlay();
return;
}
tt_app_stop();
}
static void get_season_stats(int season, int* total, int* cached){
int t=0,c=0;
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season==season){
t++;
if(G.eps[i].seen) c++;
}
}
if(total) *total=t;
if(cached) *cached=c;
}
void ui_update_status_locked(const char* msg){
if(G.lbl_status && msg) lv_label_set_text(G.lbl_status, msg);
}
void ui_update_header_locked(void){
if(!G.lbl_title && !G.lbl_season) return;
if(G.fetching){
if(G.lbl_season) lv_label_set_text(G.lbl_season, "Fetching...");
return;
}
if(G.season_cnt==0){
if(G.lbl_season) lv_label_set_text(G.lbl_season, "No seasons");
return;
}
int total=0,cached=0;
get_season_stats(G.cur_season,&total,&cached);
char buf[64];
if(total>0){
snprintf(buf,sizeof(buf),"S%02d • %d/%d on SD • %d eps", G.cur_season, cached, total, total);
}else{
snprintf(buf,sizeof(buf),"S%02d • %d eps", G.cur_season, total);
}
if(G.lbl_season) lv_label_set_text(G.lbl_season, buf);
if(G.lbl_title){
// Keep app name
// optionally update title to count
lv_label_set_text(G.lbl_title, "Discovery Mountain");
}
}
static void rebuild_episode_list_locked(void){
if(!G.ep_list_cont) return;
clear_container(G.ep_list_cont);
if(G.fetching){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"Fetching episodes...");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
lv_obj_align(lbl,LV_ALIGN_TOP_MID,0,20);
return;
}
if(G.ep_cnt==0){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"No episodes");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
return;
}
// Count episodes for cur season
int count=0;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season) count++;
if(count==0){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"No episodes in season");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
// Also show all if fallback?
// list first few from all
return;
}
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season!=G.cur_season) continue;
EpInfo* ep=&G.eps[i];
// Row container optimized for low internal heap (4 objs per ep)
lv_obj_t* row=lv_obj_create(G.ep_list_cont);
lv_obj_set_size(row, LV_PCT(100), 50);
lv_obj_set_style_bg_color(row, ep->seen ? lv_color_hex(0x1E2A22) : lv_color_hex(0x222222),0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER,0);
lv_obj_set_style_radius(row,8,0);
lv_obj_set_style_pad_all(row,6,0);
lv_obj_set_style_border_width(row,1,0);
lv_obj_set_style_border_color(row, ep->seen ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_gap(row,6,0);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(row, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
// Left single label with title (truncated) we embed meta as second line via \n to save objects
lv_obj_t* ltitle=lv_label_create(row);
lv_obj_set_flex_grow(ltitle,1);
// Truncate title to ~26 chars for display
char short_title[30];
strncpy(short_title, ep->title, sizeof(short_title)-1);
short_title[sizeof(short_title)-1]=0;
if(strlen(ep->title) > 26){
short_title[23]='.';
short_title[24]='.';
short_title[25]='.';
short_title[26]=0;
}
char line1[64];
snprintf(line1,sizeof(line1),"E%02d: %s", ep->ep_num, short_title);
// Second line status
char line2[32];
int saved = get_saved_pos_for_slug(ep->slug);
if(ep->seen){
if(saved>5){
char tb[16]; fmt_time(tb,saved);
snprintf(line2,sizeof(line2),"On SD %s", tb);
}else{
snprintf(line2,sizeof(line2),"On SD");
}
}else{
snprintf(line2,sizeof(line2),"Not DL");
}
char tbuf[96];
snprintf(tbuf,sizeof(tbuf),"%s\n%s", line1, line2);
lv_label_set_text(ltitle, tbuf);
lv_obj_set_width(ltitle, LV_PCT(100));
lv_label_set_long_mode(ltitle, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_font(ltitle, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(ltitle, ep->seen ? lv_color_hex(0xE0E0E0) : lv_color_hex(0xCCCCCC),0);
// Right side: button
lv_obj_t* btn=lv_btn_create(row);
lv_obj_set_size(btn, 52, 34);
if(ep->seen){
lv_obj_set_style_bg_color(btn, lv_color_hex(0x2E7D32),0);
}else{
lv_obj_set_style_bg_color(btn, lv_color_hex(0x1E88E5),0);
}
lv_obj_set_style_radius(btn,6,0);
lv_obj_t* bl=lv_label_create(btn);
lv_label_set_text(bl, ep->seen ? LV_SYMBOL_PLAY : LV_SYMBOL_DOWNLOAD);
lv_obj_center(bl);
lv_obj_add_event_cb(btn, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
}
// Scroll to top after rebuild
lv_obj_scroll_to_y(G.ep_list_cont, 0, LV_ANIM_OFF);
}
void ui_rebuild_episode_list(void){
tt_lvgl_lock(portMAX_DELAY);
if(G.ep_list_cont) rebuild_episode_list_locked();
ui_update_header_locked();
tt_lvgl_unlock();
}
static void ep_action_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
// Retrieve idx from user data
int idx = (int)(intptr_t)lv_event_get_user_data(e);
// Fallback: try event target's user data via current target?
// lv_event_get_user_data should already give us the idx
if(idx<0 || idx>=G.ep_cnt) return;
if(G.downloading || G.fetching) return;
EpInfo* ep=&G.eps[idx];
ESP_LOGI(TAG,"ep_action idx %d S%02dE%02d seen %d", idx, ep->season, ep->ep_num, ep->seen);
if(ep->season != G.cur_season){
// Shouldn't happen but update cur season to match
G.cur_season = ep->season;
}
// Use start_idx which handles both DL and Play
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
if(ep->seen) lv_label_set_text(G.lbl_status,"Starting player...");
else lv_label_set_text(G.lbl_status,"Starting downloader...");
}
tt_lvgl_unlock();
start_idx(idx);
}
static void season_select_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
int season_num = (int)(intptr_t)lv_event_get_user_data(e);
if(season_num<=0) return;
ESP_LOGI(TAG,"Season selected S%02d", season_num);
// Defer overlay deletion deleting ancestor during event is unsafe
close_overlay_safe();
G.cur_season = season_num;
// Save state immediately
tt_lvgl_lock(portMAX_DELAY);
ui_update_header_locked();
rebuild_episode_list_locked();
if(G.lbl_status){
char buf[40];
snprintf(buf,sizeof(buf),"Switched to S%02d", season_num);
lv_label_set_text(G.lbl_status, buf);
}
tt_lvgl_unlock();
save_state();
// Jump to first unseen or first in season
int first=-1, first_unseen=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==season_num){
if(first==-1) first=i;
if(!G.eps[i].seen && first_unseen==-1) first_unseen=i;
}
int tgt = first_unseen!=-1?first_unseen:first;
if(tgt!=-1){
// Optional: select ep updates title but we already rebuilt list; keep cur_ep_idx
G.cur_ep_idx = tgt;
strncpy(G.last_slug, G.eps[tgt].slug, sizeof(G.last_slug)-1);
save_state();
}
}
void dropdown_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return;
lv_obj_t* dd = (lv_obj_t*)lv_event_get_target(e);
int sel=lv_dropdown_get_selected(dd);
if(sel<0||sel>=G.season_cnt) return;
int sn=G.seasons[sel].num;
close_overlay_safe();
int first=-1, first_unseen=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==sn){
if(first==-1) first=i;
if(!G.eps[i].seen && first_unseen==-1) first_unseen=i;
}
int tgt=first_unseen!=-1?first_unseen:first;
if(tgt!=-1){
G.cur_season=sn;
G.cur_ep_idx=tgt;
strncpy(G.last_slug, G.eps[tgt].slug, sizeof(G.last_slug)-1);
tt_lvgl_lock(portMAX_DELAY);
ui_update_header_locked();
rebuild_episode_list_locked();
tt_lvgl_unlock();
save_state();
}
}
static void close_btn_cb(lv_event_t* e){
(void)e;
close_overlay_safe();
}
void btn_seasons_cb(lv_event_t* e){
(void)e;
if(G.downloading) return;
if(G.fetching) return;
if(G.overlay){ close_overlay(); return; }
tt_lvgl_lock(portMAX_DELAY);
// Create overlay centered modal parented to root if possible to avoid lingering on app background
lv_obj_t* ovl_parent = G.root ? G.root : lv_scr_act();
G.overlay=lv_obj_create(ovl_parent);
lv_obj_set_size(G.overlay, 240, 280);
lv_obj_center(G.overlay);
lv_obj_set_style_bg_color(G.overlay,lv_color_hex(0x222222),0);
lv_obj_set_style_radius(G.overlay,12,0);
lv_obj_set_style_border_width(G.overlay,1,0);
lv_obj_set_style_border_color(G.overlay, lv_color_hex(0x444444),0);
lv_obj_set_style_pad_all(G.overlay,8,0);
lv_obj_set_flex_flow(G.overlay, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(G.overlay, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(G.overlay,6,0);
lv_obj_t* title=lv_label_create(G.overlay);
lv_label_set_text(title,"Select Season");
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_DEFAULT),0);
// Scrollable list container
G.season_list_cont=lv_obj_create(G.overlay);
lv_obj_set_size(G.season_list_cont, LV_PCT(100), 200);
lv_obj_set_flex_grow(G.season_list_cont,1);
lv_obj_set_style_bg_color(G.season_list_cont, lv_color_hex(0x1A1A1A),0);
lv_obj_set_style_radius(G.season_list_cont,8,0);
lv_obj_set_style_pad_all(G.season_list_cont,4,0);
lv_obj_set_flex_flow(G.season_list_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_gap(G.season_list_cont,4,0);
// Ensure scrollable
lv_obj_add_flag(G.season_list_cont, LV_OBJ_FLAG_SCROLLABLE);
for(int i=0;i<G.season_cnt;i++){
int sn=G.seasons[i].num;
int total,cached;
get_season_stats(sn,&total,&cached);
lv_obj_t* btn=lv_btn_create(G.season_list_cont);
lv_obj_set_size(btn, LV_PCT(100), 38);
lv_obj_set_style_bg_color(btn, sn==G.cur_season ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0);
lv_obj_set_style_radius(btn,6,0);
lv_obj_set_style_pad_all(btn,6,0);
lv_obj_add_event_cb(btn, season_select_cb, LV_EVENT_CLICKED, (void*)(intptr_t)sn);
char blabel[40];
if(total>0){
if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d %d/%d " LV_SYMBOL_OK, sn, cached, total);
else snprintf(blabel,sizeof(blabel),"S%02d %d/%d", sn, cached, total);
}else{
if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d " LV_SYMBOL_OK, sn);
else snprintf(blabel,sizeof(blabel),"S%02d", sn);
}
lv_obj_t* l1=lv_label_create(btn);
lv_label_set_text(l1, blabel);
lv_obj_set_style_text_font(l1, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(l1);
}
lv_obj_t* bc=lv_btn_create(G.overlay);
lv_obj_set_size(bc, LV_PCT(60), 36);
lv_obj_set_style_bg_color(bc, lv_color_hex(0x444444),0);
lv_obj_t* lc=lv_label_create(bc);
lv_label_set_text(lc,"Close");
lv_obj_center(lc);
lv_obj_add_event_cb(bc,close_btn_cb,LV_EVENT_CLICKED,NULL);
tt_lvgl_unlock();
}
void btn_dl_season_cb(lv_event_t* e){
(void)e;
if(G.downloading) return;
if(G.fetching) return;
if(G.ep_cnt==0) return;
int season = G.cur_season;
ESP_LOGI(TAG, "DL Missing S%02d pressed", season);
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
if(G.lbl_status){
char buf[40];
snprintf(buf,sizeof(buf),"Checking S%02d missing...", season);
lv_label_set_text(G.lbl_status, buf);
}
tt_lvgl_unlock();
if (!download_season(season)) {
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL Season failed no missing?");
tt_lvgl_unlock();
}
}
void build_ui(lv_obj_t* parent){
tt_lvgl_lock(portMAX_DELAY);
lv_obj_t* root = lv_obj_create(parent);
G.root = root;
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
lv_obj_set_style_bg_color(root,lv_color_hex(0x111111),0);
lv_obj_set_style_pad_all(root,6,0);
lv_obj_set_style_pad_gap(root,6,0);
lv_obj_set_style_border_width(root,0,0);
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
// Header container
lv_obj_t* header=lv_obj_create(root);
lv_obj_set_size(header, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(header,0,0);
lv_obj_set_style_pad_all(header,2,0);
lv_obj_set_style_pad_gap(header,6,0);
lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(header, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* header_left=lv_obj_create(header);
lv_obj_set_size(header_left, LV_PCT(80), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(header_left, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(header_left,0,0);
lv_obj_set_style_pad_all(header_left,0,0);
lv_obj_set_flex_flow(header_left, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_gap(header_left,2,0);
lv_obj_remove_flag(header_left, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_grow(header_left,1);
G.lbl_title=lv_label_create(header_left);
lv_label_set_text(G.lbl_title,"Discovery Mountain");
lv_obj_set_style_text_font(G.lbl_title, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
lv_obj_set_style_text_color(G.lbl_title, lv_color_hex(0xFFFFFF),0);
G.lbl_season=lv_label_create(header_left);
lv_label_set_text(G.lbl_season,"Loading seasons...");
lv_obj_set_style_text_font(G.lbl_season, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(G.lbl_season,lv_color_hex(0xAAAAAA),0);
// Close button top right
lv_obj_t* btn_close=lv_btn_create(header);
lv_obj_set_size(btn_close, 36, 32);
lv_obj_set_style_bg_color(btn_close, lv_color_hex(0x333333),0);
lv_obj_set_style_radius(btn_close,6,0);
lv_obj_t* l_close=lv_label_create(btn_close);
lv_label_set_text(l_close, LV_SYMBOL_CLOSE);
lv_obj_set_style_text_font(l_close, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(l_close);
lv_obj_add_event_cb(btn_close, btn_close_cb, LV_EVENT_CLICKED, NULL);
// Action row: Seasons + DL Missing
lv_obj_t* row=lv_obj_create(root);
lv_obj_set_size(row,LV_PCT(100),44);
lv_obj_set_flex_flow(row,LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_bg_opa(row,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(row,0,0);
lv_obj_set_style_pad_all(row,0,0);
lv_obj_set_style_pad_gap(row,8,0);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* bs=lv_btn_create(row);
lv_obj_set_size(bs,LV_PCT(48),36);
G.btn_seasons=bs;
lv_obj_set_style_bg_color(bs, lv_color_hex(0x333333),0);
lv_obj_set_style_radius(bs,6,0);
lv_obj_t* ls=lv_label_create(bs);
lv_label_set_text(ls,"Seasons");
lv_obj_set_style_text_font(ls, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(ls);
lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* bd=lv_btn_create(row);
lv_obj_set_size(bd,LV_PCT(48),36);
G.btn_dl_missing=bd;
lv_obj_set_style_bg_color(bd, lv_color_hex(0x1E88E5), 0);
lv_obj_set_style_radius(bd,6,0);
lv_obj_t* ld=lv_label_create(bd);
lv_label_set_text(ld,"DL Missing");
lv_obj_set_style_text_font(ld, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(ld);
lv_obj_add_event_cb(bd,btn_dl_season_cb,LV_EVENT_CLICKED,NULL);
// Episode list container fills remaining space
G.ep_list_cont=lv_obj_create(root);
lv_obj_set_size(G.ep_list_cont, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_grow(G.ep_list_cont, 1);
lv_obj_set_style_bg_color(G.ep_list_cont, lv_color_hex(0x0E0E0E),0);
lv_obj_set_style_bg_opa(G.ep_list_cont, LV_OPA_COVER,0);
lv_obj_set_style_radius(G.ep_list_cont,8,0);
lv_obj_set_style_pad_all(G.ep_list_cont,4,0);
lv_obj_set_style_pad_gap(G.ep_list_cont,4,0);
lv_obj_set_flex_flow(G.ep_list_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(G.ep_list_cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
// Ensure scrollable
lv_obj_add_flag(G.ep_list_cont, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(G.ep_list_cont, LV_SCROLLBAR_MODE_AUTO);
lv_obj_t* hint=lv_label_create(G.ep_list_cont);
lv_label_set_text(hint,"Loading episodes...");
lv_obj_set_style_text_color(hint, lv_color_hex(0x888888),0);
lv_obj_set_style_text_font(hint, lvgl_get_text_font(FONT_SIZE_SMALL),0);
// Status bar at bottom
G.lbl_status=lv_label_create(root);
lv_label_set_text(G.lbl_status,"Fetching data...");
lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0);
lv_obj_set_style_text_font(G.lbl_status, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_width(G.lbl_status, LV_PCT(100));
lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_DOT);
// Legacy placeholders nulled
G.lbl_meta=NULL;
G.bar=NULL;
G.lbl_time=NULL;
G.btn_play=NULL;
G.dl_overlay=NULL;
G.ui_timer_handle=NULL;
G.ui_timer_handle = lv_timer_create(ui_timer,300,NULL);
tt_lvgl_unlock();
}
void ui_deinit(void){
if(G.ui_timer_handle){
lv_timer_delete(G.ui_timer_handle);
G.ui_timer_handle=NULL;
}
}
void ui_timer(lv_timer_t* t){
(void)t;
// If app is closing (root null) skip
if(!G.root) return;
if(G.fetching){
return;
}
if(G.downloading && G.dl_overlay){
update_dl_overlay_ui();
}
}
void btn_play_cb(lv_event_t* e){ (void)e; /* legacy, not used */ }
void btn_prev_cb(lv_event_t* e){ (void)e; /* legacy */ }
void btn_next_cb(lv_event_t* e){ (void)e; /* legacy */ }
-28
View File
@@ -1,28 +0,0 @@
#pragma once
#include "app_context.h"
#ifdef __cplusplus
extern "C" {
#endif
void build_ui(lv_obj_t* parent);
void ui_timer(lv_timer_t* t);
void btn_play_cb(lv_event_t* e);
void btn_prev_cb(lv_event_t* e);
void btn_next_cb(lv_event_t* e);
void btn_seasons_cb(lv_event_t* e);
void btn_dl_season_cb(lv_event_t* e);
void close_overlay(void);
void dropdown_cb(lv_event_t* e);
// New library UI helpers
void ui_rebuild_episode_list(void);
void ui_update_header_locked(void);
void ui_update_status_locked(const char* msg);
void ui_deinit(void);
#ifdef __cplusplus
}
#endif
+11 -7
View File
@@ -1,7 +1,11 @@
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
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3
[app]
id=one.tactility.discoverymountain
name=Discovery Mountain
versionName=0.1.0
versionCode=1
type=user
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.epubreader
app.version.name=0.4.0
app.version.code=4
app.name=Epub Reader
app.description=Epub and text file reader. Requires PSRAM!
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.epubreader
versionName=0.1.0
versionCode=1
name=Epub Reader
description=Epub and text file reader. Requires PSRAM!
-16
View File
@@ -1,16 +0,0 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(EspNowBridge)
tactility_project(EspNowBridge)
Binary file not shown.
-11
View File
@@ -1,11 +0,0 @@
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
)
@@ -1,739 +0,0 @@
#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;
}
}
}
@@ -1,108 +0,0 @@
#pragma once
#include <TactilityCpp/App.h>
#include <atomic>
#include <optional>
#include <string>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lvgl.h>
#include <tactility/drivers/wifi.h>
/** RAII guard: pauses WifiService's background auto-connect scan for the guard's lifetime. See
* tactility/wifi_auto_scan.h - belt-and-suspenders measure, not sufficient on its own (see the
* REBOOT comment in EspNowBridge.cpp). */
class AutoScanPauseGuard {
public:
AutoScanPauseGuard();
~AutoScanPauseGuard();
AutoScanPauseGuard(const AutoScanPauseGuard&) = delete;
AutoScanPauseGuard& operator=(const AutoScanPauseGuard&) = delete;
};
class EspNowBridge final : public App {
public:
EspNowBridge() = default;
EspNowBridge(const EspNowBridge&) = delete;
EspNowBridge& operator=(const EspNowBridge&) = delete;
void onCreate(AppHandle app) override;
void onDestroy(AppHandle app) override;
void onShow(AppHandle app, lv_obj_t* parent) override;
void onHide(AppHandle app) override;
void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
// Public so the free-function dispatchToUi() work callbacks in EspNowBridge.cpp (which run
// outside any member-function's lexical scope, unlike the inline lambdas in performUpdate())
// can call them.
void setStatus(const std::string& text);
void setProgress(int percent);
private:
AppHandle appHandle_ = nullptr;
AppLaunchId pickFileLaunchId_ = 0;
std::string pendingUpdateFilePath_;
Device* wifiDevice_ = nullptr;
// Resolved once in onShow() via wifi_get_firmware_ops() - null on a WiFi device with no
// updatable co-processor (e.g. a native, non-hosted chip). All OTA/version-query calls go
// through this generic interface, not any esp_hosted-specific API directly.
const FirmwareOps* firmwareOps_ = nullptr;
void* firmwareCtx_ = nullptr;
// Set once in onShow(), false once onHide() tears the widget tree down - checked (via
// dispatchToUi(), below) before touching any lv_obj_t*, since the OTA worker task and the
// WiFi-event callback can both outlive a hide/app-switch.
std::atomic<bool> isShown_{false};
// Only one EspNowBridge instance is ever live at a time (app loader owns a single instance
// per running app), so a single static "is this instance still current" pointer, guarded by
// an atomic, substitutes for the internal app's shared_ptr-based lifetime guard - the OTA
// worker task and dispatchToUi()'s lv_async_call closures check liveInstance_ == this before
// touching any member, instead of holding a shared_ptr to keep `this` alive.
static std::atomic<EspNowBridge*> liveInstance_;
TaskHandle_t updateTask_ = nullptr;
// Number of background tasks (updateTaskEntry, waitForTransportTaskEntry) currently running
// against this instance's members. onDestroy() must wait for this to hit 0 before returning -
// the app framework frees this instance shortly after onDestroy() returns (see Loader.cpp),
// so any task still touching `this` past that point is a use-after-free.
std::atomic<int> outstandingTasks_{0};
SemaphoreHandle_t taskDoneSemaphore_ = nullptr;
// Outlives performUpdate() deliberately, so auto-scan stays paused across the async gap
// between performUpdate() returning and the automatic restart - see performUpdate().
std::optional<AutoScanPauseGuard> heldAutoScanPauseGuard_;
lv_obj_t* currentVersionLabel_ = nullptr;
lv_obj_t* statusLabel_ = nullptr;
lv_obj_t* progressBar_ = nullptr;
lv_obj_t* updateButton_ = nullptr;
lv_obj_t* updateBundledButton_ = nullptr;
lv_obj_t* enableWifiButton_ = nullptr;
void refreshCurrentVersion();
bool isWifiRadioOn();
void refreshWifiPrompt();
/** Enables/disables both update-trigger buttons together - only one performUpdate() can run
* at a time (see updateTask_), regardless of which button started it. */
void setUpdateButtonsDisabled(bool disabled);
/** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance_ is
* still this instance (checked at dispatch time and again right before running, on the LVGL
* task) and isShown_ is true (this app's widget tree exists). */
void dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*));
void performUpdate(const std::string& filePath);
void startUpdateTask(const std::string& filePath);
static void updateTaskEntry(void* arg);
static void onUpdateButtonClicked(lv_event_t* event);
static void onUpdateBundledButtonClicked(lv_event_t* event);
static void onEnableWifiButtonClicked(lv_event_t* event);
static void onWifiEvent(Device* device, void* callbackContext, WifiEvent event);
static void waitForTransportTaskEntry(void* arg);
};
-11
View File
@@ -1,11 +0,0 @@
#include "EspNowBridge.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<EspNowBridge>();
return 0;
}
}
-8
View File
@@ -1,8 +0,0 @@
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.
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.gpio
app.version.name=0.7.0
app.version.code=7
app.name=GPIO
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.gpio
versionName=0.4.0
versionCode=4
name=GPIO
+152 -160
View File
@@ -1,9 +1,9 @@
/**
* @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.
* @brief GameBoy DMG Emulator Tactility (Peanut-GB) I2 canvas version
* Fixes black screen after reboot: previous RGB565 PSRAM buffer required flush_cache handler which is NULL on ESP32S3 LVGL port.
* I2 (2bpp indexed) = 160*144*2/8 = 5760 bytes fits internal RAM, no cache coherency, no WiFi/SD starvation.
* Uses lv_canvas_set_palette for 4 grays per GB_PALETTE.
*/
#include <tt_app.h>
@@ -13,11 +13,9 @@
#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. */
#include <esp_log.h>
#undef ESP_LOGI
#undef ESP_LOGW
#undef ESP_LOGE
@@ -40,8 +38,23 @@ void lv_image_cache_drop(const void * src);
#include "peanut_gb.h"
#define TAG "GameBoy"
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb"
#define ROMS_DIR "/data/roms/gb"
#define ROMS_DIR_LEGACY "/data/roms/gb"
#define DEFAULT_ROM_PATH_1 "/sdcard/Roms/GB/default.gb"
#define DEFAULT_ROM_PATH_2 "/sdcard/roms/gb/default.gb"
#define DEFAULT_ROM_PATH_3 "/data/Roms/GB/default.gb"
#define DEFAULT_ROM_PATH_4 "/data/roms/gb/default.gb"
#define DEFAULT_ROM_PATH DEFAULT_ROM_PATH_1
#define ROMS_DIR ROMS_DIR_LEGACY
static const char* ROMS_CANDIDATE_DIRS[] = {
"/sdcard/Roms/GB",
"/sdcard/Roms",
"/sdcard/roms/gb",
"/sdcard/ROMS/GB",
"/data/Roms/GB",
"/data/roms/gb",
"/data/Roms",
ROMS_DIR_LEGACY,
};
#define MAX_ROMS 64
#define MAX_PATH 512
#define MAX_ROM_SIZE (2 * 1024 * 1024)
@@ -49,14 +62,6 @@ void lv_image_cache_drop(const void * src);
#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 {
@@ -74,8 +79,8 @@ typedef struct {
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 */
uint8_t* fb_indexed;
lv_draw_buf_t* fb_draw_buf;
lv_obj_t* canvas;
lv_obj_t* root_wrapper;
lv_obj_t* browser_wrapper;
@@ -106,7 +111,7 @@ typedef struct {
bool rom_loaded;
uint32_t fps_frames;
int64_t fps_last_us;
uint32_t lines_drawn; /* debug: should be 144 per frame */
uint32_t lines_drawn;
} AppCtx;
typedef struct {
@@ -114,15 +119,18 @@ typedef struct {
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;
}
static void* alloc_internal(size_t size) {
void* p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
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;
@@ -140,37 +148,22 @@ static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const u
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 gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) { (void)gb;(void)err;(void)addr; }
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 (!ctx || !ctx->fb_indexed) return;
if (line >= FRAME_H) return;
uint16_t* dst = &ctx->fb_native[line * FRAME_W];
uint8_t* row = &ctx->fb_indexed[(line * FRAME_W) / 4];
for (int x=0;x<FRAME_W;x++) {
uint8_t shade = pixels[x] & 0x03;
dst[x] = GB_PALETTE[shade];
int byte_idx = x >> 2;
int shift = 6 - ((x & 0x03) * 2);
row[byte_idx] = (uint8_t)((row[byte_idx] & ~(0x03 << shift)) | ((shade & 0x03) << shift));
}
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';
@@ -184,16 +177,15 @@ static void load_cart_ram(AppCtx* ctx) {
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 (save_sz==0) 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; }
if (!ctx->cart_ram){ 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);
if (!f) return;
fread(ctx->cart_ram,1,save_sz,f); fclose(f);
}
static void save_cart_ram(AppCtx* ctx) {
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
@@ -201,23 +193,20 @@ static void save_cart_ram(AppCtx* ctx) {
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);
if (!f) return;
fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
}
/* 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; }
if (!f) 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; }
if (sz<=0 || sz>MAX_ROM_SIZE){ 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; }
if (!buf){ 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 (read!=(size_t)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; }
@@ -227,35 +216,42 @@ static bool load_rom_file(AppCtx* ctx, const char* path) {
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; }
if (err!=GB_INIT_NO_ERROR){ 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);
for (size_t d=0; d < sizeof(ROMS_CANDIDATE_DIRS)/sizeof(ROMS_CANDIDATE_DIRS[0]) && ctx->rom_count < MAX_ROMS; d++) {
const char* dir_path = ROMS_CANDIDATE_DIRS[d];
DIR* dir=opendir(dir_path);
if (!dir) continue;
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,".sgb")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
if (!is_gb) continue;
char full[MAX_PATH];
snprintf(full,sizeof(full),"%s/%s",dir_path,ent->d_name);
bool dup=false;
for(int i=0;i<ctx->rom_count;i++){
if (strcasecmp(ctx->roms[i].filename, ent->d_name)==0) { dup=true; break; }
}
if (dup) 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';
strncpy(e->fullpath, full, sizeof(e->fullpath)-1); e->fullpath[sizeof(e->fullpath)-1]='\0';
}
closedir(dir);
}
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;
@@ -263,22 +259,19 @@ static void emu_timer_cb(lv_timer_t* timer) {
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);
if (ctx->lines_drawn == 0) {
static uint32_t tick=0; tick++;
for (int y=0;y<FRAME_H;y++) {
uint8_t* row=&ctx->fb_indexed[(y*FRAME_W)/4];
for (int x=0;x<FRAME_W;x++) {
int bi=x>>2; int sh=6-((x&3)*2);
uint8_t shade = (uint8_t)((x + y + tick) & 0x03);
row[bi] = (uint8_t)((row[bi] & ~(0x03<<sh)) | (shade<<sh));
}
}
}
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
lv_obj_invalidate(ctx->canvas);
ctx->fps_frames++;
@@ -287,31 +280,20 @@ static void emu_timer_cb(lv_timer_t* timer) {
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);
}
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 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);
@@ -330,13 +312,11 @@ static void key_press_cb(lv_event_t* e){
}
}
/* 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);
@@ -345,51 +325,37 @@ static void rom_button_event(lv_event_t* e){
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);
}
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);
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");
}
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_text_fmt(info, "GameBoy I2 fix Place ROMs in Roms/GB Found %d ROMs", 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_label_set_text(ctx->status_label, "Select a ROM tap .gb in Files app also works");
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_label_set_text(lbl, "No ROMs. Put .gb in /sdcard/Roms/GB");
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
} else {
for (int i=0;i<ctx->rom_count;i++){
@@ -399,7 +365,6 @@ static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
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);
@@ -449,18 +414,13 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
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_indexed){
size_t fb_bytes = (FRAME_W * FRAME_H) / 4;
ctx->fb_indexed=(uint8_t*)alloc_internal(fb_bytes);
if (ctx->fb_indexed){ ctx->framebuffer_allocated=true; memset(ctx->fb_indexed,0,fb_bytes); }
}
if (ctx->fb_native){
if (ctx->fb_indexed){
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;
@@ -470,21 +430,23 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
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);
lv_canvas_set_buffer(ctx->canvas, ctx->fb_indexed, FRAME_W, FRAME_H, LV_COLOR_FORMAT_I2);
lv_color32_t c0 = { .red=0xFF, .green=0xFF, .blue=0xFF, .alpha=0xFF };
lv_color32_t c1 = { .red=0xAA, .green=0xAA, .blue=0xAA, .alpha=0xFF };
lv_color32_t c2 = { .red=0x55, .green=0x55, .blue=0x55, .alpha=0xFF };
lv_color32_t c3 = { .red=0x00, .green=0x00, .blue=0x00, .alpha=0xFF };
lv_canvas_set_palette(ctx->canvas, 0, c0);
lv_canvas_set_palette(ctx->canvas, 1, c1);
lv_canvas_set_palette(ctx->canvas, 2, c2);
lv_canvas_set_palette(ctx->canvas, 3, c3);
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 (ctx->fb_draw_buf) 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_set_style_border_color(ctx->canvas, lv_color_hex(0x00FF00), 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);
@@ -509,7 +471,6 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
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);
@@ -518,7 +479,6 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
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]);
@@ -532,7 +492,6 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
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;
@@ -586,7 +545,6 @@ static void switch_to_emu(AppCtx* ctx){
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; }
@@ -596,15 +554,13 @@ 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->fb_indexed) heap_caps_free(ctx->fb_indexed);
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_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;
@@ -638,11 +594,47 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent){
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
BundleHandle bundle = tt_app_get_parameters(app);
char file_param[512] = {0};
bool hasFileParam = false;
const char* file_to_try = NULL;
if (bundle && tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param))) {
hasFileParam = file_param[0] != '\0';
file_to_try = file_param;
}
if (hasFileParam && file_to_try) {
char resolved[MAX_PATH];
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wformat-truncation"
if (strncmp(file_to_try, "/sdcard", 7) == 0 || strncmp(file_to_try, "/data", 5) == 0) {
snprintf(resolved, sizeof(resolved), "%s", file_to_try);
} else if (file_to_try[0] == '/') {
snprintf(resolved, sizeof(resolved), "/sdcard%s", file_to_try);
} else {
char truncated_file[400];
snprintf(truncated_file, sizeof(truncated_file), "%s", file_to_try);
snprintf(resolved, sizeof(resolved), "/sdcard/%s", truncated_file);
}
#pragma GCC diagnostic pop
struct stat st;
if (stat(resolved, &st)==0 && load_rom_file(ctx, resolved)) {
build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx);
return;
}
if (load_rom_file(ctx, file_to_try)) {
build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx);
return;
}
}
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)){
const char* default_candidates[] = { DEFAULT_ROM_PATH_1, DEFAULT_ROM_PATH_2, DEFAULT_ROM_PATH_3, DEFAULT_ROM_PATH_4, ROMS_DIR_LEGACY "/default.gb" };
for (size_t i=0;i<sizeof(default_candidates)/sizeof(default_candidates[0]);i++) {
struct stat st;
if (stat(default_candidates[i], &st)==0 && load_rom_file(ctx, default_candidates[i])) {
build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx);
return;
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.graphicsdemo
app.version.name=0.6.0
app.version.code=6
app.name=Graphics Demo
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.graphicsdemo
versionName=0.3.0
versionCode=3
name=Graphics Demo
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.helloworld
app.version.name=0.6.0
app.version.code=6
app.name=Hello World
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.helloworld
versionName=0.3.0
versionCode=3
name=Hello World
-16
View File
@@ -1,16 +0,0 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(ImmichElias)
tactility_project(ImmichElias)
-9
View File
@@ -1,9 +0,0 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
idf_component_register(
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
REQUIRES TactilitySDK esp_http_client
)
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-variable)
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.imichelias
app.version.name=0.1.0
app.version.code=1
app.name=Immich Elias
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.m5unittest
app.version.name=0.4.0
app.version.code=4
app.name=M5 Unit Test
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.m5unittest
versionName=0.1.0
versionCode=1
name=M5 Unit Test
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.magic8ball
app.version.name=0.5.0
app.version.code=5
app.name=Magic 8-Ball
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.magic8ball
versionName=0.2.0
versionCode=2
name=Magic 8-Ball
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.mcpscreen
app.version.name=0.1.0
app.version.code=1
app.name=MCP Screen
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.mcpscreen
versionName=0.1.0
versionCode=1
name=MCP Screen
+14 -46
View File
@@ -211,29 +211,12 @@ void MediaKeys::startHid() {
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
}
void MediaKeys::teardownBt() {
// Remove callback FIRST - stops any in-flight BT events from firing against
// our (possibly already freed) UI widget pointers after this returns.
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
// Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() /
// ble_gatts_start() which corrupts NimBLE heap while the host task is still
// running. HID device is a persistent kernel device; hid_device_start() cleans
// up stale context on next use. Explicit stop is handled by handleSwitchToggle.
// Restore the radio/device to the state we found them in.
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
if (_btDevice && _deviceWasStarted) device_stop(_btDevice);
_btDevice = nullptr;
_hidDevice = nullptr;
_radioWasOff = false;
_deviceWasStarted = false;
}
void MediaKeys::handleSwitchToggle(bool enabled) {
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
_isEnabled = enabled;
if (enabled) {
_btDevice = device_find_first_by_type(&BLUETOOTH_TYPE);
_btDevice = bluetooth_find_first_ready_device();
if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false;
@@ -241,19 +224,6 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
return;
}
// Device may not be started yet (BT disabled in DTS by default to save memory).
if (!device_is_ready(_btDevice)) {
LOG_I(TAG, "BT device not started, starting now");
if (device_start(_btDevice) != ERROR_NONE) {
LOG_E(TAG, "Failed to start BT device");
_btDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
_deviceWasStarted = true;
}
bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
// Register callback before enabling radio so we don't miss the state-change event.
@@ -277,10 +247,12 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
} else {
_radioEnabling = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
// Explicit user toggle-off: stop HID cleanly (safe here since we're on the
// LVGL task and the user intentionally disabled, so no race with app teardown).
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
teardownBt();
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
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);
}
}
@@ -349,23 +321,19 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
}
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
// Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app).
struct Device* btDev = device_find_first_by_type(&BLUETOOTH_TYPE);
if (btDev && device_is_ready(btDev)) {
enum BtRadioState radioState;
if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) {
lv_obj_add_state(_switchWidget, LV_STATE_CHECKED);
handleSwitchToggle(true);
}
}
}
void MediaKeys::onHide(AppHandle /*appHandle*/) {
_radioEnabling = false;
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_btDevice = nullptr;
_hidDevice = nullptr;
_isEnabled = false;
_radioEnabling = false;
_radioWasOff = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
teardownBt();
if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr;
+3 -6
View File
@@ -2,7 +2,6 @@
#include <TactilityCpp/App.h>
#include <lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h>
#include <tt_app.h>
@@ -24,10 +23,9 @@ class MediaKeys final : public App {
struct Device* _hidDevice = nullptr;
// State - accessed from both LVGL thread and BT callback thread
std::atomic<bool> _isEnabled {false};
std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON
std::atomic<bool> _radioWasOff {false}; // true if we turned the radio on (restore on exit)
std::atomic<bool> _deviceWasStarted{false}; // true if we called device_start (restore on exit)
std::atomic<bool> _isEnabled {false};
std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON
std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off)
// Static event callbacks
static void onSwitchToggled(lv_event_t* e);
@@ -38,7 +36,6 @@ class MediaKeys final : public App {
static void sendKeyTask(void* param);
// Instance methods called by static callbacks
void teardownBt(); // remove callback + stop HID + restore radio/device state
void handleSwitchToggle(bool enabled);
void handleButtonPress(uint32_t buttonId);
void startHid(); // called once radio is confirmed ON
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.mediakeys
app.version.name=0.4.0
app.version.code=4
app.name=Media Keys
app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.mediakeys
versionName=0.1.0
versionCode=1
name=Media Keys
description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
+1 -2
View File
@@ -2,6 +2,5 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK esp_http_client
REQUIRES TactilitySDK
)
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-deprecated-declarations -Wno-unused-but-set-variable -Wno-format-truncation)
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.mp3player
app.version.name=1.0.0
app.version.code=1
app.name=MP3 Player
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.mp3player
versionName=1.0.0
versionCode=1
name=MP3 Player
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.mystifydemo
app.version.name=0.6.0
app.version.code=6
app.name=Mystify Demo
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.mystifydemo
versionName=0.3.0
versionCode=3
name=Mystify Demo
+1 -1
View File
@@ -10,5 +10,5 @@ idf_component_register(
../../../Libraries/TactilityCpp/Include
../../../Libraries/GameKit/Include
../../../Libraries/SfxEngine/Include
REQUIRES TactilitySDK
REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio
)
+32 -51
View File
@@ -1,7 +1,9 @@
#include "DungeonModel.h"
#ifndef LV_ABS
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
#endif
namespace PocketDungeon {
uint32_t DungeonModel::nextRandom() {
@@ -9,75 +11,48 @@ uint32_t DungeonModel::nextRandom() {
return (seed >> 16u) & 0x7FFFu;
}
void DungeonModel::reset(uint32_t seedValue, bool tutorials) {
void DungeonModel::reset(uint32_t seedValue) {
seed = seedValue;
state = DungeonState {};
state.floor = tutorials ? 0 : 2;
state.floor = 1;
state.hp = 5;
state.gold = 0;
state.gameOver = false;
generateFloor();
}
void DungeonModel::clearAllTiles() {
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
state.tiles[y][x] = Tile::Floor;
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { state.tiles[0][x] = Tile::Wall; state.tiles[DUNGEON_ROWS-1][x] = Tile::Wall; }
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { state.tiles[y][0] = Tile::Wall; state.tiles[y][DUNGEON_COLS-1] = Tile::Wall; }
}
void DungeonModel::addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp) {
if (state.entityCount >= MAX_ENTITIES) return;
state.entities[state.entityCount++] = Entity { type, pos, hp, true };
}
void DungeonModel::generateTutorialFloor(uint8_t tId) {
clearAllTiles();
state.entityCount = 0;
state.renderRows = TUTORIAL_ROWS;
for (uint8_t y = TUTORIAL_ROWS + 1; y < DUNGEON_ROWS; ++y)
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
state.tiles[y][x] = Tile::Wall;
state.playerPos = {1, 1};
if (tId == 0) {
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
} else {
state.tiles[1][3] = Tile::Treasure;
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
addEnemy(EntityType::Bat, {5, 1}, 1);
}
}
void DungeonModel::generateFloor() {
if (state.floor < 2) { generateTutorialFloor(static_cast<uint8_t>(state.floor)); return; }
state.renderRows = DUNGEON_ROWS;
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
const bool edge = (x == 0 || y == 0 || x == DUNGEON_COLS - 1 || y == DUNGEON_ROWS - 1);
state.tiles[y][x] = edge ? Tile::Wall : Tile::Floor;
}
}
state.playerPos = {1, 1};
state.entityCount = 0;
state.tiles[2][4] = Tile::Wall;
state.tiles[3][4] = Tile::Wall;
state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure;
state.tiles[5][7] = Tile::Stairs;
if (state.floor > 2) {
if (state.floor > 1) {
auto x = static_cast<uint8_t>(2 + (nextRandom() % 5));
auto y = static_cast<uint8_t>(1 + (nextRandom() % 5));
if (!(x == 1 && y == 1) && !(x == 7 && y == 5)) state.tiles[y][x] = Tile::Wall;
}
uint16_t realFloor = state.floor - 1;
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(state.floor / 3));
addEnemy(EntityType::Bat, {3, 5}, 1);
if (realFloor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
if (state.floor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
}
int DungeonModel::findEnemyAt(GameKit::GridPos pos) const {
for (uint8_t i = 0; i < state.entityCount; ++i) {
const auto& e = state.entities[i];
if (e.alive && e.pos == pos) return i;
const auto& entity = state.entities[i];
if (entity.alive && entity.pos == pos) return i;
}
return -1;
}
@@ -95,8 +70,11 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
if (enemyIdx >= 0) {
Entity& enemy = state.entities[enemyIdx];
enemy.hp -= 1;
if (enemy.hp <= 0) { enemy.alive = false; state.gold += enemy.type == EntityType::Bat ? 2 : 1; }
if (!isTutorial()) moveMonsters();
if (enemy.hp <= 0) {
enemy.alive = false;
state.gold += enemy.type == EntityType::Bat ? 2 : 1;
}
moveMonsters();
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
return MoveResult::Attacked;
}
@@ -109,19 +87,15 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
result = MoveResult::Treasure;
} else if (tile == Tile::Stairs) {
state.floor += 1;
if (state.floor >= 2) {
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
if (state.gold > bestGold) bestGold = state.gold;
}
if (state.floor > bestFloor) bestFloor = state.floor;
if (state.gold > bestGold) bestGold = state.gold;
generateFloor();
return MoveResult::Stairs;
}
if (!isTutorial()) moveMonsters();
moveMonsters();
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
if (state.floor >= 2) {
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
if (state.gold > bestGold) bestGold = state.gold;
}
if (state.floor > bestFloor) bestFloor = state.floor;
if (state.gold > bestGold) bestGold = state.gold;
return result;
}
@@ -131,19 +105,26 @@ void DungeonModel::moveMonsters() {
if (!enemy.alive) continue;
const int16_t dx = state.playerPos.x - enemy.pos.x;
const int16_t dy = state.playerPos.y - enemy.pos.y;
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) { state.hp -= 1; continue; }
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) {
state.hp -= 1;
continue;
}
GameKit::GridPos target = enemy.pos;
if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) {
const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
const int* dir = dirs[nextRandom() % 4];
target.x += dir[0]; target.y += dir[1];
target.x += dir[0];
target.y += dir[1];
} else if (LV_ABS(dx) > LV_ABS(dy)) {
target.x += dx > 0 ? 1 : -1;
} else if (dy != 0) {
target.y += dy > 0 ? 1 : -1;
}
if (target == state.playerPos) state.hp -= 1;
else if (isWalkable(target) && findEnemyAt(target) < 0) enemy.pos = target;
if (target == state.playerPos) {
state.hp -= 1;
} else if (isWalkable(target) && findEnemyAt(target) < 0) {
enemy.pos = target;
}
}
}
@@ -4,9 +4,9 @@
#include <cstdint>
namespace PocketDungeon {
static constexpr uint8_t DUNGEON_COLS = 9;
static constexpr uint8_t DUNGEON_ROWS = 7;
static constexpr uint8_t TUTORIAL_ROWS = 2;
static constexpr uint8_t MAX_ENTITIES = 8;
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
@@ -25,11 +25,10 @@ struct DungeonState {
Entity entities[MAX_ENTITIES] {};
uint8_t entityCount = 0;
GameKit::GridPos playerPos {1, 1};
uint16_t floor = 0; // 0,1 tutorial, 2+ real
uint16_t floor = 1;
uint16_t gold = 0;
int8_t hp = 5;
bool gameOver = false;
uint8_t renderRows = DUNGEON_ROWS;
};
class DungeonModel {
@@ -43,11 +42,9 @@ class DungeonModel {
int findEnemyAt(GameKit::GridPos pos) const;
bool isWalkable(GameKit::GridPos pos) const;
void moveMonsters();
void generateTutorialFloor(uint8_t tId);
void clearAllTiles();
public:
void reset(uint32_t seedValue = 0xC0FFEE, bool tutorials = true);
void reset(uint32_t seedValue = 0xC0FFEE);
void generateFloor();
MoveResult movePlayer(int dx, int dy);
@@ -55,9 +52,6 @@ public:
uint16_t getBestFloor() const { return bestFloor; }
uint16_t getBestGold() const { return bestGold; }
void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; }
bool isTutorial() const { return state.floor < 2; }
uint8_t tutorialId() const { return static_cast<uint8_t>(state.floor); }
uint16_t displayFloor() const { return state.floor < 2 ? 1 : state.floor - 1; }
};
} // namespace PocketDungeon
@@ -19,7 +19,7 @@ void DungeonRenderer::create(lv_obj_t* parent) {
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
status = lv_label_create(root);
lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN);
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 6);
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 8);
const lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
const lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
const uint16_t maxCellW = static_cast<uint16_t>((screenW - 22) / DUNGEON_COLS);
@@ -48,7 +48,8 @@ lv_color_t DungeonRenderer::tileColor(Tile tile) {
case Tile::Wall: return lv_color_hex(COLOR_WALL);
case Tile::Stairs: return lv_color_hex(COLOR_STAIRS);
case Tile::Treasure: return lv_color_hex(COLOR_TREASURE);
case Tile::Floor: default: return lv_color_hex(COLOR_FLOOR);
case Tile::Floor:
default: return lv_color_hex(COLOR_FLOOR);
}
}
@@ -64,64 +65,48 @@ lv_color_t DungeonRenderer::entityColor(EntityType type) {
switch (type) {
case EntityType::Player: return lv_color_hex(COLOR_PLAYER);
case EntityType::Bat: return lv_color_hex(COLOR_BAT);
case EntityType::Slime: default: return lv_color_hex(COLOR_SLIME);
case EntityType::Slime:
default: return lv_color_hex(COLOR_SLIME);
}
}
void DungeonRenderer::clearEntityLabels() {
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
lv_label_set_text(labels[y][x], "");
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
}
}
}
void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) {
const DungeonState& state = model.getState();
char buf[96];
if (model.isTutorial()) {
std::snprintf(buf, sizeof(buf), "TUTORIAL %u/2 HP:%d Gold:%u", model.tutorialId()+1, state.hp, state.gold);
} else {
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", model.displayFloor(), state.hp, state.gold, model.getBestFloor(), model.getBestGold());
}
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", state.floor, state.hp, state.gold, model.getBestFloor(), model.getBestGold());
lv_label_set_text(status, buf);
clearEntityLabels();
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
bool visible = y < state.renderRows;
if (!visible) {
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) lv_obj_add_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
continue;
}
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
lv_obj_clear_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
const Tile tile = state.tiles[y][x];
lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN);
lv_label_set_text(labels[y][x], tileText(tile));
}
}
if (state.renderRows == TUTORIAL_ROWS) {
lv_obj_set_height(board, grid.cellPx * TUTORIAL_ROWS);
lv_obj_align(board, LV_ALIGN_CENTER, 0, -10);
} else {
lv_obj_set_height(board, grid.rows * grid.cellPx);
lv_obj_align(board, LV_ALIGN_CENTER, 0, 8);
}
for (uint8_t i = 0; i < state.entityCount; ++i) {
const Entity& e = state.entities[i];
if (!e.alive) continue;
const char* sym = e.type == EntityType::Bat ? "b" : "s";
lv_label_set_text(labels[e.pos.y][e.pos.x], sym);
lv_obj_set_style_text_color(labels[e.pos.y][e.pos.x], entityColor(e.type), LV_PART_MAIN);
const Entity& entity = state.entities[i];
if (!entity.alive) continue;
const char* symbol = entity.type == EntityType::Bat ? "b" : "s";
lv_label_set_text(labels[entity.pos.y][entity.pos.x], symbol);
lv_obj_set_style_text_color(labels[entity.pos.y][entity.pos.x], entityColor(entity.type), LV_PART_MAIN);
}
lv_label_set_text(labels[state.playerPos.y][state.playerPos.x], "@");
lv_obj_set_style_text_color(labels[state.playerPos.y][state.playerPos.x], lv_color_hex(COLOR_PLAYER), LV_PART_MAIN);
const char* text = model.isTutorial() ? (model.tutorialId()==0 ? "TUT1: @ to >" : "TUT2: Kill b, $ , >") : "Arrows/Swipe/Tap";
const char* text = "Move with arrows, swipe, or tap a direction";
switch (lastResult) {
case MoveResult::Blocked: text = "Wall blocks"; break;
case MoveResult::Attacked: text = "You strike!"; break;
case MoveResult::Treasure: text = "Treasure! +5"; break;
case MoveResult::Stairs: text = model.isTutorial() ? "Tutorial done!" : "Down..."; break;
case MoveResult::Blocked: text = "A wall blocks the way"; break;
case MoveResult::Attacked: text = "You strike the monster"; break;
case MoveResult::Treasure: text = "Treasure found!"; break;
case MoveResult::Stairs: text = "Down the stairs..."; break;
case MoveResult::Dead: text = "Game over - tap to restart"; break;
default: break;
}
+34 -281
View File
@@ -1,8 +1,5 @@
#include "PocketDungeon.h"
#include <cstdio>
extern "C" {
#include <tt_app.h>
}
#include <tt_lvgl_toolbar.h>
namespace PocketDungeon {
@@ -10,26 +7,19 @@ static constexpr uint32_t TICK_MS = 200;
static constexpr const char* PREF_BEST_FLOOR = "bestFloor";
static constexpr const char* PREF_BEST_GOLD = "bestGold";
static constexpr uint32_t COLOR_BG = 0x10111A;
static constexpr uint32_t COLOR_PANEL = 0x1D2030;
static constexpr uint32_t COLOR_ACCENT = 0x365CF5;
static constexpr uint32_t COLOR_OVERLAY = 0x000000;
void PocketDungeon::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(parent, lv_color_hex(COLOR_BG), LV_PART_MAIN);
root = parent;
(void) tt_lvgl_toolbar_create_for_app(parent, app);
model.setBests(static_cast<uint16_t>(prefs.getInt(PREF_BEST_FLOOR, 1)), static_cast<uint16_t>(prefs.getInt(PREF_BEST_GOLD, 0)));
model.reset(lv_tick_get(), true);
model.reset(lv_tick_get());
if (sfx == nullptr) {
sfx = new SfxEngine();
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
}
phase = AppPhase::Intro;
onEnter(parent);
loop.start(TICK_MS, this);
(void) app;
}
void PocketDungeon::onHide(AppHandle app) {
@@ -37,300 +27,63 @@ void PocketDungeon::onHide(AppHandle app) {
loop.stop();
onExit();
saveBests();
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; }
if (sfx != nullptr) {
sfx->stop();
delete sfx;
sfx = nullptr;
}
root = nullptr;
}
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
root = sceneRoot;
GameKit::attachInput(root, this);
showIntro();
renderer.create(sceneRoot);
GameKit::attachInput(sceneRoot, this);
lastResult = MoveResult::None;
}
void PocketDungeon::onExit() { clearIntro(); clearGame(); }
void PocketDungeon::attachInputToCurrent() {
if (root) GameKit::attachInput(root, this);
if (introContainer) {
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
GameKit::attachInput(introContainer, this);
}
if (gameContainer) {
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
GameKit::attachInput(gameContainer, this);
lv_obj_add_event_cb(gameContainer, onGameContainerLongPress, LV_EVENT_LONG_PRESSED, this);
}
if (pauseOverlay) {
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
GameKit::attachInput(pauseOverlay, this);
}
}
void PocketDungeon::clearIntro() { if (introContainer) { lv_obj_delete(introContainer); introContainer = nullptr; } }
void PocketDungeon::clearGame() {
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
if (gameContainer) { lv_obj_delete(gameContainer); gameContainer = nullptr; }
}
void PocketDungeon::showIntro() {
clearGame(); clearIntro();
phase = AppPhase::Intro;
introContainer = lv_obj_create(root);
lv_obj_set_size(introContainer, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(introContainer, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(introContainer, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(introContainer, lv_color_hex(COLOR_BG), LV_PART_MAIN);
lv_obj_remove_flag(introContainer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(introContainer, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(introContainer, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(introContainer, 6, LV_PART_MAIN);
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
renderIntro();
attachInputToCurrent();
}
void PocketDungeon::showGame() {
clearIntro(); clearGame();
phase = AppPhase::Playing;
gameContainer = lv_obj_create(root);
lv_obj_set_size(gameContainer, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(gameContainer, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(gameContainer, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(gameContainer, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_remove_flag(gameContainer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
renderer.create(gameContainer);
lv_obj_t* pauseBtn = lv_btn_create(gameContainer);
lv_obj_set_size(pauseBtn, 32, 26);
lv_obj_set_style_bg_color(pauseBtn, lv_color_hex(0x2A2D40), LV_PART_MAIN);
lv_obj_set_style_radius(pauseBtn, 6, LV_PART_MAIN);
lv_obj_set_style_pad_all(pauseBtn, 0, LV_PART_MAIN);
lv_obj_align(pauseBtn, LV_ALIGN_TOP_RIGHT, -6, 4);
lv_obj_add_event_cb(pauseBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
lv_obj_t* pauseLbl = lv_label_create(pauseBtn);
lv_label_set_text(pauseLbl, LV_SYMBOL_CLOSE);
lv_obj_center(pauseLbl);
lastResult = MoveResult::None;
attachInputToCurrent();
render();
if (sfx) sfx->play(SfxId::Warp);
}
void PocketDungeon::showPause() {
if (pauseOverlay) return;
phase = AppPhase::Paused;
pauseOverlay = lv_obj_create(root);
lv_obj_set_size(pauseOverlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(pauseOverlay, 12, LV_PART_MAIN);
lv_obj_set_style_border_width(pauseOverlay, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(pauseOverlay, lv_color_hex(COLOR_OVERLAY), LV_PART_MAIN);
lv_obj_set_style_bg_opa(pauseOverlay, LV_OPA_70, LV_PART_MAIN);
lv_obj_remove_flag(pauseOverlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(pauseOverlay, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(pauseOverlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(pauseOverlay, 10, LV_PART_MAIN);
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* box = lv_obj_create(pauseOverlay);
lv_obj_set_width(box, LV_PCT(80));
lv_obj_set_height(box, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(box, 14, LV_PART_MAIN);
lv_obj_set_style_bg_color(box, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_border_width(box, 0, LV_PART_MAIN);
lv_obj_set_style_radius(box, 12, LV_PART_MAIN);
lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(box, 10, LV_PART_MAIN);
lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* t = lv_label_create(box);
lv_label_set_text(t, "Paused");
lv_obj_set_style_text_color(t, lv_color_white(), LV_PART_MAIN);
lv_obj_t* resumeBtn = lv_btn_create(box);
lv_obj_set_width(resumeBtn, LV_PCT(100));
lv_obj_set_height(resumeBtn, 38);
lv_obj_set_style_bg_color(resumeBtn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
lv_obj_set_style_radius(resumeBtn, 10, LV_PART_MAIN);
lv_obj_add_event_cb(resumeBtn, onResumeButtonClicked, LV_EVENT_CLICKED, this);
lv_obj_t* rl = lv_label_create(resumeBtn);
lv_label_set_text(rl, "Resume");
lv_obj_center(rl);
lv_obj_t* exitBtn = lv_btn_create(box);
lv_obj_set_width(exitBtn, LV_PCT(100));
lv_obj_set_height(exitBtn, 36);
lv_obj_set_style_bg_color(exitBtn, lv_color_hex(0x2E303F), LV_PART_MAIN);
lv_obj_set_style_radius(exitBtn, 10, LV_PART_MAIN);
lv_obj_add_event_cb(exitBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
lv_obj_t* el = lv_label_create(exitBtn);
lv_label_set_text(el, "Exit Game");
lv_obj_center(el);
if (sfx) sfx->play(SfxId::MenuOpen);
attachInputToCurrent();
}
void PocketDungeon::hidePause() {
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
phase = AppPhase::Playing;
if (sfx) sfx->play(SfxId::MenuClose);
attachInputToCurrent();
render();
}
void PocketDungeon::exitGame() {
saveBests();
if (sfx) sfx->play(SfxId::Cancel);
tt_app_stop();
}
void PocketDungeon::renderIntro() {
if (!introContainer) return;
lv_obj_t* titleRow = lv_obj_create(introContainer);
lv_obj_set_width(titleRow, LV_PCT(100));
lv_obj_set_height(titleRow, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(titleRow, 6, LV_PART_MAIN);
lv_obj_set_style_border_width(titleRow, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_remove_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(titleRow, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(titleRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(titleRow, 2, LV_PART_MAIN);
lv_obj_t* title = lv_label_create(titleRow);
lv_label_set_text(title, "POCKET DUNGEON");
lv_obj_set_style_text_color(title, lv_color_hex(0xF8F3E6), LV_PART_MAIN);
lv_obj_t* subtitle = lv_label_create(titleRow);
lv_label_set_text(subtitle, "Tiny paper roguelike");
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x8A9FBF), LV_PART_MAIN);
char bestBuf[64];
std::snprintf(bestBuf, sizeof(bestBuf), "Best F%u G%u", model.getBestFloor(), model.getBestGold());
lv_obj_t* best = lv_label_create(titleRow);
lv_label_set_text(best, bestBuf);
lv_obj_set_style_text_color(best, lv_color_hex(0x7A8196), LV_PART_MAIN);
lv_obj_t* mainRow = lv_obj_create(introContainer);
lv_obj_set_width(mainRow, LV_PCT(100));
lv_obj_set_flex_grow(mainRow, 1);
lv_obj_set_style_pad_all(mainRow, 4, LV_PART_MAIN);
lv_obj_set_style_pad_column(mainRow, 8, LV_PART_MAIN);
lv_obj_set_style_pad_bottom(mainRow, 8, LV_PART_MAIN);
lv_obj_set_style_border_width(mainRow, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(mainRow, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(mainRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(mainRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_remove_flag(mainRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* leftCol = lv_obj_create(mainRow);
lv_obj_set_flex_grow(leftCol, 1);
lv_obj_set_height(leftCol, LV_PCT(100));
lv_obj_set_style_pad_all(leftCol, 0, LV_PART_MAIN);
lv_obj_set_style_pad_right(leftCol, 4, LV_PART_MAIN);
lv_obj_set_style_pad_bottom(leftCol, 6, LV_PART_MAIN);
lv_obj_set_style_border_width(leftCol, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(leftCol, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(leftCol, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(leftCol, 8, LV_PART_MAIN);
lv_obj_add_flag(leftCol, LV_OBJ_FLAG_SCROLLABLE);
auto makeCard = [](lv_obj_t* parent, const char* tTitle, const char* body, uint32_t col) {
lv_obj_t* card = lv_obj_create(parent);
lv_obj_set_width(card, LV_PCT(100));
lv_obj_set_height(card, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(card, 9, LV_PART_MAIN);
lv_obj_set_style_pad_row(card, 3, LV_PART_MAIN);
lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_border_width(card, 0, LV_PART_MAIN);
lv_obj_set_style_radius(card, 10, LV_PART_MAIN);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* t = lv_label_create(card);
lv_label_set_text(t, tTitle);
lv_obj_set_style_text_color(t, lv_color_hex(col), LV_PART_MAIN);
lv_obj_t* b = lv_label_create(card);
lv_label_set_text(b, body);
lv_obj_set_style_text_color(b, lv_color_hex(0xD9DDE8), LV_PART_MAIN);
lv_label_set_long_mode(b, LV_LABEL_LONG_WRAP);
lv_obj_set_width(b, LV_PCT(100));
return card;
};
makeCard(leftCol, "GOAL", "Reach deepest floor.\nCollect gold, survive.", 0xD99B23);
makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit", 0x6D8CFF);
lv_obj_t* rightRail = lv_obj_create(mainRow);
lv_obj_set_width(rightRail, 44);
lv_obj_set_height(rightRail, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(rightRail, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(rightRail, 10, LV_PART_MAIN);
lv_obj_set_style_border_width(rightRail, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(rightRail, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(rightRail, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(rightRail, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(rightRail, LV_OBJ_FLAG_SCROLLABLE);
auto makeIconBtn = [](lv_obj_t* parent, const char* symbol, uint32_t bg, lv_event_cb_t cb, void* user, int s) -> lv_obj_t* {
lv_obj_t* btn = lv_btn_create(parent);
lv_obj_set_size(btn, s, s);
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN);
lv_obj_set_style_radius(btn, s / 3, LV_PART_MAIN);
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user);
lv_obj_t* lbl = lv_label_create(btn);
lv_label_set_text(lbl, symbol);
lv_obj_center(lbl);
lv_obj_set_style_text_color(lbl, lv_color_white(), LV_PART_MAIN);
return btn;
};
makeIconBtn(rightRail, LV_SYMBOL_PLAY, COLOR_ACCENT, onStartButtonClicked, this, 38);
makeIconBtn(rightRail, LV_SYMBOL_CLOSE, 0x252836, onExitButtonClicked, this, 30);
}
void PocketDungeon::onStartButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->showGame(); lv_event_stop_bubbling(e); }
void PocketDungeon::onResumeButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->hidePause(); lv_event_stop_bubbling(e); }
void PocketDungeon::onExitButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->exitGame(); lv_event_stop_bubbling(e); }
void PocketDungeon::onGameContainerLongPress(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s && s->phase==AppPhase::Playing) s->showPause(); }
void PocketDungeon::onExit() {}
void PocketDungeon::onInput(const GameKit::InputEvent& input) {
if (input.kind == GameKit::InputKind::Cancel || input.kind == GameKit::InputKind::Menu) { exitGame(); return; }
if (phase == AppPhase::Intro) { if (input.kind != GameKit::InputKind::Unknown) showGame(); return; }
if (phase == AppPhase::Paused) { if (input.kind != GameKit::InputKind::Unknown) hidePause(); return; }
int dx=0, dy=0;
int dx = 0;
int dy = 0;
switch (input.kind) {
case GameKit::InputKind::Up: dy=-1; break;
case GameKit::InputKind::Down: dy=1; break;
case GameKit::InputKind::Left: dx=-1; break;
case GameKit::InputKind::Right: dx=1; break;
case GameKit::InputKind::Up: dy = -1; break;
case GameKit::InputKind::Down: dy = 1; break;
case GameKit::InputKind::Left: dx = -1; break;
case GameKit::InputKind::Right: dx = 1; break;
default: break;
}
if (model.getState().gameOver) {
if (dx!=0||dy!=0||input.kind==GameKit::InputKind::Confirm||input.kind==GameKit::InputKind::Touch) {
model.reset(lv_tick_get(), true); lastResult=MoveResult::None; render();
}
model.reset(lv_tick_get());
lastResult = MoveResult::None;
render();
return;
}
if (dx!=0||dy!=0) {
lastResult=model.movePlayer(dx,dy);
if (dx != 0 || dy != 0) {
lastResult = model.movePlayer(dx, dy);
playResultSound(lastResult);
if (lastResult==MoveResult::Dead) saveBests();
if (lastResult == MoveResult::Dead) saveBests();
render();
}
}
void PocketDungeon::onTick(const GameKit::Tick& tick){ (void)tick; }
void PocketDungeon::render(){
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return;
if (!gameContainer) return;
void PocketDungeon::onTick(const GameKit::Tick& tick) {
(void) tick;
}
void PocketDungeon::render() {
renderer.render(model, lastResult);
}
void PocketDungeon::saveBests(){
void PocketDungeon::saveBests() {
prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor());
prefs.putInt(PREF_BEST_GOLD, model.getBestGold());
}
void PocketDungeon::playResultSound(MoveResult r){
if (!sfx) return;
switch(r){
void PocketDungeon::playResultSound(MoveResult result) {
if (sfx == nullptr) return;
switch (result) {
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
@@ -9,15 +9,8 @@
namespace PocketDungeon {
enum class AppPhase : uint8_t { Intro, Playing, Paused };
class PocketDungeon final : public App, public GameKit::Scene {
lv_obj_t* root = nullptr;
lv_obj_t* introContainer = nullptr;
lv_obj_t* gameContainer = nullptr;
lv_obj_t* pauseOverlay = nullptr;
AppPhase phase = AppPhase::Intro;
GameKit::Loop loop;
GameKit::Prefs prefs {"PocketDungeon"};
DungeonModel model;
@@ -27,20 +20,6 @@ class PocketDungeon final : public App, public GameKit::Scene {
void playResultSound(MoveResult result);
void saveBests();
void showIntro();
void showGame();
void showPause();
void hidePause();
void clearIntro();
void clearGame();
void renderIntro();
void attachInputToCurrent();
void exitGame();
static void onStartButtonClicked(lv_event_t* e);
static void onResumeButtonClicked(lv_event_t* e);
static void onExitButtonClicked(lv_event_t* e);
static void onGameContainerLongPress(lv_event_t* e);
public:
void onShow(AppHandle app, lv_obj_t* parent) override;
+11 -9
View File
@@ -1,9 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.pocketdungeon
app.version.name=0.1.2
app.version.code=3
app.name=Pocket Dungeon
app.description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
app.flags=HideStatusBar
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.pocketdungeon
versionName=0.1.0
versionCode=1
name=Pocket Dungeon
description=Tiny paper-inspired dungeon crawler
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.reynabot
app.version.name=1.0.0
app.version.code=1
app.name=ReynaBot
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.reynabot
versionName=1.0.0
versionCode=1
name=ReynaBot
-12
View File
@@ -1,12 +0,0 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(SdDownloader)
tactility_project(SdDownloader)
-6
View File
@@ -1,6 +0,0 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS Source
REQUIRES TactilitySDK esp_http_client
)
-460
View File
@@ -1,460 +0,0 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_bundle.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_http_client.h"
#include "esp_heap_caps.h"
#define TAG "SDDL"
#define DEFAULT_URL "http://192.168.68.110:8098/463_Discovering_Discovery_Mountain_S01E01/discovery_mountain_s01e01_esp32_24k.mp3"
#define DEFAULT_PATH "/sdcard/download/test2.mp3"
typedef struct {
lv_obj_t *bar;
lv_obj_t *lbl_url;
lv_obj_t *lbl_path;
lv_obj_t *lbl_pct;
lv_obj_t *lbl_detail;
lv_obj_t *lbl_status;
lv_obj_t *btn_cancel;
char url[512];
char path[512];
bool override_existing;
AppHandle app_handle;
TaskHandle_t dl_handle;
volatile bool downloading;
volatile bool cancel_req;
volatile int total;
volatile int expected;
int last_pct;
bool result_success;
} AppCtx;
static AppCtx G;
static void ensure_parent_dir(const char *filepath) {
if (!filepath) return;
char path_copy[512];
strncpy(path_copy, filepath, sizeof(path_copy)-1);
path_copy[sizeof(path_copy)-1]=0;
char *last_slash = strrchr(path_copy, '/');
if (!last_slash) return;
*last_slash = 0;
if (strlen(path_copy)==0) return;
char cur[512] = "";
if (path_copy[0]=='/') strcpy(cur, "/");
char *start = path_copy[0]=='/' ? path_copy+1 : path_copy;
int len = strlen(start);
int i = 0;
while (i <= len) {
int j = i;
while (j < len && start[j] != '/') j++;
if (j > i) {
size_t cur_len = strlen(cur);
if (cur_len > 0 && cur[cur_len-1] != '/') {
if (cur_len + 1 < sizeof(cur)) {
cur[cur_len] = '/';
cur[cur_len+1] = 0;
}
}
char saved = start[j];
start[j] = 0;
strncat(cur, start+i, sizeof(cur)-strlen(cur)-1);
start[j] = saved;
mkdir(cur, 0777);
}
i = j+1;
}
}
static void update_ui_status(const char *txt) {
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, txt);
tt_lvgl_unlock();
}
static void set_result_and_close(bool success) {
G.result_success = success;
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
BundleHandle result_bundle = NULL;
if (free_internal > 8000) {
result_bundle = tt_bundle_alloc();
}
if (result_bundle) {
tt_bundle_put_bool(result_bundle, "success", success);
// Keep bundle minimal to avoid low heap crash (previous put_string path 512B caused OOM)
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, result_bundle);
} else {
// Low heap return OK without bundle (DM will still refresh via has_slug)
if (free_internal <= 8000) {
ESP_LOGW(TAG, "Low heap %d, returning result without bundle", free_internal);
}
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, NULL);
}
// Change Cancel button to Close once done (manual close, auto-close unreliable)
tt_lvgl_lock(portMAX_DELAY);
if (G.btn_cancel) {
lv_obj_t* lbl = lv_obj_get_child(G.btn_cancel, 0);
if (lbl) lv_label_set_text(lbl, "Close");
}
tt_lvgl_unlock();
}
static bool download_one_file(const char *url, const char *path) {
ESP_LOGI(TAG, "DL one file url=%s path=%s override=%d", url, path, G.override_existing);
struct stat st;
if (stat(path, &st)==0) {
if (!G.override_existing) {
ESP_LOGW(TAG, "File exists and override=false: %s (returning true as cached)", path);
G.total = st.st_size;
update_ui_status("Already on SD");
return true;
} else {
ESP_LOGI(TAG, "File exists but override=true, overwriting %s", path);
unlink(path);
}
}
ensure_parent_dir(path);
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
ESP_LOGI(TAG, "Heap before DL: internal=%d", free_internal);
if (free_internal < 10000) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
if (strlen(url) < 8 || strncmp(url, "http://", 7)!=0) {
update_ui_status("Invalid URL");
return false;
}
char tmp_path[520];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
unlink(tmp_path);
FILE *f = fopen(tmp_path, "wb");
if (!f) {
ESP_LOGE(TAG, "fopen fail %s errno=%d", tmp_path, errno);
update_ui_status("Failed to open file");
return false;
}
esp_http_client_config_t cfg = {
.url = url,
.timeout_ms = 15000,
.buffer_size = 4096,
.buffer_size_tx = 1024,
.keep_alive_enable = false,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) {
fclose(f);
unlink(tmp_path);
update_ui_status("HTTP init failed");
return false;
}
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
update_ui_status("HTTP open failed");
return false;
}
int content_len = esp_http_client_fetch_headers(client);
int status = esp_http_client_get_status_code(client);
ESP_LOGI(TAG, "HTTP status %d, content_len %d", status, content_len);
if (status != 200) {
char msg[64];
snprintf(msg, sizeof(msg), "HTTP %d", status);
update_ui_status(msg);
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
return false;
}
G.expected = content_len;
uint8_t *buf = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!buf) {
buf = malloc(4096);
if (!buf) {
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
update_ui_status("No memory");
return false;
}
}
int total = 0;
int timeouts = 0;
bool ok = true;
while (1) {
if (G.cancel_req) { ok = false; break; }
if (content_len >=0 && total >= content_len) break;
int r = esp_http_client_read(client, (char*)buf, 4096);
if (r < 0) {
timeouts++;
if (timeouts >= 5) { ok = false; break; }
vTaskDelay(pdMS_TO_TICKS(200));
continue;
}
if (r == 0) break;
timeouts = 0;
if (fwrite(buf, 1, r, f) != (size_t)r) { ok = false; break; }
total += r;
G.total = total;
int pct = 0;
if (content_len > 0) {
pct = (int)((int64_t)total * 100 / content_len);
if (pct<0) pct=0;
if (pct>100) pct=100;
}
if (pct != G.last_pct) {
G.last_pct = pct;
tt_lvgl_lock(portMAX_DELAY);
if (G.bar) lv_bar_set_value(G.bar, pct, LV_ANIM_OFF);
if (G.lbl_detail) {
char d[64];
if (content_len>0) snprintf(d,sizeof(d),"%d / %d KB", total/1024, content_len/1024);
else snprintf(d,sizeof(d),"%d KB", total/1024);
lv_label_set_text(G.lbl_detail, d);
}
if (G.lbl_pct) {
char b[32];
if (content_len>0) snprintf(b,sizeof(b),"%d%%",pct);
else snprintf(b,sizeof(b),"%d KB", total/1024);
lv_label_set_text(G.lbl_pct, b);
}
tt_lvgl_unlock();
}
if ((total / 4096) % 8 == 0) {
fflush(f);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
heap_caps_free(buf);
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
if (G.cancel_req) { unlink(tmp_path); update_ui_status("Canceled"); return false; }
if (!ok || total < 512) { unlink(tmp_path); update_ui_status("Failed error"); return false; }
if (content_len >=0 && total < content_len * 95 / 100) { unlink(tmp_path); update_ui_status("Failed incomplete"); return false; }
if (rename(tmp_path, path) != 0) { unlink(tmp_path); update_ui_status("Rename failed"); return false; }
return true;
}
static void download_task(void *arg) {
(void)arg;
G.downloading = true;
G.cancel_req = false;
G.last_pct = -1;
char url[512];
char path[512];
strncpy(url, G.url, sizeof(url)-1);
strncpy(path, G.path, sizeof(path)-1);
url[sizeof(url)-1]=0;
path[sizeof(path)-1]=0;
bool ok = download_one_file(url, path);
if (ok) {
char msg[64];
snprintf(msg, sizeof(msg), "Done! %d KB", G.total/1024);
update_ui_status(msg);
}
G.downloading = false;
G.dl_handle = NULL;
set_result_and_close(ok);
vTaskDelete(NULL);
}
static void btn_cancel_cb(lv_event_t *e) {
(void)e;
if (!G.downloading) {
tt_app_stop();
return;
}
G.cancel_req = true;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Canceling...");
tt_lvgl_unlock();
}
static void build_ui(void) {
tt_lvgl_lock(portMAX_DELAY);
lv_obj_t *root = lv_obj_create(lv_scr_act());
lv_obj_set_size(root, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(root, 8, 0);
lv_obj_set_style_bg_color(root, lv_color_hex(0x111111), 0);
lv_obj_set_style_border_width(root, 0, 0);
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(root, 6, 0);
lv_obj_t *title = lv_label_create(root);
lv_label_set_text(title, "Downloading...");
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
G.lbl_url = lv_label_create(root);
lv_label_set_text(G.lbl_url, G.url);
lv_obj_set_width(G.lbl_url, LV_PCT(100));
lv_label_set_long_mode(G.lbl_url, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(G.lbl_url, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_url, lv_color_hex(0x888888), 0);
G.lbl_path = lv_label_create(root);
lv_label_set_text(G.lbl_path, G.path);
lv_obj_set_width(G.lbl_path, LV_PCT(100));
lv_label_set_long_mode(G.lbl_path, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(G.lbl_path, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_path, lv_color_hex(0xAAAAAA), 0);
G.bar = lv_bar_create(root);
lv_obj_set_size(G.bar, LV_PCT(100), 14);
lv_bar_set_range(G.bar, 0, 100);
lv_bar_set_value(G.bar, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(G.bar, lv_color_hex(0x333333), LV_PART_MAIN);
lv_obj_set_style_bg_color(G.bar, lv_color_hex(0x2E7D32), LV_PART_INDICATOR);
lv_obj_t *row = lv_obj_create(root);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
G.lbl_pct = lv_label_create(row);
lv_label_set_text(G.lbl_pct, "0%");
lv_obj_set_style_text_font(G.lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
G.lbl_detail = lv_label_create(row);
lv_label_set_text(G.lbl_detail, "-");
lv_obj_set_style_text_font(G.lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_detail, lv_color_hex(0x888888), 0);
G.lbl_status = lv_label_create(root);
lv_label_set_text(G.lbl_status, "Starting...");
lv_obj_set_width(G.lbl_status, LV_PCT(100));
lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_color(G.lbl_status, lv_color_hex(0xCCCCCC), 0);
G.btn_cancel = lv_button_create(root);
lv_obj_set_size(G.btn_cancel, 100, 40);
lv_obj_set_style_bg_color(G.btn_cancel, lv_color_hex(0x444444), 0);
lv_obj_t *lbl = lv_label_create(G.btn_cancel);
lv_label_set_text(lbl, "Cancel");
lv_obj_center(lbl);
lv_obj_add_event_cb(G.btn_cancel, btn_cancel_cb, LV_EVENT_CLICKED, NULL);
tt_lvgl_unlock();
}
static void onShow(AppHandle app, void* data, lv_obj_t* parent) {
(void)data; (void)parent;
memset(&G, 0, sizeof(G));
G.app_handle = app;
G.last_pct = -1;
BundleHandle bundle = tt_app_get_parameters(app);
bool has_url = false;
bool has_path = false;
if (bundle) {
char url_buf[512] = {0};
char path_buf[512] = {0};
bool override_val = false;
if (tt_bundle_opt_string(bundle, "url", url_buf, sizeof(url_buf)) ||
tt_bundle_opt_string(bundle, "file", url_buf, sizeof(url_buf))) {
strncpy(G.url, url_buf, sizeof(G.url)-1);
has_url = true;
}
if (tt_bundle_opt_string(bundle, "path", path_buf, sizeof(path_buf)) ||
tt_bundle_opt_string(bundle, "dest", path_buf, sizeof(path_buf)) ||
tt_bundle_opt_string(bundle, "file_dest", path_buf, sizeof(path_buf))) {
strncpy(G.path, path_buf, sizeof(G.path)-1);
has_path = true;
}
if (tt_bundle_opt_bool(bundle, "override", &override_val)) {
G.override_existing = override_val;
} else {
bool ow2 = false;
if (tt_bundle_opt_bool(bundle, "overwrite", &ow2)) G.override_existing = ow2;
}
}
if (!has_url) strncpy(G.url, DEFAULT_URL, sizeof(G.url)-1);
if (!has_path) strncpy(G.path, DEFAULT_PATH, sizeof(G.path)-1);
mkdir("/sdcard", 0777);
mkdir("/sdcard/download", 0777);
mkdir("/sdcard/dm", 0777);
build_ui();
bool should_autostart = has_url && has_path;
if (!has_url && !has_path) {
should_autostart = true;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Demo mode no args, using defaults");
tt_lvgl_unlock();
}
ESP_LOGI(TAG, "Autostart=%d url=%s path=%s override=%d", should_autostart, G.url, G.path, G.override_existing);
if (should_autostart) {
BaseType_t res = xTaskCreate(download_task, "sd_dl", 8192, NULL, 5, &G.dl_handle);
ESP_LOGI(TAG, "xTaskCreate res=%d handle=%p", res, G.dl_handle);
if (res != pdPASS) update_ui_status("Failed to create DL task");
} else {
update_ui_status("Missing url/path args");
}
}
static void onHide(AppHandle app, void* data) {
(void)app; (void)data;
if (G.dl_handle) {
G.cancel_req = true;
for (int i=0;i<20 && G.dl_handle!=NULL; i++) vTaskDelay(pdMS_TO_TICKS(100));
if (G.dl_handle) { vTaskDelete(G.dl_handle); G.dl_handle = NULL; G.downloading = false; }
}
}
int main(int argc, char* argv[]) {
(void)argc; (void)argv;
tt_app_register((AppRegistration){ .onShow = onShow, .onHide = onHide });
return 0;
}
-7
View File
@@ -1,7 +0,0 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.sddownloader
app.name=SD Downloader
app.version.name=1.0.0
app.version.code=1
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.serialconsole
app.version.name=0.7.0
app.version.code=7
app.name=Serial Console
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.serialconsole
versionName=0.4.0
versionCode=4
name=Serial Console
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.snake
app.version.name=0.8.0
app.version.code=8
app.name=Snake
app.description=Classic Snake game
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.snake
versionName=0.5.0
versionCode=5
name=Snake
description=Classic Snake game
+1
View File
@@ -57,6 +57,7 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
if (sfxEngine == nullptr) {
sfxEngine = new SfxEngine();
sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
// Load settings
bool soundEnabled;
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.tamatac
app.version.name=0.4.0
app.version.code=4
app.name=TamaTac
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.tamatac
versionName=0.1.0
versionCode=1
name=TamaTac
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.todolist
app.version.name=0.5.0
app.version.code=5
app.name=Todo List
app.description=Simple task list manager
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.todolist
versionName=0.2.0
versionCode=2
name=Todo List
description=Simple task list manager
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.twoeleven
app.version.name=0.7.0
app.version.code=7
app.name=2048
app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.twoeleven
versionName=0.4.0
versionCode=4
name=2048
description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
-16
View File
@@ -1,16 +0,0 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(VoiceRecorder)
tactility_project(VoiceRecorder)
-6
View File
@@ -1,6 +0,0 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK
)
+169 -229
View File
@@ -1,15 +1,16 @@
/**
* Voice Recorder App for Tactility OS
*
* Records voice memos (16kHz 16-bit Mono WAV) to /sdcard/memos/
* Uses audio-stream API with resampling (native codec 44100 -> app 16000)
* Records voice memos (16kHz 16-bit Mono WAV format) to the SD card (/sdcard/memos/)
* and plays them back.
*/
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
@@ -30,18 +31,6 @@
#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes
#define AUDIO_BUF_SIZE 4096
// ─── Scroll animation flags ───
// LVGL label scroll animation (LV_LABEL_LONG_SCROLL_*) causes continuous
// invalidations and screen refreshes that can starve the audio_stream task.
// LVGL's lv_list_add_button() internally sets SCROLL_CIRCULAR by default (see
// lv_list.c), so we must override it for memo names.
//
// User request: apply flag only to recordings names on the list.
#define ENABLE_SCROLL_ANIM_WHEN_IDLE 1
#define ENABLE_SCROLL_ANIM_DURING_AUDIO 0
#define ENABLE_MEMO_LIST_SCROLL_ANIM 0 // 0 = DOT truncation (no anim, safe for audio), 1 = SCROLL_CIRCULAR
#define ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO 0 // Force DOT during recording/playback regardless of above
#define MAX_MEMOS 50
#define MAX_FILENAME_LEN 64
@@ -53,9 +42,7 @@ typedef enum {
} AudioState;
typedef struct {
struct Device* stream_dev;
AudioStreamHandle input_handle;
AudioStreamHandle output_handle;
struct Device* i2s_dev;
uint8_t* audio_buf;
AudioState state;
int volume;
@@ -78,19 +65,19 @@ typedef struct {
} AppCtx;
typedef struct __attribute__((packed)) {
char riff[4];
uint32_t overall_size;
char wave[4];
char fmt_chunk_marker[4];
uint32_t length_of_fmt;
uint16_t format_type;
uint16_t channels;
uint32_t sample_rate;
uint32_t byterate;
uint16_t block_align;
uint16_t bits_per_sample;
char data_chunk_header[4];
uint32_t data_size;
char riff[4]; // "RIFF"
uint32_t overall_size; // file size - 8
char wave[4]; // "WAVE"
char fmt_chunk_marker[4]; // "fmt "
uint32_t length_of_fmt; // 16 for PCM
uint16_t format_type; // 1 for PCM
uint16_t channels; // 1 for mono
uint32_t sample_rate; // 16000
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
uint16_t block_align; // channels * (bits_per_sample / 8)
uint16_t bits_per_sample; // 16
char data_chunk_header[4]; // "data"
uint32_t data_size; // data size in bytes
} WavHeader;
static AppCtx g_ctx;
@@ -100,20 +87,6 @@ static void update_ui(AppCtx* ctx);
static void refresh_memo_list(AppCtx* ctx);
static void on_memo_selected(lv_event_t* e);
static bool find_audio_stream_device(AppCtx* ctx) {
struct Device* dev = device_find_by_name("audio-stream");
if (dev) {
ctx->stream_dev = dev;
return true;
}
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
if (dev) {
ctx->stream_dev = dev;
return true;
}
return false;
}
/* ─── WAV Header Writer ─── */
static void write_wav_header(FILE* f, uint32_t sample_rate, uint16_t bits_per_sample, uint16_t channels, uint32_t data_size) {
WavHeader header;
@@ -141,8 +114,10 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
struct tm timeinfo;
localtime_r(&rawtime, &timeinfo);
// Format: YYYYMMDD_HHMMSS (e.g., 20260629_232202)
strftime(datetime_buf, sizeof(datetime_buf), "%Y%m%d_%H%M%S", &timeinfo);
// Default unique name candidate
snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf);
char filepath[256];
@@ -150,21 +125,24 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
struct stat st;
if (stat(filepath, &st) != 0) {
// File does not exist, safe to use
return;
}
// File exists, let's search for a unique name by appending _N
int suffix = 1;
while (1) {
snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix);
snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename);
if (stat(filepath, &st) != 0) {
// Found a unique name!
return;
}
suffix++;
}
}
/* ─── Record Task (audio-stream input, resampled to 16k mono) ─── */
/* ─── Record Task ─── */
static void record_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
char filepath[256];
@@ -186,24 +164,29 @@ static void record_task(void* arg) {
return;
}
// Write default header
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
// Open input stream via audio-stream (resampler provides 16k mono regardless of codec native rate)
struct AudioStreamConfig in_cfg = {
/* Configure I2S for recording - RLCD fix: ES7210 outputs stereo (2 mics)
* even when we want mono file. Working MicroPython uses I2S.STEREO for RX
* then extracts left channel. If we request MONO, driver uses MONO slot mode
* but ES7210 still sends 2 slots, causing channel mismatch / low pitch.
* So request STEREO from I2S and downmix to mono in the loop.
* See audio_util.py: i2s = I2S(..., format=I2S.STEREO, rate=16000) */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channels = 1
.channel_left = 0,
.channel_right = 1 // STEREO for ES7210 dual mic - we downmix to mono below
};
error_t err = audio_stream_open_input(ctx->stream_dev, &in_cfg, &ctx->input_handle);
if (err == ERROR_NONE) {
// Ensure mic is unmuted and at max gain (ES8311 0..24dB)
audio_stream_set_mute(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, false);
audio_stream_set_volume(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
ESP_LOGI(TAG, "Mic unmuted, gain 100%%");
}
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
ESP_LOGE(TAG, "Failed to set I2S config: %d", err);
fclose(f);
unlink(filepath);
tt_lvgl_lock(portMAX_DELAY);
@@ -216,54 +199,68 @@ static void record_task(void* arg) {
return;
}
ESP_LOGI(TAG, "Recording started -> %s (via audio-stream %u Hz)", filepath, (unsigned)SAMPLE_RATE);
ESP_LOGI(TAG, "Recording started -> %s", filepath);
size_t total_written = 0;
uint32_t start_time = xTaskGetTickCount();
uint32_t last_ui_sec = 0;
bool first_ui = true;
while (ctx->state == STATE_RECORDING) {
size_t bytes_read = 0;
err = audio_stream_read(ctx->input_handle, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(200));
err = i2s_controller_read(ctx->i2s_dev, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(100));
if (err == ERROR_NONE && bytes_read > 0) {
// Data already 16k mono from audio-stream resampler, no downmix needed
size_t written = fwrite(ctx->audio_buf, 1, bytes_read, f);
if (written != bytes_read) {
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)bytes_read);
// ES7210 stereo -> mono downmix: extract left channel (every other 16-bit sample)
// Working MP: for i in 0..bytes_read step 4: mono[j:j+2]=buffer[i:i+2]
// CHUNK_BYTES=1024 mono, but we read AUDIO_BUF_SIZE stereo (4096) then downmix to half
size_t mono_bytes = bytes_read / 2;
// In-place downmix using separate temp? Use static tmp buffer via audio_buf + mono offset
// Easiest: convert in-place from end to start to avoid overwrite
// Stereo layout: L0 R0 L1 R1 ... each 2 bytes, total bytes_read
// We want L0 L1 L2 ... -> mono_bytes
// Do backward copy: dest index = mono_bytes-2, src left = bytes_read-4
// This avoids needing second buffer
for (int src = (int)bytes_read - 4, dst = (int)mono_bytes - 2; src >= 0 && dst >= 0; src -= 4, dst -= 2) {
ctx->audio_buf[dst] = ctx->audio_buf[src];
ctx->audio_buf[dst+1] = ctx->audio_buf[src+1];
}
size_t written = fwrite(ctx->audio_buf, 1, mono_bytes, f);
if (written != mono_bytes) {
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)mono_bytes);
break;
}
total_written += written;
}
// Update elapsed time in UI
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
// Throttle UI updates to once per second to reduce LVGL invalidations during recording
if (first_ui || elapsed_sec != last_ui_sec) {
first_ui = false;
last_ui_sec = elapsed_sec;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
tt_lvgl_unlock();
}
// Animate progress bar using modulo
int bar_val = (int)(((xTaskGetTickCount() - start_time) * 100) / (configTICK_RATE_HZ * 300)); // 5 min max
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, bar_val % 100, LV_ANIM_OFF);
tt_lvgl_unlock();
}
audio_stream_close(ctx->input_handle);
ctx->input_handle = NULL;
// Rewrite WAV header with actual recorded size
fseek(f, 0, SEEK_SET);
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
fclose(f);
ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written);
// Reset I2S controller to stop/release DMA channel
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
refresh_memo_list(ctx);
// Select the new file automatically
ctx->selected_index = -1;
for (int i = 0; i < ctx->memo_count; i++) {
if (strcmp(ctx->memos[i], filename) == 0) {
@@ -271,7 +268,7 @@ static void record_task(void* arg) {
break;
}
}
update_ui(ctx);
tt_lvgl_unlock();
@@ -279,7 +276,7 @@ static void record_task(void* arg) {
vTaskDelete(NULL);
}
/* ─── Playback Task (audio-stream output with resampling) ─── */
/* ─── Playback Task ─── */
static void play_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
@@ -308,6 +305,7 @@ static void play_task(void* arg) {
return;
}
// Read and parse WAV header
WavHeader header;
if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) {
ESP_LOGE(TAG, "Failed to read WAV header");
@@ -322,6 +320,7 @@ static void play_task(void* arg) {
return;
}
// Verify file structure
if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) {
ESP_LOGE(TAG, "Invalid WAV format");
fclose(f);
@@ -335,16 +334,21 @@ static void play_task(void* arg) {
return;
}
/* Open output stream via audio-stream (resampler converts file rate -> native 44100) */
struct AudioStreamConfig out_cfg = {
/* Configure I2S based on file metadata */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = header.sample_rate,
.bits_per_sample = header.bits_per_sample,
.channels = header.channels
.channel_left = 0,
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
};
error_t err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_open_output failed for playback: %d (rate=%u)", err, (unsigned)header.sample_rate);
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
fclose(f);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
@@ -356,7 +360,7 @@ static void play_task(void* arg) {
return;
}
ESP_LOGI(TAG, "Playing via audio-stream: %s, rate=%u Hz, ch=%u, size=%u",
ESP_LOGI(TAG, "Playing: %s, rate=%u Hz, channels=%u, size=%u bytes",
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
size_t total_played = 0;
@@ -367,38 +371,40 @@ static void play_task(void* arg) {
fseek(f, sizeof(WavHeader), SEEK_SET);
}
bool out_open = true;
int last_pct = -1;
uint32_t last_elapsed_sec = 0;
bool first_ui = true;
bool i2s_configured = true;
while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) {
if (out_open) {
audio_stream_close(ctx->output_handle);
ctx->output_handle = NULL;
out_open = false;
if (i2s_configured) {
// Reset I2S controller to stop DMA immediately when pausing
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
i2s_configured = false;
}
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
if (!out_open) {
err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
if (!i2s_configured) {
// Reconfigure I2S when resuming
device_lock(ctx->i2s_dev);
err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to reopen audio_stream on resume: %d", err);
ESP_LOGE(TAG, "Failed to reconfigure I2S on resume: %d", err);
break;
}
out_open = true;
i2s_configured = true;
}
size_t to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES;
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f);
if (read_bytes == 0) {
break;
break; // EOF
}
// Volume scaling
// Scaling Volume
int vol = ctx->volume;
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
size_t sample_count = read_bytes / sizeof(int16_t);
@@ -411,48 +417,45 @@ static void play_task(void* arg) {
bool write_err = false;
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
size_t written = 0;
err = audio_stream_write(ctx->output_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "audio_stream write error: %d", err);
ESP_LOGE(TAG, "I2S playback write error: %d", err);
write_err = true;
break;
}
offset += written;
}
if (write_err) break;
if (write_err) {
break;
}
total_played += read_bytes;
// UI progress update
int pct = (int)((total_played * 100) / data_size);
uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8);
uint32_t elapsed_sec = total_played / bytes_per_sec;
uint32_t total_sec = data_size / bytes_per_sec;
// Throttle UI updates only when % or second changes, to reduce LVGL work during audio
if (first_ui || pct != last_pct || elapsed_sec != last_elapsed_sec) {
first_ui = false;
last_pct = pct;
last_elapsed_sec = elapsed_sec;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u",
(unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60),
(unsigned)(total_sec / 60), (unsigned)(total_sec % 60));
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u",
(unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60),
(unsigned)(total_sec / 60), (unsigned)(total_sec % 60));
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
fclose(f);
ESP_LOGI(TAG, "Playback task complete");
if (out_open && ctx->output_handle) {
audio_stream_close(ctx->output_handle);
ctx->output_handle = NULL;
}
// Reset I2S controller to stop/release DMA channel and prevent looping noise
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
@@ -467,12 +470,12 @@ static void play_task(void* arg) {
static void on_record_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->state != STATE_IDLE) return;
if (ctx->stream_dev == NULL) return;
if (ctx->i2s_dev == NULL) return;
ctx->state = STATE_RECORDING;
update_ui(ctx);
xTaskCreate(record_task, "voice_rec", 8192, ctx, 5, &ctx->task_handle);
xTaskCreate(record_task, "voice_rec", 4096, ctx, 5, &ctx->task_handle);
}
static void on_play_pause_click(lv_event_t* e) {
@@ -482,7 +485,7 @@ static void on_play_pause_click(lv_event_t* e) {
if (ctx->state == STATE_IDLE) {
ctx->state = STATE_PLAYING;
update_ui(ctx);
xTaskCreate(play_task, "voice_play", 8192, ctx, 5, &ctx->task_handle);
xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle);
} else if (ctx->state == STATE_PLAYING) {
ctx->state = STATE_PAUSED;
update_ui(ctx);
@@ -520,12 +523,14 @@ static void on_memo_selected(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
lv_obj_t* btn = lv_event_get_target(e);
// Clear check states from other entries
uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
for (uint32_t i = 0; i < child_cnt; i++) {
lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i);
lv_obj_clear_state(child, LV_STATE_CHECKED);
}
// Apply check state to the clicked memo
lv_obj_add_state(btn, LV_STATE_CHECKED);
const char* text = lv_list_get_btn_text(ctx->lst_memos, btn);
@@ -563,6 +568,7 @@ static void refresh_memo_list(AppCtx* ctx) {
}
closedir(dir);
// Simple bubble sort
for (int i = 0; i < ctx->memo_count - 1; i++) {
for (int j = 0; j < ctx->memo_count - i - 1; j++) {
if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) {
@@ -574,94 +580,21 @@ static void refresh_memo_list(AppCtx* ctx) {
}
}
// Populate UI List
for (int i = 0; i < ctx->memo_count; i++) {
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
// ─── Fix: LVGL list buttons default to SCROLL_CIRCULAR (see lv_list.c)
// This causes constant invalidations that starve audio_stream.
// User wants flag to disable this for recording names.
// Safe handling: last child is always the label (icon is first if present)
uint32_t child_cnt = lv_obj_get_child_cnt(btn);
if (child_cnt > 0) {
lv_obj_t* label = lv_obj_get_child(btn, child_cnt - 1);
if (label) {
#if ENABLE_MEMO_LIST_SCROLL_ANIM
// Allow scrolling when flag enabled but force DOT during audio if that flag is off
#if ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#else
// During audio we still force DOT; this func runs at list refresh (idle time)
// To respect flag for idle, we set SCROLL here; update_ui will force DOT during audio if needed
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#endif
#else
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
#endif
}
}
if (i == ctx->selected_index) {
lv_obj_add_state(btn, LV_STATE_CHECKED);
}
}
}
// Helper to force DOT on memo list during audio playback/recording if needed
static void set_memo_list_scroll_enabled(AppCtx* ctx, bool enable) {
if (!ctx->lst_memos) return;
#if ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO
// If flag allows scroll during audio, do nothing
if (enable) return;
#endif
// Force all memo labels to DOT (no animation) when audio active
uint32_t list_child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
for (uint32_t i = 0; i < list_child_cnt; i++) {
lv_obj_t* btn = lv_obj_get_child(ctx->lst_memos, i);
if (!btn) continue;
uint32_t btn_child_cnt = lv_obj_get_child_cnt(btn);
if (btn_child_cnt == 0) continue;
lv_obj_t* label = lv_obj_get_child(btn, btn_child_cnt - 1);
if (label) {
lv_label_set_long_mode(label, enable ?
#if ENABLE_MEMO_LIST_SCROLL_ANIM
LV_LABEL_LONG_SCROLL_CIRCULAR
#else
LV_LABEL_LONG_DOT
#endif
: LV_LABEL_LONG_DOT);
}
}
}
// Helper to control label scrolling based on audio state
static void set_status_scroll_enabled(AppCtx* ctx, bool enable) {
if (!ctx->lbl_status) return;
if (enable) {
#if ENABLE_SCROLL_ANIM_WHEN_IDLE
// Allow scrolling when idle if flag says so, otherwise DOT
// We intentionally keep status as DOT even when idle to avoid
// unnecessary redraws, but this hook lets future tweaks enable it.
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#else
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#endif
} else {
#if ENABLE_SCROLL_ANIM_DURING_AUDIO
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_SCROLL_CIRCULAR);
#else
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#endif
}
}
static void update_ui(AppCtx* ctx) {
if (ctx->lbl_status == NULL) return;
switch (ctx->state) {
case STATE_RECORDING:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED);
@@ -669,8 +602,6 @@ static void update_ui(AppCtx* ctx) {
break;
case STATE_PLAYING:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE);
@@ -679,8 +610,6 @@ static void update_ui(AppCtx* ctx) {
break;
case STATE_PAUSED:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY);
@@ -690,14 +619,12 @@ static void update_ui(AppCtx* ctx) {
case STATE_IDLE:
default:
set_status_scroll_enabled(ctx, true);
set_memo_list_scroll_enabled(ctx, true);
lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED);
if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) {
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_delete, LV_STATE_DISABLED);
char status_buf[128];
snprintf(status_buf, sizeof(status_buf), "Selected: %s", ctx->memos[ctx->selected_index]);
lv_label_set_text(ctx->lbl_status, status_buf);
@@ -720,101 +647,115 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
g_ctx.volume = 80;
g_ctx.selected_index = -1;
// Create the memos storage directory if missing
mkdir("/sdcard/memos", 0755);
// Allocate audio buffer
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
if (g_ctx.audio_buf == NULL) {
ESP_LOGE(TAG, "Failed to allocate audio buffer");
}
if (!find_audio_stream_device(&g_ctx)) {
ESP_LOGE(TAG, "audio-stream device not found!");
// Locate I2S hardware channel
g_ctx.i2s_dev = device_find_by_name("i2s0");
if (g_ctx.i2s_dev == NULL) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
}
// ─── UI Setup ───
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Premium Player Card
lv_obj_t* card = lv_obj_create(parent);
lv_obj_set_size(card, lv_pct(95), lv_pct(88));
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
lv_obj_set_style_radius(card, 15, 0);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); // mocha base
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); // mocha surface0
lv_obj_set_style_border_width(card, 2, 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_set_style_pad_all(card, 10, 0);
lv_obj_set_style_pad_gap(card, 8, 0);
// Status Area
g_ctx.lbl_status = lv_label_create(card);
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
lv_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); // mocha text
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
// Progress Bar
g_ctx.bar_progress = lv_bar_create(card);
lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8);
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); // mocha surface1
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); // mocha blue
// Memos List
g_ctx.lst_memos = lv_list_create(card);
lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85);
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0);
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0); // mocha mantle
lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0);
lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0);
lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0);
// Controls Box
lv_obj_t* ctrl_box = lv_obj_create(card);
lv_obj_remove_style_all(ctrl_box);
lv_obj_set_size(ctrl_box, lv_pct(95), 40);
lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Rec Button
g_ctx.btn_record = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_record, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_record, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0);
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); // mocha red
lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
lv_obj_center(lbl_rec);
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
// Play/Pause Button
g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_play_pause, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0);
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); // mocha blue
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
lv_obj_center(lbl_play);
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
// Stop Button
g_ctx.btn_stop = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_stop, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0);
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0); // mocha subtext1
lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
lv_obj_center(lbl_stop);
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
// Delete Button
g_ctx.btn_delete = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_delete, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0);
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); // mocha sapphire
lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete);
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
lv_obj_center(lbl_del);
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
if (g_ctx.stream_dev == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: audio-stream missing");
// Initialize list and control state
if (g_ctx.i2s_dev == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S 'i2s0' missing");
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
@@ -837,17 +778,16 @@ static void onHideApp(AppHandle app, void* data) {
g_ctx.state = STATE_IDLE;
}
// Wait for task completion
while (g_ctx.task_handle != NULL) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (g_ctx.input_handle) {
audio_stream_close(g_ctx.input_handle);
g_ctx.input_handle = NULL;
}
if (g_ctx.output_handle) {
audio_stream_close(g_ctx.output_handle);
g_ctx.output_handle = NULL;
// Reset I2S to ensure DMA channel is stopped
if (g_ctx.i2s_dev != NULL) {
device_lock(g_ctx.i2s_dev);
i2s_controller_reset(g_ctx.i2s_dev);
device_unlock(g_ctx.i2s_dev);
}
if (g_ctx.audio_buf != NULL) {
-7
View File
@@ -1,7 +0,0 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3
app.id=one.tactility.voicerecorder
app.version.name=1.0.0
app.version.code=1
app.name=Voice Recorder
-70
View File
@@ -1,70 +0,0 @@
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
)
+1 -3
View File
@@ -26,7 +26,6 @@
#include <cstdint>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
@@ -278,8 +277,7 @@ private:
// State
//--------------------------------------------------------------------------
Device* streamDevice_ = nullptr;
AudioStreamHandle streamHandle_ = nullptr;
Device* i2sDevice_ = nullptr;
TaskHandle_t task_ = nullptr;
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
QueueHandle_t msgQueue_ = nullptr;
+5 -4
View File
@@ -35,8 +35,11 @@ if (!engine->start()) {
ESP_LOGE(TAG, "Failed to start SfxEngine");
return;
}
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
engine->play(SfxId::Coin); // Predefined SFX
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
engine->setVolume(0.7f); // Volume control
engine->stop();
delete engine;
@@ -84,11 +87,9 @@ idf_component_register(
- `void stopVoice(voice)` - Stop specific voice
### Settings
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
- `void setEnabled(bool)` - Mute/unmute
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.
- `void applyVolumePreset(VolumePreset)` - Apply Quiet/Normal/Loud preset (configures volume, gate, normalization)
### Mixing (consistent with SoundEngine)
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
+36 -53
View File
@@ -9,8 +9,7 @@
#include "SfxEngine.h"
#include "SfxDefinitions.h"
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/drivers/i2s_controller.h>
#include <cmath>
#include <cstring>
#include "esp_log.h"
@@ -425,7 +424,7 @@ void SfxEngine::audioTaskFunc(void* param) {
size_t written;
QueueMsg msg;
ESP_LOGI(TAG, "Audio task started (audio-stream)");
ESP_LOGI(TAG, "Audio task started");
while (self->running_) {
// Process queued messages (non-blocking)
@@ -464,26 +463,19 @@ void SfxEngine::audioTaskFunc(void* param) {
// Fill audio buffer (member buffer to avoid stack pressure)
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
// Write via audio-stream (resampled to native codec rate, e.g. 44100)
if (self->streamHandle_ == nullptr) {
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
error_t error = audio_stream_write(self->streamHandle_, self->audioBuffer_,
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(200));
// Write to I2S
error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
if (error != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_write error %d", error);
ESP_LOGE(TAG, "I2S write error");
self->running_ = false;
break;
}
}
// Flush silence to avoid pop
if (self->streamHandle_ != nullptr) {
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
size_t dummy = 0;
audio_stream_write(self->streamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &dummy, pdMS_TO_TICKS(100));
}
// Flush silence
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
ESP_LOGI(TAG, "Audio task exiting");
@@ -502,38 +494,33 @@ void SfxEngine::audioTaskFunc(void* param) {
bool SfxEngine::start() {
if (running_) return true;
// Find audio-stream device (new audio provision)
streamDevice_ = nullptr;
streamHandle_ = nullptr;
// Find I2S device
i2sDevice_ = nullptr;
device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) {
if (!device_is_ready(device)) return true;
Device** devicePtr = static_cast<Device**>(context);
*devicePtr = device;
return false;
});
streamDevice_ = device_find_by_name("audio-stream");
if (streamDevice_ == nullptr) {
// Fallback: find first device of AUDIO_STREAM_TYPE
device_for_each_of_type(&AUDIO_STREAM_TYPE, &streamDevice_, [](Device* device, void* context) {
if (!device_is_ready(device)) return true;
Device** devPtr = static_cast<Device**>(context);
*devPtr = device;
return false;
});
}
if (streamDevice_ == nullptr) {
ESP_LOGW(TAG, "No audio-stream device found - audio provision missing");
if (i2sDevice_ == nullptr) {
ESP_LOGW(TAG, "No I2S device found");
return false;
}
// Open output stream with resampling (app wants 16k stereo, codec native is 44100)
struct AudioStreamConfig cfg = {
// Configure I2S
I2sConfig config = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = 16,
.channels = 2
.channel_left = 0,
.channel_right = 0
};
error_t error = audio_stream_open_output(streamDevice_, &cfg, &streamHandle_);
error_t error = i2s_controller_set_config(i2sDevice_, &config);
if (error != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error);
streamDevice_ = nullptr;
streamHandle_ = nullptr;
ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
i2sDevice_ = nullptr;
return false;
}
@@ -541,13 +528,12 @@ bool SfxEngine::start() {
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
if (msgQueue_ == nullptr) {
ESP_LOGE(TAG, "Failed to create message queue");
audio_stream_close(streamHandle_);
streamHandle_ = nullptr;
streamDevice_ = nullptr;
i2s_controller_reset(i2sDevice_);
i2sDevice_ = nullptr;
return false;
}
// Start audio task (needs slightly larger stack for audio_stream write path)
// Start audio task
running_ = true;
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
if (result != pdPASS) {
@@ -555,13 +541,12 @@ bool SfxEngine::start() {
running_ = false;
vQueueDelete(msgQueue_);
msgQueue_ = nullptr;
audio_stream_close(streamHandle_);
streamHandle_ = nullptr;
streamDevice_ = nullptr;
i2s_controller_reset(i2sDevice_);
i2sDevice_ = nullptr;
return false;
}
ESP_LOGI(TAG, "SfxEngine started via audio-stream (voices=%d, sampleRate=%d -> resampled to native)", NUM_VOICES, SAMPLE_RATE);
ESP_LOGI(TAG, "SfxEngine started (voices=%d, sampleRate=%d)", NUM_VOICES, SAMPLE_RATE);
return true;
}
@@ -590,13 +575,11 @@ void SfxEngine::stop() {
msgQueue_ = nullptr;
}
if (streamHandle_ != nullptr) {
audio_stream_close(streamHandle_);
streamHandle_ = nullptr;
if (i2sDevice_ != nullptr) {
i2s_controller_reset(i2sDevice_);
i2sDevice_ = nullptr;
}
streamDevice_ = nullptr;
ESP_LOGI(TAG, "SfxEngine stopped");
}
+40 -55
View File
@@ -1,23 +1,14 @@
import json
import subprocess
import tarfile
import os
import tempfile
import configparser
import sys
from datetime import datetime, UTC
def read_properties_file(path):
properties = {}
with open(path, "r") as file:
for line in file:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
config = configparser.RawConfigParser()
config.read(path)
return config
def get_manifest(appPath):
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz
@@ -71,49 +62,51 @@ def get_manifest(appPath):
return None
def get_versioned_file_name(manifest):
app_id = manifest["app.id"]
version_code = manifest["app.version.code"]
app_id = manifest["app"]["id"]
version_code = manifest["app"]["versionCode"]
return f"{app_id}-{version_code}.app"
def get_os_version(manifest):
sdk = manifest["target.sdk"]
sdk = manifest["target"]["sdk"]
# Remove trailing hyphen suffix if present
if "-" in sdk:
return sdk.rsplit("-", 1)[0].strip()
else:
return sdk
def check_and_get_sdk_version(manifest_map):
"""Ensure all apps target the same (simplified) SDK version and return it."""
versions = {get_os_version(manifest) for manifest in manifest_map.values()}
if len(versions) != 1:
print(f"ERROR: Apps target multiple SDK versions: {sorted(versions)}. All apps must target the same SDK version.")
sys.exit(1)
return next(iter(versions))
def get_git_commit_hash():
return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
def manifest_config_to_flat_json(manifest):
"""Convert a flat (V2) manifest dict into a flat JSON-like dict.
"""Convert a ConfigParser manifest into a flat JSON-like dict.
Expected keys:
app.id -> appId
app.version.name -> appVersionName
app.version.code -> appVersionCode (int)
app.name -> appName
app.description -> appDescription (optional; default "")
target.sdk -> targetSdk
target.platforms -> targetPlatforms (comma-separated list)
Expected sections/keys (case-insensitive for keys):
- [app]
id -> appId
versionName -> appVersionName
versionCode -> appVersionCode (int)
name -> appName
description -> appDescription (optional; default "")
- [target]
sdk -> targetSdk
platforms -> targetPlatforms (comma-separated list)
Unknown/missing values fall back to sensible defaults per requirements.
"""
def get_opt(section, option, default=None):
if not manifest.has_section(section):
return default
# try exact option then lowercase (RawConfigParser lowercases by default)
if manifest.has_option(section, option):
return manifest.get(section, option)
low = option.lower()
if manifest.has_option(section, low):
return manifest.get(section, low)
return default
# Map values
app_id = manifest.get("app.id", "")
app_version_name = manifest.get("app.version.name", "")
app_version_code_raw = manifest.get("app.version.code", "0")
app_name = manifest.get("app.name", "")
app_description = manifest.get("app.description", "") or ""
app_id = get_opt("app", "id", "")
app_version_name = get_opt("app", "versionName", "")
app_version_code_raw = get_opt("app", "versionCode", "0")
app_name = get_opt("app", "name", "")
app_description = get_opt("app", "description", "") or ""
# Coerce version code to int safely
try:
@@ -121,8 +114,8 @@ def manifest_config_to_flat_json(manifest):
except Exception:
app_version_code = 0
target_sdk = manifest.get("target.sdk", "")
platforms_raw = manifest.get("target.platforms", "")
target_sdk = get_opt("target", "sdk", "")
platforms_raw = get_opt("target", "platforms", "")
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
filename = get_versioned_file_name(manifest)
@@ -149,23 +142,15 @@ if __name__ == "__main__":
sys.exit()
app_directory = sys.argv[1]
manifest_map = {}
output_json = {
"apps": []
}
any_manifest = None
if os.path.exists(app_directory):
for file in os.listdir(app_directory):
if file.endswith(".app"):
file_path = os.path.join(app_directory, file)
manifest_map[file_path] = get_manifest(file_path)
# All bundled apps must target the same SDK version; this becomes the CDN path segment
sdk_version = check_and_get_sdk_version(manifest_map)
with open("sdk_version.txt", "w") as f:
f.write(sdk_version)
print(f"SDK version: {sdk_version}")
output_json = {
"sdkVersion": sdk_version,
"created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
"gitCommit": get_git_commit_hash(),
"apps": []
}
# Rename files and collect manifest data into output json object
for file_path in manifest_map.keys():
print(f"Processing {file_path}: {manifest_map[file_path]}")
@@ -179,4 +164,4 @@ if __name__ == "__main__":
any_manifest = manifest
# Write JSON
output_json_path = os.path.join(app_directory, "apps.json")
write_json(output_json_path, output_json)
write_json(output_json_path, output_json)
+36 -23
View File
@@ -1,3 +1,4 @@
import configparser
import json
import os
import re
@@ -12,7 +13,7 @@ import tarfile
from urllib.parse import urlparse
ttbuild_path = ".tactility"
ttbuild_version = "4.1.0"
ttbuild_version = "3.5.1"
ttbuild_cdn = "https://cdn.tactilityproject.org"
ttbuild_sdk_json_validity = 3600 # seconds
ttport = 6666
@@ -105,17 +106,9 @@ def get_url(ip, path):
return f"http://{ip}:{ttport}{path}"
def read_properties_file(path):
properties = {}
with open(path, "r") as file:
for line in file:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
config = configparser.RawConfigParser()
config.read(path)
return config
#endregion Core
@@ -192,7 +185,7 @@ def fetch_sdkconfig_files(platform_targets):
for platform in platform_targets:
sdkconfig_filename = f"sdkconfig.app.{platform}"
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path):
exit_with_error(f"Failed to download sdkconfig file for {platform}")
#endregion SDK helpers
@@ -238,12 +231,32 @@ def read_manifest():
return read_properties_file("manifest.properties")
def validate_manifest(manifest):
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"):
if key not in manifest:
exit_with_error(f"Invalid manifest format: {key} not found")
# [manifest]
if not "manifest" in manifest:
exit_with_error("Invalid manifest format: [manifest] not found")
if not "version" in manifest["manifest"]:
exit_with_error("Invalid manifest format: [manifest] version not found")
# [target]
if not "target" in manifest:
exit_with_error("Invalid manifest format: [target] not found")
if not "sdk" in manifest["target"]:
exit_with_error("Invalid manifest format: [target] sdk not found")
if not "platforms" in manifest["target"]:
exit_with_error("Invalid manifest format: [target] platforms not found")
# [app]
if not "app" in manifest:
exit_with_error("Invalid manifest format: [app] not found")
if not "id" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] id not found")
if not "versionName" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] versionName not found")
if not "versionCode" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] versionCode not found")
if not "name" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] name not found")
def is_valid_manifest_platform(manifest, platform):
manifest_platforms = manifest["target.platforms"].split(",")
manifest_platforms = manifest["target"]["platforms"].split(",")
return platform in manifest_platforms
def validate_manifest_platform(manifest, platform):
@@ -252,7 +265,7 @@ def validate_manifest_platform(manifest, platform):
def get_manifest_target_platforms(manifest, requested_platform):
if requested_platform == "" or requested_platform is None:
return manifest["target.platforms"].split(",")
return manifest["target"]["platforms"].split(",")
else:
validate_manifest_platform(manifest, requested_platform)
return [requested_platform]
@@ -499,7 +512,7 @@ def build_action(manifest, platform_arg, skip_build):
if use_local_sdk:
global local_base_path
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
validate_local_sdks(platforms_to_build, manifest["target.sdk"])
validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
if should_fetch_sdkconfig_files(platforms_to_build):
fetch_sdkconfig_files(platforms_to_build)
@@ -508,7 +521,7 @@ def build_action(manifest, platform_arg, skip_build):
sdk_json = read_sdk_json()
validate_self(sdk_json)
# Build
sdk_version = manifest["target.sdk"]
sdk_version = manifest["target"]["sdk"]
if not use_local_sdk:
if not sdk_download_all(sdk_version, platforms_to_build):
exit_with_error("Failed to download one or more SDKs")
@@ -557,7 +570,7 @@ def get_device_info(ip):
print_status_error(f"Device info request failed: {e}")
def run_action(manifest, ip):
app_id = manifest["app.id"]
app_id = manifest["app"]["id"]
print_status_busy("Running")
url = get_url(ip, "/app/run")
params = {'id': app_id}
@@ -601,7 +614,7 @@ def install_action(ip, platforms):
return False
def uninstall_action(manifest, ip):
app_id = manifest["app.id"]
app_id = manifest["app"]["id"]
print_status_busy("Uninstalling")
url = get_url(ip, "/app/uninstall")
params = {'id': app_id}
@@ -657,7 +670,7 @@ if __name__ == "__main__":
exit_with_error("manifest.properties not found")
manifest = read_manifest()
validate_manifest(manifest)
all_platform_targets = manifest["target.platforms"].split(",")
all_platform_targets = manifest["target"]["platforms"].split(",")
# Update SDK cache (tool.json)
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
exit_with_error("Failed to retrieve SDK info")