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
61 changed files with 3295 additions and 2273 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 name: Release Apps
outputs:
sdk_version:
description: 'Common SDK version shared by all bundled apps'
value: ${{ steps.release.outputs.sdk_version }}
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
@@ -16,11 +11,8 @@ runs:
run: rsync -av downloaded_apps/*/*.app cdn_files/ run: rsync -av downloaded_apps/*/*.app cdn_files/
shell: bash shell: bash
- name: 'Create CDN release files' - name: 'Create CDN release files'
id: release run: python release.py cdn_files/
shell: bash shell: bash
run: |
python release.py cdn_files/
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
- name: 'Upload Artifact' - name: 'Upload Artifact'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
+1 -22
View File
@@ -12,12 +12,10 @@ jobs:
Build: Build:
strategy: strategy:
matrix: 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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Build" - name: "Build"
uses: ./.github/actions/build-app uses: ./.github/actions/build-app
with: with:
@@ -25,26 +23,7 @@ jobs:
Bundle: Bundle:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Build] needs: [Build]
outputs:
sdk_version: ${{ steps.release.outputs.sdk_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: "Build" - name: "Build"
id: release
uses: ./.github/actions/release-apps uses: ./.github/actions/release-apps
PublishApps:
runs-on: ubuntu-latest
needs: [Bundle]
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Publish Apps"
env:
CDN_ID: ${{ secrets.CDN_ID }}
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
uses: ./.github/actions/publish-apps
with:
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3 [target]
app.id=one.tactility.audiotest sdk=0.7.0-dev
app.version.name=0.1.0 platforms=esp32s3
app.version.code=1 [app]
app.name=Audio Test 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_BIBLE_BOOKS 66
#define MAX_BOOK_NAME 40 #define MAX_BOOK_NAME 40
#define MAX_VERSE_TEXT 2048 #define MAX_VERSE_TEXT 2048
#define MAX_PATH 128 #define MAX_PATH 320
typedef struct { typedef struct {
uint32_t offset; uint32_t offset;
@@ -239,7 +239,7 @@ static bool parse_books_json(AppCtx* ctx, const char* json_str) {
const char* obj_end = strchr(obj_start, '}'); const char* obj_end = strchr(obj_start, '}');
if (!obj_end) break; if (!obj_end) break;
// parse fields within obj_start..obj_end // parse fields within obj_start..obj_end
char temp[256]; char temp[512];
size_t len = (size_t)(obj_end - obj_start + 1); size_t len = (size_t)(obj_end - obj_start + 1);
if (len >= sizeof(temp)) len = sizeof(temp)-1; if (len >= sizeof(temp)) len = sizeof(temp)-1;
memcpy(temp, obj_start, len); memcpy(temp, obj_start, len);
@@ -672,27 +672,27 @@ static void show_current_verse(AppCtx* ctx) {
const char* text_ptr = "(empty)"; const char* text_ptr = "(empty)";
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off); if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
// Fix stack overflow: verse_buf was 2048 bytes on stack, causing gui task (4096) overflow char verse_buf[MAX_VERSE_TEXT];
// Use heap allocation strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1);
char* verse_buf = (char*)malloc(MAX_VERSE_TEXT); verse_buf[sizeof(verse_buf)-1]='\0';
if (!verse_buf) {
ESP_LOGE(TAG, "Failed to malloc verse_buf");
return;
}
strncpy(verse_buf, text_ptr, MAX_VERSE_TEXT-1);
verse_buf[MAX_VERSE_TEXT-1]='\0';
char ref_buf[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); snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
char ref_pretty[80]; // Editorial reference style like your image: "PSALMS 23:1" with tracking
char bname_upper[40]; 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); strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
bname_upper[sizeof(bname_upper)-1]='\0'; bname_upper[sizeof(bname_upper)-1]='\0';
for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p); for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p);
snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum); snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum);
if (ctx->lbl_verse) { if (ctx->lbl_verse) {
// start faded for transition
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0); lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
lv_label_set_text(ctx->lbl_verse, verse_buf); lv_label_set_text(ctx->lbl_verse, verse_buf);
apply_verse_scaling(ctx, verse_buf); apply_verse_scaling(ctx, verse_buf);
@@ -741,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); if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED); else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
} }
free(verse_buf);
save_progress(ctx); save_progress(ctx);
verse_fade_in(ctx); verse_fade_in(ctx);
} }
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.bibleverse sdk=0.8.0-dev
app.version.name=0.1.0 platforms=esp32s3,esp32p4
app.version.code=2 [app]
app.name=Bible Verse id=one.tactility.bibleverse
app.description=Single verse at a time, advances each minute. Tap to show controls. versionName=0.1.0
versionCode=1
name=Bible Verse
description=Single verse at a time, advances each minute. Tap to show controls.
+125 -100
View File
@@ -4,7 +4,7 @@
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/audio_stream.h> #include <tactility/drivers/i2s_controller.h>
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
@@ -45,8 +45,7 @@ typedef struct {
} BookMetadata; } BookMetadata;
typedef struct { typedef struct {
struct Device* stream_dev; struct Device* i2s_dev;
AudioStreamHandle stream_handle;
PlaybackState state; PlaybackState state;
int volume; int volume;
@@ -87,19 +86,19 @@ typedef struct {
} AppCtx; } AppCtx;
typedef struct __attribute__((packed)) { typedef struct __attribute__((packed)) {
char riff[4]; char riff[4]; // "RIFF"
uint32_t overall_size; uint32_t overall_size; // file size - 8
char wave[4]; char wave[4]; // "WAVE"
char fmt_chunk_marker[4]; char fmt_chunk_marker[4]; // "fmt "
uint32_t length_of_fmt; uint32_t length_of_fmt; // 16 for PCM
uint16_t format_type; uint16_t format_type; // 1 for PCM
uint16_t channels; uint16_t channels; // 1 for mono
uint32_t sample_rate; uint32_t sample_rate; // 16000
uint32_t byterate; uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
uint16_t block_align; uint16_t block_align; // channels * (bits_per_sample / 8)
uint16_t bits_per_sample; uint16_t bits_per_sample; // 16
char data_chunk_header[4]; char data_chunk_header[4]; // "data"
uint32_t data_size; uint32_t data_size; // data size in bytes
} WavHeader; } WavHeader;
static AppCtx g_ctx; static AppCtx g_ctx;
@@ -114,47 +113,6 @@ static void play_mp3(AppCtx* ctx);
static void play_wav(AppCtx* ctx); static void play_wav(AppCtx* ctx);
static void scan_books(AppCtx* ctx); static void scan_books(AppCtx* ctx);
/* ─── Audio-stream helpers ─── */
static bool find_audio_stream_device(AppCtx* ctx) {
// Prefer device_find_first_by_type but keep compatibility with name lookup
struct Device* dev = device_find_by_name("audio-stream");
if (dev) {
ctx->stream_dev = dev;
return true;
}
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
if (dev) {
ctx->stream_dev = dev;
return true;
}
return false;
}
static void close_stream_if_open(AppCtx* ctx) {
if (ctx->stream_handle) {
audio_stream_close(ctx->stream_handle);
ctx->stream_handle = NULL;
}
}
static bool open_output_stream(AppCtx* ctx, uint32_t sample_rate, uint8_t channels, uint8_t bits) {
close_stream_if_open(ctx);
if (!ctx->stream_dev) return false;
struct AudioStreamConfig cfg = {
.sample_rate = sample_rate,
.bits_per_sample = bits,
.channels = channels
};
error_t err = audio_stream_open_output(ctx->stream_dev, &cfg, &ctx->stream_handle);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_open_output failed: %d (rate=%u ch=%u)", err, (unsigned)sample_rate, channels);
ctx->stream_handle = NULL;
return false;
}
ESP_LOGI(TAG, "audio_stream output opened: %u Hz %u ch %u-bit", (unsigned)sample_rate, channels, bits);
return true;
}
/* ─── UI Helper to update status labels & button states ─── */ /* ─── UI Helper to update status labels & button states ─── */
static void update_ui(AppCtx* ctx) { static void update_ui(AppCtx* ctx) {
if (!ctx->btn_play_pause) return; if (!ctx->btn_play_pause) return;
@@ -227,6 +185,7 @@ static void on_auto_advance_timer(lv_timer_t* timer) {
static void handle_audio_finished(AppCtx* ctx) { static void handle_audio_finished(AppCtx* ctx) {
if (ctx->current_page + 1 < ctx->page_count) { if (ctx->current_page + 1 < ctx->page_count) {
// Create a one-shot timer with repeat count 1 to run on the GUI thread
lv_timer_t* timer = lv_timer_create(on_auto_advance_timer, 0, ctx); lv_timer_t* timer = lv_timer_create(on_auto_advance_timer, 0, ctx);
lv_timer_set_repeat_count(timer, 1); lv_timer_set_repeat_count(timer, 1);
} else { } else {
@@ -249,6 +208,7 @@ static void wait_for_playback_task_to_exit(AppCtx* ctx) {
/* ─── Load Page Content (Image, Caption, Audio path) ─── */ /* ─── Load Page Content (Image, Caption, Audio path) ─── */
static void load_page(AppCtx* ctx, int page_index, bool start_audio) { static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
// 1. Stop current audio if running and wait for thread to finish
wait_for_playback_task_to_exit(ctx); wait_for_playback_task_to_exit(ctx);
if (page_index < 0 || page_index >= ctx->page_count) return; if (page_index < 0 || page_index >= ctx->page_count) return;
@@ -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); lv_image_set_src(ctx->img_page, lv_img_path);
} else { } else {
ESP_LOGW(TAG, "Image file not found: %s", img_path); ESP_LOGW(TAG, "Image file not found: %s", img_path);
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); // Show default fallback icon
} }
} else { } else {
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
} }
// Update Page index indicator
// Update Page index indicator (e.g. "1 / 12")
char ind_buf[32]; char ind_buf[32];
snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count); snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count);
lv_label_set_text(ctx->lbl_indicator, ind_buf); lv_label_set_text(ctx->lbl_indicator, ind_buf);
@@ -296,16 +258,18 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
ctx->current_audio_path[0] = '\0'; ctx->current_audio_path[0] = '\0';
} }
// Update UI states (like Prev/Next buttons)
update_ui(ctx); update_ui(ctx);
// Start playback if requested and valid // Start playback if requested and valid
if (start_audio && ctx->current_audio_path[0] != '\0') { if (start_audio && ctx->current_audio_path[0] != '\0') {
// Verify audio file exists
FILE* audio_file = fopen(ctx->current_audio_path, "rb"); FILE* audio_file = fopen(ctx->current_audio_path, "rb");
if (audio_file) { if (audio_file) {
fclose(audio_file); fclose(audio_file);
ctx->state = STATE_PLAYING; ctx->state = STATE_PLAYING;
update_ui(ctx); update_ui(ctx);
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle); xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
} else { } else {
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path); ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
const char* buttons[] = {"OK"}; const char* buttons[] = {"OK"};
@@ -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) { static void play_mp3(AppCtx* ctx) {
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
vTaskDelay(pdMS_TO_TICKS(400)); vTaskDelay(pdMS_TO_TICKS(400));
FILE* file = fopen(ctx->current_audio_path, "rb"); FILE* file = fopen(ctx->current_audio_path, "rb");
@@ -389,12 +354,14 @@ static void play_mp3(AppCtx* ctx) {
int sample_rate = 0; int sample_rate = 0;
int channels = 0; int channels = 0;
ESP_LOGI(TAG, "Starting MP3 playback 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) { while (ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) { if (ctx->state == STATE_PAUSED) {
if (ctx->stream_handle) { if (sample_rate != 0) {
close_stream_if_open(ctx); device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
sample_rate = 0; sample_rate = 0;
channels = 0; channels = 0;
} }
@@ -423,6 +390,7 @@ static void play_mp3(AppCtx* ctx) {
if (info.frame_bytes <= 0) { if (info.frame_bytes <= 0) {
if (eof) break; if (eof) break;
// Resync
memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes); memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes);
continue; continue;
} }
@@ -432,10 +400,22 @@ static void play_mp3(AppCtx* ctx) {
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes); memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
if (samples > 0) { if (samples > 0) {
// Open/reopen stream on format change // Configure I2S
if (sample_rate != info.hz || channels != info.channels) { if (sample_rate != info.hz || channels != info.channels) {
if (!open_output_stream(ctx, (uint32_t)info.hz, (uint8_t)info.channels, 16)) { struct I2sConfig config = {
ESP_LOGE(TAG, "Failed to open audio stream for MP3: %d Hz %d ch", info.hz, info.channels); .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; break;
} }
sample_rate = info.hz; sample_rate = info.hz;
@@ -451,15 +431,15 @@ static void play_mp3(AppCtx* ctx) {
samples_ptr[i] = (int16_t)scaled; 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 offset = 0;
size_t data_size = sample_count * sizeof(int16_t); size_t data_size = sample_count * sizeof(int16_t);
bool write_err = false; bool write_err = false;
while (offset < data_size && ctx->state == STATE_PLAYING) { while (offset < data_size && ctx->state == STATE_PLAYING) {
size_t written = 0; size_t written = 0;
error_t err = 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) { 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; write_err = true;
break; break;
} }
@@ -485,7 +465,12 @@ static void play_mp3(AppCtx* ctx) {
} }
fclose(file); fclose(file);
close_stream_if_open(ctx);
if (ctx->i2s_dev) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
bool stopped_externally = (ctx->state == STATE_IDLE); bool stopped_externally = (ctx->state == STATE_IDLE);
@@ -504,8 +489,9 @@ static void play_mp3(AppCtx* ctx) {
vTaskDelete(NULL); vTaskDelete(NULL);
} }
/* ─── WAV Decoder Routine (audio-stream) ─── */ /* ─── WAV Decoder Routine ─── */
static void play_wav(AppCtx* ctx) { static void play_wav(AppCtx* ctx) {
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
vTaskDelay(pdMS_TO_TICKS(400)); vTaskDelay(pdMS_TO_TICKS(400));
FILE* file = fopen(ctx->current_audio_path, "rb"); FILE* file = fopen(ctx->current_audio_path, "rb");
@@ -552,36 +538,49 @@ static void play_wav(AppCtx* ctx) {
fseek(file, sizeof(WavHeader), SEEK_SET); fseek(file, sizeof(WavHeader), SEEK_SET);
} }
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) { struct I2sConfig config = {
ESP_LOGE(TAG, "Failed to open audio stream for WAV: %u Hz %u ch", (unsigned)header.sample_rate, header.channels); .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); fclose(file);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
ctx->playback_task_handle = NULL; ctx->playback_task_handle = NULL;
vTaskDelete(NULL); vTaskDelete(NULL);
return; return;
} }
ESP_LOGI(TAG, "Starting WAV 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; size_t total_played = 0;
bool stream_open = true; bool i2s_configured = true;
while (total_played < data_size && ctx->state != STATE_IDLE) { while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) { if (ctx->state == STATE_PAUSED) {
if (stream_open) { if (i2s_configured) {
close_stream_if_open(ctx); device_lock(ctx->i2s_dev);
stream_open = false; i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
i2s_configured = false;
} }
vTaskDelay(pdMS_TO_TICKS(50)); vTaskDelay(pdMS_TO_TICKS(50));
continue; continue;
} }
if (!stream_open) { if (!i2s_configured) {
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) break; device_lock(ctx->i2s_dev);
stream_open = true; 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; 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; samples_ptr[i] = (int16_t)scaled;
} }
// Write via audio_stream // Play to I2S
size_t offset = 0; size_t offset = 0;
bool write_err = false; bool write_err = false;
while (offset < read_bytes && ctx->state == STATE_PLAYING) { while (offset < read_bytes && ctx->state == STATE_PLAYING) {
size_t written = 0; size_t written = 0;
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) { 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; write_err = true;
break; break;
} }
@@ -615,6 +614,7 @@ static void play_wav(AppCtx* ctx) {
total_played += read_bytes; total_played += read_bytes;
// Update progress bar
int pct = (int)((total_played * 100) / data_size); int pct = (int)((total_played * 100) / data_size);
if (pct < 0) pct = 0; if (pct < 0) pct = 0;
if (pct > 100) pct = 100; if (pct > 100) pct = 100;
@@ -629,7 +629,12 @@ static void play_wav(AppCtx* ctx) {
} }
fclose(file); fclose(file);
close_stream_if_open(ctx);
if (ctx->i2s_dev) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
bool stopped_externally = (ctx->state == STATE_IDLE); bool stopped_externally = (ctx->state == STATE_IDLE);
@@ -699,6 +704,7 @@ static void on_book_selected(lv_event_t* e) {
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN);
// Load first page (no auto-play initially)
load_page(ctx, 0, false); load_page(ctx, 0, false);
} }
@@ -789,7 +795,7 @@ static void on_play_pause_click(lv_event_t* e) {
if (ctx->state == STATE_IDLE) { if (ctx->state == STATE_IDLE) {
ctx->state = STATE_PLAYING; ctx->state = STATE_PLAYING;
update_ui(ctx); update_ui(ctx);
xTaskCreate(audio_playback_task, "audio_play", 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) { } else if (ctx->state == STATE_PLAYING) {
ctx->state = STATE_PAUSED; ctx->state = STATE_PAUSED;
update_ui(ctx); update_ui(ctx);
@@ -851,9 +857,10 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t)); g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
#endif #endif
// Find audio-stream device (provides resampling to native 44100) // Find I2S sound device
if (!find_audio_stream_device(&g_ctx)) { g_ctx.i2s_dev = device_find_by_name("i2s0");
ESP_LOGE(TAG, "audio-stream device not found! Tried 'audio-stream' name and AUDIO_STREAM_TYPE"); if (!g_ctx.i2s_dev) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
} }
// Create dual layouts // Create dual layouts
@@ -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_border_width(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0); lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0); lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0); 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_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Picker list
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper); g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100)); lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0); lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
@@ -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_all(g_ctx.player_wrapper, 0, 0);
lv_obj_set_style_pad_gap(g_ctx.player_wrapper, 0, 0); lv_obj_set_style_pad_gap(g_ctx.player_wrapper, 0, 0);
lv_obj_set_style_bg_color(g_ctx.player_wrapper, lv_color_hex(0x1E1E2E), 0); lv_obj_set_style_bg_color(g_ctx.player_wrapper, lv_color_hex(0x1E1E2E), 0);
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE); 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); lv_obj_add_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_HIDDEN);
// Page Image (covers the whole screen as background)
g_ctx.img_page = lv_image_create(g_ctx.player_wrapper); g_ctx.img_page = lv_image_create(g_ctx.player_wrapper);
lv_obj_align(g_ctx.img_page, LV_ALIGN_CENTER, 0, 0); lv_obj_align(g_ctx.img_page, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0); lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0);
@@ -896,17 +906,19 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_add_flag(g_ctx.img_page, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(g_ctx.img_page, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(g_ctx.img_page, on_image_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(g_ctx.img_page, on_image_click, LV_EVENT_CLICKED, &g_ctx);
// Custom Player header (Back button, Book Title, Page Indicator)
g_ctx.header_bar = lv_obj_create(g_ctx.player_wrapper); g_ctx.header_bar = lv_obj_create(g_ctx.player_wrapper);
lv_obj_t* header_bar = g_ctx.header_bar; lv_obj_t* header_bar = g_ctx.header_bar;
lv_obj_set_size(header_bar, LV_PCT(100), 36); lv_obj_set_size(header_bar, LV_PCT(100), 36);
lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_radius(header_bar, 0, 0); lv_obj_set_style_radius(header_bar, 0, 0);
lv_obj_set_style_bg_color(header_bar, lv_color_hex(0x11111B), 0); lv_obj_set_style_bg_color(header_bar, lv_color_hex(0x11111B), 0);
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0); 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_color(header_bar, lv_color_hex(0x313244), 0);
lv_obj_set_style_border_width(header_bar, 1, LV_PART_MAIN); lv_obj_set_style_border_width(header_bar, 1, LV_PART_MAIN);
lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE);
// Back button
lv_obj_t* btn_back = lv_button_create(header_bar); lv_obj_t* btn_back = lv_button_create(header_bar);
lv_obj_set_size(btn_back, 44, 26); lv_obj_set_size(btn_back, 44, 26);
lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0);
@@ -917,25 +929,28 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_center(lbl_back); lv_obj_center(lbl_back);
lv_obj_add_event_cb(btn_back, on_back_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(btn_back, on_back_click, LV_EVENT_CLICKED, &g_ctx);
// Title
g_ctx.lbl_book_title = lv_label_create(header_bar); g_ctx.lbl_book_title = lv_label_create(header_bar);
lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_CENTER, 0, 0); lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_width(g_ctx.lbl_book_title, 180); lv_obj_set_width(g_ctx.lbl_book_title, 180);
lv_obj_set_style_text_align(g_ctx.lbl_book_title, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_align(g_ctx.lbl_book_title, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xCDD6F4), 0); lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xCDD6F4), 0);
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT); 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); g_ctx.lbl_indicator = lv_label_create(header_bar);
lv_obj_align(g_ctx.lbl_indicator, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_align(g_ctx.lbl_indicator, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_style_text_color(g_ctx.lbl_indicator, lv_color_hex(0xA6ADC8), 0); lv_obj_set_style_text_color(g_ctx.lbl_indicator, lv_color_hex(0xA6ADC8), 0);
lv_label_set_text(g_ctx.lbl_indicator, "1 / 1"); lv_label_set_text(g_ctx.lbl_indicator, "1 / 1");
// Bottom Controls container (overlaying background image)
g_ctx.ctrl_bar = lv_obj_create(g_ctx.player_wrapper); g_ctx.ctrl_bar = lv_obj_create(g_ctx.player_wrapper);
lv_obj_t* ctrl_bar = g_ctx.ctrl_bar; lv_obj_t* ctrl_bar = g_ctx.ctrl_bar;
lv_obj_set_size(ctrl_bar, LV_PCT(100), 40); lv_obj_set_size(ctrl_bar, LV_PCT(100), 40);
lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_radius(ctrl_bar, 0, 0); lv_obj_set_style_radius(ctrl_bar, 0, 0);
lv_obj_set_style_bg_color(ctrl_bar, lv_color_hex(0x11111B), 0); lv_obj_set_style_bg_color(ctrl_bar, lv_color_hex(0x11111B), 0);
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0); 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_border_width(ctrl_bar, 0, 0);
lv_obj_set_style_pad_all(ctrl_bar, 0, 0); lv_obj_set_style_pad_all(ctrl_bar, 0, 0);
lv_obj_set_style_pad_gap(ctrl_bar, 0, 0); lv_obj_set_style_pad_gap(ctrl_bar, 0, 0);
@@ -943,6 +958,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_flex_align(ctrl_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_align(ctrl_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(ctrl_bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
// Progress Bar (thin bar running along the top of control bar)
g_ctx.bar_progress = lv_bar_create(g_ctx.player_wrapper); g_ctx.bar_progress = lv_bar_create(g_ctx.player_wrapper);
lv_obj_set_size(g_ctx.bar_progress, LV_PCT(100), 4); lv_obj_set_size(g_ctx.bar_progress, LV_PCT(100), 4);
lv_obj_align_to(g_ctx.bar_progress, ctrl_bar, LV_ALIGN_OUT_TOP_MID, 0, 0); lv_obj_align_to(g_ctx.bar_progress, ctrl_bar, LV_ALIGN_OUT_TOP_MID, 0, 0);
@@ -951,6 +967,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
// Prev Button
g_ctx.btn_prev = lv_button_create(ctrl_bar); g_ctx.btn_prev = lv_button_create(ctrl_bar);
lv_obj_set_size(g_ctx.btn_prev, 54, 30); lv_obj_set_size(g_ctx.btn_prev, 54, 30);
lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0); lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0);
@@ -960,6 +977,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_center(lbl_prev); lv_obj_center(lbl_prev);
lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx);
// Play/Pause Button
g_ctx.btn_play_pause = lv_button_create(ctrl_bar); g_ctx.btn_play_pause = lv_button_create(ctrl_bar);
lv_obj_set_size(g_ctx.btn_play_pause, 94, 30); lv_obj_set_size(g_ctx.btn_play_pause, 94, 30);
lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0); lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0);
@@ -970,6 +988,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_center(lbl_play_btn); lv_obj_center(lbl_play_btn);
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
// Next Button
g_ctx.btn_next = lv_button_create(ctrl_bar); g_ctx.btn_next = lv_button_create(ctrl_bar);
lv_obj_set_size(g_ctx.btn_next, 54, 30); lv_obj_set_size(g_ctx.btn_next, 54, 30);
lv_obj_set_style_radius(g_ctx.btn_next, 15, 0); lv_obj_set_style_radius(g_ctx.btn_next, 15, 0);
@@ -983,12 +1002,18 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
g_ctx.state = STATE_IDLE; g_ctx.state = STATE_IDLE;
update_ui(&g_ctx); update_ui(&g_ctx);
// Scan Books
scan_books(&g_ctx); scan_books(&g_ctx);
} }
static void onHideApp(AppHandle app, void* data) { static void onHideApp(AppHandle app, void* data) {
wait_for_playback_task_to_exit(&g_ctx); wait_for_playback_task_to_exit(&g_ctx);
close_stream_if_open(&g_ctx);
if (g_ctx.i2s_dev) {
device_lock(g_ctx.i2s_dev);
i2s_controller_reset(g_ctx.i2s_dev);
device_unlock(g_ctx.i2s_dev);
}
if (g_ctx.manifest_root) { if (g_ctx.manifest_root) {
cJSON_Delete(g_ctx.manifest_root); cJSON_Delete(g_ctx.manifest_root);
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3 [target]
app.id=one.tactility.bookplayer sdk=0.8.0-dev
app.version.name=1.0.0 platforms=esp32s3
app.version.code=1 [app]
app.name=Book Player 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.brainfuck sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=Brainfuck interpreter id=one.tactility.brainfuck
app.description=Brainfuck esoteric language interpreter 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) { if (!sfxEngine) {
sfxEngine = new SfxEngine(); sfxEngine = new SfxEngine();
sfxEngine->start(); sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
sfxEngine->setEnabled(soundEnabled); sfxEngine->setEnabled(soundEnabled);
} }
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.breakout sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=Breakout id=one.tactility.breakout
app.description=Classic brick-breaking arcade game 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.calculator sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Calculator id=one.tactility.calculator
versionName=0.3.0
versionCode=3
name=Calculator
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.diceware sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Diceware id=one.tactility.diceware
versionName=0.3.0
versionCode=3
name=Diceware
@@ -1,16 +1,12 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake) include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH}) if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH}) set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else() else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK") set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}") message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
endif() endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(DiscoveryMountain)
project(EspNowBridge) tactility_project(DiscoveryMountain)
tactility_project(EspNowBridge)
@@ -1,6 +1,5 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c) file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register( idf_component_register(
SRCS ${SOURCE_FILES} SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK REQUIRES TactilitySDK esp_http_client json
) )
+390
View File
@@ -0,0 +1,390 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_bundle.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_http_client.h"
#include "cJSON.h"
#define MINIMP3_IMPLEMENTATION
#define MINIMP3_NO_SIMD
#include "minimp3.h"
#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;
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);
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;
}
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(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);
}
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; }
}
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();
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,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 });
return 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
[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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.epubreader sdk=0.7.0-dev
app.version.name=0.4.0 platforms=esp32s3,esp32p4
app.version.code=4 [app]
app.name=Epub Reader id=one.tactility.epubreader
app.description=Epub and text file reader. Requires PSRAM! versionName=0.1.0
versionCode=1
name=Epub Reader
description=Epub and text file reader. Requires PSRAM!
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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.gpio sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=GPIO id=one.tactility.gpio
versionName=0.4.0
versionCode=4
name=GPIO
+152 -160
View File
@@ -1,9 +1,9 @@
/** /**
* @file main.c * @file main.c
* @brief GameBoy DMG Emulator for Tactility (Peanut-GB prototype, no audio) * @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.
* MIT licensed Peanut-GB core vendored in Libraries/PeanutGB/peanut_gb.h * I2 (2bpp indexed) = 160*144*2/8 = 5760 bytes fits internal RAM, no cache coherency, no WiFi/SD starvation.
* See app README for notes. * Uses lv_canvas_set_palette for 4 grays per GB_PALETTE.
*/ */
#include <tt_app.h> #include <tt_app.h>
@@ -13,11 +13,9 @@
#include <tt_lvgl_keyboard.h> #include <tt_lvgl_keyboard.h>
#include <lvgl.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); 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_LOGI
#undef ESP_LOGW #undef ESP_LOGW
#undef ESP_LOGE #undef ESP_LOGE
@@ -40,8 +38,23 @@ void lv_image_cache_drop(const void * src);
#include "peanut_gb.h" #include "peanut_gb.h"
#define TAG "GameBoy" #define TAG "GameBoy"
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb" #define ROMS_DIR_LEGACY "/data/roms/gb"
#define ROMS_DIR "/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_ROMS 64
#define MAX_PATH 512 #define MAX_PATH 512
#define MAX_ROM_SIZE (2 * 1024 * 1024) #define MAX_ROM_SIZE (2 * 1024 * 1024)
@@ -49,14 +62,6 @@ void lv_image_cache_drop(const void * src);
#define FRAME_H 144 #define FRAME_H 144
#define TICK_MS 16 #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 enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
typedef struct { typedef struct {
@@ -74,8 +79,8 @@ typedef struct {
char rom_title[32]; char rom_title[32];
uint8_t joypad_state; uint8_t joypad_state;
uint16_t* fb_native; /* RGB565 tightly packed 160*144, raw u16 avoids lv_color16_t bitfield */ uint8_t* fb_indexed;
lv_draw_buf_t* fb_draw_buf; /* draw_buf that canvas src points to owned by us so we can invalidate cache */ lv_draw_buf_t* fb_draw_buf;
lv_obj_t* canvas; lv_obj_t* canvas;
lv_obj_t* root_wrapper; lv_obj_t* root_wrapper;
lv_obj_t* browser_wrapper; lv_obj_t* browser_wrapper;
@@ -106,7 +111,7 @@ typedef struct {
bool rom_loaded; bool rom_loaded;
uint32_t fps_frames; uint32_t fps_frames;
int64_t fps_last_us; int64_t fps_last_us;
uint32_t lines_drawn; /* debug: should be 144 per frame */ uint32_t lines_drawn;
} AppCtx; } AppCtx;
typedef struct { typedef struct {
@@ -114,15 +119,18 @@ typedef struct {
uint8_t joypad_bit; uint8_t joypad_bit;
} BtnUserData; } BtnUserData;
/* PSRAM helpers */
static void* alloc_psram(size_t size) { static void* alloc_psram(size_t size) {
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); 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 = 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; return p;
} }
/* Peanut-GB callbacks */
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) { static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
AppCtx* ctx = (AppCtx*)gb->direct.priv; AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->rom_data) return 0xFF; 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 (!ctx || !ctx->cart_ram) return;
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val; 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) { 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; }
const char* err_str = "UNKNOWN";
switch (err) {
case GB_INVALID_OPCODE: err_str = "INVALID OPCODE"; break;
case GB_INVALID_READ: err_str = "INVALID READ"; break;
case GB_INVALID_WRITE: err_str = "INVALID WRITE"; break;
default: break;
}
ESP_LOGE(TAG, "GB error %s (%d) @ %04X", err_str, err, addr);
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (ctx && ctx->cart_ram_size) {
char sp[MAX_PATH];
strncpy(sp, ctx->rom_path, MAX_PATH-1); sp[MAX_PATH-1]='\0';
char* dot=strrchr(sp,'.'); char* sl=strrchr(sp,'/');
if (dot && (!sl || dot>sl)) snprintf(dot, MAX_PATH-(dot-sp), ".sav"); else strncat(sp,".sav",MAX_PATH-strlen(sp)-1);
FILE* f=fopen(sp,"wb"); if(f){fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);}
}
}
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) { static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
AppCtx* ctx = (AppCtx*)gb->direct.priv; AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->fb_native) return; if (!ctx || !ctx->fb_indexed) return;
if (line >= FRAME_H) 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++) { for (int x=0;x<FRAME_W;x++) {
uint8_t shade = pixels[x] & 0x03; 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++; ctx->lines_drawn++;
} }
/* Save path */
static void get_save_path(const char* rom_path, char* out, size_t out_len) { static void get_save_path(const char* rom_path, char* out, size_t out_len) {
if (!rom_path || !out) return; if (!rom_path || !out) return;
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0'; 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)); get_save_path(ctx->rom_path, save_path, sizeof(save_path));
size_t save_sz=0; 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 (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; } if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
ctx->cart_ram_size=save_sz; ctx->cart_ram_size=save_sz;
ctx->cart_ram=alloc_psram(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); memset(ctx->cart_ram,0,save_sz);
FILE* f=fopen(save_path,"rb"); FILE* f=fopen(save_path,"rb");
if (!f){ ESP_LOGI(TAG,"No save %s",save_path); return; } if (!f) return;
size_t r=fread(ctx->cart_ram,1,save_sz,f); fclose(f); fread(ctx->cart_ram,1,save_sz,f); fclose(f);
ESP_LOGI(TAG,"Loaded save %s %zu/%zu",save_path,r,save_sz);
} }
static void save_cart_ram(AppCtx* ctx) { static void save_cart_ram(AppCtx* ctx) {
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return; 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]; char save_path[MAX_PATH];
get_save_path(ctx->rom_path, save_path, sizeof(save_path)); get_save_path(ctx->rom_path, save_path, sizeof(save_path));
FILE* f=fopen(save_path,"wb"); FILE* f=fopen(save_path,"wb");
if (!f){ ESP_LOGE(TAG,"Save open fail %s",save_path); return; } if (!f) return;
size_t w=fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f); fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
ESP_LOGI(TAG,"Saved RAM %s %zu",save_path,w);
} }
/* ROM loading */
static bool load_rom_file(AppCtx* ctx, const char* path) { static bool load_rom_file(AppCtx* ctx, const char* path) {
if (!ctx || !path) return false; if (!ctx || !path) return false;
ESP_LOGI(TAG,"Loading ROM %s",path);
FILE* f=fopen(path,"rb"); 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); 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); 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); 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->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; } 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)); memset(&ctx->gb,0,sizeof(ctx->gb));
ctx->joypad_state=0xFF; 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); 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); gb_init_lcd(&ctx->gb, lcd_draw_line);
load_cart_ram(ctx); load_cart_ram(ctx);
gb_reset(&ctx->gb); 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); 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; ctx->rom_loaded=true;
return true; return true;
} }
static void scan_rom_dir(AppCtx* ctx) { static void scan_rom_dir(AppCtx* ctx) {
ctx->rom_count=0; ctx->rom_count=0;
DIR* dir=opendir(ROMS_DIR); for (size_t d=0; d < sizeof(ROMS_CANDIDATE_DIRS)/sizeof(ROMS_CANDIDATE_DIRS[0]) && ctx->rom_count < MAX_ROMS; d++) {
if (!dir){ ESP_LOGW(TAG,"ROMS dir missing %s",ROMS_DIR); return; } const char* dir_path = ROMS_CANDIDATE_DIRS[d];
struct dirent* ent; DIR* dir=opendir(dir_path);
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){ if (!dir) continue;
if (ent->d_name[0]=='.') continue; struct dirent* ent;
size_t len=strlen(ent->d_name); while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
if (len<3) continue; if (ent->d_name[0]=='.') 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)); size_t len=strlen(ent->d_name);
if (!is_gb) continue; if (len<3) continue;
RomEntry* e=&ctx->roms[ctx->rom_count++]; 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));
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0'; if (!is_gb) continue;
snprintf(e->fullpath, sizeof(e->fullpath), "%s/%s", ROMS_DIR, ent->d_name); 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) { static void emu_timer_cb(lv_timer_t* timer) {
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer); AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return; 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; ctx->gb.direct.joypad = ctx->joypad_state;
gb_run_frame(&ctx->gb); gb_run_frame(&ctx->gb);
/* Critical fix: raw buffer mutated, LVGL image cache is stale. if (ctx->lines_drawn == 0) {
Without this, canvas shows whatever was first uploaded (gray/black) and FPS label keeps updating, static uint32_t tick=0; tick++;
giving "FPS but no game". */ for (int y=0;y<FRAME_H;y++) {
if (ctx->fb_draw_buf) { uint8_t* row=&ctx->fb_indexed[(y*FRAME_W)/4];
/* Invalidate both D-Cache (PSRAM) and LVGL image cache */ for (int x=0;x<FRAME_W;x++) {
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL); int bi=x>>2; int sh=6-((x&3)*2);
lv_image_cache_drop(ctx->fb_draw_buf); uint8_t shade = (uint8_t)((x + y + tick) & 0x03);
/* Also invalidate area via the canvas src buf if different object */ row[bi] = (uint8_t)((row[bi] & ~(0x03<<sh)) | (shade<<sh));
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->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
lv_obj_invalidate(ctx->canvas); lv_obj_invalidate(ctx->canvas);
ctx->fps_frames++; ctx->fps_frames++;
@@ -287,31 +280,20 @@ static void emu_timer_cb(lv_timer_t* timer) {
int64_t elapsed = now - ctx->fps_last_us; int64_t elapsed = now - ctx->fps_last_us;
if (elapsed >= 1000000) { if (elapsed >= 1000000) {
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed); uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
if (ctx->status_label) { 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);
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); printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
ctx->fps_frames = 0; ctx->fps_frames = 0;
ctx->fps_last_us = now; ctx->fps_last_us = now;
} }
} }
/* Input helpers */
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) { static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
if (!ctx) return; if (!ctx) return;
if (pressed) ctx->joypad_state &= (uint8_t)~bit; if (pressed) ctx->joypad_state &= (uint8_t)~bit;
else ctx->joypad_state |= bit; else ctx->joypad_state |= bit;
} }
static void input_down_cb(lv_event_t* e){ 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); }
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e); 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); }
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){ static void key_press_cb(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e); AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
uint32_t key=lv_event_get_key(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_browser_ui(AppCtx* ctx, lv_obj_t* parent);
static void build_emu_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_browser(AppCtx* ctx);
static void switch_to_emu(AppCtx* ctx); static void switch_to_emu(AppCtx* ctx);
/* ROM selection events */
static void rom_button_event(lv_event_t* e){ static void rom_button_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e); AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
lv_obj_t* btn=lv_event_get_target_obj(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; ctx->selected_rom_idx=*pIdx;
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return; if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath; const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
if (load_rom_file(ctx, path)){ if (load_rom_file(ctx, path)) switch_to_emu(ctx);
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);
} 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){ static void default_rom_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e); AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){ if (load_rom_file(ctx, DEFAULT_ROM_PATH)) switch_to_emu(ctx);
switch_to_emu(ctx); else if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found");
} 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);
} }
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){ static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_clean(parent); lv_obj_clean(parent);
ctx->rom_list=NULL; ctx->rom_list=NULL;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 4, 0); lv_obj_set_style_pad_all(parent, 4, 0);
lv_obj_set_style_bg_color(parent, lv_color_black(), 0); lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
lv_obj_t* info=lv_label_create(parent); 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_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info, LV_PCT(100)); lv_obj_set_width(info, LV_PCT(100));
lv_obj_set_style_text_color(info, lv_color_white(), 0); lv_obj_set_style_text_color(info, lv_color_white(), 0);
ctx->status_label=lv_label_create(parent); 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_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
lv_obj_t* list=lv_list_create(parent); lv_obj_t* list=lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100)); lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1); lv_obj_set_flex_grow(list, 1);
ctx->rom_list=list; ctx->rom_list=list;
if (ctx->rom_count==0){ if (ctx->rom_count==0){
lv_obj_t* lbl=lv_label_create(parent); 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); lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
} else { } else {
for (int i=0;i<ctx->rom_count;i++){ 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_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
} }
} }
lv_obj_t* default_btn=lv_btn_create(parent); lv_obj_t* default_btn=lv_btn_create(parent);
lv_obj_set_width(default_btn, LV_PCT(100)); lv_obj_set_width(default_btn, LV_PCT(100));
lv_obj_t* dlbl=lv_label_create(default_btn); 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_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); lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
if (!ctx->fb_native){ if (!ctx->fb_indexed){
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t); size_t fb_bytes = (FRAME_W * FRAME_H) / 4;
ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes); ctx->fb_indexed=(uint8_t*)alloc_internal(fb_bytes);
if (ctx->fb_native){ if (ctx->fb_indexed){ ctx->framebuffer_allocated=true; memset(ctx->fb_indexed,0,fb_bytes); }
ctx->framebuffer_allocated=true;
memset(ctx->fb_native,0,fb_bytes);
/* Initial grey fill so we can visually confirm buffer ownership even before first gb_run_frame */
for(int i=0;i<FRAME_W*FRAME_H;i++) ctx->fb_native[i]=0x4208;
}
} }
if (ctx->fb_native){ if (ctx->fb_indexed){
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL); lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL); lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
int avail_h = disp_h - 160; 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; else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
ctx->canvas = lv_canvas_create(canvas_cont); 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_indexed, FRAME_W, FRAME_H, LV_COLOR_FORMAT_I2);
lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565); 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); ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
if (ctx->fb_draw_buf && ctx->fb_draw_buf->data) { if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
/* Force fresh cache state */
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
lv_image_cache_drop(ctx->fb_draw_buf);
}
if (scale > 1) { if (scale > 1) {
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale)); lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2); 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_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); lv_obj_center(ctx->canvas);
} else { } else {
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0); lv_obj_t* 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_bg_opa(dpad,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(dpad,0,0); lv_obj_set_style_border_width(dpad,0,0);
lv_obj_set_style_pad_all(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* 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* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32); lv_obj_t* 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(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(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); 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; 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[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]); btn_ud[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_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_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_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* 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); 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; 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); build_emu_ui(ctx, ctx->emu_wrapper);
} }
/* Lifecycle */
static void* create_data(void){ static void* create_data(void){
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx)); AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; } 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; AppCtx* ctx=(AppCtx*)data;
if (!ctx) return; if (!ctx) return;
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer); 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->rom_data) heap_caps_free(ctx->rom_data);
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram); if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
free(ctx); free(ctx);
} }
static void on_create(AppHandle app, void* data){ static void on_create(AppHandle app, void* data){ AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app; }
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_destroy(AppHandle app, void* data){ (void)app; (void)data; }
static void on_show(AppHandle app, void* data, lv_obj_t* parent){ static void on_show(AppHandle app, void* data, lv_obj_t* parent){
AppCtx* ctx=(AppCtx*)data; if (!ctx) return; AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
ctx->app_handle=app; 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_set_style_border_width(ctx->emu_wrapper,0,0);
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN); 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); scan_rom_dir(ctx);
struct stat st; 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" };
bool default_exists=(stat(DEFAULT_ROM_PATH,&st)==0); for (size_t i=0;i<sizeof(default_candidates)/sizeof(default_candidates[0]);i++) {
if (default_exists){ struct stat st;
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){ if (stat(default_candidates[i], &st)==0 && load_rom_file(ctx, default_candidates[i])) {
build_browser_ui(ctx, ctx->browser_wrapper); build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx); switch_to_emu(ctx);
return; return;
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.graphicsdemo sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Graphics Demo 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.helloworld sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32s3
app.version.code=6 [app]
app.name=Hello World id=one.tactility.helloworld
versionName=0.3.0
versionCode=3
name=Hello World
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.m5unittest sdk=0.7.0-dev
app.version.name=0.4.0 platforms=esp32s3,esp32p4
app.version.code=4 [app]
app.name=M5 Unit Test 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.magic8ball sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=Magic 8-Ball 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3 [target]
app.id=one.tactility.mcpscreen sdk=0.7.0-dev
app.version.name=0.1.0 platforms=esp32s3
app.version.code=1 [app]
app.name=MCP Screen 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(); if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
} }
void MediaKeys::teardownBt() {
// Remove callback FIRST - stops any in-flight BT events from firing against
// our (possibly already freed) UI widget pointers after this returns.
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
// Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() /
// ble_gatts_start() which corrupts NimBLE heap while the host task is still
// running. HID device is a persistent kernel device; hid_device_start() cleans
// up stale context on next use. Explicit stop is handled by handleSwitchToggle.
// Restore the radio/device to the state we found them in.
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
if (_btDevice && _deviceWasStarted) device_stop(_btDevice);
_btDevice = nullptr;
_hidDevice = nullptr;
_radioWasOff = false;
_deviceWasStarted = false;
}
void MediaKeys::handleSwitchToggle(bool enabled) { void MediaKeys::handleSwitchToggle(bool enabled) {
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF"); LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
_isEnabled = enabled; _isEnabled = enabled;
if (enabled) { if (enabled) {
_btDevice = device_find_first_by_type(&BLUETOOTH_TYPE); _btDevice = bluetooth_find_first_ready_device();
if (!_btDevice) { if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found"); LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false; _isEnabled = false;
@@ -241,19 +224,6 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
return; return;
} }
// Device may not be started yet (BT disabled in DTS by default to save memory).
if (!device_is_ready(_btDevice)) {
LOG_I(TAG, "BT device not started, starting now");
if (device_start(_btDevice) != ERROR_NONE) {
LOG_E(TAG, "Failed to start BT device");
_btDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
_deviceWasStarted = true;
}
bluetooth_set_device_name(_btDevice, "Tactility Media Keys"); bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
// Register callback before enabling radio so we don't miss the state-change event. // Register callback before enabling radio so we don't miss the state-change event.
@@ -277,10 +247,12 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
} else { } else {
_radioEnabling = false; _radioEnabling = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
// Explicit user toggle-off: stop HID cleanly (safe here since we're on the
// LVGL task and the user intentionally disabled, so no race with app teardown).
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice); if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
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); 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); lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
// Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app).
struct Device* btDev = device_find_first_by_type(&BLUETOOTH_TYPE);
if (btDev && device_is_ready(btDev)) {
enum BtRadioState radioState;
if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) {
lv_obj_add_state(_switchWidget, LV_STATE_CHECKED);
handleSwitchToggle(true);
}
}
} }
void MediaKeys::onHide(AppHandle /*appHandle*/) { void MediaKeys::onHide(AppHandle /*appHandle*/) {
_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; _isEnabled = false;
_radioEnabling = false;
_radioWasOff = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
teardownBt();
if (_keyHighlightTimer) { if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer); lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr; _keyHighlightTimer = nullptr;
+3 -6
View File
@@ -2,7 +2,6 @@
#include <TactilityCpp/App.h> #include <TactilityCpp/App.h>
#include <lvgl.h> #include <lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h> #include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h> #include <tactility/drivers/bluetooth_hid_device.h>
#include <tt_app.h> #include <tt_app.h>
@@ -24,10 +23,9 @@ class MediaKeys final : public App {
struct Device* _hidDevice = nullptr; struct Device* _hidDevice = nullptr;
// State - accessed from both LVGL thread and BT callback thread // State - accessed from both LVGL thread and BT callback thread
std::atomic<bool> _isEnabled {false}; std::atomic<bool> _isEnabled {false};
std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON
std::atomic<bool> _radioWasOff {false}; // true if we turned the radio on (restore on exit) std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off)
std::atomic<bool> _deviceWasStarted{false}; // true if we called device_start (restore on exit)
// Static event callbacks // Static event callbacks
static void onSwitchToggled(lv_event_t* e); static void onSwitchToggled(lv_event_t* e);
@@ -38,7 +36,6 @@ class MediaKeys final : public App {
static void sendKeyTask(void* param); static void sendKeyTask(void* param);
// Instance methods called by static callbacks // Instance methods called by static callbacks
void teardownBt(); // remove callback + stop HID + restore radio/device state
void handleSwitchToggle(bool enabled); void handleSwitchToggle(bool enabled);
void handleButtonPress(uint32_t buttonId); void handleButtonPress(uint32_t buttonId);
void startHid(); // called once radio is confirmed ON void startHid(); // called once radio is confirmed ON
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.mediakeys sdk=0.7.0-dev
app.version.name=0.4.0 platforms=esp32s3,esp32p4
app.version.code=4 [app]
app.name=Media Keys id=one.tactility.mediakeys
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. 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.
+42 -40
View File
@@ -3,8 +3,7 @@
#include <tt_lvgl_toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/audio_stream.h> #include <tactility/drivers/i2s_controller.h>
#include <tactility/drivers/audio_codec.h>
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
@@ -28,8 +27,7 @@ typedef enum {
} PlaybackState; } PlaybackState;
typedef struct { typedef struct {
struct Device* stream_dev; struct Device* i2s_dev;
AudioStreamHandle stream_handle;
char filepath[512]; char filepath[512];
PlaybackState state; PlaybackState state;
@@ -121,17 +119,17 @@ static void mp3_playback_task(void* arg) {
ctx->eof = false; ctx->eof = false;
ctx->sample_rate = 0; ctx->sample_rate = 0;
ctx->channels = 0; ctx->channels = 0;
ctx->stream_handle = NULL;
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size); ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
while (ctx->state != STATE_IDLE) { while (ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) { if (ctx->state == STATE_PAUSED) {
if (ctx->stream_handle != NULL) { if (ctx->sample_rate != 0) {
// Close stream immediately on pause to stop DMA // Reset I2S immediately on pause to stop DMA loops
audio_stream_close(ctx->stream_handle); device_lock(ctx->i2s_dev);
ctx->stream_handle = NULL; i2s_controller_reset(ctx->i2s_dev);
// Clear cached format to force re-open on resume device_unlock(ctx->i2s_dev);
// Clear configuration cache to force reconfig on resume
ctx->sample_rate = 0; ctx->sample_rate = 0;
ctx->channels = 0; ctx->channels = 0;
} }
@@ -174,30 +172,30 @@ static void mp3_playback_task(void* arg) {
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes); memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
if (samples > 0) { if (samples > 0) {
// Configure audio-stream if format changed // Configure I2S if format changed
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) { if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
if (ctx->stream_handle != NULL) { struct I2sConfig config = {
audio_stream_close(ctx->stream_handle); .communication_format = I2S_FORMAT_STAND_I2S,
ctx->stream_handle = NULL;
}
struct AudioStreamConfig config = {
.sample_rate = (uint32_t)info.hz, .sample_rate = (uint32_t)info.hz,
.bits_per_sample = 16, .bits_per_sample = 16,
.channels = (uint8_t)info.channels .channel_left = 0,
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
}; };
error_t err = audio_stream_open_output(ctx->stream_dev, &config, &ctx->stream_handle); 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) { if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to open audio stream: %d", err); ESP_LOGE(TAG, "Failed to set config: %d", err);
break; break;
} }
ctx->sample_rate = info.hz; ctx->sample_rate = info.hz;
ctx->channels = info.channels; ctx->channels = info.channels;
ESP_LOGI(TAG, "Audio stream opened: %d Hz, %d channels", info.hz, info.channels); ESP_LOGI(TAG, "I2S configured: %d Hz, %d channels", info.hz, info.channels);
} }
// Adjust volume (after resampler) // Adjust volume
int vol = ctx->volume; int vol = ctx->volume;
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf; int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
size_t sample_count = (size_t)samples * info.channels; size_t sample_count = (size_t)samples * info.channels;
@@ -206,15 +204,15 @@ static void mp3_playback_task(void* arg) {
samples_ptr[i] = (int16_t)scaled; samples_ptr[i] = (int16_t)scaled;
} }
// Play audio via audio-stream (resampled to native 44100) // Play audio to I2S
size_t offset = 0; size_t offset = 0;
size_t data_size = sample_count * sizeof(int16_t); size_t data_size = sample_count * sizeof(int16_t);
bool write_err = false; bool write_err = false;
while (offset < data_size && ctx->state == STATE_PLAYING) { while (offset < data_size && ctx->state == STATE_PLAYING) {
size_t written = 0; size_t written = 0;
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(500)); 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) { if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "Audio stream write failed: %d", err); ESP_LOGE(TAG, "I2S write failed: %d", err);
write_err = true; write_err = true;
break; break;
} }
@@ -234,15 +232,18 @@ static void mp3_playback_task(void* arg) {
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock(); tt_lvgl_unlock();
} }
// taskYIELD removed to avoid audio gaps - decoder loop is fast enough
} }
fclose(ctx->file); fclose(ctx->file);
ctx->file = NULL; ctx->file = NULL;
// Close audio stream to clean up DMA / resampler task // Reset I2S controller to clean up DMA channels and stop looping noise
if (ctx->stream_handle != NULL) { if (ctx->i2s_dev) {
audio_stream_close(ctx->stream_handle); device_lock(ctx->i2s_dev);
ctx->stream_handle = NULL; i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
} }
ESP_LOGI(TAG, "Playback task finished"); ESP_LOGI(TAG, "Playback task finished");
@@ -291,10 +292,10 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE); g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t)); g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
// Find audio-stream device (resampling layer over ES8311 codec) // Find device
g_ctx.stream_dev = device_find_by_name("audio-stream"); g_ctx.i2s_dev = device_find_by_name("i2s0");
if (!g_ctx.stream_dev) { if (!g_ctx.i2s_dev) {
ESP_LOGE(TAG, "Audio-stream device not found!"); ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
} }
// Parse launch parameters // Parse launch parameters
@@ -392,8 +393,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_center(lbl_stop); lv_obj_center(lbl_stop);
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
if (!g_ctx.stream_dev) { if (!g_ctx.i2s_dev) {
lv_label_set_text(g_ctx.lbl_status, "Error: Audio Not Found"); lv_label_set_text(g_ctx.lbl_status, "Error: I2S Not Found");
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED); lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED); lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
} else if (!g_ctx.input_buf || !g_ctx.pcm_buf) { } else if (!g_ctx.input_buf || !g_ctx.pcm_buf) {
@@ -408,7 +409,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
if (g_ctx.filepath[0] != '\0') { if (g_ctx.filepath[0] != '\0') {
g_ctx.state = STATE_PLAYING; g_ctx.state = STATE_PLAYING;
update_ui(&g_ctx); update_ui(&g_ctx);
xTaskCreate(mp3_playback_task, "mp3_play", 6144, &g_ctx, 6, &g_ctx.playback_task_handle); xTaskCreate(mp3_playback_task, "mp3_play", 4096, &g_ctx, 5, &g_ctx.playback_task_handle);
} }
} }
} }
@@ -423,10 +424,11 @@ static void onHideApp(AppHandle app, void* data) {
vTaskDelay(pdMS_TO_TICKS(10)); vTaskDelay(pdMS_TO_TICKS(10));
} }
// Close any open audio stream // Reset I2S to ensure DMA channel is stopped
if (g_ctx.stream_handle != NULL) { if (g_ctx.i2s_dev) {
audio_stream_close(g_ctx.stream_handle); device_lock(g_ctx.i2s_dev);
g_ctx.stream_handle = NULL; i2s_controller_reset(g_ctx.i2s_dev);
device_unlock(g_ctx.i2s_dev);
} }
if (g_ctx.input_buf) { if (g_ctx.input_buf) {
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3 [target]
app.id=one.tactility.mp3player sdk=0.7.0-dev
app.version.name=1.0.0 platforms=esp32s3
app.version.code=1 [app]
app.name=MP3 Player 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.mystifydemo sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Mystify Demo 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/TactilityCpp/Include
../../../Libraries/GameKit/Include ../../../Libraries/GameKit/Include
../../../Libraries/SfxEngine/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" #include "DungeonModel.h"
#ifndef LV_ABS #ifndef LV_ABS
#define LV_ABS(x) ((x) > 0 ? (x) : -(x)) #define LV_ABS(x) ((x) > 0 ? (x) : -(x))
#endif #endif
namespace PocketDungeon { namespace PocketDungeon {
uint32_t DungeonModel::nextRandom() { uint32_t DungeonModel::nextRandom() {
@@ -9,75 +11,48 @@ uint32_t DungeonModel::nextRandom() {
return (seed >> 16u) & 0x7FFFu; return (seed >> 16u) & 0x7FFFu;
} }
void DungeonModel::reset(uint32_t seedValue, bool tutorials) { void DungeonModel::reset(uint32_t seedValue) {
seed = seedValue; seed = seedValue;
state = DungeonState {}; state = DungeonState {};
state.floor = tutorials ? 0 : 2; state.floor = 1;
state.hp = 5; state.hp = 5;
state.gold = 0; state.gold = 0;
state.gameOver = false; state.gameOver = false;
generateFloor(); 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) { void DungeonModel::addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp) {
if (state.entityCount >= MAX_ENTITIES) return; if (state.entityCount >= MAX_ENTITIES) return;
state.entities[state.entityCount++] = Entity { type, pos, hp, true }; 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() { void DungeonModel::generateFloor() {
if (state.floor < 2) { generateTutorialFloor(static_cast<uint8_t>(state.floor)); return; } for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
state.renderRows = DUNGEON_ROWS;
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
const bool edge = (x == 0 || y == 0 || x == DUNGEON_COLS - 1 || y == DUNGEON_ROWS - 1); 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.tiles[y][x] = edge ? Tile::Wall : Tile::Floor;
} }
}
state.playerPos = {1, 1}; state.playerPos = {1, 1};
state.entityCount = 0; state.entityCount = 0;
state.tiles[2][4] = Tile::Wall; state.tiles[2][4] = Tile::Wall;
state.tiles[3][4] = Tile::Wall; state.tiles[3][4] = Tile::Wall;
state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure; state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure;
state.tiles[5][7] = Tile::Stairs; state.tiles[5][7] = Tile::Stairs;
if (state.floor > 2) { if (state.floor > 1) {
auto x = static_cast<uint8_t>(2 + (nextRandom() % 5)); auto x = static_cast<uint8_t>(2 + (nextRandom() % 5));
auto y = static_cast<uint8_t>(1 + (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; 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>(state.floor / 3));
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
addEnemy(EntityType::Bat, {3, 5}, 1); 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 { int DungeonModel::findEnemyAt(GameKit::GridPos pos) const {
for (uint8_t i = 0; i < state.entityCount; ++i) { for (uint8_t i = 0; i < state.entityCount; ++i) {
const auto& e = state.entities[i]; const auto& entity = state.entities[i];
if (e.alive && e.pos == pos) return i; if (entity.alive && entity.pos == pos) return i;
} }
return -1; return -1;
} }
@@ -95,8 +70,11 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
if (enemyIdx >= 0) { if (enemyIdx >= 0) {
Entity& enemy = state.entities[enemyIdx]; Entity& enemy = state.entities[enemyIdx];
enemy.hp -= 1; enemy.hp -= 1;
if (enemy.hp <= 0) { enemy.alive = false; state.gold += enemy.type == EntityType::Bat ? 2 : 1; } if (enemy.hp <= 0) {
if (!isTutorial()) moveMonsters(); enemy.alive = false;
state.gold += enemy.type == EntityType::Bat ? 2 : 1;
}
moveMonsters();
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; } if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
return MoveResult::Attacked; return MoveResult::Attacked;
} }
@@ -109,19 +87,15 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
result = MoveResult::Treasure; result = MoveResult::Treasure;
} else if (tile == Tile::Stairs) { } else if (tile == Tile::Stairs) {
state.floor += 1; state.floor += 1;
if (state.floor >= 2) { if (state.floor > bestFloor) bestFloor = state.floor;
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1; if (state.gold > bestGold) bestGold = state.gold;
if (state.gold > bestGold) bestGold = state.gold;
}
generateFloor(); generateFloor();
return MoveResult::Stairs; return MoveResult::Stairs;
} }
if (!isTutorial()) moveMonsters(); moveMonsters();
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; } if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
if (state.floor >= 2) { if (state.floor > bestFloor) bestFloor = state.floor;
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1; if (state.gold > bestGold) bestGold = state.gold;
if (state.gold > bestGold) bestGold = state.gold;
}
return result; return result;
} }
@@ -131,19 +105,26 @@ void DungeonModel::moveMonsters() {
if (!enemy.alive) continue; if (!enemy.alive) continue;
const int16_t dx = state.playerPos.x - enemy.pos.x; const int16_t dx = state.playerPos.x - enemy.pos.x;
const int16_t dy = state.playerPos.y - enemy.pos.y; 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; GameKit::GridPos target = enemy.pos;
if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) { if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) {
const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}}; const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
const int* dir = dirs[nextRandom() % 4]; 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)) { } else if (LV_ABS(dx) > LV_ABS(dy)) {
target.x += dx > 0 ? 1 : -1; target.x += dx > 0 ? 1 : -1;
} else if (dy != 0) { } else if (dy != 0) {
target.y += dy > 0 ? 1 : -1; target.y += dy > 0 ? 1 : -1;
} }
if (target == state.playerPos) state.hp -= 1; if (target == state.playerPos) {
else if (isWalkable(target) && findEnemyAt(target) < 0) enemy.pos = target; state.hp -= 1;
} else if (isWalkable(target) && findEnemyAt(target) < 0) {
enemy.pos = target;
}
} }
} }
@@ -4,9 +4,9 @@
#include <cstdint> #include <cstdint>
namespace PocketDungeon { namespace PocketDungeon {
static constexpr uint8_t DUNGEON_COLS = 9; static constexpr uint8_t DUNGEON_COLS = 9;
static constexpr uint8_t DUNGEON_ROWS = 7; static constexpr uint8_t DUNGEON_ROWS = 7;
static constexpr uint8_t TUTORIAL_ROWS = 2;
static constexpr uint8_t MAX_ENTITIES = 8; static constexpr uint8_t MAX_ENTITIES = 8;
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure }; enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
@@ -25,11 +25,10 @@ struct DungeonState {
Entity entities[MAX_ENTITIES] {}; Entity entities[MAX_ENTITIES] {};
uint8_t entityCount = 0; uint8_t entityCount = 0;
GameKit::GridPos playerPos {1, 1}; GameKit::GridPos playerPos {1, 1};
uint16_t floor = 0; // 0,1 tutorial, 2+ real uint16_t floor = 1;
uint16_t gold = 0; uint16_t gold = 0;
int8_t hp = 5; int8_t hp = 5;
bool gameOver = false; bool gameOver = false;
uint8_t renderRows = DUNGEON_ROWS;
}; };
class DungeonModel { class DungeonModel {
@@ -43,11 +42,9 @@ class DungeonModel {
int findEnemyAt(GameKit::GridPos pos) const; int findEnemyAt(GameKit::GridPos pos) const;
bool isWalkable(GameKit::GridPos pos) const; bool isWalkable(GameKit::GridPos pos) const;
void moveMonsters(); void moveMonsters();
void generateTutorialFloor(uint8_t tId);
void clearAllTiles();
public: public:
void reset(uint32_t seedValue = 0xC0FFEE, bool tutorials = true); void reset(uint32_t seedValue = 0xC0FFEE);
void generateFloor(); void generateFloor();
MoveResult movePlayer(int dx, int dy); MoveResult movePlayer(int dx, int dy);
@@ -55,9 +52,6 @@ public:
uint16_t getBestFloor() const { return bestFloor; } uint16_t getBestFloor() const { return bestFloor; }
uint16_t getBestGold() const { return bestGold; } uint16_t getBestGold() const { return bestGold; }
void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; } 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 } // namespace PocketDungeon
@@ -19,7 +19,7 @@ void DungeonRenderer::create(lv_obj_t* parent) {
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
status = lv_label_create(root); status = lv_label_create(root);
lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN); 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 screenW = lv_display_get_horizontal_resolution(nullptr);
const lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr); const lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
const uint16_t maxCellW = static_cast<uint16_t>((screenW - 22) / DUNGEON_COLS); const uint16_t 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::Wall: return lv_color_hex(COLOR_WALL);
case Tile::Stairs: return lv_color_hex(COLOR_STAIRS); case Tile::Stairs: return lv_color_hex(COLOR_STAIRS);
case Tile::Treasure: return lv_color_hex(COLOR_TREASURE); 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) { switch (type) {
case EntityType::Player: return lv_color_hex(COLOR_PLAYER); case EntityType::Player: return lv_color_hex(COLOR_PLAYER);
case EntityType::Bat: return lv_color_hex(COLOR_BAT); 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() { 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) { for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
lv_label_set_text(labels[y][x], ""); lv_label_set_text(labels[y][x], "");
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN); lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
} }
}
} }
void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) { void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) {
const DungeonState& state = model.getState(); const DungeonState& state = model.getState();
char buf[96]; char buf[96];
if (model.isTutorial()) { std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", state.floor, state.hp, state.gold, model.getBestFloor(), model.getBestGold());
std::snprintf(buf, sizeof(buf), "TUTORIAL %u/2 HP:%d Gold:%u", model.tutorialId()+1, state.hp, state.gold);
} else {
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", model.displayFloor(), state.hp, state.gold, model.getBestFloor(), model.getBestGold());
}
lv_label_set_text(status, buf); lv_label_set_text(status, buf);
clearEntityLabels(); clearEntityLabels();
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { 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) { 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]; const Tile tile = state.tiles[y][x];
lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN); lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN);
lv_label_set_text(labels[y][x], tileText(tile)); 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) { for (uint8_t i = 0; i < state.entityCount; ++i) {
const Entity& e = state.entities[i]; const Entity& entity = state.entities[i];
if (!e.alive) continue; if (!entity.alive) continue;
const char* sym = e.type == EntityType::Bat ? "b" : "s"; const char* symbol = entity.type == EntityType::Bat ? "b" : "s";
lv_label_set_text(labels[e.pos.y][e.pos.x], sym); lv_label_set_text(labels[entity.pos.y][entity.pos.x], symbol);
lv_obj_set_style_text_color(labels[e.pos.y][e.pos.x], entityColor(e.type), LV_PART_MAIN); 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_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); lv_obj_set_style_text_color(labels[state.playerPos.y][state.playerPos.x], lv_color_hex(COLOR_PLAYER), LV_PART_MAIN);
const char* text = "Move with arrows, swipe, or tap a direction";
const char* text = model.isTutorial() ? (model.tutorialId()==0 ? "TUT1: @ to >" : "TUT2: Kill b, $ , >") : "Arrows/Swipe/Tap";
switch (lastResult) { switch (lastResult) {
case MoveResult::Blocked: text = "Wall blocks"; break; case MoveResult::Blocked: text = "A wall blocks the way"; break;
case MoveResult::Attacked: text = "You strike!"; break; case MoveResult::Attacked: text = "You strike the monster"; break;
case MoveResult::Treasure: text = "Treasure! +5"; break; case MoveResult::Treasure: text = "Treasure found!"; break;
case MoveResult::Stairs: text = model.isTutorial() ? "Tutorial done!" : "Down..."; break; case MoveResult::Stairs: text = "Down the stairs..."; break;
case MoveResult::Dead: text = "Game over - tap to restart"; break; case MoveResult::Dead: text = "Game over - tap to restart"; break;
default: break; default: break;
} }
+34 -281
View File
@@ -1,8 +1,5 @@
#include "PocketDungeon.h" #include "PocketDungeon.h"
#include <cstdio> #include <tt_lvgl_toolbar.h>
extern "C" {
#include <tt_app.h>
}
namespace PocketDungeon { 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_FLOOR = "bestFloor";
static constexpr const char* PREF_BEST_GOLD = "bestGold"; 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) { void PocketDungeon::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN); 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; 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.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) { if (sfx == nullptr) {
sfx = new SfxEngine(); sfx = new SfxEngine();
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet); if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
} }
phase = AppPhase::Intro;
onEnter(parent); onEnter(parent);
loop.start(TICK_MS, this); loop.start(TICK_MS, this);
(void) app;
} }
void PocketDungeon::onHide(AppHandle app) { void PocketDungeon::onHide(AppHandle app) {
@@ -37,300 +27,63 @@ void PocketDungeon::onHide(AppHandle app) {
loop.stop(); loop.stop();
onExit(); onExit();
saveBests(); saveBests();
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; } if (sfx != nullptr) {
sfx->stop();
delete sfx;
sfx = nullptr;
}
root = nullptr; root = nullptr;
} }
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) { void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
root = sceneRoot; renderer.create(sceneRoot);
GameKit::attachInput(root, this); GameKit::attachInput(sceneRoot, this);
showIntro();
lastResult = MoveResult::None; 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(); render();
} }
void PocketDungeon::exitGame() { void PocketDungeon::onExit() {}
saveBests();
if (sfx) sfx->play(SfxId::Cancel);
tt_app_stop();
}
void PocketDungeon::renderIntro() {
if (!introContainer) return;
lv_obj_t* titleRow = lv_obj_create(introContainer);
lv_obj_set_width(titleRow, LV_PCT(100));
lv_obj_set_height(titleRow, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(titleRow, 6, LV_PART_MAIN);
lv_obj_set_style_border_width(titleRow, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_remove_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(titleRow, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(titleRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(titleRow, 2, LV_PART_MAIN);
lv_obj_t* title = lv_label_create(titleRow);
lv_label_set_text(title, "POCKET DUNGEON");
lv_obj_set_style_text_color(title, lv_color_hex(0xF8F3E6), LV_PART_MAIN);
lv_obj_t* subtitle = lv_label_create(titleRow);
lv_label_set_text(subtitle, "Tiny paper roguelike");
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x8A9FBF), LV_PART_MAIN);
char bestBuf[64];
std::snprintf(bestBuf, sizeof(bestBuf), "Best F%u G%u", model.getBestFloor(), model.getBestGold());
lv_obj_t* best = lv_label_create(titleRow);
lv_label_set_text(best, bestBuf);
lv_obj_set_style_text_color(best, lv_color_hex(0x7A8196), LV_PART_MAIN);
lv_obj_t* mainRow = lv_obj_create(introContainer);
lv_obj_set_width(mainRow, LV_PCT(100));
lv_obj_set_flex_grow(mainRow, 1);
lv_obj_set_style_pad_all(mainRow, 4, LV_PART_MAIN);
lv_obj_set_style_pad_column(mainRow, 8, LV_PART_MAIN);
lv_obj_set_style_pad_bottom(mainRow, 8, LV_PART_MAIN);
lv_obj_set_style_border_width(mainRow, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(mainRow, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(mainRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(mainRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_remove_flag(mainRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* leftCol = lv_obj_create(mainRow);
lv_obj_set_flex_grow(leftCol, 1);
lv_obj_set_height(leftCol, LV_PCT(100));
lv_obj_set_style_pad_all(leftCol, 0, LV_PART_MAIN);
lv_obj_set_style_pad_right(leftCol, 4, LV_PART_MAIN);
lv_obj_set_style_pad_bottom(leftCol, 6, LV_PART_MAIN);
lv_obj_set_style_border_width(leftCol, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(leftCol, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(leftCol, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(leftCol, 8, LV_PART_MAIN);
lv_obj_add_flag(leftCol, LV_OBJ_FLAG_SCROLLABLE);
auto makeCard = [](lv_obj_t* parent, const char* tTitle, const char* body, uint32_t col) {
lv_obj_t* card = lv_obj_create(parent);
lv_obj_set_width(card, LV_PCT(100));
lv_obj_set_height(card, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(card, 9, LV_PART_MAIN);
lv_obj_set_style_pad_row(card, 3, LV_PART_MAIN);
lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_border_width(card, 0, LV_PART_MAIN);
lv_obj_set_style_radius(card, 10, LV_PART_MAIN);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* t = lv_label_create(card);
lv_label_set_text(t, tTitle);
lv_obj_set_style_text_color(t, lv_color_hex(col), LV_PART_MAIN);
lv_obj_t* b = lv_label_create(card);
lv_label_set_text(b, body);
lv_obj_set_style_text_color(b, lv_color_hex(0xD9DDE8), LV_PART_MAIN);
lv_label_set_long_mode(b, LV_LABEL_LONG_WRAP);
lv_obj_set_width(b, LV_PCT(100));
return card;
};
makeCard(leftCol, "GOAL", "Reach deepest floor.\nCollect gold, survive.", 0xD99B23);
makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit", 0x6D8CFF);
lv_obj_t* rightRail = lv_obj_create(mainRow);
lv_obj_set_width(rightRail, 44);
lv_obj_set_height(rightRail, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(rightRail, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(rightRail, 10, LV_PART_MAIN);
lv_obj_set_style_border_width(rightRail, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(rightRail, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(rightRail, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(rightRail, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(rightRail, LV_OBJ_FLAG_SCROLLABLE);
auto makeIconBtn = [](lv_obj_t* parent, const char* symbol, uint32_t bg, lv_event_cb_t cb, void* user, int s) -> lv_obj_t* {
lv_obj_t* btn = lv_btn_create(parent);
lv_obj_set_size(btn, s, s);
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN);
lv_obj_set_style_radius(btn, s / 3, LV_PART_MAIN);
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user);
lv_obj_t* lbl = lv_label_create(btn);
lv_label_set_text(lbl, symbol);
lv_obj_center(lbl);
lv_obj_set_style_text_color(lbl, lv_color_white(), LV_PART_MAIN);
return btn;
};
makeIconBtn(rightRail, LV_SYMBOL_PLAY, COLOR_ACCENT, onStartButtonClicked, this, 38);
makeIconBtn(rightRail, LV_SYMBOL_CLOSE, 0x252836, onExitButtonClicked, this, 30);
}
void PocketDungeon::onStartButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->showGame(); lv_event_stop_bubbling(e); }
void PocketDungeon::onResumeButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->hidePause(); lv_event_stop_bubbling(e); }
void PocketDungeon::onExitButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->exitGame(); lv_event_stop_bubbling(e); }
void PocketDungeon::onGameContainerLongPress(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s && s->phase==AppPhase::Playing) s->showPause(); }
void PocketDungeon::onInput(const GameKit::InputEvent& input) { void PocketDungeon::onInput(const GameKit::InputEvent& input) {
if (input.kind == GameKit::InputKind::Cancel || input.kind == GameKit::InputKind::Menu) { exitGame(); return; } int dx = 0;
if (phase == AppPhase::Intro) { if (input.kind != GameKit::InputKind::Unknown) showGame(); return; } int dy = 0;
if (phase == AppPhase::Paused) { if (input.kind != GameKit::InputKind::Unknown) hidePause(); return; }
int dx=0, dy=0;
switch (input.kind) { switch (input.kind) {
case GameKit::InputKind::Up: dy=-1; break; case GameKit::InputKind::Up: dy = -1; break;
case GameKit::InputKind::Down: dy=1; break; case GameKit::InputKind::Down: dy = 1; break;
case GameKit::InputKind::Left: dx=-1; break; case GameKit::InputKind::Left: dx = -1; break;
case GameKit::InputKind::Right: dx=1; break; case GameKit::InputKind::Right: dx = 1; break;
default: break; default: break;
} }
if (model.getState().gameOver) { if (model.getState().gameOver) {
if (dx!=0||dy!=0||input.kind==GameKit::InputKind::Confirm||input.kind==GameKit::InputKind::Touch) { model.reset(lv_tick_get());
model.reset(lv_tick_get(), true); lastResult=MoveResult::None; render(); lastResult = MoveResult::None;
} render();
return; return;
} }
if (dx!=0||dy!=0) { if (dx != 0 || dy != 0) {
lastResult=model.movePlayer(dx,dy); lastResult = model.movePlayer(dx, dy);
playResultSound(lastResult); playResultSound(lastResult);
if (lastResult==MoveResult::Dead) saveBests(); if (lastResult == MoveResult::Dead) saveBests();
render(); render();
} }
} }
void PocketDungeon::onTick(const GameKit::Tick& tick){ (void)tick; } void PocketDungeon::onTick(const GameKit::Tick& tick) {
void PocketDungeon::render(){ (void) tick;
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return; }
if (!gameContainer) return;
void PocketDungeon::render() {
renderer.render(model, lastResult); renderer.render(model, lastResult);
} }
void PocketDungeon::saveBests(){
void PocketDungeon::saveBests() {
prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor()); prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor());
prefs.putInt(PREF_BEST_GOLD, model.getBestGold()); prefs.putInt(PREF_BEST_GOLD, model.getBestGold());
} }
void PocketDungeon::playResultSound(MoveResult r){
if (!sfx) return; void PocketDungeon::playResultSound(MoveResult result) {
switch(r){ if (sfx == nullptr) return;
switch (result) {
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break; case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
case MoveResult::Treasure: sfx->play(SfxId::Coin); break; case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
case MoveResult::Stairs: sfx->play(SfxId::Warp); break; case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
@@ -9,15 +9,8 @@
namespace PocketDungeon { namespace PocketDungeon {
enum class AppPhase : uint8_t { Intro, Playing, Paused };
class PocketDungeon final : public App, public GameKit::Scene { class PocketDungeon final : public App, public GameKit::Scene {
lv_obj_t* root = nullptr; 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::Loop loop;
GameKit::Prefs prefs {"PocketDungeon"}; GameKit::Prefs prefs {"PocketDungeon"};
DungeonModel model; DungeonModel model;
@@ -27,20 +20,6 @@ class PocketDungeon final : public App, public GameKit::Scene {
void playResultSound(MoveResult result); void playResultSound(MoveResult result);
void saveBests(); 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: public:
void onShow(AppHandle app, lv_obj_t* parent) override; void onShow(AppHandle app, lv_obj_t* parent) override;
+11 -9
View File
@@ -1,9 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.pocketdungeon sdk=0.7.0-dev
app.version.name=0.1.2 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=3 [app]
app.name=Pocket Dungeon id=one.tactility.pocketdungeon
app.description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen versionName=0.1.0
app.flags=HideStatusBar versionCode=1
name=Pocket Dungeon
description=Tiny paper-inspired dungeon crawler
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3 [target]
app.id=one.tactility.reynabot sdk=0.7.0-dev
app.version.name=1.0.0 platforms=esp32s3
app.version.code=1 [app]
app.name=ReynaBot id=one.tactility.reynabot
versionName=1.0.0
versionCode=1
name=ReynaBot
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.serialconsole sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=Serial Console 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.snake sdk=0.7.0-dev
app.version.name=0.8.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=8 [app]
app.name=Snake id=one.tactility.snake
app.description=Classic Snake game 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) { if (sfxEngine == nullptr) {
sfxEngine = new SfxEngine(); sfxEngine = new SfxEngine();
sfxEngine->start(); sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
// Load settings // Load settings
bool soundEnabled; bool soundEnabled;
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.tamatac sdk=0.7.0-dev
app.version.name=0.4.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=4 [app]
app.name=TamaTac id=one.tactility.tamatac
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM. 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.todolist sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=Todo List id=one.tactility.todolist
app.description=Simple task list manager 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 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.twoeleven sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=2048 id=one.tactility.twoeleven
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). 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)
+153 -104
View File
@@ -1,15 +1,16 @@
/** /**
* Voice Recorder App for Tactility OS * Voice Recorder App for Tactility OS
* *
* Records voice memos (16kHz 16-bit Mono WAV) to /sdcard/memos/ * Records voice memos (16kHz 16-bit Mono WAV format) to the SD card (/sdcard/memos/)
* Uses audio-stream API with resampling (native codec 44100 -> app 16000) * and plays them back.
*/ */
#include <tt_app.h> #include <tt_app.h>
#include <tt_lvgl.h> #include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/audio_stream.h> #include <tactility/drivers/i2s_controller.h>
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
@@ -41,9 +42,7 @@ typedef enum {
} AudioState; } AudioState;
typedef struct { typedef struct {
struct Device* stream_dev; struct Device* i2s_dev;
AudioStreamHandle input_handle;
AudioStreamHandle output_handle;
uint8_t* audio_buf; uint8_t* audio_buf;
AudioState state; AudioState state;
int volume; int volume;
@@ -66,19 +65,19 @@ typedef struct {
} AppCtx; } AppCtx;
typedef struct __attribute__((packed)) { typedef struct __attribute__((packed)) {
char riff[4]; char riff[4]; // "RIFF"
uint32_t overall_size; uint32_t overall_size; // file size - 8
char wave[4]; char wave[4]; // "WAVE"
char fmt_chunk_marker[4]; char fmt_chunk_marker[4]; // "fmt "
uint32_t length_of_fmt; uint32_t length_of_fmt; // 16 for PCM
uint16_t format_type; uint16_t format_type; // 1 for PCM
uint16_t channels; uint16_t channels; // 1 for mono
uint32_t sample_rate; uint32_t sample_rate; // 16000
uint32_t byterate; uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
uint16_t block_align; uint16_t block_align; // channels * (bits_per_sample / 8)
uint16_t bits_per_sample; uint16_t bits_per_sample; // 16
char data_chunk_header[4]; char data_chunk_header[4]; // "data"
uint32_t data_size; uint32_t data_size; // data size in bytes
} WavHeader; } WavHeader;
static AppCtx g_ctx; static AppCtx g_ctx;
@@ -88,20 +87,6 @@ static void update_ui(AppCtx* ctx);
static void refresh_memo_list(AppCtx* ctx); static void refresh_memo_list(AppCtx* ctx);
static void on_memo_selected(lv_event_t* e); static void on_memo_selected(lv_event_t* e);
static bool find_audio_stream_device(AppCtx* ctx) {
struct Device* dev = device_find_by_name("audio-stream");
if (dev) {
ctx->stream_dev = dev;
return true;
}
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
if (dev) {
ctx->stream_dev = dev;
return true;
}
return false;
}
/* ─── WAV Header Writer ─── */ /* ─── WAV Header Writer ─── */
static void write_wav_header(FILE* f, uint32_t sample_rate, uint16_t bits_per_sample, uint16_t channels, uint32_t data_size) { static void write_wav_header(FILE* f, uint32_t sample_rate, uint16_t bits_per_sample, uint16_t channels, uint32_t data_size) {
WavHeader header; WavHeader header;
@@ -129,8 +114,10 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
struct tm timeinfo; struct tm timeinfo;
localtime_r(&rawtime, &timeinfo); localtime_r(&rawtime, &timeinfo);
// Format: YYYYMMDD_HHMMSS (e.g., 20260629_232202)
strftime(datetime_buf, sizeof(datetime_buf), "%Y%m%d_%H%M%S", &timeinfo); strftime(datetime_buf, sizeof(datetime_buf), "%Y%m%d_%H%M%S", &timeinfo);
// Default unique name candidate
snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf); snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf);
char filepath[256]; char filepath[256];
@@ -138,21 +125,24 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
struct stat st; struct stat st;
if (stat(filepath, &st) != 0) { if (stat(filepath, &st) != 0) {
// File does not exist, safe to use
return; return;
} }
// File exists, let's search for a unique name by appending _N
int suffix = 1; int suffix = 1;
while (1) { while (1) {
snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix); snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix);
snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename); snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename);
if (stat(filepath, &st) != 0) { if (stat(filepath, &st) != 0) {
// Found a unique name!
return; return;
} }
suffix++; suffix++;
} }
} }
/* ─── Record Task (audio-stream input, resampled to 16k mono) ─── */ /* ─── Record Task ─── */
static void record_task(void* arg) { static void record_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg; AppCtx* ctx = (AppCtx*)arg;
char filepath[256]; char filepath[256];
@@ -174,24 +164,29 @@ static void record_task(void* arg) {
return; return;
} }
// Write default header
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0); write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
// Open input stream via audio-stream (resampler provides 16k mono regardless of codec native rate) /* Configure I2S for recording - RLCD fix: ES7210 outputs stereo (2 mics)
struct AudioStreamConfig in_cfg = { * 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, .sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE, .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); device_lock(ctx->i2s_dev);
if (err == ERROR_NONE) { error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
// Ensure mic is unmuted and at max gain (ES8311 0..24dB) device_unlock(ctx->i2s_dev);
audio_stream_set_mute(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, false);
audio_stream_set_volume(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
ESP_LOGI(TAG, "Mic unmuted, gain 100%%");
}
if (err != ERROR_NONE) { if (err != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err); ESP_LOGE(TAG, "Failed to set I2S config: %d", err);
fclose(f); fclose(f);
unlink(filepath); unlink(filepath);
tt_lvgl_lock(portMAX_DELAY); tt_lvgl_lock(portMAX_DELAY);
@@ -204,47 +199,68 @@ static void record_task(void* arg) {
return; 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; size_t total_written = 0;
uint32_t start_time = xTaskGetTickCount(); uint32_t start_time = xTaskGetTickCount();
while (ctx->state == STATE_RECORDING) { while (ctx->state == STATE_RECORDING) {
size_t bytes_read = 0; size_t bytes_read = 0;
err = 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) { if (err == ERROR_NONE && bytes_read > 0) {
// Data already 16k mono from audio-stream resampler, no downmix needed // ES7210 stereo -> mono downmix: extract left channel (every other 16-bit sample)
size_t written = fwrite(ctx->audio_buf, 1, bytes_read, f); // Working MP: for i in 0..bytes_read step 4: mono[j:j+2]=buffer[i:i+2]
if (written != bytes_read) { // CHUNK_BYTES=1024 mono, but we read AUDIO_BUF_SIZE stereo (4096) then downmix to half
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)bytes_read); 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; break;
} }
total_written += written; total_written += written;
} }
// Update elapsed time in UI
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ; uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
char status_buf[64]; char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60)); snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
// Animate progress bar using modulo
int bar_val = (int)(((xTaskGetTickCount() - start_time) * 100) / (configTICK_RATE_HZ * 300)); // 5 min max
tt_lvgl_lock(portMAX_DELAY); tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf); lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF); lv_bar_set_value(ctx->bar_progress, bar_val % 100, LV_ANIM_OFF);
tt_lvgl_unlock(); tt_lvgl_unlock();
} }
audio_stream_close(ctx->input_handle); // Rewrite WAV header with actual recorded size
ctx->input_handle = NULL;
fseek(f, 0, SEEK_SET); fseek(f, 0, SEEK_SET);
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written); write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
fclose(f); fclose(f);
ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written); ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written);
// Reset I2S controller to stop/release DMA channel
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
tt_lvgl_lock(portMAX_DELAY); tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE; ctx->state = STATE_IDLE;
refresh_memo_list(ctx); refresh_memo_list(ctx);
// Select the new file automatically
ctx->selected_index = -1; ctx->selected_index = -1;
for (int i = 0; i < ctx->memo_count; i++) { for (int i = 0; i < ctx->memo_count; i++) {
if (strcmp(ctx->memos[i], filename) == 0) { if (strcmp(ctx->memos[i], filename) == 0) {
@@ -260,7 +276,7 @@ static void record_task(void* arg) {
vTaskDelete(NULL); vTaskDelete(NULL);
} }
/* ─── Playback Task (audio-stream output with resampling) ─── */ /* ─── Playback Task ─── */
static void play_task(void* arg) { static void play_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg; AppCtx* ctx = (AppCtx*)arg;
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) { if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
@@ -289,6 +305,7 @@ static void play_task(void* arg) {
return; return;
} }
// Read and parse WAV header
WavHeader header; WavHeader header;
if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) { if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) {
ESP_LOGE(TAG, "Failed to read WAV header"); ESP_LOGE(TAG, "Failed to read WAV header");
@@ -303,6 +320,7 @@ static void play_task(void* arg) {
return; return;
} }
// Verify file structure
if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) { if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) {
ESP_LOGE(TAG, "Invalid WAV format"); ESP_LOGE(TAG, "Invalid WAV format");
fclose(f); fclose(f);
@@ -316,16 +334,21 @@ static void play_task(void* arg) {
return; return;
} }
/* Open output stream via audio-stream (resampler converts file rate -> native 44100) */ /* Configure I2S based on file metadata */
struct AudioStreamConfig out_cfg = { struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = header.sample_rate, .sample_rate = header.sample_rate,
.bits_per_sample = header.bits_per_sample, .bits_per_sample = header.bits_per_sample,
.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) { 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); fclose(f);
tt_lvgl_lock(portMAX_DELAY); tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE; ctx->state = STATE_IDLE;
@@ -337,7 +360,7 @@ static void play_task(void* arg) {
return; 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); filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
size_t total_played = 0; size_t total_played = 0;
@@ -348,35 +371,40 @@ static void play_task(void* arg) {
fseek(f, sizeof(WavHeader), SEEK_SET); fseek(f, sizeof(WavHeader), SEEK_SET);
} }
bool out_open = true; bool i2s_configured = true;
while (total_played < data_size && ctx->state != STATE_IDLE) { while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) { if (ctx->state == STATE_PAUSED) {
if (out_open) { if (i2s_configured) {
audio_stream_close(ctx->output_handle); // Reset I2S controller to stop DMA immediately when pausing
ctx->output_handle = NULL; device_lock(ctx->i2s_dev);
out_open = false; i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
i2s_configured = false;
} }
vTaskDelay(pdMS_TO_TICKS(50)); vTaskDelay(pdMS_TO_TICKS(50));
continue; continue;
} }
if (!out_open) { if (!i2s_configured) {
err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle); // 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) { 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; 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 to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES;
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f); size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f);
if (read_bytes == 0) { if (read_bytes == 0) {
break; break; // EOF
} }
// Volume scaling // Scaling Volume
int vol = ctx->volume; int vol = ctx->volume;
int16_t* samples_ptr = (int16_t*)ctx->audio_buf; int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
size_t sample_count = read_bytes / sizeof(int16_t); size_t sample_count = read_bytes / sizeof(int16_t);
@@ -389,19 +417,22 @@ static void play_task(void* arg) {
bool write_err = false; bool write_err = false;
while (offset < read_bytes && ctx->state == STATE_PLAYING) { while (offset < read_bytes && ctx->state == STATE_PLAYING) {
size_t written = 0; size_t written = 0;
err = 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) { 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; write_err = true;
break; break;
} }
offset += written; offset += written;
} }
if (write_err) break; if (write_err) {
break;
}
total_played += read_bytes; total_played += read_bytes;
// UI progress update
int pct = (int)((total_played * 100) / data_size); int pct = (int)((total_played * 100) / data_size);
uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8); uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8);
uint32_t elapsed_sec = total_played / bytes_per_sec; uint32_t elapsed_sec = total_played / bytes_per_sec;
@@ -421,10 +452,10 @@ static void play_task(void* arg) {
fclose(f); fclose(f);
ESP_LOGI(TAG, "Playback task complete"); ESP_LOGI(TAG, "Playback task complete");
if (out_open && ctx->output_handle) { // Reset I2S controller to stop/release DMA channel and prevent looping noise
audio_stream_close(ctx->output_handle); device_lock(ctx->i2s_dev);
ctx->output_handle = NULL; i2s_controller_reset(ctx->i2s_dev);
} device_unlock(ctx->i2s_dev);
tt_lvgl_lock(portMAX_DELAY); tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE; ctx->state = STATE_IDLE;
@@ -439,12 +470,12 @@ static void play_task(void* arg) {
static void on_record_click(lv_event_t* e) { static void on_record_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->state != STATE_IDLE) return; if (ctx->state != STATE_IDLE) return;
if (ctx->stream_dev == NULL) return; if (ctx->i2s_dev == NULL) return;
ctx->state = STATE_RECORDING; ctx->state = STATE_RECORDING;
update_ui(ctx); 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) { static void on_play_pause_click(lv_event_t* e) {
@@ -454,7 +485,7 @@ static void on_play_pause_click(lv_event_t* e) {
if (ctx->state == STATE_IDLE) { if (ctx->state == STATE_IDLE) {
ctx->state = STATE_PLAYING; ctx->state = STATE_PLAYING;
update_ui(ctx); update_ui(ctx);
xTaskCreate(play_task, "voice_play", 8192, ctx, 5, &ctx->task_handle); xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle);
} else if (ctx->state == STATE_PLAYING) { } else if (ctx->state == STATE_PLAYING) {
ctx->state = STATE_PAUSED; ctx->state = STATE_PAUSED;
update_ui(ctx); update_ui(ctx);
@@ -492,12 +523,14 @@ static void on_memo_selected(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
lv_obj_t* btn = lv_event_get_target(e); lv_obj_t* btn = lv_event_get_target(e);
// Clear check states from other entries
uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos); uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
for (uint32_t i = 0; i < child_cnt; i++) { for (uint32_t i = 0; i < child_cnt; i++) {
lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i); lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i);
lv_obj_clear_state(child, LV_STATE_CHECKED); lv_obj_clear_state(child, LV_STATE_CHECKED);
} }
// Apply check state to the clicked memo
lv_obj_add_state(btn, LV_STATE_CHECKED); lv_obj_add_state(btn, LV_STATE_CHECKED);
const char* text = lv_list_get_btn_text(ctx->lst_memos, btn); const char* text = lv_list_get_btn_text(ctx->lst_memos, btn);
@@ -535,6 +568,7 @@ static void refresh_memo_list(AppCtx* ctx) {
} }
closedir(dir); closedir(dir);
// Simple bubble sort
for (int i = 0; i < ctx->memo_count - 1; i++) { for (int i = 0; i < ctx->memo_count - 1; i++) {
for (int j = 0; j < ctx->memo_count - i - 1; j++) { for (int j = 0; j < ctx->memo_count - i - 1; j++) {
if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) { if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) {
@@ -546,6 +580,7 @@ static void refresh_memo_list(AppCtx* ctx) {
} }
} }
// Populate UI List
for (int i = 0; i < ctx->memo_count; i++) { for (int i = 0; i < ctx->memo_count; i++) {
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]); lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx); lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
@@ -612,100 +647,115 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
g_ctx.volume = 80; g_ctx.volume = 80;
g_ctx.selected_index = -1; g_ctx.selected_index = -1;
// Create the memos storage directory if missing
mkdir("/sdcard/memos", 0755); mkdir("/sdcard/memos", 0755);
// Allocate audio buffer
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE); g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
if (g_ctx.audio_buf == NULL) { if (g_ctx.audio_buf == NULL) {
ESP_LOGE(TAG, "Failed to allocate audio buffer"); ESP_LOGE(TAG, "Failed to allocate audio buffer");
} }
if (!find_audio_stream_device(&g_ctx)) { // Locate I2S hardware channel
ESP_LOGE(TAG, "audio-stream device not found!"); 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_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Premium Player Card
lv_obj_t* card = lv_obj_create(parent); lv_obj_t* card = lv_obj_create(parent);
lv_obj_set_size(card, lv_pct(95), lv_pct(88)); lv_obj_set_size(card, lv_pct(95), lv_pct(88));
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12); lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
lv_obj_set_style_radius(card, 15, 0); lv_obj_set_style_radius(card, 15, 0);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); 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); 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_style_border_width(card, 2, 0);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(card, 10, 0); lv_obj_set_style_pad_all(card, 10, 0);
lv_obj_set_style_pad_gap(card, 8, 0); lv_obj_set_style_pad_gap(card, 8, 0);
// Status Area
g_ctx.lbl_status = lv_label_create(card); g_ctx.lbl_status = lv_label_create(card);
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95)); lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); 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..."); lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
// Progress Bar
g_ctx.bar_progress = lv_bar_create(card); g_ctx.bar_progress = lv_bar_create(card);
lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8); lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8);
lv_bar_set_range(g_ctx.bar_progress, 0, 100); lv_bar_set_range(g_ctx.bar_progress, 0, 100);
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF); lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); 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); 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); g_ctx.lst_memos = lv_list_create(card);
lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85); lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85);
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0); 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_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0);
lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0); lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0);
lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0); lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0);
// Controls Box
lv_obj_t* ctrl_box = lv_obj_create(card); lv_obj_t* ctrl_box = lv_obj_create(card);
lv_obj_remove_style_all(ctrl_box); lv_obj_remove_style_all(ctrl_box);
lv_obj_set_size(ctrl_box, lv_pct(95), 40); lv_obj_set_size(ctrl_box, lv_pct(95), 40);
lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Rec Button
g_ctx.btn_record = lv_btn_create(ctrl_box); g_ctx.btn_record = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_record, 45, 32); lv_obj_set_size(g_ctx.btn_record, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_record, 8, 0); lv_obj_set_style_radius(g_ctx.btn_record, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); 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_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record); lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO); lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
lv_obj_center(lbl_rec); lv_obj_center(lbl_rec);
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
// Play/Pause Button
g_ctx.btn_play_pause = lv_btn_create(ctrl_box); g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_play_pause, 45, 32); lv_obj_set_size(g_ctx.btn_play_pause, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0); lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); 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_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause); lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY); lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
lv_obj_center(lbl_play); lv_obj_center(lbl_play);
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
// Stop Button
g_ctx.btn_stop = lv_btn_create(ctrl_box); g_ctx.btn_stop = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_stop, 45, 32); lv_obj_set_size(g_ctx.btn_stop, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0); lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0); 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_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop); lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP); lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
lv_obj_center(lbl_stop); lv_obj_center(lbl_stop);
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
// Delete Button
g_ctx.btn_delete = lv_btn_create(ctrl_box); g_ctx.btn_delete = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_delete, 45, 32); lv_obj_set_size(g_ctx.btn_delete, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0); lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); 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_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete); lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete);
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH); lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
lv_obj_center(lbl_del); lv_obj_center(lbl_del);
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx); lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
if (g_ctx.stream_dev == NULL) { // Initialize list and control state
lv_label_set_text(g_ctx.lbl_status, "ERROR: audio-stream missing"); 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_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED); lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED); lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
@@ -728,17 +778,16 @@ static void onHideApp(AppHandle app, void* data) {
g_ctx.state = STATE_IDLE; g_ctx.state = STATE_IDLE;
} }
// Wait for task completion
while (g_ctx.task_handle != NULL) { while (g_ctx.task_handle != NULL) {
vTaskDelay(pdMS_TO_TICKS(10)); vTaskDelay(pdMS_TO_TICKS(10));
} }
if (g_ctx.input_handle) { // Reset I2S to ensure DMA channel is stopped
audio_stream_close(g_ctx.input_handle); if (g_ctx.i2s_dev != NULL) {
g_ctx.input_handle = NULL; device_lock(g_ctx.i2s_dev);
} i2s_controller_reset(g_ctx.i2s_dev);
if (g_ctx.output_handle) { device_unlock(g_ctx.i2s_dev);
audio_stream_close(g_ctx.output_handle);
g_ctx.output_handle = NULL;
} }
if (g_ctx.audio_buf != NULL) { 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 <cstdint>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "freertos/queue.h" #include "freertos/queue.h"
@@ -278,8 +277,7 @@ private:
// State // State
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
Device* streamDevice_ = nullptr; Device* i2sDevice_ = nullptr;
AudioStreamHandle streamHandle_ = nullptr;
TaskHandle_t task_ = nullptr; TaskHandle_t task_ = nullptr;
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
QueueHandle_t msgQueue_ = nullptr; QueueHandle_t msgQueue_ = nullptr;
+5 -4
View File
@@ -35,8 +35,11 @@ if (!engine->start()) {
ESP_LOGE(TAG, "Failed to start SfxEngine"); ESP_LOGE(TAG, "Failed to start SfxEngine");
return; return;
} }
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
engine->play(SfxId::Coin); // Predefined SFX engine->play(SfxId::Coin); // Predefined SFX
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
engine->setVolume(0.7f); // Volume control
engine->stop(); engine->stop();
delete engine; delete engine;
@@ -84,11 +87,9 @@ idf_component_register(
- `void stopVoice(voice)` - Stop specific voice - `void stopVoice(voice)` - Stop specific voice
### Settings ### Settings
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
- `void setEnabled(bool)` - Mute/unmute - `void setEnabled(bool)` - Mute/unmute
- `void applyVolumePreset(VolumePreset)` - Apply Quiet/Normal/Loud preset (configures volume, gate, normalization)
Loudness is controlled by the system output volume (set via the audio_stream device / Settings UI),
not by SfxEngine itself -- a fixed app-side gain on top of hardware attenuation gets swamped at low
system volumes, so there's no separate volume control here.
### Mixing (consistent with SoundEngine) ### Mixing (consistent with SoundEngine)
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on) - `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
+36 -53
View File
@@ -9,8 +9,7 @@
#include "SfxEngine.h" #include "SfxEngine.h"
#include "SfxDefinitions.h" #include "SfxDefinitions.h"
#include <tactility/device.h> #include <tactility/drivers/i2s_controller.h>
#include <tactility/drivers/audio_stream.h>
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include "esp_log.h" #include "esp_log.h"
@@ -425,7 +424,7 @@ void SfxEngine::audioTaskFunc(void* param) {
size_t written; size_t written;
QueueMsg msg; QueueMsg msg;
ESP_LOGI(TAG, "Audio task started (audio-stream)"); ESP_LOGI(TAG, "Audio task started");
while (self->running_) { while (self->running_) {
// Process queued messages (non-blocking) // Process queued messages (non-blocking)
@@ -464,26 +463,19 @@ void SfxEngine::audioTaskFunc(void* param) {
// Fill audio buffer (member buffer to avoid stack pressure) // Fill audio buffer (member buffer to avoid stack pressure)
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES); self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
// Write via audio-stream (resampled to native codec rate, e.g. 44100) // Write to I2S
if (self->streamHandle_ == nullptr) { error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
vTaskDelay(pdMS_TO_TICKS(10)); sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
continue;
}
error_t error = audio_stream_write(self->streamHandle_, self->audioBuffer_,
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(200));
if (error != ERROR_NONE) { if (error != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_write error %d", error); ESP_LOGE(TAG, "I2S write error");
self->running_ = false; self->running_ = false;
break; break;
} }
} }
// Flush silence to avoid pop // Flush silence
if (self->streamHandle_ != nullptr) { memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_)); i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
size_t dummy = 0;
audio_stream_write(self->streamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &dummy, pdMS_TO_TICKS(100));
}
ESP_LOGI(TAG, "Audio task exiting"); ESP_LOGI(TAG, "Audio task exiting");
@@ -502,38 +494,33 @@ void SfxEngine::audioTaskFunc(void* param) {
bool SfxEngine::start() { bool SfxEngine::start() {
if (running_) return true; if (running_) return true;
// Find audio-stream device (new audio provision) // Find I2S device
streamDevice_ = nullptr; i2sDevice_ = nullptr;
streamHandle_ = 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 (i2sDevice_ == nullptr) {
if (streamDevice_ == nullptr) { ESP_LOGW(TAG, "No I2S device found");
// 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");
return false; return false;
} }
// Open output stream with resampling (app wants 16k stereo, codec native is 44100) // Configure I2S
struct AudioStreamConfig cfg = { I2sConfig config = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE, .sample_rate = SAMPLE_RATE,
.bits_per_sample = 16, .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) { if (error != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error); ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
streamDevice_ = nullptr; i2sDevice_ = nullptr;
streamHandle_ = nullptr;
return false; return false;
} }
@@ -541,13 +528,12 @@ bool SfxEngine::start() {
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg)); msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
if (msgQueue_ == nullptr) { if (msgQueue_ == nullptr) {
ESP_LOGE(TAG, "Failed to create message queue"); ESP_LOGE(TAG, "Failed to create message queue");
audio_stream_close(streamHandle_); i2s_controller_reset(i2sDevice_);
streamHandle_ = nullptr; i2sDevice_ = nullptr;
streamDevice_ = nullptr;
return false; return false;
} }
// Start audio task (needs slightly larger stack for audio_stream write path) // Start audio task
running_ = true; running_ = true;
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_); BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
if (result != pdPASS) { if (result != pdPASS) {
@@ -555,13 +541,12 @@ bool SfxEngine::start() {
running_ = false; running_ = false;
vQueueDelete(msgQueue_); vQueueDelete(msgQueue_);
msgQueue_ = nullptr; msgQueue_ = nullptr;
audio_stream_close(streamHandle_); i2s_controller_reset(i2sDevice_);
streamHandle_ = nullptr; i2sDevice_ = nullptr;
streamDevice_ = nullptr;
return false; 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; return true;
} }
@@ -590,13 +575,11 @@ void SfxEngine::stop() {
msgQueue_ = nullptr; msgQueue_ = nullptr;
} }
if (streamHandle_ != nullptr) { if (i2sDevice_ != nullptr) {
audio_stream_close(streamHandle_); i2s_controller_reset(i2sDevice_);
streamHandle_ = nullptr; i2sDevice_ = nullptr;
} }
streamDevice_ = nullptr;
ESP_LOGI(TAG, "SfxEngine stopped"); ESP_LOGI(TAG, "SfxEngine stopped");
} }
+39 -54
View File
@@ -1,23 +1,14 @@
import json import json
import subprocess
import tarfile import tarfile
import os import os
import tempfile import tempfile
import configparser
import sys import sys
from datetime import datetime, UTC
def read_properties_file(path): def read_properties_file(path):
properties = {} config = configparser.RawConfigParser()
with open(path, "r") as file: config.read(path)
for line in file: return config
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
def get_manifest(appPath): def get_manifest(appPath):
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz """Extract only the file named 'manifest.properties' from the given tar/tar.gz
@@ -71,49 +62,51 @@ def get_manifest(appPath):
return None return None
def get_versioned_file_name(manifest): def get_versioned_file_name(manifest):
app_id = manifest["app.id"] app_id = manifest["app"]["id"]
version_code = manifest["app.version.code"] version_code = manifest["app"]["versionCode"]
return f"{app_id}-{version_code}.app" return f"{app_id}-{version_code}.app"
def get_os_version(manifest): def get_os_version(manifest):
sdk = manifest["target.sdk"] sdk = manifest["target"]["sdk"]
# Remove trailing hyphen suffix if present # Remove trailing hyphen suffix if present
if "-" in sdk: if "-" in sdk:
return sdk.rsplit("-", 1)[0].strip() return sdk.rsplit("-", 1)[0].strip()
else: else:
return sdk return sdk
def 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): 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: Expected sections/keys (case-insensitive for keys):
app.id -> appId - [app]
app.version.name -> appVersionName id -> appId
app.version.code -> appVersionCode (int) versionName -> appVersionName
app.name -> appName versionCode -> appVersionCode (int)
app.description -> appDescription (optional; default "") name -> appName
target.sdk -> targetSdk description -> appDescription (optional; default "")
target.platforms -> targetPlatforms (comma-separated list) - [target]
sdk -> targetSdk
platforms -> targetPlatforms (comma-separated list)
Unknown/missing values fall back to sensible defaults per requirements. Unknown/missing values fall back to sensible defaults per requirements.
""" """
def get_opt(section, option, default=None):
if not manifest.has_section(section):
return default
# try exact option then lowercase (RawConfigParser lowercases by default)
if manifest.has_option(section, option):
return manifest.get(section, option)
low = option.lower()
if manifest.has_option(section, low):
return manifest.get(section, low)
return default
# Map values # Map values
app_id = manifest.get("app.id", "") app_id = get_opt("app", "id", "")
app_version_name = manifest.get("app.version.name", "") app_version_name = get_opt("app", "versionName", "")
app_version_code_raw = manifest.get("app.version.code", "0") app_version_code_raw = get_opt("app", "versionCode", "0")
app_name = manifest.get("app.name", "") app_name = get_opt("app", "name", "")
app_description = manifest.get("app.description", "") or "" app_description = get_opt("app", "description", "") or ""
# Coerce version code to int safely # Coerce version code to int safely
try: try:
@@ -121,8 +114,8 @@ def manifest_config_to_flat_json(manifest):
except Exception: except Exception:
app_version_code = 0 app_version_code = 0
target_sdk = manifest.get("target.sdk", "") target_sdk = get_opt("target", "sdk", "")
platforms_raw = manifest.get("target.platforms", "") 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 [] target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
filename = get_versioned_file_name(manifest) filename = get_versioned_file_name(manifest)
@@ -149,23 +142,15 @@ if __name__ == "__main__":
sys.exit() sys.exit()
app_directory = sys.argv[1] app_directory = sys.argv[1]
manifest_map = {} manifest_map = {}
output_json = {
"apps": []
}
any_manifest = None any_manifest = None
if os.path.exists(app_directory): if os.path.exists(app_directory):
for file in os.listdir(app_directory): for file in os.listdir(app_directory):
if file.endswith(".app"): if file.endswith(".app"):
file_path = os.path.join(app_directory, file) file_path = os.path.join(app_directory, file)
manifest_map[file_path] = get_manifest(file_path) manifest_map[file_path] = get_manifest(file_path)
# All bundled apps must target the same SDK version; this becomes the CDN path segment
sdk_version = check_and_get_sdk_version(manifest_map)
with open("sdk_version.txt", "w") as f:
f.write(sdk_version)
print(f"SDK version: {sdk_version}")
output_json = {
"sdkVersion": sdk_version,
"created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
"gitCommit": get_git_commit_hash(),
"apps": []
}
# Rename files and collect manifest data into output json object # Rename files and collect manifest data into output json object
for file_path in manifest_map.keys(): for file_path in manifest_map.keys():
print(f"Processing {file_path}: {manifest_map[file_path]}") print(f"Processing {file_path}: {manifest_map[file_path]}")
+36 -23
View File
@@ -1,3 +1,4 @@
import configparser
import json import json
import os import os
import re import re
@@ -12,7 +13,7 @@ import tarfile
from urllib.parse import urlparse from urllib.parse import urlparse
ttbuild_path = ".tactility" ttbuild_path = ".tactility"
ttbuild_version = "4.1.0" ttbuild_version = "3.5.1"
ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_cdn = "https://cdn.tactilityproject.org"
ttbuild_sdk_json_validity = 3600 # seconds ttbuild_sdk_json_validity = 3600 # seconds
ttport = 6666 ttport = 6666
@@ -105,17 +106,9 @@ def get_url(ip, path):
return f"http://{ip}:{ttport}{path}" return f"http://{ip}:{ttport}{path}"
def read_properties_file(path): def read_properties_file(path):
properties = {} config = configparser.RawConfigParser()
with open(path, "r") as file: config.read(path)
for line in file: return config
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
#endregion Core #endregion Core
@@ -192,7 +185,7 @@ def fetch_sdkconfig_files(platform_targets):
for platform in platform_targets: for platform in platform_targets:
sdkconfig_filename = f"sdkconfig.app.{platform}" sdkconfig_filename = f"sdkconfig.app.{platform}"
target_path = os.path.join(ttbuild_path, sdkconfig_filename) target_path = os.path.join(ttbuild_path, sdkconfig_filename)
if not download_file(f"{ttbuild_cdn}/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}") exit_with_error(f"Failed to download sdkconfig file for {platform}")
#endregion SDK helpers #endregion SDK helpers
@@ -238,12 +231,32 @@ def read_manifest():
return read_properties_file("manifest.properties") return read_properties_file("manifest.properties")
def validate_manifest(manifest): def validate_manifest(manifest):
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"): # [manifest]
if key not in manifest: if not "manifest" in manifest:
exit_with_error(f"Invalid manifest format: {key} not found") 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): def is_valid_manifest_platform(manifest, platform):
manifest_platforms = manifest["target.platforms"].split(",") manifest_platforms = manifest["target"]["platforms"].split(",")
return platform in manifest_platforms return platform in manifest_platforms
def validate_manifest_platform(manifest, platform): def validate_manifest_platform(manifest, platform):
@@ -252,7 +265,7 @@ def validate_manifest_platform(manifest, platform):
def get_manifest_target_platforms(manifest, requested_platform): def get_manifest_target_platforms(manifest, requested_platform):
if requested_platform == "" or requested_platform is None: if requested_platform == "" or requested_platform is None:
return manifest["target.platforms"].split(",") return manifest["target"]["platforms"].split(",")
else: else:
validate_manifest_platform(manifest, requested_platform) validate_manifest_platform(manifest, requested_platform)
return [requested_platform] return [requested_platform]
@@ -499,7 +512,7 @@ def build_action(manifest, platform_arg, skip_build):
if use_local_sdk: if use_local_sdk:
global local_base_path global local_base_path
local_base_path = os.environ.get("TACTILITY_SDK_PATH") local_base_path = os.environ.get("TACTILITY_SDK_PATH")
validate_local_sdks(platforms_to_build, manifest["target.sdk"]) validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
if should_fetch_sdkconfig_files(platforms_to_build): if should_fetch_sdkconfig_files(platforms_to_build):
fetch_sdkconfig_files(platforms_to_build) fetch_sdkconfig_files(platforms_to_build)
@@ -508,7 +521,7 @@ def build_action(manifest, platform_arg, skip_build):
sdk_json = read_sdk_json() sdk_json = read_sdk_json()
validate_self(sdk_json) validate_self(sdk_json)
# Build # Build
sdk_version = manifest["target.sdk"] sdk_version = manifest["target"]["sdk"]
if not use_local_sdk: if not use_local_sdk:
if not sdk_download_all(sdk_version, platforms_to_build): if not sdk_download_all(sdk_version, platforms_to_build):
exit_with_error("Failed to download one or more SDKs") exit_with_error("Failed to download one or more SDKs")
@@ -557,7 +570,7 @@ def get_device_info(ip):
print_status_error(f"Device info request failed: {e}") print_status_error(f"Device info request failed: {e}")
def run_action(manifest, ip): def run_action(manifest, ip):
app_id = manifest["app.id"] app_id = manifest["app"]["id"]
print_status_busy("Running") print_status_busy("Running")
url = get_url(ip, "/app/run") url = get_url(ip, "/app/run")
params = {'id': app_id} params = {'id': app_id}
@@ -601,7 +614,7 @@ def install_action(ip, platforms):
return False return False
def uninstall_action(manifest, ip): def uninstall_action(manifest, ip):
app_id = manifest["app.id"] app_id = manifest["app"]["id"]
print_status_busy("Uninstalling") print_status_busy("Uninstalling")
url = get_url(ip, "/app/uninstall") url = get_url(ip, "/app/uninstall")
params = {'id': app_id} params = {'id': app_id}
@@ -657,7 +670,7 @@ if __name__ == "__main__":
exit_with_error("manifest.properties not found") exit_with_error("manifest.properties not found")
manifest = read_manifest() manifest = read_manifest()
validate_manifest(manifest) validate_manifest(manifest)
all_platform_targets = manifest["target.platforms"].split(",") all_platform_targets = manifest["target"]["platforms"].split(",")
# Update SDK cache (tool.json) # Update SDK cache (tool.json)
if not use_local_sdk and should_update_tool_json() and not update_tool_json(): if not use_local_sdk and should_update_tool_json() and not update_tool_json():
exit_with_error("Failed to retrieve SDK info") exit_with_error("Failed to retrieve SDK info")