Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59612020bb | |||
| e42036a044 | |||
| 52bf3e1870 | |||
| 03e65fb7f7 | |||
| e010828e5e | |||
| 479a448900 | |||
| f785bd3fb2 | |||
| ef9d4e8de9 | |||
| 6e83272c8e | |||
| 9def43b4ed | |||
| c258aebca1 | |||
| 4a153099f9 | |||
| aebee50d25 | |||
| 7c3c4ad2fe | |||
| 2a45c98fb3 | |||
| 25b966a340 | |||
| b2e9046438 | |||
| f07d347fd9 | |||
| 8f46e03070 | |||
| 7121009d0d | |||
| 8721a653e7 | |||
| 694b68de1a | |||
| 71bf2631f4 | |||
| 4ab2377970 | |||
| 8a0f9ef4e4 |
@@ -39,6 +39,12 @@ print(f"undef {len(undef)} missing {missing}")
|
||||
|
||||
### New missing from v2/v3/v4/v5 UI passes
|
||||
|
||||
- `device_get_by_name`, `device_get_first_by_type` (out-param API) → **Not exported** on flashed 0.8.0-dev (verified via serial on .129: `E ELF: Can't find symbol device_get_by_name`). The firmware exports the **legacy** `device_find_by_name` / `device_find_first_by_type` instead. Gotcha: newer SDK/CDN headers declare only `device_get_*` (so `device_find_*` fails to compile), but the flashed firmware only exports `device_find_*` (so `device_get_*` fails at load). Fix: call the exported legacy `device_find_*` and declare them locally in the app source since the SDK header dropped them:
|
||||
```c
|
||||
extern struct Device* device_find_by_name(const char* name);
|
||||
extern struct Device* device_find_first_by_type(const struct DeviceType* type);
|
||||
```
|
||||
Verify the ACTUAL load on-device via serial (`/dev/cu.usbmodem*`, 115200) — `verify_symbols.py` compares against local firmware source and can report 0 missing even when the flashed firmware's export table differs.
|
||||
- `lv_obj_set_style_bg_grad_color/dir/stop` → **Not exported** 0.8.0-dev. Gradient fails `missing symbol`. Use solid `0x0E0E14`.
|
||||
- `LV_OPA_95` → **Not defined** (only 0/10/20/30/40/50/60/70/80/90/100/COVER/TRANSP). Use `COVER` or `90`.
|
||||
- `lv_arc_create`, `lv_arc_set_range/value/bg_angles`, `lv_arc_get_value` → **Not exported on 0.7.0-dev**, added `dff93cb6 Add lv_arc.h symbols (#496)` after 0.7 release. Symptom blue modal `Error / Application failed to start: missing symbol / OK`, heap healthy ~31KB not OOM. Fix horizontal slide using `lv_slider_create` + `lv_indev_get_point/active`. See `book-browser.md`.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Publish Apps
|
||||
|
||||
inputs:
|
||||
sdk_version:
|
||||
description: The SDK version that determines the path on the CDN
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: 'Download cdn-files'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: 'cdn-files'
|
||||
path: cdn_files
|
||||
- name: 'Install boto3'
|
||||
shell: bash
|
||||
run: pip install boto3
|
||||
- name: 'Upload files'
|
||||
shell: bash
|
||||
run: python Buildscripts/CDN/upload-app-files.py cdn_files ${{ inputs.sdk_version }} ${{ env.CDN_ID }} ${{ env.CDN_TOKEN_NAME }} ${{ env.CDN_TOKEN_VALUE }}
|
||||
@@ -1,5 +1,10 @@
|
||||
name: Release Apps
|
||||
|
||||
outputs:
|
||||
sdk_version:
|
||||
description: 'Common SDK version shared by all bundled apps'
|
||||
value: ${{ steps.release.outputs.sdk_version }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -11,8 +16,11 @@ runs:
|
||||
run: rsync -av downloaded_apps/*/*.app cdn_files/
|
||||
shell: bash
|
||||
- name: 'Create CDN release files'
|
||||
run: python release.py cdn_files/
|
||||
id: release
|
||||
shell: bash
|
||||
run: |
|
||||
python release.py cdn_files/
|
||||
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -12,10 +12,12 @@ jobs:
|
||||
Build:
|
||||
strategy:
|
||||
matrix:
|
||||
app_name: [Brainfuck, Breakout, BookPlayer, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven]
|
||||
app_name: [AudioTest, BibleVerse, BookPlayer, Brainfuck, Breakout, Calculator, Diceware, EpubReader, EspNowBridge, GPIO, GameBoy, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, McpScreen, MediaKeys, Mp3Player, MystifyDemo, PocketDungeon, ReynaBot, RobotArm, SerialConsole, Snake, TamaTac, TodoList, TwoEleven, VoiceRecorder]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Build"
|
||||
uses: ./.github/actions/build-app
|
||||
with:
|
||||
@@ -23,7 +25,26 @@ jobs:
|
||||
Bundle:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Build]
|
||||
outputs:
|
||||
sdk_version: ${{ steps.release.outputs.sdk_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Build"
|
||||
id: release
|
||||
uses: ./.github/actions/release-apps
|
||||
PublishApps:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Bundle]
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Publish Apps"
|
||||
env:
|
||||
CDN_ID: ${{ secrets.CDN_ID }}
|
||||
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
|
||||
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
|
||||
uses: ./.github/actions/publish-apps
|
||||
with:
|
||||
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
|
||||
|
||||
@@ -9,23 +9,42 @@ Tactility external ELF apps — loaded at runtime via firmware symbol table. Eac
|
||||
Upstream: `https://github.com/TactilityProject/TactilityApps`
|
||||
Personal Gitea mirror: `https://git.reynafamily.com/adolforeyna/tactility_apps` (`personal` remote)
|
||||
|
||||
Dev board IP: `192.168.68.112` (S3, OS 0.8.0-dev, SDK 0.8.0-dev) — main deploy target. `.111` also online. OS image is 0.8.0-dev so manifest should use `sdk=0.8.0-dev` or loader warns.
|
||||
Dev board IP: `192.168.68.112` (S3, OS 0.8.0-dev, SDK 0.8.0-dev) — main deploy target. `.111` and `.114` also online. OS image is 0.8.0-dev so manifest should use `sdk=0.8.0-dev` or loader warns.
|
||||
|
||||
## Local Workstation
|
||||
|
||||
- Host: M2 Air (14,2) macOS 26.5.2
|
||||
- Project: `/Users/adolforeyna/Projects/Tactility/apps`
|
||||
- ESP-IDF: `/Users/adolforeyna/esp/esp-idf`, Python env `idf5.3_py3.9_env` at `~/.espressif/python_env/idf5.3_py3.9_env`
|
||||
- Tactility SDK: `/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK` (built locally, `--local-sdk`)
|
||||
- Host: macOS 26.5.2
|
||||
- Project: `/Users/adolforeyna/Projects/electronics/tactility/tactility_apps`
|
||||
- ESP-IDF: `/Users/adolforeyna/esp/esp-idf` (v5.5.2), Python env `idf5.5_py3.9_env` at `~/.espressif/python_env/idf5.5_py3.9_env` (NOT `idf5.3_py3.9_env` — that env does not exist on this machine)
|
||||
|
||||
### Tactility SDK location (IMPORTANT)
|
||||
|
||||
- When you run `tactility.py ... build` **without** `--local-sdk`, it auto-downloads the SDK to the per-app cache:
|
||||
`Apps/<Name>/.tactility/<version>-<platform>/TactilitySDK` (e.g. `Apps/BookPlayer/.tactility/0.8.0-dev-esp32s3/TactilitySDK`).
|
||||
- **The freshly CDN-downloaded 0.8.0-dev SDK for esp32s3 is broken for this setup** — it fails at CMake with
|
||||
`Failed to resolve component 'app-module' required by component 'TactilitySDK': unknown name` and also renames the
|
||||
device API (`device_find_*` → `device_get_by_name/device_get_first_by_type` out-param style). It is a *different,
|
||||
newer* SDK than the working one.
|
||||
- **The known-good SDK is the locally-cached copy** already present in the other apps'
|
||||
`.tactility/0.8.0-dev-esp32s3/TactilitySDK` (the one that carries a `Drivers/` folder). It builds clean.
|
||||
- Workaround when a fresh download fails: copy the working cached SDK over the broken one:
|
||||
```bash
|
||||
SRC=Apps/PipecatVoice/.tactility/0.8.0-dev-esp32s3 # known-good copy
|
||||
DST=Apps/MyApp/.tactility/0.8.0-dev-esp32s3
|
||||
rm -rf "$DST/TactilitySDK"; cp -R "$SRC/TactilitySDK" "$DST/"
|
||||
```
|
||||
Then rebuild. (Resolving the root cause on the newer SDK is still TODO — consider pinning/downloading the cached zip
|
||||
so this isn't needed per-app.)
|
||||
|
||||
### Hardware
|
||||
|
||||
| Board | IP | Port | Notes |
|
||||
|-------|-----|------|-------|
|
||||
| ES3C28P 2.8" color 240x320 | `192.168.68.112` | `/dev/cu.usbmodem101` | Primary deploy target, 16MB flash, OCT PSRAM, SD for ELF assets |
|
||||
| Waveshare RLCD 4.2" mono 400x300 ST7305 | `192.168.68.111`? .1101 USB | `/dev/cu.usbmodem1101` | Secondary, compact density, mono threshold 60, font 18 |
|
||||
| ES3C28P 2.8" color 240x320 | `192.168.68.114` | `/dev/cu.usbmodem101` | Secondary S3 (used for recent BookPlayer deploys) |
|
||||
| Waveshare RLCD 4.2" mono 400x300 ST7305 | `192.168.68.111` | `/dev/cu.usbmodem1101` | Secondary, compact density, SD threshold 60, font 18 |
|
||||
|
||||
User shorthand: "ending in 112" → `192.168.68.112`.
|
||||
User shorthand: "ending in 112" → `192.168.68.112`. Install is dashboard-port API (80), not dev port 6666.
|
||||
|
||||
## Build, Deploy, Run — Correct Env Wrapper
|
||||
|
||||
@@ -34,11 +53,10 @@ Hermes desktop Python 3.11 venv pollutes `PYTHONPATH` → `tactility.py` crashes
|
||||
Always:
|
||||
|
||||
```bash
|
||||
cd /Users/adolforeyna/Projects/Tactility/apps
|
||||
cd /Users/adolforeyna/Projects/electronics/tactility/tactility_apps
|
||||
unset PYTHONPATH; unset PYTHONHOME
|
||||
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env
|
||||
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.5_py3.9_env
|
||||
source /Users/adolforeyna/esp/esp-idf/export.sh
|
||||
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
|
||||
|
||||
# Build
|
||||
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/MyApp clean
|
||||
@@ -51,6 +69,7 @@ xtensa-esp32s3-elf-nm -D Apps/MyApp/build/cmake-build-esp32s3/MyApp.app.elf | gr
|
||||
python .claude/skills/tactility-app-development/scripts/verify_symbols.py Apps/MyApp
|
||||
|
||||
# Install to device (dashboard API port 80, not dev port 6666)
|
||||
# Preferred: use reyna-cli (see "Device discovery & management via reyna-cli" below)
|
||||
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/MyApp install 192.168.68.112 esp32s3
|
||||
# or dashboard directly:
|
||||
curl -X PUT http://192.168.68.112/api/apps/install -F "file=@build/MyApp.app"
|
||||
@@ -65,6 +84,41 @@ curl http://192.168.68.112/api/apps # list installed
|
||||
# If 6666 refused → use dashboard PUT above
|
||||
```
|
||||
|
||||
### Device discovery & management via reyna-cli
|
||||
|
||||
`reyna-cli` (at `/Users/adolforeyna/.local/bin/reyna-cli`) wraps the Tactility LAN web API with a device registry
|
||||
(`~/.hermes/local_devices.yaml`). Prefer it over raw IPs/curl for fleet discovery and management. Boards are
|
||||
addressed by their stable registry **ID** (not IP — DHCP IPs change); `devices list` maps IDs→IPs.
|
||||
|
||||
Registered Tactility boards (from `reyna-cli devices list`):
|
||||
|
||||
| ID | IP | Board |
|
||||
|----|----|-------|
|
||||
| `kidosos_a790` | `.129` | Personal 3.5-inch Tactility Board (Dev) — USB `/dev/cu.usbmodem*` for serial |
|
||||
| `kidosos_5c5c` | `.114` | Grace's 2.8-inch board |
|
||||
| `kidosos_5690` | `.107` | Elias's 2.8-inch board |
|
||||
| `kidosos_591c` | `.128` | Kitchen 2.8-inch board |
|
||||
| `esp32_screen` | `.123` | ESP32 Screen |
|
||||
| `little32` | `.122` | Little32 Kitchen ESP32 Screen |
|
||||
|
||||
Commands:
|
||||
|
||||
```bash
|
||||
reyna-cli tactility discover # probe all boards; online/offline + sysinfo
|
||||
reyna-cli devices list # registry (id, ip, display name, status)
|
||||
reyna-cli tactility sysinfo kidsos_a790 # firmware/version/heap/psram/SD
|
||||
reyna-cli tactility apps kidsos_a790 # installed apps
|
||||
reyna-cli tactility install kidsos_5c5c build/MyApp.app # PUT install by device id
|
||||
reyna-cli tactility run kidsos_5c5c one.tactility.myapp # launch app
|
||||
reyna-cli tactility report kidsos_a790 # apps + SD + bible/podcast usage
|
||||
# most commands accept --json for machine-readable output
|
||||
```
|
||||
|
||||
Use a serial monitor on the USB board for loader/ELF diagnostics: `/dev/cu.usbmodem*` @ 115200
|
||||
(e.g. `idf5.5_py3.9_env/bin/python -m serial.tools.miniterm /dev/cu.usbmodem31201 115200`). This is how you
|
||||
capture `E ELF: Can't find symbol X` on `.129` — `verify_symbols.py` only compares against local firmware
|
||||
SOURCE and can pass even when the flashed firmware's export table differs.
|
||||
|
||||
### manifest.properties
|
||||
|
||||
```properties
|
||||
@@ -103,6 +157,7 @@ Keep app source as pure C (no `auto`, no lambdas in `.c`) or rename to `.cpp`.
|
||||
Tactility firmware exports symbols via `ESP_ELFSYM_EXPORT` / `DEFINE_MODULE_SYMBOL` in `firmware/Modules/lvgl-module/source/symbols.c`. If ELF references an unexported symbol → device modal `Error / Application failed to start: missing symbol`, heap still healthy (~31KB free) → NOT OOM.
|
||||
|
||||
Known missing on 0.8.0-dev:
|
||||
- `device_get_by_name` / `device_get_first_by_type` (out-param API) → **Not exported** by flashed firmware; use legacy exported `device_find_by_name` / `device_find_first_by_type` (declare them locally — newer SDK headers dropped them). Gotcha: `verify_symbols.py` compares vs local firmware SOURCE and can pass even when the flashed firmware's export table differs — confirm on-device via serial (`/dev/cu.usbmodem*`, 115200): `E ELF: Can't find symbol X`.
|
||||
- `lv_font_montserrat_14/18` → use `lvgl_get_text_font(SMALL/DEFAULT/LARGE)` via `tactility/lvgl_fonts.h`
|
||||
- `lv_obj_set_style_bg_grad_color/dir/stop`, `LV_OPA_95/5`, `LV_SYMBOL_STAR` → use solid colors, COVER/90
|
||||
- `lv_obj_set_style_max_width` → use `LV_PCT(92)` fixed percents for gutters
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.audiotest
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Audio Test
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.audiotest
|
||||
app.version.name=0.1.0
|
||||
app.version.code=1
|
||||
app.name=Audio Test
|
||||
|
||||
@@ -33,7 +33,7 @@ LV_FONT_DECLARE(georgia_regular_24)
|
||||
#define MAX_BIBLE_BOOKS 66
|
||||
#define MAX_BOOK_NAME 40
|
||||
#define MAX_VERSE_TEXT 2048
|
||||
#define MAX_PATH 320
|
||||
#define MAX_PATH 128
|
||||
|
||||
typedef struct {
|
||||
uint32_t offset;
|
||||
@@ -239,7 +239,7 @@ static bool parse_books_json(AppCtx* ctx, const char* json_str) {
|
||||
const char* obj_end = strchr(obj_start, '}');
|
||||
if (!obj_end) break;
|
||||
// parse fields within obj_start..obj_end
|
||||
char temp[512];
|
||||
char temp[256];
|
||||
size_t len = (size_t)(obj_end - obj_start + 1);
|
||||
if (len >= sizeof(temp)) len = sizeof(temp)-1;
|
||||
memcpy(temp, obj_start, len);
|
||||
@@ -672,27 +672,27 @@ static void show_current_verse(AppCtx* ctx) {
|
||||
const char* text_ptr = "(empty)";
|
||||
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
|
||||
|
||||
char verse_buf[MAX_VERSE_TEXT];
|
||||
strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1);
|
||||
verse_buf[sizeof(verse_buf)-1]='\0';
|
||||
// Fix stack overflow: verse_buf was 2048 bytes on stack, causing gui task (4096) overflow
|
||||
// Use heap allocation
|
||||
char* verse_buf = (char*)malloc(MAX_VERSE_TEXT);
|
||||
if (!verse_buf) {
|
||||
ESP_LOGE(TAG, "Failed to malloc verse_buf");
|
||||
return;
|
||||
}
|
||||
strncpy(verse_buf, text_ptr, MAX_VERSE_TEXT-1);
|
||||
verse_buf[MAX_VERSE_TEXT-1]='\0';
|
||||
|
||||
char ref_buf[96];
|
||||
char ref_buf[64];
|
||||
snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
|
||||
|
||||
// Editorial reference style like your image: "PSALMS 23:1" with tracking
|
||||
char ref_pretty[108];
|
||||
// Use uppercase for book? Keep title for now but uppercase style for editorial match
|
||||
// We'll format as "— PSALMS 23:1 —" feel? For dark mode keep minimal like ref image
|
||||
// Reference image shows lines flanking citation - we do with letterspacing + dim
|
||||
// For now: "PSALMS 23:1" style - uppercase via runtime? We'll uppercase book name
|
||||
char bname_upper[48];
|
||||
char ref_pretty[80];
|
||||
char bname_upper[40];
|
||||
strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
|
||||
bname_upper[sizeof(bname_upper)-1]='\0';
|
||||
for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p);
|
||||
snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum);
|
||||
|
||||
if (ctx->lbl_verse) {
|
||||
// start faded for transition
|
||||
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
|
||||
lv_label_set_text(ctx->lbl_verse, verse_buf);
|
||||
apply_verse_scaling(ctx, verse_buf);
|
||||
@@ -741,6 +741,7 @@ static void show_current_verse(AppCtx* ctx) {
|
||||
if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||
}
|
||||
free(verse_buf);
|
||||
save_progress(ctx);
|
||||
verse_fade_in(ctx);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
[app]
|
||||
id=one.tactility.bibleverse
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Bible Verse
|
||||
description=Single verse at a time, advances each minute. Tap to show controls.
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3,esp32p4
|
||||
app.id=one.tactility.bibleverse
|
||||
app.version.name=0.1.0
|
||||
app.version.code=2
|
||||
app.name=Bible Verse
|
||||
app.description=Single verse at a time, advances each minute. Tap to show controls.
|
||||
|
||||
@@ -63,3 +63,11 @@ Each book subfolder must contain a `manifest.json` file. Here is a sample format
|
||||
|
||||
- **Images**: PNG or JPG format. Recommended size is `320×240` pixels (or scaled to match standard aspect ratios).
|
||||
- **Audio**: Mono `MP3` or uncompressed standard `WAV` files. For ESP32-S3 systems, lower sampling rates (e.g. 16 kHz mono) are recommended for memory efficiency.
|
||||
|
||||
## Book Selection and Playback
|
||||
|
||||
The picker displays the selected book's first page image full-screen with its title.
|
||||
Tap the left or right side of the cover, or swipe right or left, to choose the
|
||||
previous or next book. Tap the Play button to begin page-one narration through the
|
||||
same playback flow used by the in-book player. Once playing, the existing
|
||||
previous, pause/play, next, progress, and auto-advance controls remain unchanged.
|
||||
|
||||
+439
-188
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.bookplayer
|
||||
versionName=1.0.0
|
||||
versionCode=1
|
||||
name=Book Player
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.bookplayer
|
||||
app.version.name=1.0.0
|
||||
app.version.code=1
|
||||
app.name=Book Player
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.brainfuck
|
||||
versionName=0.2.0
|
||||
versionCode=2
|
||||
name=Brainfuck interpreter
|
||||
description=Brainfuck esoteric language interpreter
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.brainfuck
|
||||
app.version.name=0.5.0
|
||||
app.version.code=5
|
||||
app.name=Brainfuck interpreter
|
||||
app.description=Brainfuck esoteric language interpreter
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
#include <esp_random.h>
|
||||
#include <tt_lvgl_keyboard.h>
|
||||
|
||||
#include <tactility/lvgl_module.h>
|
||||
#include <tactility/lvgl_fonts.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/fonts.h>
|
||||
|
||||
constexpr auto* TAG = "Breakout";
|
||||
|
||||
@@ -124,7 +124,6 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
||||
if (!sfxEngine) {
|
||||
sfxEngine = new SfxEngine();
|
||||
sfxEngine->start();
|
||||
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
||||
sfxEngine->setEnabled(soundEnabled);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.breakout
|
||||
versionName=0.2.0
|
||||
versionCode=2
|
||||
name=Breakout
|
||||
description=Classic brick-breaking arcade game
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.breakout
|
||||
app.version.name=0.5.0
|
||||
app.version.code=5
|
||||
app.name=Breakout
|
||||
app.description=Classic brick-breaking arcade game
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.calculator
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Calculator
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.calculator
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Calculator
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.diceware
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Diceware
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.diceware
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Diceware
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
#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
@@ -1,11 +0,0 @@
|
||||
[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
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
[app]
|
||||
id=one.tactility.epubreader
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Epub Reader
|
||||
description=Epub and text file reader. Requires PSRAM!
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3,esp32p4
|
||||
app.id=one.tactility.epubreader
|
||||
app.version.name=0.4.0
|
||||
app.version.code=4
|
||||
app.name=Epub Reader
|
||||
app.description=Epub and text file reader. Requires PSRAM!
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||
else()
|
||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
endif()
|
||||
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(EspNowBridge)
|
||||
tactility_project(EspNowBridge)
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES
|
||||
Source/*.c*
|
||||
)
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
# Library headers must be included directly,
|
||||
# because all regular dependencies get stripped by elf_loader's cmake script
|
||||
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
|
||||
REQUIRES TactilitySDK bootloader_support esp_app_format
|
||||
)
|
||||
@@ -0,0 +1,739 @@
|
||||
#include "EspNowBridge.h"
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/wifi.h>
|
||||
#include <tactility/wifi_auto_scan.h>
|
||||
#include <tactility/firmware/firmware.h>
|
||||
|
||||
#include <tt_app.h>
|
||||
#include <tt_app_fileselection.h>
|
||||
#include <tt_bundle.h>
|
||||
#include <tt_lock.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
#include <esp_app_desc.h>
|
||||
#include <esp_app_format.h>
|
||||
#include <esp_system.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
static constexpr auto* TAG = "EspNowBridge";
|
||||
static constexpr size_t CHUNK_SIZE = 1500;
|
||||
static constexpr uint32_t TRANSPORT_WAIT_TIMEOUT_MS = 5000;
|
||||
static constexpr uint32_t UPDATE_TASK_STACK_SIZE = 8192;
|
||||
|
||||
AutoScanPauseGuard::AutoScanPauseGuard() { wifi_auto_scan_set_paused(true); }
|
||||
AutoScanPauseGuard::~AutoScanPauseGuard() { wifi_auto_scan_set_paused(false); }
|
||||
|
||||
// Binary partition table format (gen_esp32part.py STRUCT_FORMAT '<2sBBLL16sL'): a flat array of
|
||||
// 32-byte little-endian records starting at flash offset PARTITION_TABLE_OFFSET, terminated by
|
||||
// an all-0xFF entry or an MD5-checksum record (magic 0xEBEB). Not exposed as a C header by
|
||||
// ESP-IDF (only the Python generator knows the format) - this is a hand-ported minimal reader,
|
||||
// just enough to locate the app partition inside a merged/factory bin.
|
||||
static constexpr size_t PARTITION_TABLE_OFFSET = 0x8000;
|
||||
static constexpr size_t PARTITION_TABLE_MAX_ENTRIES = 128; // covers the largest partition table IDF supports (0x1000 / 32)
|
||||
static constexpr uint16_t PARTITION_ENTRY_MAGIC = 0x50AA; // little-endian bytes 0xAA, 0x50
|
||||
static constexpr uint16_t PARTITION_MD5_MAGIC = 0xEBEB;
|
||||
static constexpr uint8_t PARTITION_TYPE_APP = 0x00;
|
||||
static constexpr uint8_t PARTITION_SUBTYPE_FACTORY = 0x00;
|
||||
static constexpr uint8_t PARTITION_SUBTYPE_OTA_0 = 0x10;
|
||||
|
||||
struct __attribute__((packed)) PartitionEntry {
|
||||
uint16_t magic;
|
||||
uint8_t type;
|
||||
uint8_t subtype;
|
||||
uint32_t offset;
|
||||
uint32_t size;
|
||||
char name[16];
|
||||
uint32_t flags;
|
||||
};
|
||||
static_assert(sizeof(PartitionEntry) == 32, "partition table entry must be 32 bytes");
|
||||
|
||||
/**
|
||||
* Scans the partition table embedded in a merged/factory bin (at PARTITION_TABLE_OFFSET) for
|
||||
* the app partition to flash: prefers "factory" if present, otherwise the first OTA slot
|
||||
* (ota_0) - matches what a real M5Stack ESP-Hosted factory image contains.
|
||||
* @return true if an app partition was found, with appOffset/appSize set to its location
|
||||
* within the file (these are the same as the absolute flash offsets the merged bin preserves).
|
||||
*/
|
||||
static bool findAppPartitionInMergedBin(FILE* file, size_t& appOffset, size_t& appSize) {
|
||||
if (fseek(file, static_cast<long>(PARTITION_TABLE_OFFSET), SEEK_SET) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool foundFactory = false;
|
||||
bool foundOta0 = false;
|
||||
size_t factoryOffset = 0, factorySize = 0;
|
||||
size_t ota0Offset = 0, ota0Size = 0;
|
||||
|
||||
for (size_t i = 0; i < PARTITION_TABLE_MAX_ENTRIES; i++) {
|
||||
PartitionEntry entry;
|
||||
if (fread(&entry, 1, sizeof(entry), file) != sizeof(entry)) {
|
||||
break;
|
||||
}
|
||||
if (entry.magic == PARTITION_MD5_MAGIC) {
|
||||
break;
|
||||
}
|
||||
if (entry.magic != PARTITION_ENTRY_MAGIC) {
|
||||
break;
|
||||
}
|
||||
if (entry.type == PARTITION_TYPE_APP) {
|
||||
if (entry.subtype == PARTITION_SUBTYPE_FACTORY) {
|
||||
foundFactory = true;
|
||||
factoryOffset = entry.offset;
|
||||
factorySize = entry.size;
|
||||
} else if (entry.subtype == PARTITION_SUBTYPE_OTA_0 && !foundOta0) {
|
||||
foundOta0 = true;
|
||||
ota0Offset = entry.offset;
|
||||
ota0Size = entry.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundFactory) {
|
||||
appOffset = factoryOffset;
|
||||
appSize = factorySize;
|
||||
return true;
|
||||
}
|
||||
if (foundOta0) {
|
||||
appOffset = ota0Offset;
|
||||
appSize = ota0Size;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the app image at the given file offset and extracts its version string. The actual
|
||||
* transfer size used for the OTA loop is just the real remaining file size from appOffset (see
|
||||
* performUpdate) - hand-computing the image's "logical" size from segment headers + checksum/
|
||||
* hash padding drifts a bit short of the real length, so we just use the file size instead.
|
||||
*/
|
||||
static bool parseImageHeader(FILE* file, size_t appOffset, char* versionOut, size_t versionOutLen, std::string* errorOut = nullptr) {
|
||||
esp_image_header_t imageHeader;
|
||||
if (fseek(file, static_cast<long>(appOffset), SEEK_SET) != 0 ||
|
||||
fread(&imageHeader, 1, sizeof(imageHeader), file) != sizeof(imageHeader)) {
|
||||
if (errorOut != nullptr) {
|
||||
*errorOut = "Failed to read image header";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (imageHeader.magic != ESP_IMAGE_HEADER_MAGIC) {
|
||||
if (errorOut != nullptr) {
|
||||
*errorOut = "Selected file is not a valid firmware image (bad magic)";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fail fast on a wrong-chip image (e.g. an ESP32 or S3 binary picked by mistake) before
|
||||
// streaming the whole file over the paced, slow bridge link - esp_hosted_slave_ota_end()
|
||||
// would eventually catch this too, but only after the entire transfer already completed.
|
||||
if (imageHeader.chip_id != ESP_CHIP_ID_ESP32C6) {
|
||||
if (errorOut != nullptr) {
|
||||
char buf[96];
|
||||
snprintf(buf, sizeof(buf), "Wrong chip: image targets chip id %u, expected ESP32-C6",
|
||||
(unsigned)imageHeader.chip_id);
|
||||
*errorOut = buf;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_image_segment_header_t segmentHeader;
|
||||
size_t firstSegmentOffset = appOffset + sizeof(imageHeader);
|
||||
if (fseek(file, static_cast<long>(firstSegmentOffset), SEEK_SET) != 0 ||
|
||||
fread(&segmentHeader, 1, sizeof(segmentHeader), file) != sizeof(segmentHeader)) {
|
||||
if (errorOut != nullptr) {
|
||||
*errorOut = "Failed to read first segment header";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_app_desc_t appDesc;
|
||||
size_t appDescOffset = appOffset + sizeof(imageHeader) + sizeof(segmentHeader);
|
||||
if (fseek(file, static_cast<long>(appDescOffset), SEEK_SET) == 0 && fread(&appDesc, 1, sizeof(appDesc), file) == sizeof(appDesc)) {
|
||||
strncpy(versionOut, appDesc.version, versionOutLen - 1);
|
||||
versionOut[versionOutLen - 1] = '\0';
|
||||
} else {
|
||||
strncpy(versionOut, "unknown", versionOutLen - 1);
|
||||
versionOut[versionOutLen - 1] = '\0';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool getCurrentVersionString(const FirmwareOps* ops, void* ctx, char* versionOut, size_t versionOutLen) {
|
||||
FirmwareInfo info = {};
|
||||
if (ops == nullptr || ops->get_info(ctx, &info) != ERROR_NONE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (info.name[0] != '\0') {
|
||||
snprintf(versionOut, versionOutLen, "%u.%u.%u (%s)",
|
||||
(unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch, info.name);
|
||||
} else {
|
||||
snprintf(versionOut, versionOutLen, "%u.%u.%u",
|
||||
(unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Only slave firmware >= v2.6.0 implements esp_hosted_slave_ota_activate() - older slaves
|
||||
* reject/lack the RPC entirely. Matches upstream's host_performs_slave_ota example. */
|
||||
static bool activateSupported(uint32_t major, uint32_t minor) {
|
||||
return (major > 2) || (major == 2 && minor > 5);
|
||||
}
|
||||
|
||||
std::atomic<EspNowBridge*> EspNowBridge::liveInstance_{nullptr};
|
||||
|
||||
void EspNowBridge::onCreate(AppHandle app) {
|
||||
appHandle_ = app;
|
||||
taskDoneSemaphore_ = xSemaphoreCreateBinary();
|
||||
liveInstance_ = this;
|
||||
}
|
||||
|
||||
void EspNowBridge::onDestroy(AppHandle /*app*/) {
|
||||
// Clear liveInstance_ first so any task still running bails out at its next liveInstance_
|
||||
// check instead of continuing to touch this instance's members.
|
||||
liveInstance_ = nullptr;
|
||||
|
||||
// Wait for any outstanding background task (OTA update, transport-wait) to actually finish -
|
||||
// the app framework frees this instance shortly after onDestroy() returns, so a task that
|
||||
// outlives it would dereference freed memory.
|
||||
while (outstandingTasks_.load() > 0) {
|
||||
if (taskDoneSemaphore_ != nullptr) {
|
||||
xSemaphoreTake(taskDoneSemaphore_, pdMS_TO_TICKS(1000));
|
||||
}
|
||||
}
|
||||
|
||||
if (taskDoneSemaphore_ != nullptr) {
|
||||
vSemaphoreDelete(taskDoneSemaphore_);
|
||||
taskDoneSemaphore_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void EspNowBridge::refreshCurrentVersion() {
|
||||
char versionStr[32];
|
||||
if (getCurrentVersionString(firmwareOps_, firmwareCtx_, versionStr, sizeof(versionStr))) {
|
||||
lv_label_set_text_fmt(currentVersionLabel_, "Co-processor firmware: %s", versionStr);
|
||||
} else {
|
||||
lv_label_set_text(currentVersionLabel_, "Co-processor firmware: unknown (link not up)");
|
||||
}
|
||||
}
|
||||
|
||||
bool EspNowBridge::isWifiRadioOn() {
|
||||
if (wifiDevice_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
WifiRadioState radioState = WIFI_RADIO_STATE_OFF;
|
||||
if (wifi_get_radio_state(wifiDevice_, &radioState) != ERROR_NONE) {
|
||||
return false;
|
||||
}
|
||||
// ON with any station state (disconnected/pending/connected) is fine - the ESP-NOW bridge
|
||||
// just needs the radio + esp_hosted transport up, not a completed AP connection.
|
||||
return radioState == WIFI_RADIO_STATE_ON;
|
||||
}
|
||||
|
||||
void EspNowBridge::refreshWifiPrompt() {
|
||||
if (isWifiRadioOn()) {
|
||||
lv_obj_add_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN);
|
||||
setUpdateButtonsDisabled(false);
|
||||
} else {
|
||||
lv_obj_clear_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN);
|
||||
setUpdateButtonsDisabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
void EspNowBridge::setUpdateButtonsDisabled(bool disabled) {
|
||||
if (disabled) {
|
||||
lv_obj_add_state(updateButton_, LV_STATE_DISABLED);
|
||||
lv_obj_add_state(updateBundledButton_, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_clear_state(updateButton_, LV_STATE_DISABLED);
|
||||
lv_obj_clear_state(updateBundledButton_, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
void EspNowBridge::setStatus(const std::string& text) {
|
||||
lv_label_set_text(statusLabel_, text.c_str());
|
||||
}
|
||||
|
||||
void EspNowBridge::setProgress(int percent) {
|
||||
lv_bar_set_value(progressBar_, percent, LV_ANIM_OFF);
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct UiDispatchPayload {
|
||||
EspNowBridge* instance;
|
||||
void (*work)(EspNowBridge&, void*);
|
||||
void* context;
|
||||
void (*freeContext)(void*);
|
||||
};
|
||||
}
|
||||
|
||||
void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)) {
|
||||
auto* payload = new UiDispatchPayload{this, work, context, freeContext};
|
||||
// lv_async_call() itself is an LVGL operation and must be lock-guarded when called from a
|
||||
// non-LVGL task (see tt_lvgl_lock()'s doc comment) - the OTA worker task calls dispatchToUi()
|
||||
// repeatedly during the transfer, and without this lock most of those calls were silently
|
||||
// racing LVGL's own task and getting lost (only the very last status update, right before
|
||||
// esp_restart(), happened to land - everything else stayed stuck at "Waiting for
|
||||
// co-processor link...").
|
||||
bool locked = tt_lvgl_lock(TT_LVGL_DEFAULT_LOCK_TIME);
|
||||
if (!locked) {
|
||||
// Without the lock, lv_async_call() itself would be touching LVGL's internal timer list
|
||||
// unguarded - and if it happened to still enqueue successfully, the callback below would
|
||||
// later fire against `payload` after we've already freed it here. Drop the update instead.
|
||||
if (freeContext != nullptr) {
|
||||
freeContext(context);
|
||||
}
|
||||
delete payload;
|
||||
return;
|
||||
}
|
||||
|
||||
lv_result_t result = lv_async_call([](void* userData) {
|
||||
auto* payload = static_cast<UiDispatchPayload*>(userData);
|
||||
if (EspNowBridge::liveInstance_.load() == payload->instance && payload->instance->isShown_.load()) {
|
||||
payload->work(*payload->instance, payload->context);
|
||||
}
|
||||
if (payload->freeContext != nullptr) {
|
||||
payload->freeContext(payload->context);
|
||||
}
|
||||
delete payload;
|
||||
}, payload);
|
||||
tt_lvgl_unlock();
|
||||
|
||||
if (result != LV_RESULT_OK) {
|
||||
if (freeContext != nullptr) {
|
||||
freeContext(context);
|
||||
}
|
||||
delete payload;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
void workSetStatus(EspNowBridge& app, void* context) {
|
||||
app.setStatus(*static_cast<std::string*>(context));
|
||||
}
|
||||
void freeString(void* context) { delete static_cast<std::string*>(context); }
|
||||
|
||||
void workSetProgress(EspNowBridge& app, void* context) {
|
||||
app.setProgress(*static_cast<int*>(context));
|
||||
}
|
||||
void freeInt(void* context) { delete static_cast<int*>(context); }
|
||||
|
||||
} // namespace
|
||||
|
||||
void EspNowBridge::performUpdate(const std::string& filePath) {
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setUpdateButtonsDisabled(true);
|
||||
app.setProgress(0);
|
||||
app.setStatus("Waiting for co-processor link...");
|
||||
}, nullptr, nullptr);
|
||||
|
||||
if (firmwareOps_ == nullptr) {
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("This WiFi device has no updatable co-processor");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firmwareOps_->wait_ready(firmwareCtx_, TRANSPORT_WAIT_TIMEOUT_MS)) {
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Co-processor link not available - update cancelled");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
FILE* file = fopen(filePath.c_str(), "rb");
|
||||
if (file == nullptr) {
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Failed to open selected file");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
fseek(file, 0, SEEK_END);
|
||||
long fileSizeSigned = ftell(file);
|
||||
if (fileSizeSigned <= 0) {
|
||||
fclose(file);
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Failed to determine file size");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
size_t fileSize = static_cast<size_t>(fileSizeSigned);
|
||||
|
||||
// Support both a plain app image (starting with the app image header at offset 0) and a
|
||||
// merged/factory bin (e.g. M5Stack's official ESP-Hosted factory image) - detected by whether
|
||||
// a valid partition table is found at PARTITION_TABLE_OFFSET.
|
||||
size_t appOffset = 0;
|
||||
size_t partitionSize = 0;
|
||||
bool isMergedBin = findAppPartitionInMergedBin(file, appOffset, partitionSize);
|
||||
if (isMergedBin && appOffset >= fileSize) {
|
||||
fclose(file);
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Merged bin's app partition is outside the file - selected file looks truncated");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
char newVersion[32];
|
||||
std::string parseError;
|
||||
if (!parseImageHeader(file, appOffset, newVersion, sizeof(newVersion), &parseError)) {
|
||||
fclose(file);
|
||||
dispatchToUi(workSetStatus, new std::string(parseError), freeString);
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Merged bins pad the app partition to its declared size; a plain app image is exactly as
|
||||
// long as the app itself. Transfer whichever is smaller.
|
||||
size_t remainingInFile = fileSize - appOffset;
|
||||
size_t firmwareSize = isMergedBin ? std::min(partitionSize, remainingInFile) : remainingInFile;
|
||||
|
||||
std::string versionStr(newVersion);
|
||||
{
|
||||
char buf[64];
|
||||
snprintf(buf, sizeof(buf), "Pushing firmware %s...", versionStr.c_str());
|
||||
dispatchToUi(workSetStatus, new std::string(buf), freeString);
|
||||
}
|
||||
|
||||
// Held on the app instance (not a local variable) so it outlives this function - see
|
||||
// heldAutoScanPauseGuard_'s declaration for why. Released when the host actually restarts
|
||||
// (moot, since esp_restart() doesn't return) or if the update fails early below.
|
||||
heldAutoScanPauseGuard_.emplace();
|
||||
|
||||
FirmwareUpdateRequest updateRequest = {};
|
||||
updateRequest.image_size = firmwareSize;
|
||||
FirmwareUpdateHandle* handle = nullptr;
|
||||
if (firmwareOps_->begin(firmwareCtx_, &updateRequest, &handle) != ERROR_NONE) {
|
||||
fclose(file);
|
||||
heldAutoScanPauseGuard_.reset();
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Failed to start OTA on co-processor");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fseek(file, static_cast<long>(appOffset), SEEK_SET) != 0) {
|
||||
fclose(file);
|
||||
firmwareOps_->abort(handle);
|
||||
heldAutoScanPauseGuard_.reset();
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Failed to seek to firmware start");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t chunk[CHUNK_SIZE];
|
||||
size_t sent = 0;
|
||||
bool writeFailed = false;
|
||||
int lastReportedPercent = -1;
|
||||
|
||||
while (sent < firmwareSize) {
|
||||
size_t toRead = (firmwareSize - sent > CHUNK_SIZE) ? CHUNK_SIZE : (firmwareSize - sent);
|
||||
size_t actuallyRead = fread(chunk, 1, toRead, file);
|
||||
if (actuallyRead != toRead) {
|
||||
LOG_E(TAG, "Failed to read file at offset %zu", sent);
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (firmwareOps_->write(handle, chunk, actuallyRead) != ERROR_NONE) {
|
||||
LOG_E(TAG, "firmwareOps_->write() failed at offset %zu", sent);
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Pace the transfer - esp_hosted's SDIO driver only retries a write twice with no
|
||||
// backoff before giving up and restarting the host. Back-to-back chunk writes with zero
|
||||
// gap were observed to saturate the bus enough to trigger a genuine SDIO timeout
|
||||
// mid-transfer, not just around the post-activate reboot.
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
|
||||
sent += actuallyRead;
|
||||
|
||||
// Only touch LVGL every couple of percent, not every 1500-byte chunk - frequent
|
||||
// display-bus activity during the transfer was implicated in SDIO transport crashes
|
||||
// under sustained OTA write load.
|
||||
int percent = (int)((sent * 100) / firmwareSize);
|
||||
if (percent != lastReportedPercent) {
|
||||
dispatchToUi(workSetProgress, new int(percent), freeInt);
|
||||
lastReportedPercent = percent;
|
||||
}
|
||||
}
|
||||
|
||||
fclose(file);
|
||||
|
||||
if (writeFailed) {
|
||||
firmwareOps_->abort(handle);
|
||||
heldAutoScanPauseGuard_.reset();
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Update failed while transferring firmware");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (firmwareOps_->finish(handle) != ERROR_NONE) {
|
||||
heldAutoScanPauseGuard_.reset();
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Failed to finalize OTA on co-processor");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the *currently running* (pre-update) slave version - the new image isn't running
|
||||
// yet - and skip straight to the required host restart for older slaves.
|
||||
FirmwareInfo runningInfo = {};
|
||||
bool canActivate = firmwareOps_->get_info(firmwareCtx_, &runningInfo) == ERROR_NONE
|
||||
&& activateSupported(runningInfo.fw_major, runningInfo.fw_minor);
|
||||
|
||||
if (canActivate) {
|
||||
if (firmwareOps_->activate(firmwareCtx_) != ERROR_NONE) {
|
||||
heldAutoScanPauseGuard_.reset();
|
||||
dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.setStatus("Failed to activate new firmware - co-processor still running old firmware");
|
||||
app.setUpdateButtonsDisabled(false);
|
||||
}, nullptr, nullptr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// heldAutoScanPauseGuard_ is deliberately left held (never explicitly released) - the host
|
||||
// restarts itself immediately below, and there's no safe window to resume normal WiFi
|
||||
// activity before that.
|
||||
{
|
||||
char buf[80];
|
||||
if (canActivate) {
|
||||
snprintf(buf, sizeof(buf), "Firmware %s activated - restarting...", versionStr.c_str());
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "Firmware %s pushed - restarting to apply...", versionStr.c_str());
|
||||
}
|
||||
dispatchToUi(workSetStatus, new std::string(buf), freeString);
|
||||
}
|
||||
|
||||
// Give the status message above a moment to actually be seen before the restart cuts the
|
||||
// display, then restart.
|
||||
vTaskDelay(pdMS_TO_TICKS(1500));
|
||||
esp_restart();
|
||||
}
|
||||
|
||||
void EspNowBridge::updateTaskEntry(void* arg) {
|
||||
auto* self = static_cast<EspNowBridge*>(arg);
|
||||
self->performUpdate(self->pendingUpdateFilePath_);
|
||||
self->updateTask_ = nullptr;
|
||||
if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) {
|
||||
xSemaphoreGive(self->taskDoneSemaphore_);
|
||||
}
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void EspNowBridge::startUpdateTask(const std::string& filePath) {
|
||||
if (updateTask_ != nullptr) {
|
||||
return;
|
||||
}
|
||||
pendingUpdateFilePath_ = filePath;
|
||||
outstandingTasks_.fetch_add(1);
|
||||
if (xTaskCreate(updateTaskEntry, "espnow_bridge_ota", UPDATE_TASK_STACK_SIZE / sizeof(StackType_t), this, tskIDLE_PRIORITY + 1, &updateTask_) != pdPASS) {
|
||||
outstandingTasks_.fetch_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
void EspNowBridge::onUpdateButtonClicked(lv_event_t* /*event*/) {
|
||||
auto* self = liveInstance_.load();
|
||||
if (self == nullptr || !self->isWifiRadioOn()) {
|
||||
return;
|
||||
}
|
||||
self->pickFileLaunchId_ = tt_app_fileselection_start_for_existing_file();
|
||||
}
|
||||
|
||||
// Name of the slave bridge firmware bundled in this app's assets/ folder
|
||||
// lets users flash the known-good bridge firmware without needing to source/copy a
|
||||
// .bin onto the SD card themselves. The SD-card picker (onUpdateButtonClicked above) stays
|
||||
// available too, for factory-image downgrades or custom builds.
|
||||
static constexpr auto* BUNDLED_FIRMWARE_ASSET_NAME = "espnow_bridge_slave_c6.bin";
|
||||
|
||||
void EspNowBridge::onUpdateBundledButtonClicked(lv_event_t* /*event*/) {
|
||||
auto* self = liveInstance_.load();
|
||||
if (self == nullptr || !self->isWifiRadioOn()) {
|
||||
return;
|
||||
}
|
||||
char assetPath[256] = {};
|
||||
size_t assetPathSize = sizeof(assetPath);
|
||||
tt_app_get_assets_child_path(self->appHandle_, BUNDLED_FIRMWARE_ASSET_NAME, assetPath, &assetPathSize);
|
||||
if (assetPath[0] == '\0') {
|
||||
LOG_E(TAG, "Failed to resolve bundled firmware asset path");
|
||||
return;
|
||||
}
|
||||
self->startUpdateTask(assetPath);
|
||||
}
|
||||
|
||||
void EspNowBridge::onEnableWifiButtonClicked(lv_event_t* /*event*/) {
|
||||
auto* self = liveInstance_.load();
|
||||
if (self == nullptr || self->wifiDevice_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
device_start(self->wifiDevice_);
|
||||
// start_device() allocates a fresh driver context (Platforms/platform-esp32's
|
||||
// esp32_wifi.cpp), which wipes any event callback registered before the device was started -
|
||||
// re-register now that it's actually running. Also refresh once directly rather than relying
|
||||
// solely on the next WifiEvent, so the "WiFi on" prompt updates immediately even though the
|
||||
// co-processor firmware version below isn't available yet.
|
||||
wifi_add_event_callback(self->wifiDevice_, self, onWifiEvent);
|
||||
self->refreshWifiPrompt();
|
||||
self->refreshCurrentVersion();
|
||||
|
||||
// The co-processor RPC transport isn't up the instant device_start() returns - it comes up
|
||||
// asynchronously (~1-2s later) - so firmwareOps_->get_info() above reliably fails right after
|
||||
// enabling WiFi. Nothing else reliably re-triggers a version refresh once the transport
|
||||
// actually comes up (WifiEvent only covers radio/station state, not transport readiness), so
|
||||
// wait for it explicitly on a background task and refresh once it's ready.
|
||||
if (self->firmwareOps_ != nullptr) {
|
||||
self->outstandingTasks_.fetch_add(1);
|
||||
if (xTaskCreate(waitForTransportTaskEntry, "espnow_bridge_wait", 4096 / sizeof(StackType_t), self, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) {
|
||||
self->outstandingTasks_.fetch_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EspNowBridge::waitForTransportTaskEntry(void* arg) {
|
||||
auto* self = static_cast<EspNowBridge*>(arg);
|
||||
constexpr uint32_t WAIT_TIMEOUT_MS = 10000;
|
||||
// liveInstance_ must be checked before touching any member of self - if onDestroy() already
|
||||
// ran, `self` may be freed, and dereferencing self->firmwareOps_ first would be a
|
||||
// use-after-free even just to read the pointer.
|
||||
if (liveInstance_.load() == self && self->firmwareOps_ != nullptr
|
||||
&& self->firmwareOps_->wait_ready(self->firmwareCtx_, WAIT_TIMEOUT_MS)
|
||||
&& liveInstance_.load() == self) {
|
||||
self->dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.refreshCurrentVersion();
|
||||
}, nullptr, nullptr);
|
||||
}
|
||||
if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) {
|
||||
xSemaphoreGive(self->taskDoneSemaphore_);
|
||||
}
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void EspNowBridge::onWifiEvent(Device* /*device*/, void* callbackContext, WifiEvent /*event*/) {
|
||||
auto* self = static_cast<EspNowBridge*>(callbackContext);
|
||||
if (liveInstance_.load() != self) {
|
||||
return;
|
||||
}
|
||||
self->dispatchToUi([](EspNowBridge& app, void*) {
|
||||
app.refreshWifiPrompt();
|
||||
app.refreshCurrentVersion();
|
||||
}, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) {
|
||||
isShown_ = true;
|
||||
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(wrapper, 8, LV_STATE_DEFAULT);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
|
||||
currentVersionLabel_ = lv_label_create(wrapper);
|
||||
lv_obj_set_style_pad_bottom(currentVersionLabel_, 12, LV_STATE_DEFAULT);
|
||||
|
||||
enableWifiButton_ = lv_button_create(wrapper);
|
||||
lv_obj_add_event_cb(enableWifiButton_, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||
auto* enableWifiButtonLabel = lv_label_create(enableWifiButton_);
|
||||
lv_label_set_text(enableWifiButtonLabel, "Enable WiFi (required for co-processor link)");
|
||||
lv_obj_set_style_pad_bottom(enableWifiButton_, 12, LV_STATE_DEFAULT);
|
||||
|
||||
updateBundledButton_ = lv_button_create(wrapper);
|
||||
lv_obj_add_event_cb(updateBundledButton_, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||
auto* updateBundledButtonLabel = lv_label_create(updateBundledButton_);
|
||||
lv_label_set_text(updateBundledButtonLabel, "Update to bundled firmware");
|
||||
lv_obj_set_style_pad_bottom(updateBundledButton_, 12, LV_STATE_DEFAULT);
|
||||
|
||||
updateButton_ = lv_button_create(wrapper);
|
||||
lv_obj_add_event_cb(updateButton_, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||
auto* updateButtonLabel = lv_label_create(updateButton_);
|
||||
lv_label_set_text(updateButtonLabel, "Update from SD card...");
|
||||
lv_obj_set_style_pad_bottom(updateButton_, 12, LV_STATE_DEFAULT);
|
||||
|
||||
progressBar_ = lv_bar_create(wrapper);
|
||||
lv_obj_set_size(progressBar_, LV_PCT(100), LV_PCT(6));
|
||||
lv_bar_set_range(progressBar_, 0, 100);
|
||||
lv_bar_set_value(progressBar_, 0, LV_ANIM_OFF);
|
||||
|
||||
statusLabel_ = lv_label_create(wrapper);
|
||||
lv_label_set_text(statusLabel_, "Ready");
|
||||
|
||||
wifiDevice_ = wifi_find_first_registered_device();
|
||||
if (wifiDevice_ != nullptr) {
|
||||
wifi_add_event_callback(wifiDevice_, this, onWifiEvent);
|
||||
if (wifi_get_firmware_ops(wifiDevice_, &firmwareOps_, &firmwareCtx_) != ERROR_NONE) {
|
||||
firmwareOps_ = nullptr;
|
||||
firmwareCtx_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
refreshCurrentVersion();
|
||||
refreshWifiPrompt();
|
||||
|
||||
// If an SD-card file was picked before this onShow() ran (FileSelection tears down and
|
||||
// rebuilds this app's whole widget tree), perform the update now that widgets are valid
|
||||
// again. The bundled-firmware button doesn't go through this path - it calls
|
||||
// startUpdateTask() directly since there's no separate app launch/result round trip involved.
|
||||
if (!pendingUpdateFilePath_.empty()) {
|
||||
std::string path = std::move(pendingUpdateFilePath_);
|
||||
pendingUpdateFilePath_.clear();
|
||||
startUpdateTask(path);
|
||||
}
|
||||
}
|
||||
|
||||
void EspNowBridge::onHide(AppHandle /*app*/) {
|
||||
isShown_ = false;
|
||||
if (wifiDevice_ != nullptr) {
|
||||
wifi_remove_event_callback(wifiDevice_, onWifiEvent);
|
||||
wifiDevice_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void EspNowBridge::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, AppResult result, BundleHandle resultData) {
|
||||
if (launchId != pickFileLaunchId_) {
|
||||
return;
|
||||
}
|
||||
pickFileLaunchId_ = 0;
|
||||
|
||||
if (result == APP_RESULT_OK && resultData != nullptr) {
|
||||
char pathBuf[256] = {};
|
||||
if (tt_app_fileselection_get_result_path(resultData, pathBuf, sizeof(pathBuf))) {
|
||||
pendingUpdateFilePath_ = pathBuf;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
#include <TactilityCpp/App.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <tactility/drivers/wifi.h>
|
||||
|
||||
/** RAII guard: pauses WifiService's background auto-connect scan for the guard's lifetime. See
|
||||
* tactility/wifi_auto_scan.h - belt-and-suspenders measure, not sufficient on its own (see the
|
||||
* REBOOT comment in EspNowBridge.cpp). */
|
||||
class AutoScanPauseGuard {
|
||||
public:
|
||||
AutoScanPauseGuard();
|
||||
~AutoScanPauseGuard();
|
||||
AutoScanPauseGuard(const AutoScanPauseGuard&) = delete;
|
||||
AutoScanPauseGuard& operator=(const AutoScanPauseGuard&) = delete;
|
||||
};
|
||||
|
||||
class EspNowBridge final : public App {
|
||||
public:
|
||||
EspNowBridge() = default;
|
||||
EspNowBridge(const EspNowBridge&) = delete;
|
||||
EspNowBridge& operator=(const EspNowBridge&) = delete;
|
||||
|
||||
void onCreate(AppHandle app) override;
|
||||
void onDestroy(AppHandle app) override;
|
||||
void onShow(AppHandle app, lv_obj_t* parent) override;
|
||||
void onHide(AppHandle app) override;
|
||||
void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
|
||||
|
||||
// Public so the free-function dispatchToUi() work callbacks in EspNowBridge.cpp (which run
|
||||
// outside any member-function's lexical scope, unlike the inline lambdas in performUpdate())
|
||||
// can call them.
|
||||
void setStatus(const std::string& text);
|
||||
void setProgress(int percent);
|
||||
|
||||
private:
|
||||
AppHandle appHandle_ = nullptr;
|
||||
AppLaunchId pickFileLaunchId_ = 0;
|
||||
std::string pendingUpdateFilePath_;
|
||||
Device* wifiDevice_ = nullptr;
|
||||
|
||||
// Resolved once in onShow() via wifi_get_firmware_ops() - null on a WiFi device with no
|
||||
// updatable co-processor (e.g. a native, non-hosted chip). All OTA/version-query calls go
|
||||
// through this generic interface, not any esp_hosted-specific API directly.
|
||||
const FirmwareOps* firmwareOps_ = nullptr;
|
||||
void* firmwareCtx_ = nullptr;
|
||||
|
||||
// Set once in onShow(), false once onHide() tears the widget tree down - checked (via
|
||||
// dispatchToUi(), below) before touching any lv_obj_t*, since the OTA worker task and the
|
||||
// WiFi-event callback can both outlive a hide/app-switch.
|
||||
std::atomic<bool> isShown_{false};
|
||||
|
||||
// Only one EspNowBridge instance is ever live at a time (app loader owns a single instance
|
||||
// per running app), so a single static "is this instance still current" pointer, guarded by
|
||||
// an atomic, substitutes for the internal app's shared_ptr-based lifetime guard - the OTA
|
||||
// worker task and dispatchToUi()'s lv_async_call closures check liveInstance_ == this before
|
||||
// touching any member, instead of holding a shared_ptr to keep `this` alive.
|
||||
static std::atomic<EspNowBridge*> liveInstance_;
|
||||
|
||||
TaskHandle_t updateTask_ = nullptr;
|
||||
|
||||
// Number of background tasks (updateTaskEntry, waitForTransportTaskEntry) currently running
|
||||
// against this instance's members. onDestroy() must wait for this to hit 0 before returning -
|
||||
// the app framework frees this instance shortly after onDestroy() returns (see Loader.cpp),
|
||||
// so any task still touching `this` past that point is a use-after-free.
|
||||
std::atomic<int> outstandingTasks_{0};
|
||||
SemaphoreHandle_t taskDoneSemaphore_ = nullptr;
|
||||
|
||||
// Outlives performUpdate() deliberately, so auto-scan stays paused across the async gap
|
||||
// between performUpdate() returning and the automatic restart - see performUpdate().
|
||||
std::optional<AutoScanPauseGuard> heldAutoScanPauseGuard_;
|
||||
|
||||
lv_obj_t* currentVersionLabel_ = nullptr;
|
||||
lv_obj_t* statusLabel_ = nullptr;
|
||||
lv_obj_t* progressBar_ = nullptr;
|
||||
lv_obj_t* updateButton_ = nullptr;
|
||||
lv_obj_t* updateBundledButton_ = nullptr;
|
||||
lv_obj_t* enableWifiButton_ = nullptr;
|
||||
|
||||
void refreshCurrentVersion();
|
||||
bool isWifiRadioOn();
|
||||
void refreshWifiPrompt();
|
||||
/** Enables/disables both update-trigger buttons together - only one performUpdate() can run
|
||||
* at a time (see updateTask_), regardless of which button started it. */
|
||||
void setUpdateButtonsDisabled(bool disabled);
|
||||
/** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance_ is
|
||||
* still this instance (checked at dispatch time and again right before running, on the LVGL
|
||||
* task) and isShown_ is true (this app's widget tree exists). */
|
||||
void dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*));
|
||||
void performUpdate(const std::string& filePath);
|
||||
void startUpdateTask(const std::string& filePath);
|
||||
|
||||
static void updateTaskEntry(void* arg);
|
||||
static void onUpdateButtonClicked(lv_event_t* event);
|
||||
static void onUpdateBundledButtonClicked(lv_event_t* event);
|
||||
static void onEnableWifiButtonClicked(lv_event_t* event);
|
||||
static void onWifiEvent(Device* device, void* callbackContext, WifiEvent event);
|
||||
static void waitForTransportTaskEntry(void* arg);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "EspNowBridge.h"
|
||||
#include <TactilityCpp/App.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
registerApp<EspNowBridge>();
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32p4
|
||||
app.id=one.tactility.espnowbridge
|
||||
app.version.name=0.1.0
|
||||
app.version.code=1
|
||||
app.name=ESP-NOW Bridge
|
||||
app.description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support.
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.gpio
|
||||
versionName=0.4.0
|
||||
versionCode=4
|
||||
name=GPIO
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.gpio
|
||||
app.version.name=0.7.0
|
||||
app.version.code=7
|
||||
app.name=GPIO
|
||||
|
||||
+160
-152
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @file main.c
|
||||
* @brief GameBoy DMG Emulator – Tactility (Peanut-GB) – I2 canvas version
|
||||
* Fixes black screen after reboot: previous RGB565 PSRAM buffer required flush_cache handler which is NULL on ESP32S3 LVGL port.
|
||||
* I2 (2bpp indexed) = 160*144*2/8 = 5760 bytes – fits internal RAM, no cache coherency, no WiFi/SD starvation.
|
||||
* Uses lv_canvas_set_palette for 4 grays per GB_PALETTE.
|
||||
* @brief GameBoy DMG Emulator for Tactility (Peanut-GB prototype, no audio)
|
||||
*
|
||||
* MIT licensed Peanut-GB core vendored in Libraries/PeanutGB/peanut_gb.h
|
||||
* See app README for notes.
|
||||
*/
|
||||
|
||||
#include <tt_app.h>
|
||||
@@ -13,9 +13,11 @@
|
||||
#include <tt_lvgl_keyboard.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
/* lv_image_cache_drop is not in public LVGL headers but exported by Tactility firmware */
|
||||
void lv_image_cache_drop(const void * src);
|
||||
|
||||
#include <esp_log.h>
|
||||
|
||||
/* Board firmware 0.8.0-dev/IDF 5.3.2 does not export esp_log for side-loaded ELFs. */
|
||||
#undef ESP_LOGI
|
||||
#undef ESP_LOGW
|
||||
#undef ESP_LOGE
|
||||
@@ -38,23 +40,8 @@ void lv_image_cache_drop(const void * src);
|
||||
#include "peanut_gb.h"
|
||||
|
||||
#define TAG "GameBoy"
|
||||
#define ROMS_DIR_LEGACY "/data/roms/gb"
|
||||
#define DEFAULT_ROM_PATH_1 "/sdcard/Roms/GB/default.gb"
|
||||
#define DEFAULT_ROM_PATH_2 "/sdcard/roms/gb/default.gb"
|
||||
#define DEFAULT_ROM_PATH_3 "/data/Roms/GB/default.gb"
|
||||
#define DEFAULT_ROM_PATH_4 "/data/roms/gb/default.gb"
|
||||
#define DEFAULT_ROM_PATH DEFAULT_ROM_PATH_1
|
||||
#define ROMS_DIR ROMS_DIR_LEGACY
|
||||
static const char* ROMS_CANDIDATE_DIRS[] = {
|
||||
"/sdcard/Roms/GB",
|
||||
"/sdcard/Roms",
|
||||
"/sdcard/roms/gb",
|
||||
"/sdcard/ROMS/GB",
|
||||
"/data/Roms/GB",
|
||||
"/data/roms/gb",
|
||||
"/data/Roms",
|
||||
ROMS_DIR_LEGACY,
|
||||
};
|
||||
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb"
|
||||
#define ROMS_DIR "/data/roms/gb"
|
||||
#define MAX_ROMS 64
|
||||
#define MAX_PATH 512
|
||||
#define MAX_ROM_SIZE (2 * 1024 * 1024)
|
||||
@@ -62,6 +49,14 @@ static const char* ROMS_CANDIDATE_DIRS[] = {
|
||||
#define FRAME_H 144
|
||||
#define TICK_MS 16
|
||||
|
||||
/* RGB565 direct – no bitfield endian ambiguity */
|
||||
static const uint16_t GB_PALETTE[4] = {
|
||||
0xFFFF, // white
|
||||
0x8C51, // light gray ~ 0b10001 100010 10001 but pre-tuned
|
||||
0x4A49, // dark gray
|
||||
0x0000 // black
|
||||
};
|
||||
|
||||
typedef enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
|
||||
|
||||
typedef struct {
|
||||
@@ -79,8 +74,8 @@ typedef struct {
|
||||
char rom_title[32];
|
||||
uint8_t joypad_state;
|
||||
|
||||
uint8_t* fb_indexed;
|
||||
lv_draw_buf_t* fb_draw_buf;
|
||||
uint16_t* fb_native; /* RGB565 tightly packed 160*144, raw u16 avoids lv_color16_t bitfield */
|
||||
lv_draw_buf_t* fb_draw_buf; /* draw_buf that canvas src points to – owned by us so we can invalidate cache */
|
||||
lv_obj_t* canvas;
|
||||
lv_obj_t* root_wrapper;
|
||||
lv_obj_t* browser_wrapper;
|
||||
@@ -111,7 +106,7 @@ typedef struct {
|
||||
bool rom_loaded;
|
||||
uint32_t fps_frames;
|
||||
int64_t fps_last_us;
|
||||
uint32_t lines_drawn;
|
||||
uint32_t lines_drawn; /* debug: should be 144 per frame */
|
||||
} AppCtx;
|
||||
|
||||
typedef struct {
|
||||
@@ -119,18 +114,15 @@ typedef struct {
|
||||
uint8_t joypad_bit;
|
||||
} BtnUserData;
|
||||
|
||||
/* PSRAM helpers */
|
||||
static void* alloc_psram(size_t size) {
|
||||
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
|
||||
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);
|
||||
if (!p) p = malloc(size);
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Peanut-GB callbacks */
|
||||
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
|
||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||
if (!ctx || !ctx->rom_data) return 0xFF;
|
||||
@@ -148,22 +140,37 @@ static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const u
|
||||
if (!ctx || !ctx->cart_ram) return;
|
||||
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
|
||||
}
|
||||
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) { (void)gb;(void)err;(void)addr; }
|
||||
|
||||
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) {
|
||||
const char* err_str = "UNKNOWN";
|
||||
switch (err) {
|
||||
case GB_INVALID_OPCODE: err_str = "INVALID OPCODE"; break;
|
||||
case GB_INVALID_READ: err_str = "INVALID READ"; break;
|
||||
case GB_INVALID_WRITE: err_str = "INVALID WRITE"; break;
|
||||
default: break;
|
||||
}
|
||||
ESP_LOGE(TAG, "GB error %s (%d) @ %04X", err_str, err, addr);
|
||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||
if (ctx && ctx->cart_ram_size) {
|
||||
char sp[MAX_PATH];
|
||||
strncpy(sp, ctx->rom_path, MAX_PATH-1); sp[MAX_PATH-1]='\0';
|
||||
char* dot=strrchr(sp,'.'); char* sl=strrchr(sp,'/');
|
||||
if (dot && (!sl || dot>sl)) snprintf(dot, MAX_PATH-(dot-sp), ".sav"); else strncat(sp,".sav",MAX_PATH-strlen(sp)-1);
|
||||
FILE* f=fopen(sp,"wb"); if(f){fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);}
|
||||
}
|
||||
}
|
||||
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
|
||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||
if (!ctx || !ctx->fb_indexed) return;
|
||||
if (!ctx || !ctx->fb_native) return;
|
||||
if (line >= FRAME_H) return;
|
||||
uint8_t* row = &ctx->fb_indexed[(line * FRAME_W) / 4];
|
||||
uint16_t* dst = &ctx->fb_native[line * FRAME_W];
|
||||
for (int x=0;x<FRAME_W;x++) {
|
||||
uint8_t shade = pixels[x] & 0x03;
|
||||
int byte_idx = x >> 2;
|
||||
int shift = 6 - ((x & 0x03) * 2);
|
||||
row[byte_idx] = (uint8_t)((row[byte_idx] & ~(0x03 << shift)) | ((shade & 0x03) << shift));
|
||||
dst[x] = GB_PALETTE[shade];
|
||||
}
|
||||
ctx->lines_drawn++;
|
||||
}
|
||||
|
||||
/* Save path */
|
||||
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
|
||||
if (!rom_path || !out) return;
|
||||
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
|
||||
@@ -177,15 +184,16 @@ static void load_cart_ram(AppCtx* ctx) {
|
||||
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||
size_t save_sz=0;
|
||||
if (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
|
||||
if (save_sz==0) return;
|
||||
if (save_sz==0) { ESP_LOGI(TAG,"No save RAM"); return; }
|
||||
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
|
||||
ctx->cart_ram_size=save_sz;
|
||||
ctx->cart_ram=alloc_psram(save_sz);
|
||||
if (!ctx->cart_ram){ ctx->cart_ram_size=0; return; }
|
||||
if (!ctx->cart_ram){ ESP_LOGE(TAG,"cart RAM alloc fail %zu",save_sz); ctx->cart_ram_size=0; return; }
|
||||
memset(ctx->cart_ram,0,save_sz);
|
||||
FILE* f=fopen(save_path,"rb");
|
||||
if (!f) return;
|
||||
fread(ctx->cart_ram,1,save_sz,f); fclose(f);
|
||||
if (!f){ ESP_LOGI(TAG,"No save %s",save_path); return; }
|
||||
size_t r=fread(ctx->cart_ram,1,save_sz,f); fclose(f);
|
||||
ESP_LOGI(TAG,"Loaded save %s %zu/%zu",save_path,r,save_sz);
|
||||
}
|
||||
static void save_cart_ram(AppCtx* ctx) {
|
||||
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
|
||||
@@ -193,20 +201,23 @@ static void save_cart_ram(AppCtx* ctx) {
|
||||
char save_path[MAX_PATH];
|
||||
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||
FILE* f=fopen(save_path,"wb");
|
||||
if (!f) return;
|
||||
fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
|
||||
if (!f){ ESP_LOGE(TAG,"Save open fail %s",save_path); return; }
|
||||
size_t w=fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
|
||||
ESP_LOGI(TAG,"Saved RAM %s %zu",save_path,w);
|
||||
}
|
||||
|
||||
/* ROM loading */
|
||||
static bool load_rom_file(AppCtx* ctx, const char* path) {
|
||||
if (!ctx || !path) return false;
|
||||
ESP_LOGI(TAG,"Loading ROM %s",path);
|
||||
FILE* f=fopen(path,"rb");
|
||||
if (!f) return false;
|
||||
if (!f){ ESP_LOGE(TAG,"Open fail %s",path); return false; }
|
||||
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
|
||||
if (sz<=0 || sz>MAX_ROM_SIZE){ fclose(f); return false; }
|
||||
if (sz<=0 || sz>MAX_ROM_SIZE){ ESP_LOGE(TAG,"Bad size %ld %s",sz,path); fclose(f); return false; }
|
||||
uint8_t* buf=alloc_psram((size_t)sz);
|
||||
if (!buf){ fclose(f); return false; }
|
||||
if (!buf){ ESP_LOGE(TAG,"Alloc fail %ld",sz); fclose(f); return false; }
|
||||
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
|
||||
if (read!=(size_t)sz){ heap_caps_free(buf); return false; }
|
||||
if (read!=(size_t)sz){ ESP_LOGE(TAG,"Short read %zu vs %ld",read,sz); heap_caps_free(buf); return false; }
|
||||
|
||||
if (ctx->rom_data) { if (ctx->cart_ram) save_cart_ram(ctx); heap_caps_free(ctx->rom_data); }
|
||||
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
|
||||
@@ -216,42 +227,35 @@ static bool load_rom_file(AppCtx* ctx, const char* path) {
|
||||
memset(&ctx->gb,0,sizeof(ctx->gb));
|
||||
ctx->joypad_state=0xFF;
|
||||
enum gb_init_error_e err=gb_init(&ctx->gb, gb_rom_read, gb_cart_ram_read, gb_cart_ram_write, gb_error_handler, ctx);
|
||||
if (err!=GB_INIT_NO_ERROR){ heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
|
||||
if (err!=GB_INIT_NO_ERROR){ ESP_LOGE(TAG,"gb_init %d",err); heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
|
||||
gb_init_lcd(&ctx->gb, lcd_draw_line);
|
||||
load_cart_ram(ctx);
|
||||
gb_reset(&ctx->gb);
|
||||
char title[32]={0}; gb_get_rom_name(&ctx->gb, title); strncpy(ctx->rom_title,title,sizeof(ctx->rom_title)-1);
|
||||
ESP_LOGI(TAG,"ROM OK title='%s' size=%zu save=%zu",ctx->rom_title,ctx->rom_size,ctx->cart_ram_size);
|
||||
ctx->rom_loaded=true;
|
||||
return true;
|
||||
}
|
||||
static void scan_rom_dir(AppCtx* ctx) {
|
||||
ctx->rom_count=0;
|
||||
for (size_t d=0; d < sizeof(ROMS_CANDIDATE_DIRS)/sizeof(ROMS_CANDIDATE_DIRS[0]) && ctx->rom_count < MAX_ROMS; d++) {
|
||||
const char* dir_path = ROMS_CANDIDATE_DIRS[d];
|
||||
DIR* dir=opendir(dir_path);
|
||||
if (!dir) continue;
|
||||
struct dirent* ent;
|
||||
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
|
||||
if (ent->d_name[0]=='.') continue;
|
||||
size_t len=strlen(ent->d_name);
|
||||
if (len<3) continue;
|
||||
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".sgb")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
|
||||
if (!is_gb) continue;
|
||||
char full[MAX_PATH];
|
||||
snprintf(full,sizeof(full),"%s/%s",dir_path,ent->d_name);
|
||||
bool dup=false;
|
||||
for(int i=0;i<ctx->rom_count;i++){
|
||||
if (strcasecmp(ctx->roms[i].filename, ent->d_name)==0) { dup=true; break; }
|
||||
}
|
||||
if (dup) continue;
|
||||
RomEntry* e=&ctx->roms[ctx->rom_count++];
|
||||
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
|
||||
strncpy(e->fullpath, full, sizeof(e->fullpath)-1); e->fullpath[sizeof(e->fullpath)-1]='\0';
|
||||
}
|
||||
closedir(dir);
|
||||
DIR* dir=opendir(ROMS_DIR);
|
||||
if (!dir){ ESP_LOGW(TAG,"ROMS dir missing %s",ROMS_DIR); return; }
|
||||
struct dirent* ent;
|
||||
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
|
||||
if (ent->d_name[0]=='.') continue;
|
||||
size_t len=strlen(ent->d_name);
|
||||
if (len<3) continue;
|
||||
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
|
||||
if (!is_gb) continue;
|
||||
RomEntry* e=&ctx->roms[ctx->rom_count++];
|
||||
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
|
||||
snprintf(e->fullpath, sizeof(e->fullpath), "%s/%s", ROMS_DIR, ent->d_name);
|
||||
}
|
||||
closedir(dir);
|
||||
ESP_LOGI(TAG,"Found %d ROMs",ctx->rom_count);
|
||||
}
|
||||
|
||||
/* Emu timer – THIS IS WHERE "FPS but no image" WAS: missing cache drop */
|
||||
static void emu_timer_cb(lv_timer_t* timer) {
|
||||
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
|
||||
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
|
||||
@@ -259,19 +263,22 @@ static void emu_timer_cb(lv_timer_t* timer) {
|
||||
ctx->gb.direct.joypad = ctx->joypad_state;
|
||||
gb_run_frame(&ctx->gb);
|
||||
|
||||
if (ctx->lines_drawn == 0) {
|
||||
static uint32_t tick=0; tick++;
|
||||
for (int y=0;y<FRAME_H;y++) {
|
||||
uint8_t* row=&ctx->fb_indexed[(y*FRAME_W)/4];
|
||||
for (int x=0;x<FRAME_W;x++) {
|
||||
int bi=x>>2; int sh=6-((x&3)*2);
|
||||
uint8_t shade = (uint8_t)((x + y + tick) & 0x03);
|
||||
row[bi] = (uint8_t)((row[bi] & ~(0x03<<sh)) | (shade<<sh));
|
||||
/* Critical fix: raw buffer mutated, LVGL image cache is stale.
|
||||
Without this, canvas shows whatever was first uploaded (gray/black) and FPS label keeps updating,
|
||||
giving "FPS but no game". */
|
||||
if (ctx->fb_draw_buf) {
|
||||
/* Invalidate both D-Cache (PSRAM) and LVGL image cache */
|
||||
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
||||
lv_image_cache_drop(ctx->fb_draw_buf);
|
||||
/* Also invalidate area via the canvas src buf if different object */
|
||||
if (ctx->canvas) {
|
||||
lv_draw_buf_t* c_db = lv_canvas_get_draw_buf(ctx->canvas);
|
||||
if (c_db && c_db != ctx->fb_draw_buf) {
|
||||
lv_draw_buf_invalidate_cache(c_db, NULL);
|
||||
lv_image_cache_drop(c_db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
|
||||
lv_obj_invalidate(ctx->canvas);
|
||||
|
||||
ctx->fps_frames++;
|
||||
@@ -280,20 +287,31 @@ static void emu_timer_cb(lv_timer_t* timer) {
|
||||
int64_t elapsed = now - ctx->fps_last_us;
|
||||
if (elapsed >= 1000000) {
|
||||
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
|
||||
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||
if (ctx->status_label) {
|
||||
lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||
}
|
||||
printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||
ctx->fps_frames = 0;
|
||||
ctx->fps_last_us = now;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input helpers */
|
||||
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
|
||||
if (!ctx) return;
|
||||
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
|
||||
else ctx->joypad_state |= bit;
|
||||
}
|
||||
static void input_down_cb(lv_event_t* e){ BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e); if (!ud) return; set_joypad_bit(ud->ctx, ud->joypad_bit, true); }
|
||||
static void input_up_cb(lv_event_t* e){ BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e); if (!ud) return; set_joypad_bit(ud->ctx, ud->joypad_bit, false); }
|
||||
static void input_down_cb(lv_event_t* e){
|
||||
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
|
||||
if (!ud) return;
|
||||
set_joypad_bit(ud->ctx, ud->joypad_bit, true);
|
||||
}
|
||||
static void input_up_cb(lv_event_t* e){
|
||||
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
|
||||
if (!ud) return;
|
||||
set_joypad_bit(ud->ctx, ud->joypad_bit, false);
|
||||
}
|
||||
static void key_press_cb(lv_event_t* e){
|
||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||
uint32_t key=lv_event_get_key(e);
|
||||
@@ -312,11 +330,13 @@ static void key_press_cb(lv_event_t* e){
|
||||
}
|
||||
}
|
||||
|
||||
/* Forward declarations for UI switching */
|
||||
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||
static void switch_to_browser(AppCtx* ctx);
|
||||
static void switch_to_emu(AppCtx* ctx);
|
||||
|
||||
/* ROM selection events */
|
||||
static void rom_button_event(lv_event_t* e){
|
||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn=lv_event_get_target_obj(e);
|
||||
@@ -325,37 +345,51 @@ static void rom_button_event(lv_event_t* e){
|
||||
ctx->selected_rom_idx=*pIdx;
|
||||
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
|
||||
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
|
||||
if (load_rom_file(ctx, path)) switch_to_emu(ctx);
|
||||
else if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
|
||||
if (load_rom_file(ctx, path)){
|
||||
switch_to_emu(ctx);
|
||||
} else {
|
||||
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
|
||||
}
|
||||
}
|
||||
static void default_rom_event(lv_event_t* e){
|
||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||
if (load_rom_file(ctx, DEFAULT_ROM_PATH)) switch_to_emu(ctx);
|
||||
else if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found");
|
||||
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
||||
switch_to_emu(ctx);
|
||||
} else {
|
||||
if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found in /data/roms/gb/");
|
||||
}
|
||||
}
|
||||
static void back_to_menu_event(lv_event_t* e){
|
||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||
switch_to_browser(ctx);
|
||||
}
|
||||
static void back_to_menu_event(lv_event_t* e){ AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e); switch_to_browser(ctx); }
|
||||
|
||||
/* UI builders */
|
||||
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
lv_obj_clean(parent);
|
||||
ctx->rom_list=NULL;
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(parent, 4, 0);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
||||
|
||||
lv_obj_t* info=lv_label_create(parent);
|
||||
lv_label_set_text_fmt(info, "GameBoy I2 fix – Place ROMs in Roms/GB – Found %d ROMs", ctx->rom_count);
|
||||
lv_label_set_text_fmt(info, "GameBoy (no audio) - Peanut-GB\nPlace ROMs in %s\nDefault: %s\nFound %d ROMs", ROMS_DIR, DEFAULT_ROM_PATH, ctx->rom_count);
|
||||
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(info, LV_PCT(100));
|
||||
lv_obj_set_style_text_color(info, lv_color_white(), 0);
|
||||
|
||||
ctx->status_label=lv_label_create(parent);
|
||||
lv_label_set_text(ctx->status_label, "Select a ROM – tap .gb in Files app also works");
|
||||
lv_label_set_text(ctx->status_label, "Select a ROM to start");
|
||||
lv_obj_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
|
||||
|
||||
lv_obj_t* list=lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(list, 1);
|
||||
ctx->rom_list=list;
|
||||
|
||||
if (ctx->rom_count==0){
|
||||
lv_obj_t* lbl=lv_label_create(parent);
|
||||
lv_label_set_text(lbl, "No ROMs. Put .gb in /sdcard/Roms/GB");
|
||||
lv_label_set_text(lbl, "No ROMs found in /data/roms/gb\nPut .gb files there.");
|
||||
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
|
||||
} else {
|
||||
for (int i=0;i<ctx->rom_count;i++){
|
||||
@@ -365,6 +399,7 @@ static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
lv_obj_t* default_btn=lv_btn_create(parent);
|
||||
lv_obj_set_width(default_btn, LV_PCT(100));
|
||||
lv_obj_t* dlbl=lv_label_create(default_btn);
|
||||
@@ -414,13 +449,18 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
if (!ctx->fb_indexed){
|
||||
size_t fb_bytes = (FRAME_W * FRAME_H) / 4;
|
||||
ctx->fb_indexed=(uint8_t*)alloc_internal(fb_bytes);
|
||||
if (ctx->fb_indexed){ ctx->framebuffer_allocated=true; memset(ctx->fb_indexed,0,fb_bytes); }
|
||||
if (!ctx->fb_native){
|
||||
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t);
|
||||
ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes);
|
||||
if (ctx->fb_native){
|
||||
ctx->framebuffer_allocated=true;
|
||||
memset(ctx->fb_native,0,fb_bytes);
|
||||
/* Initial grey fill so we can visually confirm buffer ownership even before first gb_run_frame */
|
||||
for(int i=0;i<FRAME_W*FRAME_H;i++) ctx->fb_native[i]=0x4208;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx->fb_indexed){
|
||||
if (ctx->fb_native){
|
||||
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
|
||||
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
|
||||
int avail_h = disp_h - 160;
|
||||
@@ -430,23 +470,21 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
|
||||
|
||||
ctx->canvas = lv_canvas_create(canvas_cont);
|
||||
lv_canvas_set_buffer(ctx->canvas, ctx->fb_indexed, FRAME_W, FRAME_H, LV_COLOR_FORMAT_I2);
|
||||
lv_color32_t c0 = { .red=0xFF, .green=0xFF, .blue=0xFF, .alpha=0xFF };
|
||||
lv_color32_t c1 = { .red=0xAA, .green=0xAA, .blue=0xAA, .alpha=0xFF };
|
||||
lv_color32_t c2 = { .red=0x55, .green=0x55, .blue=0x55, .alpha=0xFF };
|
||||
lv_color32_t c3 = { .red=0x00, .green=0x00, .blue=0x00, .alpha=0xFF };
|
||||
lv_canvas_set_palette(ctx->canvas, 0, c0);
|
||||
lv_canvas_set_palette(ctx->canvas, 1, c1);
|
||||
lv_canvas_set_palette(ctx->canvas, 2, c2);
|
||||
lv_canvas_set_palette(ctx->canvas, 3, c3);
|
||||
/* lv_canvas_set_buffer creates internal static_buf header from our raw ptr */
|
||||
lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565);
|
||||
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
|
||||
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
|
||||
if (ctx->fb_draw_buf && ctx->fb_draw_buf->data) {
|
||||
/* Force fresh cache state */
|
||||
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
||||
lv_image_cache_drop(ctx->fb_draw_buf);
|
||||
}
|
||||
|
||||
if (scale > 1) {
|
||||
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
|
||||
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
|
||||
}
|
||||
lv_obj_set_style_border_width(ctx->canvas, 1, 0);
|
||||
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x00FF00), 0);
|
||||
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x444444), 0);
|
||||
lv_obj_center(ctx->canvas);
|
||||
} else {
|
||||
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0);
|
||||
@@ -471,6 +509,7 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_border_width(dpad,0,0);
|
||||
lv_obj_set_style_pad_all(dpad,0,0);
|
||||
|
||||
lv_obj_t* up=lv_btn_create(dpad); lv_obj_set_size(up,32,32); lv_obj_set_pos(up,32,0);
|
||||
lv_obj_t* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
|
||||
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
|
||||
@@ -479,6 +518,7 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); lv_obj_center(lbl);
|
||||
lbl=lv_label_create(left); lv_label_set_text(lbl, LV_SYMBOL_LEFT); lv_obj_center(lbl);
|
||||
lbl=lv_label_create(right); lv_label_set_text(lbl, LV_SYMBOL_RIGHT); lv_obj_center(lbl);
|
||||
|
||||
ctx->btn_up=up; ctx->btn_down=down; ctx->btn_left=left; ctx->btn_right=right;
|
||||
btn_ud[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
|
||||
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]);
|
||||
@@ -492,6 +532,7 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(center_col,4,0);
|
||||
|
||||
lv_obj_t* sel_btn=lv_btn_create(center_col); lv_obj_set_size(sel_btn,60,28); lbl=lv_label_create(sel_btn); lv_label_set_text(lbl,"Sel"); lv_obj_center(lbl);
|
||||
lv_obj_t* sta_btn=lv_btn_create(center_col); lv_obj_set_size(sta_btn,60,28); lbl=lv_label_create(sta_btn); lv_label_set_text(lbl,"Sta"); lv_obj_center(lbl);
|
||||
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
|
||||
@@ -545,6 +586,7 @@ static void switch_to_emu(AppCtx* ctx){
|
||||
build_emu_ui(ctx, ctx->emu_wrapper);
|
||||
}
|
||||
|
||||
/* Lifecycle */
|
||||
static void* create_data(void){
|
||||
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
|
||||
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
|
||||
@@ -554,13 +596,15 @@ static void destroy_data(void* data){
|
||||
AppCtx* ctx=(AppCtx*)data;
|
||||
if (!ctx) return;
|
||||
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
|
||||
if (ctx->fb_indexed) heap_caps_free(ctx->fb_indexed);
|
||||
if (ctx->fb_native) heap_caps_free(ctx->fb_native);
|
||||
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
|
||||
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
|
||||
free(ctx);
|
||||
}
|
||||
static void on_create(AppHandle app, void* data){ AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app; }
|
||||
static void on_destroy(AppHandle app, void* data){ (void)app;(void)data; }
|
||||
static void on_create(AppHandle app, void* data){
|
||||
AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app;
|
||||
}
|
||||
static void on_destroy(AppHandle app, void* data){ (void)app; (void)data; }
|
||||
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
||||
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
||||
ctx->app_handle=app;
|
||||
@@ -594,47 +638,11 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
||||
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
|
||||
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
BundleHandle bundle = tt_app_get_parameters(app);
|
||||
char file_param[512] = {0};
|
||||
bool hasFileParam = false;
|
||||
const char* file_to_try = NULL;
|
||||
if (bundle && tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param))) {
|
||||
hasFileParam = file_param[0] != '\0';
|
||||
file_to_try = file_param;
|
||||
}
|
||||
|
||||
if (hasFileParam && file_to_try) {
|
||||
char resolved[MAX_PATH];
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wformat-truncation"
|
||||
if (strncmp(file_to_try, "/sdcard", 7) == 0 || strncmp(file_to_try, "/data", 5) == 0) {
|
||||
snprintf(resolved, sizeof(resolved), "%s", file_to_try);
|
||||
} else if (file_to_try[0] == '/') {
|
||||
snprintf(resolved, sizeof(resolved), "/sdcard%s", file_to_try);
|
||||
} else {
|
||||
char truncated_file[400];
|
||||
snprintf(truncated_file, sizeof(truncated_file), "%s", file_to_try);
|
||||
snprintf(resolved, sizeof(resolved), "/sdcard/%s", truncated_file);
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
struct stat st;
|
||||
if (stat(resolved, &st)==0 && load_rom_file(ctx, resolved)) {
|
||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||
switch_to_emu(ctx);
|
||||
return;
|
||||
}
|
||||
if (load_rom_file(ctx, file_to_try)) {
|
||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||
switch_to_emu(ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
scan_rom_dir(ctx);
|
||||
const char* default_candidates[] = { DEFAULT_ROM_PATH_1, DEFAULT_ROM_PATH_2, DEFAULT_ROM_PATH_3, DEFAULT_ROM_PATH_4, ROMS_DIR_LEGACY "/default.gb" };
|
||||
for (size_t i=0;i<sizeof(default_candidates)/sizeof(default_candidates[0]);i++) {
|
||||
struct stat st;
|
||||
if (stat(default_candidates[i], &st)==0 && load_rom_file(ctx, default_candidates[i])) {
|
||||
struct stat st;
|
||||
bool default_exists=(stat(DEFAULT_ROM_PATH,&st)==0);
|
||||
if (default_exists){
|
||||
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||
switch_to_emu(ctx);
|
||||
return;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.graphicsdemo
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Graphics Demo
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.graphicsdemo
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Graphics Demo
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.helloworld
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Hello World
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.helloworld
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Hello World
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||
else()
|
||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
message(WARNING "TACTILITY_SDK_PATH is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
endif()
|
||||
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
project(DiscoveryMountain)
|
||||
tactility_project(DiscoveryMountain)
|
||||
|
||||
project(LiveCaptions)
|
||||
tactility_project(LiveCaptions)
|
||||
@@ -0,0 +1,17 @@
|
||||
# Live Captions
|
||||
|
||||
ESP32-S3 external app that streams 16 kHz signed-16-bit mono microphone PCM to the Mac mini Hermes voice gateway and renders its live draft/final captions. It stores **only final captions** on the device SD card in `/sdcard/captions/YYYY-MM-DD.txt`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Before packaging, place `config.json` in the app's user-data directory (not source control):
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "ws://192.168.68.102:8642/api/esp32/voice/ws",
|
||||
"device_id": "your-registered-device-id",
|
||||
"api_key": "device-profile-key"
|
||||
}
|
||||
```
|
||||
|
||||
The app never retries automatically: connection failure remains `FAILED` until the user selects **Start** again. **Stop** sends the gateway `stop` event, waits for the final caption, appends it to that day’s text file, then returns to idle.
|
||||
@@ -0,0 +1,8 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||
REQUIRES TactilitySDK lwip
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
/* Live Captions: stream mic PCM to Mac mini and save final text on the SD card. */
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
#include <cJSON.h>
|
||||
#include <esp_log.h>
|
||||
#include <esp_random.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "websocket.h"
|
||||
|
||||
/* These legacy names are exported by the flashed 0.8.0-dev firmware. */
|
||||
struct Device* device_find_by_name(const char* name);
|
||||
struct Device* device_find_first_by_type(const struct DeviceType* type);
|
||||
|
||||
#define TAG "LiveCaptions"
|
||||
#define DEFAULT_ENDPOINT "ws://192.168.68.102:8645/api/esp32/captions/ws"
|
||||
#define DEFAULT_DEVICE_ID "tactility-14c19d1a790"
|
||||
#define PCM_BUFFER_BYTES 1024U
|
||||
#define EVENT_BUFFER_BYTES 4096U
|
||||
#define DISPLAY_WORD_LIMIT 50
|
||||
|
||||
typedef enum {
|
||||
CAPTION_IDLE,
|
||||
CAPTION_CONNECTING,
|
||||
CAPTION_LISTENING,
|
||||
CAPTION_PROCESSING,
|
||||
CAPTION_FAILED,
|
||||
} CaptionState;
|
||||
|
||||
typedef struct {
|
||||
AppHandle app;
|
||||
volatile bool visible;
|
||||
volatile bool capture_audio;
|
||||
volatile bool stop_requested;
|
||||
volatile bool session_active;
|
||||
volatile bool socket_failed;
|
||||
int fd;
|
||||
CaptionState state;
|
||||
char endpoint[128];
|
||||
char device_id[64];
|
||||
char api_key[128];
|
||||
char detail[96];
|
||||
char caption[768];
|
||||
char last_final[768];
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle input_handle;
|
||||
TaskHandle_t worker;
|
||||
TaskHandle_t receiver;
|
||||
SemaphoreHandle_t socket_lock;
|
||||
SemaphoreHandle_t audio_lock;
|
||||
lv_obj_t* caption_label;
|
||||
lv_obj_t* status_label;
|
||||
} CaptionContext;
|
||||
|
||||
static void update_ui(CaptionContext* ctx) {
|
||||
if (!ctx->visible || !tt_lvgl_lock(pdMS_TO_TICKS(100))) return;
|
||||
lv_label_set_text(ctx->caption_label, ctx->caption);
|
||||
const char* status = ctx->state == CAPTION_LISTENING ? "Connected" : (ctx->state == CAPTION_CONNECTING ? "Connecting" : (ctx->state == CAPTION_FAILED ? "Failed — Reconnecting" : "Connecting"));
|
||||
lv_label_set_text(ctx->status_label, status);
|
||||
tt_lvgl_unlock();
|
||||
}
|
||||
|
||||
static void set_state(CaptionContext* ctx, CaptionState state, const char* detail) {
|
||||
ctx->state = state;
|
||||
snprintf(ctx->detail, sizeof(ctx->detail), "%s", detail ? detail : "");
|
||||
update_ui(ctx);
|
||||
}
|
||||
|
||||
static bool find_audio_stream_device(CaptionContext* ctx) {
|
||||
ctx->stream_dev = device_find_by_name("audio-stream");
|
||||
if (ctx->stream_dev == NULL) ctx->stream_dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||
return ctx->stream_dev != NULL;
|
||||
}
|
||||
|
||||
static bool open_input_stream(CaptionContext* ctx) {
|
||||
if (ctx->stream_dev == NULL) return false;
|
||||
if (ctx->input_handle != NULL) return true;
|
||||
struct AudioStreamConfig config = {.sample_rate = 16000, .bits_per_sample = 16, .channels = 1};
|
||||
if (audio_stream_open_input(ctx->stream_dev, &config, &ctx->input_handle) != ERROR_NONE) return false;
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void close_input_stream(CaptionContext* ctx) {
|
||||
if (ctx->input_handle != NULL) {
|
||||
audio_stream_close(ctx->input_handle);
|
||||
ctx->input_handle = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static int send_locked(CaptionContext* ctx, const uint8_t* data, size_t length, bool binary) {
|
||||
if (ctx->fd < 0 || xSemaphoreTake(ctx->socket_lock, pdMS_TO_TICKS(500)) != pdTRUE) return -1;
|
||||
int result = ws_send(ctx->fd, data, length, binary);
|
||||
xSemaphoreGive(ctx->socket_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool parse_endpoint(const char* url, char* host, size_t host_size, int* port, char* path, size_t path_size) {
|
||||
if (url == NULL || strncmp(url, "ws://", 5) != 0) return false;
|
||||
const char* authority = url + 5;
|
||||
const char* slash = strchr(authority, '/');
|
||||
const char* end = slash ? slash : authority + strlen(authority);
|
||||
const char* colon = NULL;
|
||||
for (const char* p = authority; p < end; ++p) if (*p == ':') colon = p;
|
||||
size_t host_len = (size_t)((colon ? colon : end) - authority);
|
||||
if (host_len == 0 || host_len >= host_size) return false;
|
||||
memcpy(host, authority, host_len); host[host_len] = '\0';
|
||||
*port = 80;
|
||||
if (colon != NULL) {
|
||||
*port = atoi(colon + 1);
|
||||
if (*port < 1 || *port > 65535) return false;
|
||||
}
|
||||
const char* wire_path = slash ? slash : "/";
|
||||
if (strlen(wire_path) >= path_size) return false;
|
||||
snprintf(path, path_size, "%s", wire_path);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void append_final_caption(const char* text) {
|
||||
if (text == NULL || !*text) return;
|
||||
mkdir("/sdcard/captions", 0755);
|
||||
time_t now = time(NULL);
|
||||
struct tm local;
|
||||
localtime_r(&now, &local);
|
||||
char filename[32];
|
||||
strftime(filename, sizeof(filename), "%Y-%m-%d.txt", &local);
|
||||
char stamp[16];
|
||||
strftime(stamp, sizeof(stamp), "%H:%M:%S", &local);
|
||||
char path[128];
|
||||
snprintf(path, sizeof(path), "/sdcard/captions/%s", filename);
|
||||
FILE* log = fopen(path, "a");
|
||||
if (log == NULL) { ESP_LOGE(TAG, "could not append %s", path); return; }
|
||||
fprintf(log, "[%s] %s\n", stamp, text);
|
||||
fclose(log);
|
||||
}
|
||||
|
||||
static bool is_space_char(char value) { return value == ' ' || value == '\n' || value == '\r' || value == ' '; }
|
||||
|
||||
static void copy_recent_words(char* output, size_t output_size, const char* text) {
|
||||
const char* starts[DISPLAY_WORD_LIMIT];
|
||||
int count = 0;
|
||||
bool in_word = false;
|
||||
for (const char* cursor = text; *cursor; ++cursor) {
|
||||
if (is_space_char(*cursor)) { in_word = false; continue; }
|
||||
if (!in_word) { starts[count % DISPLAY_WORD_LIMIT] = cursor; ++count; in_word = true; }
|
||||
}
|
||||
const char* first = count > DISPLAY_WORD_LIMIT ? starts[count % DISPLAY_WORD_LIMIT] : text;
|
||||
snprintf(output, output_size, "%s", first);
|
||||
}
|
||||
|
||||
static void display_caption(CaptionContext* ctx, const char* text, bool final) {
|
||||
if (text == NULL || !*text) return;
|
||||
copy_recent_words(ctx->caption, sizeof(ctx->caption), text);
|
||||
if (final && strcmp(ctx->last_final, text) != 0) {
|
||||
snprintf(ctx->last_final, sizeof(ctx->last_final), "%s", text);
|
||||
append_final_caption(text);
|
||||
ctx->state = CAPTION_IDLE;
|
||||
}
|
||||
update_ui(ctx);
|
||||
}
|
||||
|
||||
static void handle_event(CaptionContext* ctx, const char* json) {
|
||||
cJSON* root = cJSON_Parse(json);
|
||||
if (root == NULL) return;
|
||||
cJSON* event = cJSON_GetObjectItem(root, "event");
|
||||
cJSON* text = cJSON_GetObjectItem(root, "text");
|
||||
if (!cJSON_IsString(event)) { cJSON_Delete(root); return; }
|
||||
const char* name = event->valuestring;
|
||||
if (strcmp(name, "ready") == 0) {
|
||||
set_state(ctx, CAPTION_CONNECTING, "Starting caption stream");
|
||||
} else if (strcmp(name, "listening") == 0) {
|
||||
set_state(ctx, CAPTION_LISTENING, "Listening — press Stop when finished");
|
||||
} else if (strcmp(name, "state") == 0) {
|
||||
cJSON* remote_state = cJSON_GetObjectItem(root, "state");
|
||||
if (cJSON_IsString(remote_state) && strcmp(remote_state->valuestring, "listening") == 0) {
|
||||
set_state(ctx, CAPTION_LISTENING, "Listening");
|
||||
} else if (cJSON_IsString(remote_state) && strcmp(remote_state->valuestring, "processing") == 0) {
|
||||
set_state(ctx, CAPTION_PROCESSING, "Captioning…");
|
||||
}
|
||||
} else if (strcmp(name, "draft") == 0 || strcmp(name, "interim_transcript") == 0 || strcmp(name, "review") == 0) {
|
||||
if (cJSON_IsString(text)) display_caption(ctx, text->valuestring, false);
|
||||
} else if (strcmp(name, "transcript") == 0 || strcmp(name, "final") == 0) {
|
||||
cJSON* is_final = cJSON_GetObjectItem(root, "isFinal");
|
||||
if (cJSON_IsString(text) && (!cJSON_IsBool(is_final) || cJSON_IsTrue(is_final) || strcmp(name, "final") == 0)) {
|
||||
display_caption(ctx, text->valuestring, true);
|
||||
}
|
||||
} else if (strcmp(name, "thinking") == 0) {
|
||||
set_state(ctx, CAPTION_PROCESSING, "Final captioning…");
|
||||
} else if (strcmp(name, "error") == 0) {
|
||||
ctx->socket_failed = true;
|
||||
set_state(ctx, CAPTION_FAILED, "Fail to connect");
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void receiver_task(void* argument) {
|
||||
CaptionContext* ctx = argument;
|
||||
uint8_t* buffer = malloc(EVENT_BUFFER_BYTES + 1U);
|
||||
if (buffer == NULL) { ctx->socket_failed = true; ctx->receiver = NULL; vTaskDelete(NULL); }
|
||||
while (ctx->visible && ctx->fd >= 0) {
|
||||
int opcode = 0; bool complete = false;
|
||||
int received = ws_recv(ctx->fd, &opcode, &complete, buffer, EVENT_BUFFER_BYTES);
|
||||
if (received < 0 || !complete) break;
|
||||
if (opcode == 0x01) { buffer[received] = '\0'; handle_event(ctx, (const char*)buffer); }
|
||||
else if (opcode == 0x09) {
|
||||
if (xSemaphoreTake(ctx->socket_lock, pdMS_TO_TICKS(500)) == pdTRUE) {
|
||||
ws_send_pong(ctx->fd, buffer, (size_t)received); xSemaphoreGive(ctx->socket_lock);
|
||||
}
|
||||
} else if (opcode == 0x08) break;
|
||||
}
|
||||
ctx->session_active = false;
|
||||
ctx->receiver = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void load_config(CaptionContext* ctx) {
|
||||
snprintf(ctx->endpoint, sizeof(ctx->endpoint), "%s", DEFAULT_ENDPOINT);
|
||||
snprintf(ctx->device_id, sizeof(ctx->device_id), "%s", DEFAULT_DEVICE_ID);
|
||||
ctx->api_key[0] = '\0';
|
||||
char path[256]; size_t size = sizeof(path);
|
||||
tt_app_get_user_data_child_path(ctx->app, "config.json", path, &size);
|
||||
FILE* file = fopen(path, "r");
|
||||
if (file == NULL) return;
|
||||
char raw[512]; size_t bytes = fread(raw, 1, sizeof(raw) - 1, file); fclose(file); raw[bytes] = '\0';
|
||||
cJSON* root = cJSON_Parse(raw);
|
||||
cJSON* endpoint = root ? cJSON_GetObjectItem(root, "server_url") : NULL;
|
||||
cJSON* device = root ? cJSON_GetObjectItem(root, "device_id") : NULL;
|
||||
cJSON* key = root ? cJSON_GetObjectItem(root, "api_key") : NULL;
|
||||
if (cJSON_IsString(endpoint)) snprintf(ctx->endpoint, sizeof(ctx->endpoint), "%s", endpoint->valuestring);
|
||||
if (cJSON_IsString(device)) snprintf(ctx->device_id, sizeof(ctx->device_id), "%s", device->valuestring);
|
||||
if (cJSON_IsString(key)) snprintf(ctx->api_key, sizeof(ctx->api_key), "%s", key->valuestring);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void worker_task(void* argument) {
|
||||
CaptionContext* ctx = argument;
|
||||
char host[64], path[96]; int port = 0;
|
||||
if (!parse_endpoint(ctx->endpoint, host, sizeof(host), &port, path, sizeof(path))) {
|
||||
set_state(ctx, CAPTION_FAILED, "Fail to connect"); ctx->worker = NULL; vTaskDelete(NULL);
|
||||
}
|
||||
set_state(ctx, CAPTION_CONNECTING, "Connecting to Mac mini");
|
||||
ctx->fd = ws_connect(host, port, path, ctx->device_id, ctx->api_key);
|
||||
if (ctx->fd < 0) { set_state(ctx, CAPTION_FAILED, "Fail to connect"); ctx->worker = NULL; vTaskDelete(NULL); }
|
||||
char start[256];
|
||||
snprintf(start, sizeof(start), "{\"v\":1,\"event\":\"start\",\"session_id\":\"cap-%08lx\",\"device_id\":\"%s\",\"audio\":{\"format\":\"pcm_s16le\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2}}", (unsigned long)esp_random(), ctx->device_id);
|
||||
if (send_locked(ctx, (const uint8_t*)start, strlen(start), false) != 0 || !open_input_stream(ctx)) {
|
||||
set_state(ctx, CAPTION_FAILED, "Fail to connect"); ws_close(ctx->fd); ctx->fd = -1; ctx->worker = NULL; vTaskDelete(NULL);
|
||||
}
|
||||
ctx->session_active = true;
|
||||
xTaskCreate(receiver_task, "caption_rx", 6144, ctx, 6, &ctx->receiver);
|
||||
set_state(ctx, CAPTION_LISTENING, "Listening — press Stop when finished");
|
||||
uint8_t pcm[PCM_BUFFER_BYTES];
|
||||
while (ctx->visible && ctx->capture_audio && !ctx->socket_failed) {
|
||||
size_t bytes = 0;
|
||||
xSemaphoreTake(ctx->audio_lock, portMAX_DELAY);
|
||||
error_t result = audio_stream_read(ctx->input_handle, pcm, sizeof(pcm), &bytes, pdMS_TO_TICKS(100));
|
||||
xSemaphoreGive(ctx->audio_lock);
|
||||
if (result == ERROR_NONE && bytes > 0 && (bytes % 2U) == 0 && send_locked(ctx, pcm, bytes, true) != 0) ctx->socket_failed = true;
|
||||
}
|
||||
xSemaphoreTake(ctx->audio_lock, portMAX_DELAY); close_input_stream(ctx); xSemaphoreGive(ctx->audio_lock);
|
||||
if (ctx->stop_requested && !ctx->socket_failed) {
|
||||
const char* stop = "{\"event\":\"stop\"}";
|
||||
send_locked(ctx, (const uint8_t*)stop, strlen(stop), false);
|
||||
set_state(ctx, CAPTION_PROCESSING, "Final captioning…");
|
||||
for (unsigned i = 0; ctx->session_active && i < 150; ++i) vTaskDelay(pdMS_TO_TICKS(100));
|
||||
}
|
||||
if (ctx->fd >= 0) { ws_send_close(ctx->fd); ws_close(ctx->fd); ctx->fd = -1; }
|
||||
bool reconnect = ctx->socket_failed && ctx->visible;
|
||||
if (reconnect) set_state(ctx, CAPTION_FAILED, "Reconnecting");
|
||||
else if (ctx->state == CAPTION_PROCESSING) set_state(ctx, CAPTION_IDLE, "");
|
||||
ctx->worker = NULL;
|
||||
if (reconnect) {
|
||||
vTaskDelay(pdMS_TO_TICKS(3000));
|
||||
if (ctx->visible) { ctx->socket_failed = false; ctx->capture_audio = true; xTaskCreate(worker_task, "caption_tx", 8192, ctx, 5, &ctx->worker); }
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void* create_data(void) { CaptionContext* ctx = calloc(1, sizeof(*ctx)); if (ctx) ctx->fd = -1; return ctx; }
|
||||
static void destroy_data(void* data) { free(data); }
|
||||
static void on_create(AppHandle app, void* data) { ((CaptionContext*)data)->app = app; }
|
||||
|
||||
static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
CaptionContext* ctx = data; ctx->visible = true; load_config(ctx); find_audio_stream_device(ctx);
|
||||
ctx->socket_lock = xSemaphoreCreateMutex(); ctx->audio_lock = xSemaphoreCreateMutex();
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
ctx->caption_label = lv_label_create(parent);
|
||||
lv_obj_set_width(ctx->caption_label, lv_pct(88));
|
||||
lv_label_set_long_mode(ctx->caption_label, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_align(ctx->caption_label, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_align(ctx->caption_label, LV_ALIGN_CENTER, 0, 0);
|
||||
ctx->status_label = lv_label_create(parent);
|
||||
lv_obj_align(ctx->status_label, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
ctx->caption[0] = '\0';
|
||||
if (ctx->stream_dev != NULL && ctx->socket_lock != NULL && ctx->audio_lock != NULL) {
|
||||
ctx->capture_audio = true; ctx->stop_requested = false; ctx->socket_failed = false;
|
||||
xTaskCreate(worker_task, "caption_tx", 8192, ctx, 5, &ctx->worker);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_hide(AppHandle app, void* data) {
|
||||
(void)app; CaptionContext* ctx = data; ctx->visible = false; ctx->capture_audio = false; ctx->stop_requested = false;
|
||||
if (ctx->fd >= 0) { ws_send_close(ctx->fd); ws_close(ctx->fd); ctx->fd = -1; }
|
||||
for (unsigned i = 0; (ctx->worker || ctx->receiver) && i < 100; ++i) vTaskDelay(pdMS_TO_TICKS(10));
|
||||
close_input_stream(ctx);
|
||||
if (ctx->socket_lock) { vSemaphoreDelete(ctx->socket_lock); ctx->socket_lock = NULL; }
|
||||
if (ctx->audio_lock) { vSemaphoreDelete(ctx->audio_lock); ctx->audio_lock = NULL; }
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
(void)argc; (void)argv;
|
||||
tt_app_register((AppRegistration){.createData=create_data,.destroyData=destroy_data,.onCreate=on_create,.onShow=on_show,.onHide=on_hide});
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#include "websocket.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <esp_log.h>
|
||||
#include <esp_random.h>
|
||||
#include <lwip/inet.h>
|
||||
#include <lwip/sockets.h>
|
||||
|
||||
#define TAG "PipecatVoiceWs"
|
||||
#define WS_HEADER_LIMIT 1024U
|
||||
#define WS_CONTROL_LIMIT 125U
|
||||
|
||||
static int send_all(int fd, const uint8_t* data, size_t length) {
|
||||
size_t sent = 0;
|
||||
while (sent < length) {
|
||||
int result = lwip_send(fd, data + sent, length - sent, 0);
|
||||
if (result <= 0) return -1;
|
||||
sent += (size_t)result;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int recv_all(int fd, uint8_t* data, size_t length) {
|
||||
size_t received = 0;
|
||||
while (received < length) {
|
||||
int result = lwip_recv(fd, data + received, length - received, 0);
|
||||
if (result <= 0) return -1;
|
||||
received += (size_t)result;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int discard(int fd, uint64_t length) {
|
||||
uint8_t buffer[256];
|
||||
while (length > 0) {
|
||||
size_t chunk = length > sizeof(buffer) ? sizeof(buffer) : (size_t)length;
|
||||
if (recv_all(fd, buffer, chunk) < 0) return -1;
|
||||
length -= chunk;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int send_frame(int fd, uint8_t opcode, const uint8_t* payload, size_t length) {
|
||||
if (length > 65535U || ((opcode & 0x08U) && length > WS_CONTROL_LIMIT)) return -1;
|
||||
uint8_t header[8];
|
||||
size_t header_length = 2;
|
||||
header[0] = 0x80U | opcode;
|
||||
if (length < 126U) {
|
||||
header[1] = 0x80U | (uint8_t)length;
|
||||
} else {
|
||||
header[1] = 0x80U | 126U;
|
||||
header[2] = (uint8_t)(length >> 8U);
|
||||
header[3] = (uint8_t)length;
|
||||
header_length = 4;
|
||||
}
|
||||
uint8_t mask[4];
|
||||
uint32_t random = esp_random();
|
||||
memcpy(mask, &random, sizeof(mask));
|
||||
memcpy(header + header_length, mask, sizeof(mask));
|
||||
header_length += sizeof(mask);
|
||||
if (send_all(fd, header, header_length) < 0) return -1;
|
||||
|
||||
uint8_t chunk[512];
|
||||
size_t offset = 0;
|
||||
while (offset < length) {
|
||||
size_t count = length - offset > sizeof(chunk) ? sizeof(chunk) : length - offset;
|
||||
for (size_t i = 0; i < count; ++i) chunk[i] = payload[offset + i] ^ mask[(offset + i) % sizeof(mask)];
|
||||
if (send_all(fd, chunk, count) < 0) return -1;
|
||||
offset += count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ws_connect(const char* host, int port, const char* path, const char* device_id, const char* api_key) {
|
||||
if (host == NULL || path == NULL || device_id == NULL || api_key == NULL || port < 1 || port > 65535) return -1;
|
||||
int fd = lwip_socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
ESP_LOGW(TAG, "socket create failed");
|
||||
return -1;
|
||||
}
|
||||
struct sockaddr_in address = {0};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons((uint16_t)port);
|
||||
address.sin_addr.s_addr = ipaddr_addr(host);
|
||||
if (address.sin_addr.s_addr == IPADDR_NONE) {
|
||||
ESP_LOGW(TAG, "endpoint address parse failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
if (lwip_connect(fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
|
||||
ESP_LOGW(TAG, "TCP connect failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
struct timeval timeout = {.tv_sec = 15, .tv_usec = 0};
|
||||
lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
char request[WS_HEADER_LIMIT];
|
||||
int request_length = snprintf(request, sizeof(request),
|
||||
"GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: MDEyMzQ1Njc4OWFiY2RlZg==\r\nSec-WebSocket-Version: 13\r\n"
|
||||
"Authorization: Bearer %s\r\nX-Device-ID: %s\r\n\r\n",
|
||||
path, host, port, api_key, device_id);
|
||||
if (request_length < 0 || (size_t)request_length >= sizeof(request) || send_all(fd, (const uint8_t*)request, (size_t)request_length) < 0) {
|
||||
ESP_LOGW(TAG, "WebSocket upgrade request failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
char response[WS_HEADER_LIMIT];
|
||||
size_t length = 0;
|
||||
while (length + 1 < sizeof(response)) {
|
||||
if (recv_all(fd, (uint8_t*)&response[length], 1) < 0) {
|
||||
ESP_LOGW(TAG, "WebSocket upgrade response failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
response[++length] = '\0';
|
||||
if (length >= 4 && memcmp(response + length - 4, "\r\n\r\n", 4) == 0) break;
|
||||
}
|
||||
if (length + 1 >= sizeof(response) || strstr(response, " 101 ") == NULL) {
|
||||
ESP_LOGW(TAG, "WebSocket upgrade rejected");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
ESP_LOGI(TAG, "WebSocket upgrade accepted");
|
||||
return fd;
|
||||
}
|
||||
|
||||
int ws_send(int fd, const uint8_t* data, size_t length, bool binary) {
|
||||
if (fd < 0 || data == NULL || length == 0) return -1;
|
||||
return send_frame(fd, binary ? 0x02U : 0x01U, data, length);
|
||||
}
|
||||
|
||||
int ws_recv(int fd, int* opcode, bool* final, uint8_t* payload, size_t maximum) {
|
||||
uint8_t header[2];
|
||||
if (fd < 0 || recv_all(fd, header, sizeof(header)) < 0) return -1;
|
||||
uint64_t length = header[1] & 0x7fU;
|
||||
if (length == 126U) {
|
||||
uint8_t extended[2];
|
||||
if (recv_all(fd, extended, sizeof(extended)) < 0) return -1;
|
||||
length = ((uint64_t)extended[0] << 8U) | extended[1];
|
||||
} else if (length == 127U) {
|
||||
uint8_t extended[8];
|
||||
if (recv_all(fd, extended, sizeof(extended)) < 0) return -1;
|
||||
length = 0;
|
||||
for (size_t i = 0; i < sizeof(extended); ++i) length = (length << 8U) | extended[i];
|
||||
}
|
||||
bool masked = (header[1] & 0x80U) != 0;
|
||||
uint8_t mask[4] = {0};
|
||||
if (masked && recv_all(fd, mask, sizeof(mask)) < 0) return -1;
|
||||
uint8_t frame_opcode = header[0] & 0x0fU;
|
||||
if (((frame_opcode & 0x08U) && (length > WS_CONTROL_LIMIT || !(header[0] & 0x80U))) || length > maximum) {
|
||||
if (discard(fd, length) < 0) return -1;
|
||||
return -2;
|
||||
}
|
||||
if (length > 0 && recv_all(fd, payload, (size_t)length) < 0) return -1;
|
||||
if (masked) for (size_t i = 0; i < (size_t)length; ++i) payload[i] ^= mask[i % sizeof(mask)];
|
||||
if (opcode) *opcode = frame_opcode;
|
||||
if (final) *final = (header[0] & 0x80U) != 0;
|
||||
return (int)length;
|
||||
}
|
||||
|
||||
int ws_send_pong(int fd, const uint8_t* payload, size_t length) { return send_frame(fd, 0x0aU, payload, length); }
|
||||
int ws_send_close(int fd) { return send_frame(fd, 0x08U, NULL, 0); }
|
||||
void ws_close(int fd) { if (fd >= 0) close(fd); }
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Connect to a WebSocket server.
|
||||
* @param host Server IP address (e.g. "192.168.68.126")
|
||||
* @param port Port number (e.g. 8642)
|
||||
* @param path WebSocket path (e.g. "/api/esp32/voice/ws")
|
||||
* @param device_id Unique device identifier
|
||||
* @param api_key Optional profile API key; never compiled into firmware
|
||||
* @return Socket file descriptor on success, or -1 on failure
|
||||
*/
|
||||
int ws_connect(const char* host, int port, const char* path, const char* device_id, const char* api_key);
|
||||
|
||||
/**
|
||||
* Send a WebSocket frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param data Data payload to send
|
||||
* @param len Length of the data payload
|
||||
* @param binary True for binary frame, false for text frame
|
||||
* @return 0 on success, or -1 on failure
|
||||
*/
|
||||
int ws_send(int fd, const uint8_t* data, size_t len, bool binary);
|
||||
|
||||
/**
|
||||
* Receive a WebSocket frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param out_opcode Pointer to store the received opcode (e.g. 0x01 text, 0x02 binary)
|
||||
* @param payload Buffer to store the received payload
|
||||
* @param max_len Maximum length of the payload buffer
|
||||
* @return Received payload length on success, -1 on connection failure, or -2 on buffer overflow
|
||||
*/
|
||||
int ws_recv(int fd, int* out_opcode, bool* out_final, uint8_t* payload, size_t max_len);
|
||||
|
||||
/**
|
||||
* Close a WebSocket connection.
|
||||
* @param fd Socket file descriptor
|
||||
*/
|
||||
void ws_close(int fd);
|
||||
|
||||
/**
|
||||
* Send a WebSocket PONG frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param payload Payload to reflect
|
||||
* @param len Length of payload
|
||||
* @return 0 on success, or -1 on failure
|
||||
*/
|
||||
int ws_send_pong(int fd, const uint8_t* payload, size_t len);
|
||||
|
||||
/** Send a clean WebSocket close control frame before closing the socket. */
|
||||
int ws_send_close(int fd);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,7 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.livecaptions
|
||||
app.version.name=1.0.0
|
||||
app.version.code=1
|
||||
app.name=Live Captions
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
[app]
|
||||
id=one.tactility.m5unittest
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=M5 Unit Test
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3,esp32p4
|
||||
app.id=one.tactility.m5unittest
|
||||
app.version.name=0.4.0
|
||||
app.version.code=4
|
||||
app.name=M5 Unit Test
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.magic8ball
|
||||
versionName=0.2.0
|
||||
versionCode=2
|
||||
name=Magic 8-Ball
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.magic8ball
|
||||
app.version.name=0.5.0
|
||||
app.version.code=5
|
||||
app.name=Magic 8-Ball
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.mcpscreen
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=MCP Screen
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.mcpscreen
|
||||
app.version.name=0.1.0
|
||||
app.version.code=1
|
||||
app.name=MCP Screen
|
||||
|
||||
@@ -211,12 +211,29 @@ void MediaKeys::startHid() {
|
||||
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
|
||||
}
|
||||
|
||||
void MediaKeys::teardownBt() {
|
||||
// Remove callback FIRST - stops any in-flight BT events from firing against
|
||||
// our (possibly already freed) UI widget pointers after this returns.
|
||||
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
||||
// Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() /
|
||||
// ble_gatts_start() which corrupts NimBLE heap while the host task is still
|
||||
// running. HID device is a persistent kernel device; hid_device_start() cleans
|
||||
// up stale context on next use. Explicit stop is handled by handleSwitchToggle.
|
||||
// Restore the radio/device to the state we found them in.
|
||||
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
||||
if (_btDevice && _deviceWasStarted) device_stop(_btDevice);
|
||||
_btDevice = nullptr;
|
||||
_hidDevice = nullptr;
|
||||
_radioWasOff = false;
|
||||
_deviceWasStarted = false;
|
||||
}
|
||||
|
||||
void MediaKeys::handleSwitchToggle(bool enabled) {
|
||||
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
|
||||
_isEnabled = enabled;
|
||||
|
||||
if (enabled) {
|
||||
_btDevice = bluetooth_find_first_ready_device();
|
||||
_btDevice = device_find_first_by_type(&BLUETOOTH_TYPE);
|
||||
if (!_btDevice) {
|
||||
LOG_E(TAG, "No Bluetooth device found");
|
||||
_isEnabled = false;
|
||||
@@ -224,6 +241,19 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Device may not be started yet (BT disabled in DTS by default to save memory).
|
||||
if (!device_is_ready(_btDevice)) {
|
||||
LOG_I(TAG, "BT device not started, starting now");
|
||||
if (device_start(_btDevice) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to start BT device");
|
||||
_btDevice = nullptr;
|
||||
_isEnabled = false;
|
||||
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
|
||||
return;
|
||||
}
|
||||
_deviceWasStarted = true;
|
||||
}
|
||||
|
||||
bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
|
||||
|
||||
// Register callback before enabling radio so we don't miss the state-change event.
|
||||
@@ -247,12 +277,10 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
|
||||
} else {
|
||||
_radioEnabling = false;
|
||||
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
||||
// Explicit user toggle-off: stop HID cleanly (safe here since we're on the
|
||||
// LVGL task and the user intentionally disabled, so no race with app teardown).
|
||||
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
|
||||
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
||||
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
||||
_radioWasOff = false;
|
||||
_btDevice = nullptr;
|
||||
_hidDevice = nullptr;
|
||||
teardownBt();
|
||||
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
@@ -321,19 +349,23 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
||||
}
|
||||
|
||||
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// 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*/) {
|
||||
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
|
||||
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
||||
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
||||
_btDevice = nullptr;
|
||||
_hidDevice = nullptr;
|
||||
_isEnabled = false;
|
||||
_radioEnabling = false;
|
||||
_radioWasOff = false;
|
||||
|
||||
_isEnabled = false;
|
||||
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
||||
teardownBt();
|
||||
if (_keyHighlightTimer) {
|
||||
lv_timer_delete(_keyHighlightTimer);
|
||||
_keyHighlightTimer = nullptr;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <TactilityCpp/App.h>
|
||||
#include <lvgl.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/bluetooth.h>
|
||||
#include <tactility/drivers/bluetooth_hid_device.h>
|
||||
#include <tt_app.h>
|
||||
@@ -23,9 +24,10 @@ class MediaKeys final : public App {
|
||||
struct Device* _hidDevice = nullptr;
|
||||
|
||||
// State - accessed from both LVGL thread and BT callback thread
|
||||
std::atomic<bool> _isEnabled {false};
|
||||
std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON
|
||||
std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off)
|
||||
std::atomic<bool> _isEnabled {false};
|
||||
std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON
|
||||
std::atomic<bool> _radioWasOff {false}; // true if we turned the radio on (restore on exit)
|
||||
std::atomic<bool> _deviceWasStarted{false}; // true if we called device_start (restore on exit)
|
||||
|
||||
// Static event callbacks
|
||||
static void onSwitchToggled(lv_event_t* e);
|
||||
@@ -36,6 +38,7 @@ class MediaKeys final : public App {
|
||||
static void sendKeyTask(void* param);
|
||||
|
||||
// Instance methods called by static callbacks
|
||||
void teardownBt(); // remove callback + stop HID + restore radio/device state
|
||||
void handleSwitchToggle(bool enabled);
|
||||
void handleButtonPress(uint32_t buttonId);
|
||||
void startHid(); // called once radio is confirmed ON
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
[app]
|
||||
id=one.tactility.mediakeys
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Media Keys
|
||||
description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3,esp32p4
|
||||
app.id=one.tactility.mediakeys
|
||||
app.version.name=0.4.0
|
||||
app.version.code=4
|
||||
app.name=Media Keys
|
||||
app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
|
||||
|
||||
+925
-120
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.mp3player
|
||||
versionName=1.0.0
|
||||
versionCode=1
|
||||
name=MP3 Player
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.mp3player
|
||||
app.version.name=1.0.0
|
||||
app.version.code=1
|
||||
app.name=MP3 Player
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.mystifydemo
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Mystify Demo
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.mystifydemo
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Mystify Demo
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||
else()
|
||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||
message(WARNING "TACTILITY_SDK_PATH is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
endif()
|
||||
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(PipecatVoice)
|
||||
tactility_project(PipecatVoice)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Pipecat Voice
|
||||
|
||||
Minimal native Tactility client for the LAN voice-adapter protocol v1. Opening the app immediately connects to `ws://192.168.68.102:8644/api/esp32/voice/ws`, sends the versioned `start` declaration, and continuously streams 16 kHz mono signed-16-bit PCM. There is no Connect button, speaker/transport picker, PTT control, text entry, transcript display, or credential on the device.
|
||||
|
||||
The Mac-hosted adapter owns VAD, STT, Pipecat/Hermes orchestration, TTS, and credentials. It returns metadata followed by a single PCM response frame; the app pauses capture, plays that response at the declared rate through Tactility `i2s0`, then resumes capture. The app only displays connection/streaming/reconnect/configuration state and uses the standard toolbar to exit.
|
||||
|
||||
## Configuration
|
||||
|
||||
`config.json` in app user data can override the non-secret endpoint and allowed adapter device id:
|
||||
|
||||
```json
|
||||
{"server_url":"ws://192.168.68.102:8644/api/esp32/voice/ws","device_id":"tactility-14c19d1a790"}
|
||||
```
|
||||
|
||||
Only `ws://` private-LAN endpoints are accepted; loopback endpoints are rejected. Invalid configuration is a terminal actionable state. Network failures use bounded 1, 2, 4, 8, 16, then 30-second reconnect delays. Protocol and payload violations close the session and reconnect; stale audio is never queued.
|
||||
|
||||
## Build and test
|
||||
|
||||
```sh
|
||||
cc -std=c11 -Wall -Wextra -Werror -I main/Source tests/test_voice_protocol.c main/Source/voice_protocol.c -o /tmp/pipecatvoice-protocol-test
|
||||
/tmp/pipecatvoice-protocol-test
|
||||
|
||||
unset PYTHONPATH PYTHONHOME
|
||||
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env
|
||||
source /Users/adolforeyna/esp/esp-idf/export.sh
|
||||
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
|
||||
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/PipecatVoice build esp32s3 --local-sdk
|
||||
```
|
||||
|
||||
The compatible adapter is documented at `/Users/adolforeyna/Projects/voice-assistant/hermes-esp32-voice-gateway/docs/lan-ws-pipecat-adapter.md`. It is the only supported endpoint; Pipecat `:7861` is not an optional device transport.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Pipecat SmallWebRTC ESP32 feasibility spike
|
||||
|
||||
Date: 2026-08-10
|
||||
|
||||
## Decision
|
||||
|
||||
**NO-GO for a Tactility external ELF on the current SDK; GO only for a separate, full ESP-IDF firmware application.**
|
||||
|
||||
Pipecat has an official native ESP32 client, so the required transport is real and source-proven. It cannot presently be used as a Tactility runtime ELF without a firmware/SDK integration project: the client is a full ESP-IDF firmware with private, static component dependencies and system configuration that the ELF loader does not provide or export. Do not replace the rejected raw-WebSocket implementation with another transport until that integration is designed and proven.
|
||||
|
||||
This is not a proposal to flash anything. No device was deployed or flashed during this spike.
|
||||
|
||||
## Source-pinned native client
|
||||
|
||||
| Item | Evidence |
|
||||
| --- | --- |
|
||||
| Client | `https://github.com/pipecat-ai/pipecat-esp32`, commit `e70e3b1f0576e502af9e390434e1a6e8a5cd0d2e`, cloned with all recursive submodules |
|
||||
| Top-level licence | MIT (`LICENSE`, copyright Daily/OpenAI) |
|
||||
| Pipecat server | local installed `pipecat-ai 1.7.0`, Python 3.11 virtual environment |
|
||||
| Server ESP32 support | `SmallWebRTCRequestHandler(..., esp32_mode=True, host=...)` munges the SDP; `smallwebrtc_sdp_munging()` removes SHA-384/SHA-512 fingerprints and retains only the chosen host's ICE candidates |
|
||||
| ESP-IDF build used | local ESP-IDF `v5.5.2`, environment `idf5.5_py3.9_env` |
|
||||
| Native build result | upstream `esp32-s3-box-3` built successfully, including `peer`, `srtp`, `esp-libopus`, Wi-Fi, HTTP client, DTLS/SRTP, Opus, and the ESP-BOX-3 BSP |
|
||||
|
||||
The official client is designed for ESP32-S3 and uses the `libpeer` API. Its `PeerConfiguration` sets `CODEC_OPUS`, creates a peer connection, installs ICE/data/audio callbacks, and invokes `peer_connection_create_offer()`. This is the source-proven native WebRTC implementation; it owns ICE, DTLS-SRTP, RTP, and Opus rather than hand-implementing any of them.
|
||||
|
||||
## Required SmallWebRTC contract
|
||||
|
||||
The product transport is SmallWebRTC HTTP signaling plus WebRTC media, never the old raw WebSocket PCM/JSON protocol:
|
||||
|
||||
1. `POST /start` with `transport: "webrtc"`, `enableDefaultIceServers: false`, and optional `body`; retain the returned `sessionId`.
|
||||
2. `POST /sessions/{sessionId}/api/offer` with `{ "sdp": ..., "type": "offer", "pc_id": optional, "restart_pc": optional, "requestData": optional }`; Pipecat returns SDP answer, type, and `pc_id`.
|
||||
3. `PATCH /sessions/{sessionId}/api/offer` with `{ "pc_id": ..., "candidates": [{ "candidate": ..., "sdp_mid": ..., "sdp_mline_index": ... }] }` for trickle ICE. An empty candidate is the end-of-candidates marker.
|
||||
4. Use the negotiated WebRTC audio track continuously. The runner starts the bot after the offer is processed.
|
||||
|
||||
The local Pipecat source also supports the direct `/api/offer` route used by the current official ESP32 example. The session form above is the approved application contract because it supports Pipecat runner session lifecycle. The live endpoint returned HTTP 200 to `/status`, but it was not restarted with `--esp32`; therefore no live offer/candidate exchange is represented as ESP32 validation.
|
||||
|
||||
## Audio adapter boundary
|
||||
|
||||
The official client source (`media.cpp`) uses 16 kHz, mono, signed 16-bit PCM (`640` bytes = 320 samples = 20 ms) and encodes it as Opus for `peer_connection_send_audio()`. Inbound WebRTC audio reaches the `onaudiotrack` callback as Opus, is decoded to the same PCM shape, and is written to the speaker codec.
|
||||
|
||||
For a future firmware-level integration, Tactility must keep ownership at the following boundary (no hard-coded board pins):
|
||||
|
||||
- acquire the existing Tactility `audio_stream` / `i2s_controller` service;
|
||||
- pull fixed 20 ms frames, 16 kHz mono S16LE, into the native client encoder;
|
||||
- feed decoded remote S16LE frames to the existing output service;
|
||||
- serialize I/O ownership, keep bounded queues, and drop stale audio rather than accumulating latency;
|
||||
- close peer/media callbacks before releasing the audio device.
|
||||
|
||||
The current kernel exports `audio_stream_open_input`, `audio_stream_open_output`, `audio_stream_read`, `audio_stream_write`, `audio_stream_close`, and `i2s_controller_read`/`i2s_controller_write`. Those APIs are the usable boundary, not a reason to configure physical pins in the app.
|
||||
|
||||
## Full-firmware build evidence
|
||||
|
||||
The following was run in a temporary checkout; non-secret placeholder Wi-Fi values were used and no flash command was run:
|
||||
|
||||
```text
|
||||
cd /tmp/pipecat-esp32-spike
|
||||
# cloned pipecat-esp32 at e70e3b1... and initialized all recursive submodules
|
||||
cd esp32-s3-box-3
|
||||
unset PYTHONPATH PYTHONHOME
|
||||
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.5_py3.9_env
|
||||
export WIFI_SSID=spike
|
||||
export WIFI_PASSWORD=spike
|
||||
export PIPECAT_SMALLWEBRTC_URL=http://192.168.68.112:7860/api/offer
|
||||
source /Users/adolforeyna/esp/esp-idf/export.sh
|
||||
idf.py build
|
||||
```
|
||||
|
||||
Actual result: `src.elf` and `src.bin` were produced; the IDF build ended with `Project build complete`. `src.bin` is **1,493,408 bytes** and the upstream 1.5 MiB app partition reported **79,712 bytes (5%) free**. `xtensa-esp32s3-elf-size src.elf` reported text `1,304,360`, data `201,012`, bss `2,863,205` (total `4,368,577`). The linked firmware has no undefined dynamic symbols.
|
||||
|
||||
This is important capacity evidence: even before adapting it to the target board and Tactility services, the supported client nearly fills its own dedicated application partition and has a 2.86 MiB BSS footprint.
|
||||
|
||||
## Why this does not link as a Tactility ELF
|
||||
|
||||
Tactility's `TactilitySDK.cmake` calls `project_elf()`. Its loader CMake builds a PIC shared ELF with `-nostartfiles -nostdlib -shared -e app_main`, and links only `main` plus explicitly listed `ELF_COMPONENTS` / `ELF_LIBS`. The current PipecatVoice component declares only `REQUIRES TactilitySDK lwip`.
|
||||
|
||||
The upstream client instead requires full firmware components including `peer`, `srtp`, `esp-libopus`, `esp_http_client`, `esp_wifi`, `nvs_flash`, `esp_psram`, `esp_netif`, mbedTLS, and ESP-BOX-3 BSP. The official `peer` static archive has unresolved references to the linked firmware environment such as `mbedtls_ssl_conf_dtls_srtp_protection_profiles`, `mbedtls_ssl_config_defaults`, `lwip_inet_ntop`, and socket/ICE helpers. The Tactility kernel export table contains the audio service APIs listed above but no `peer_connection`, `opus_*`, `srtp_*`, `mbedtls_*`, `esp_http_client*`, `esp_wifi*`, or `esp_netif*` exports.
|
||||
|
||||
Attempting the ordinary app build also hit a concrete local SDK packaging blocker before linking: `tactility.py Apps/PipecatVoice build esp32s3 --local-sdk` reported that `Buildscripts/TactilitySDK/0.8.0-dev-esp32s3/TactilitySDK` is missing. This must be corrected for later normal ELF builds, but it is distinct from the component/loader incompatibility.
|
||||
|
||||
Therefore copying the client sources or merely adding `REQUIRES peer` would not make a runnable ELF: it would either fail to find the private IDF component libraries during the ELF link or produce imports that the firmware loader cannot resolve. Statically embedding all dependencies is unproven and high-risk because of ELF size, duplicate runtime/library state, SDK configuration, and Wi-Fi/codec ownership conflicts.
|
||||
|
||||
## Security, licensing, and follow-up gate
|
||||
|
||||
- Do not log SDP, ICE details, credentials, or raw audio. The source-level HTTP helper currently logs offer/answer in debug mode; any reused code must remove that logging.
|
||||
- The upstream defaults deliberately disable TLS certificate verification for its demo. Production must use HTTPS with a pinned/validated trust chain; do not inherit that setting.
|
||||
- Preserve MIT notices for Pipecat ESP32 and audit each pinned submodule separately (`libpeer`, SRTP/libSRTP, Opus, and Espressif managed components have their own licenses).
|
||||
- Do not use the existing PipecatVoice raw WebSocket code or its historical configuration as a fallback; it is protocol-incompatible with SmallWebRTC.
|
||||
|
||||
A firmware-level project must first export/package the required WebRTC dependency set, prove an external ELF link with zero unresolved loader symbols (or move the client into firmware), set deterministic memory budgets, and then perform an `--esp32` SmallWebRTC live offer/ICE/media test. Only after that gate may the approved minimal auto-start UI be implemented.
|
||||
@@ -0,0 +1,8 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||
REQUIRES TactilitySDK lwip
|
||||
)
|
||||
@@ -0,0 +1,320 @@
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
|
||||
#include <cJSON.h>
|
||||
#include <esp_log.h>
|
||||
#include <esp_random.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "voice_protocol.h"
|
||||
#include "websocket.h"
|
||||
|
||||
/* Exported by firmware 0.8.0-dev although absent from the CDN SDK header. */
|
||||
struct Device* device_find_by_name(const char* name);
|
||||
struct Device* device_find_first_by_type(const struct DeviceType* type);
|
||||
|
||||
#define TAG "PipecatVoice"
|
||||
#define DEFAULT_ENDPOINT "ws://192.168.68.102:8644/api/esp32/voice/ws"
|
||||
#define DEFAULT_DEVICE_ID "tactility-14c19d1a790"
|
||||
|
||||
typedef struct {
|
||||
AppHandle app;
|
||||
volatile bool visible;
|
||||
volatile bool streaming;
|
||||
volatile bool playing;
|
||||
volatile bool socket_failed;
|
||||
int fd;
|
||||
PvState state;
|
||||
unsigned retry_attempt;
|
||||
size_t expected_audio_bytes;
|
||||
char endpoint[128];
|
||||
char device_id[64];
|
||||
char api_key[128];
|
||||
char detail[96];
|
||||
lv_obj_t* state_label;
|
||||
lv_obj_t* detail_label;
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle input_handle;
|
||||
AudioStreamHandle output_handle;
|
||||
TaskHandle_t worker;
|
||||
TaskHandle_t receiver;
|
||||
SemaphoreHandle_t socket_lock;
|
||||
SemaphoreHandle_t audio_lock;
|
||||
} VoiceContext;
|
||||
|
||||
static void update_ui(VoiceContext* ctx) {
|
||||
if (!ctx->visible || !tt_lvgl_lock(pdMS_TO_TICKS(100))) return;
|
||||
lv_label_set_text(ctx->state_label, pv_state_label(ctx->state));
|
||||
lv_obj_set_style_text_color(ctx->state_label,
|
||||
ctx->state == PV_STREAMING ? lv_color_hex(0x32c86e) :
|
||||
ctx->state == PV_FAILED ? lv_color_hex(0xd94a4a) : lv_color_hex(0xe0b64a), LV_PART_MAIN);
|
||||
lv_label_set_text(ctx->detail_label, ctx->detail);
|
||||
tt_lvgl_unlock();
|
||||
}
|
||||
|
||||
static void set_state(VoiceContext* ctx, PvState state, const char* detail) {
|
||||
ctx->state = state;
|
||||
snprintf(ctx->detail, sizeof(ctx->detail), "%s", detail);
|
||||
update_ui(ctx);
|
||||
}
|
||||
|
||||
static bool open_input_stream(VoiceContext* ctx) {
|
||||
if (ctx->stream_dev == NULL) return false;
|
||||
if (ctx->output_handle) { audio_stream_close(ctx->output_handle); ctx->output_handle = NULL; }
|
||||
if (ctx->input_handle) return true;
|
||||
struct AudioStreamConfig cfg = {
|
||||
.sample_rate = 16000,
|
||||
.bits_per_sample = 16,
|
||||
.channels = 1,
|
||||
};
|
||||
if (audio_stream_open_input(ctx->stream_dev, &cfg, &ctx->input_handle) != ERROR_NONE) {
|
||||
ESP_LOGW(TAG, "audio_stream_open_input failed");
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool open_output_stream(VoiceContext* ctx) {
|
||||
if (ctx->stream_dev == NULL) return false;
|
||||
if (ctx->input_handle) { audio_stream_close(ctx->input_handle); ctx->input_handle = NULL; }
|
||||
if (ctx->output_handle) return true;
|
||||
struct AudioStreamConfig cfg = {
|
||||
.sample_rate = 16000,
|
||||
.bits_per_sample = 16,
|
||||
.channels = 1,
|
||||
};
|
||||
if (audio_stream_open_output(ctx->stream_dev, &cfg, &ctx->output_handle) != ERROR_NONE) {
|
||||
ESP_LOGW(TAG, "audio_stream_open_output failed");
|
||||
return false;
|
||||
}
|
||||
audio_stream_set_mute(ctx->stream_dev, AUDIO_CODEC_DIR_OUTPUT, false);
|
||||
audio_stream_set_volume(ctx->stream_dev, AUDIO_CODEC_DIR_OUTPUT, 80.0f);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void close_audio(VoiceContext* ctx) {
|
||||
if (ctx->output_handle) { audio_stream_close(ctx->output_handle); ctx->output_handle = NULL; }
|
||||
if (ctx->input_handle) { audio_stream_close(ctx->input_handle); ctx->input_handle = NULL; }
|
||||
}
|
||||
|
||||
static int send_locked(VoiceContext* ctx, const uint8_t* data, size_t length, bool binary) {
|
||||
if (ctx->fd < 0 || xSemaphoreTake(ctx->socket_lock, pdMS_TO_TICKS(500)) != pdTRUE) return -1;
|
||||
int result = ws_send(ctx->fd, data, length, binary);
|
||||
xSemaphoreGive(ctx->socket_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void handle_event(VoiceContext* ctx, const char* text) {
|
||||
cJSON* root = cJSON_Parse(text);
|
||||
if (root == NULL) return;
|
||||
cJSON* version = cJSON_GetObjectItem(root, "v");
|
||||
cJSON* event = cJSON_GetObjectItem(root, "event");
|
||||
if (!cJSON_IsNumber(version) || version->valueint != PV_PROTOCOL_VERSION || !cJSON_IsString(event)) {
|
||||
cJSON_Delete(root);
|
||||
return;
|
||||
}
|
||||
if (strcmp(event->valuestring, "ready") == 0) {
|
||||
ctx->streaming = true;
|
||||
} else if (strcmp(event->valuestring, "state") == 0) {
|
||||
cJSON* state = cJSON_GetObjectItem(root, "state");
|
||||
if (cJSON_IsString(state) && strcmp(state->valuestring, "listening") == 0) {
|
||||
ctx->streaming = true;
|
||||
set_state(ctx, PV_STREAMING, "Continuous microphone streaming");
|
||||
}
|
||||
} else if (strcmp(event->valuestring, "audio") == 0) {
|
||||
cJSON* format = cJSON_GetObjectItem(root, "format");
|
||||
cJSON* rate = cJSON_GetObjectItem(root, "sample_rate");
|
||||
cJSON* channels = cJSON_GetObjectItem(root, "channels");
|
||||
cJSON* width = cJSON_GetObjectItem(root, "sample_width");
|
||||
cJSON* length = cJSON_GetObjectItem(root, "byte_length");
|
||||
if (cJSON_IsString(format) && cJSON_IsNumber(rate) && cJSON_IsNumber(channels) && cJSON_IsNumber(width) &&
|
||||
cJSON_IsNumber(length) && pv_valid_downstream_audio(format->valuestring, rate->valueint, channels->valueint,
|
||||
width->valueint, (size_t)length->valueint)) {
|
||||
ctx->expected_audio_bytes = (size_t)length->valueint;
|
||||
ctx->playing = true;
|
||||
set_state(ctx, PV_STREAMING, "Playing response; microphone paused");
|
||||
} else {
|
||||
ctx->socket_failed = true;
|
||||
}
|
||||
} else if (strcmp(event->valuestring, "error") == 0) {
|
||||
/* The server-provided message is intentionally not copied to display/logs. */
|
||||
ctx->socket_failed = true;
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void receiver_task(void* argument) {
|
||||
VoiceContext* ctx = argument;
|
||||
uint8_t* buffer = malloc(PV_DOWNSTREAM_MAX + 1U);
|
||||
if (buffer == NULL) { ctx->socket_failed = true; ctx->receiver = NULL; vTaskDelete(NULL); }
|
||||
while (ctx->visible && ctx->fd >= 0) {
|
||||
int opcode = 0;
|
||||
bool final = false;
|
||||
int received = ws_recv(ctx->fd, &opcode, &final, buffer, PV_DOWNSTREAM_MAX);
|
||||
if (received < 0 || !final) { ctx->socket_failed = true; break; }
|
||||
if (opcode == 0x01) {
|
||||
buffer[received] = '\0';
|
||||
handle_event(ctx, (const char*)buffer);
|
||||
} else if (opcode == 0x02) {
|
||||
if (!ctx->playing || !pv_binary_matches_metadata(ctx->expected_audio_bytes, (size_t)received)) { ctx->socket_failed = true; break; }
|
||||
xSemaphoreTake(ctx->audio_lock, portMAX_DELAY);
|
||||
bool ok = open_output_stream(ctx);
|
||||
size_t written = 0;
|
||||
if (ok) ok = audio_stream_write(ctx->output_handle, buffer, (size_t)received, &written, pdMS_TO_TICKS(3000)) == ERROR_NONE;
|
||||
open_input_stream(ctx);
|
||||
xSemaphoreGive(ctx->audio_lock);
|
||||
if (!ok || written != (size_t)received) { ctx->socket_failed = true; break; }
|
||||
ctx->expected_audio_bytes = 0;
|
||||
ctx->playing = false;
|
||||
} else if (opcode == 0x09) {
|
||||
if (xSemaphoreTake(ctx->socket_lock, pdMS_TO_TICKS(500)) == pdTRUE) {
|
||||
ws_send_pong(ctx->fd, buffer, (size_t)received);
|
||||
xSemaphoreGive(ctx->socket_lock);
|
||||
}
|
||||
} else if (opcode == 0x08) {
|
||||
ctx->socket_failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
free(buffer);
|
||||
ctx->receiver = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void close_session(VoiceContext* ctx) {
|
||||
int fd = ctx->fd;
|
||||
ctx->fd = -1;
|
||||
ctx->streaming = false;
|
||||
ctx->playing = false;
|
||||
ctx->expected_audio_bytes = 0;
|
||||
if (fd >= 0) {
|
||||
if (xSemaphoreTake(ctx->socket_lock, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
ws_send_close(fd);
|
||||
xSemaphoreGive(ctx->socket_lock);
|
||||
}
|
||||
ws_close(fd);
|
||||
}
|
||||
close_audio(ctx);
|
||||
}
|
||||
|
||||
static void worker_task(void* argument) {
|
||||
VoiceContext* ctx = argument;
|
||||
PvEndpoint endpoint;
|
||||
if (!pv_parse_endpoint(ctx->endpoint, &endpoint)) {
|
||||
set_state(ctx, PV_FAILED, "Set a private-LAN ws:// endpoint in config.json");
|
||||
ctx->worker = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
uint8_t pcm[1024];
|
||||
while (ctx->visible) {
|
||||
set_state(ctx, ctx->retry_attempt ? PV_RECONNECTING : PV_CONNECTING,
|
||||
ctx->retry_attempt ? "Retrying voice gateway" : "Connecting to voice gateway");
|
||||
ctx->socket_failed = false;
|
||||
ESP_LOGI(TAG, "Opening voice gateway session");
|
||||
ctx->fd = ws_connect(endpoint.host, endpoint.port, endpoint.path, ctx->device_id, ctx->api_key);
|
||||
if (ctx->fd >= 0) {
|
||||
char session[64];
|
||||
snprintf(session, sizeof(session), "pv-%08lx", (unsigned long)esp_random());
|
||||
char start[256];
|
||||
if (pv_make_start_json(start, sizeof(start), session, ctx->device_id) &&
|
||||
send_locked(ctx, (const uint8_t*)start, strlen(start), false) == 0) {
|
||||
if (!open_input_stream(ctx)) {
|
||||
set_state(ctx, PV_FAILED, "Audio stream unavailable");
|
||||
ctx->socket_failed = true;
|
||||
} else {
|
||||
xTaskCreate(receiver_task, "pv_rx", 6144, ctx, 6, &ctx->receiver);
|
||||
uint32_t stable_ticks = 0;
|
||||
while (ctx->visible && !ctx->socket_failed) {
|
||||
if (!ctx->streaming || ctx->playing) { vTaskDelay(pdMS_TO_TICKS(20)); continue; }
|
||||
xSemaphoreTake(ctx->audio_lock, portMAX_DELAY);
|
||||
bool opened = open_input_stream(ctx);
|
||||
size_t read = 0;
|
||||
error_t read_result = opened ? audio_stream_read(ctx->input_handle, pcm, sizeof(pcm), &read, pdMS_TO_TICKS(100)) : ERROR_RESOURCE;
|
||||
xSemaphoreGive(ctx->audio_lock);
|
||||
if (!opened) { ctx->socket_failed = true; break; }
|
||||
if (read_result == ERROR_NONE && pv_valid_pcm_chunk(read) && send_locked(ctx, pcm, read, true) < 0) ctx->socket_failed = true;
|
||||
if (++stable_ticks >= 300) ctx->retry_attempt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
close_session(ctx);
|
||||
if (!ctx->visible) break;
|
||||
uint32_t delay = pv_retry_delay_seconds(ctx->retry_attempt++);
|
||||
ESP_LOGW(TAG, "Voice gateway session unavailable; retry in %lu seconds", (unsigned long)delay);
|
||||
set_state(ctx, PV_RECONNECTING, "Gateway unavailable; retry scheduled");
|
||||
for (uint32_t second = 0; ctx->visible && second < delay; ++second) vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
ctx->worker = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void load_config(VoiceContext* ctx) {
|
||||
snprintf(ctx->endpoint, sizeof(ctx->endpoint), "%s", DEFAULT_ENDPOINT);
|
||||
snprintf(ctx->device_id, sizeof(ctx->device_id), "%s", DEFAULT_DEVICE_ID);
|
||||
ctx->api_key[0] = '\0';
|
||||
char path[256]; size_t path_size = sizeof(path);
|
||||
tt_app_get_user_data_child_path(ctx->app, "config.json", path, &path_size);
|
||||
FILE* file = fopen(path, "r");
|
||||
if (file == NULL) return;
|
||||
char json[512]; size_t bytes = fread(json, 1, sizeof(json) - 1, file); fclose(file); json[bytes] = '\0';
|
||||
cJSON* root = cJSON_Parse(json);
|
||||
cJSON* endpoint = root ? cJSON_GetObjectItem(root, "server_url") : NULL;
|
||||
cJSON* device = root ? cJSON_GetObjectItem(root, "device_id") : NULL;
|
||||
cJSON* key = root ? cJSON_GetObjectItem(root, "api_key") : NULL;
|
||||
if (cJSON_IsString(endpoint)) snprintf(ctx->endpoint, sizeof(ctx->endpoint), "%s", endpoint->valuestring);
|
||||
if (cJSON_IsString(device)) snprintf(ctx->device_id, sizeof(ctx->device_id), "%s", device->valuestring);
|
||||
if (cJSON_IsString(key)) snprintf(ctx->api_key, sizeof(ctx->api_key), "%s", key->valuestring);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void* create_data(void) { VoiceContext* ctx = calloc(1, sizeof(*ctx)); if (ctx) ctx->fd = -1; return ctx; }
|
||||
static void destroy_data(void* data) { free(data); }
|
||||
static void on_create(AppHandle app, void* data) { ((VoiceContext*)data)->app = app; }
|
||||
static void on_destroy(AppHandle app, void* data) { (void)app; (void)data; }
|
||||
|
||||
static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
VoiceContext* ctx = data; ctx->visible = true; load_config(ctx);
|
||||
ctx->stream_dev = device_find_by_name("audio-stream");
|
||||
if (ctx->stream_dev == NULL) ctx->stream_dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||
ctx->socket_lock = xSemaphoreCreateMutex();
|
||||
ctx->audio_lock = xSemaphoreCreateMutex();
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
ctx->state_label = lv_label_create(parent);
|
||||
|
||||
lv_obj_align(ctx->state_label, LV_ALIGN_CENTER, 0, -30);
|
||||
ctx->detail_label = lv_label_create(parent);
|
||||
lv_obj_set_width(ctx->detail_label, lv_pct(88)); lv_label_set_long_mode(ctx->detail_label, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_align(ctx->detail_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||
lv_obj_align(ctx->detail_label, LV_ALIGN_CENTER, 0, 25);
|
||||
if (ctx->stream_dev == NULL || ctx->socket_lock == NULL || ctx->audio_lock == NULL) set_state(ctx, PV_FAILED, "Audio service unavailable");
|
||||
else xTaskCreate(worker_task, "pv_worker", 7168, ctx, 5, &ctx->worker);
|
||||
}
|
||||
|
||||
static void on_hide(AppHandle app, void* data) {
|
||||
(void)app; VoiceContext* ctx = data; ctx->visible = false; close_session(ctx);
|
||||
for (unsigned i = 0; (ctx->worker || ctx->receiver) && i < 100; ++i) vTaskDelay(pdMS_TO_TICKS(10));
|
||||
close_audio(ctx);
|
||||
if (ctx->socket_lock) { vSemaphoreDelete(ctx->socket_lock); ctx->socket_lock = NULL; }
|
||||
if (ctx->audio_lock) { vSemaphoreDelete(ctx->audio_lock); ctx->audio_lock = NULL; }
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
(void)argc; (void)argv;
|
||||
tt_app_register((AppRegistration){.createData=create_data,.destroyData=destroy_data,.onCreate=on_create,.onDestroy=on_destroy,.onShow=on_show,.onHide=on_hide});
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "voice_protocol.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* NOTE: Do not use ctype.h (isalnum/isalpha/isdigit/isspace) in this app. Those
|
||||
* functions read the `_ctype_` table, which is resolved from the flashed firmware
|
||||
* at runtime; the firmware's table does not behave correctly for side-loaded ELF
|
||||
* apps, so isalnum('a') can return false. Use explicit ASCII range checks instead. */
|
||||
|
||||
static bool is_digit(unsigned char c) { return c >= '0' && c <= '9'; }
|
||||
|
||||
static bool is_space(unsigned char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f';
|
||||
}
|
||||
|
||||
static bool is_alnum(unsigned char c) {
|
||||
return is_digit(c) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
||||
}
|
||||
|
||||
static bool copy_part(char* destination, size_t destination_size, const char* start, size_t length) {
|
||||
if (length == 0 || length >= destination_size) return false;
|
||||
memcpy(destination, start, length);
|
||||
destination[length] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool is_loopback(const char* host) {
|
||||
return strcmp(host, "localhost") == 0 || strcmp(host, "::1") == 0 || strncmp(host, "127.", 4) == 0;
|
||||
}
|
||||
|
||||
static bool valid_identifier(const char* value) {
|
||||
if (value == NULL || *value == '\0') return false;
|
||||
for (const unsigned char* p = (const unsigned char*)value; *p; ++p) {
|
||||
if (!is_alnum(*p) && *p != '-' && *p != '_' && *p != '.') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pv_parse_endpoint(const char* url, PvEndpoint* endpoint) {
|
||||
if (url == NULL || endpoint == NULL || strncmp(url, "ws://", 5) != 0) return false;
|
||||
const char* authority = url + 5;
|
||||
const char* path = strchr(authority, '/');
|
||||
const char* authority_end = path ? path : authority + strlen(authority);
|
||||
const char* colon = NULL;
|
||||
for (const char* p = authority; p < authority_end; ++p) {
|
||||
if (*p == ':') {
|
||||
if (colon != NULL) return false;
|
||||
colon = p;
|
||||
}
|
||||
if (is_space((unsigned char)*p) || *p == '@' || *p == '?' || *p == '#') return false;
|
||||
}
|
||||
size_t host_length = (size_t)((colon ? colon : authority_end) - authority);
|
||||
if (!copy_part(endpoint->host, sizeof(endpoint->host), authority, host_length) || is_loopback(endpoint->host)) return false;
|
||||
endpoint->port = 80;
|
||||
if (colon != NULL) {
|
||||
unsigned long port = 0;
|
||||
for (const char* p = colon + 1; p < authority_end; ++p) {
|
||||
if (!is_digit((unsigned char)*p)) return false;
|
||||
port = port * 10U + (unsigned long)(*p - '0');
|
||||
if (port > 65535U) return false;
|
||||
}
|
||||
if (port == 0) return false;
|
||||
endpoint->port = (uint16_t)port;
|
||||
}
|
||||
return path == NULL ? copy_part(endpoint->path, sizeof(endpoint->path), "/", 1)
|
||||
: copy_part(endpoint->path, sizeof(endpoint->path), path, strlen(path));
|
||||
}
|
||||
|
||||
bool pv_make_start_json(char* out, size_t out_size, const char* session_id, const char* device_id) {
|
||||
if (out == NULL || !valid_identifier(session_id) || !valid_identifier(device_id)) return false;
|
||||
int written = snprintf(out, out_size,
|
||||
"{\"v\":1,\"event\":\"start\",\"session_id\":\"%s\",\"device_id\":\"%s\",\"audio\":{\"format\":\"pcm_s16le\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2}}",
|
||||
session_id, device_id);
|
||||
return written > 0 && (size_t)written < out_size;
|
||||
}
|
||||
|
||||
bool pv_valid_pcm_chunk(size_t bytes) {
|
||||
return bytes > 0 && bytes <= PV_PCM_CHUNK_MAX && (bytes % 2U) == 0;
|
||||
}
|
||||
|
||||
bool pv_valid_downstream_audio(const char* format, int sample_rate, int channels, int sample_width, size_t byte_length) {
|
||||
return format != NULL && strcmp(format, "pcm_s16le") == 0 && sample_rate > 0 && sample_rate <= 48000 &&
|
||||
channels == 1 && sample_width == 2 && byte_length > 0 && byte_length <= PV_DOWNSTREAM_MAX &&
|
||||
(byte_length % 2U) == 0;
|
||||
}
|
||||
|
||||
bool pv_binary_matches_metadata(size_t expected_bytes, size_t received_bytes) {
|
||||
return expected_bytes > 0 && expected_bytes == received_bytes;
|
||||
}
|
||||
|
||||
uint32_t pv_retry_delay_seconds(unsigned attempt) {
|
||||
uint32_t delay = 1;
|
||||
while (attempt > 0 && delay < 30) {
|
||||
delay *= 2;
|
||||
--attempt;
|
||||
}
|
||||
return delay > 30 ? 30 : delay;
|
||||
}
|
||||
|
||||
PvState pv_disconnect_state(bool endpoint_valid) {
|
||||
return endpoint_valid ? PV_RECONNECTING : PV_FAILED;
|
||||
}
|
||||
|
||||
const char* pv_state_label(PvState state) {
|
||||
switch (state) {
|
||||
case PV_CONNECTING: return "CONNECTING";
|
||||
case PV_STREAMING: return "STREAMING";
|
||||
case PV_RECONNECTING: return "RECONNECTING";
|
||||
case PV_FAILED: return "FAILED";
|
||||
default: return "FAILED";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define PV_PROTOCOL_VERSION 1
|
||||
#define PV_PCM_CHUNK_MAX 16384U
|
||||
#define PV_DOWNSTREAM_MAX 65536U
|
||||
|
||||
typedef enum {
|
||||
PV_CONNECTING,
|
||||
PV_STREAMING,
|
||||
PV_RECONNECTING,
|
||||
PV_FAILED,
|
||||
} PvState;
|
||||
|
||||
typedef struct {
|
||||
char host[64];
|
||||
char path[96];
|
||||
uint16_t port;
|
||||
} PvEndpoint;
|
||||
|
||||
bool pv_parse_endpoint(const char* url, PvEndpoint* endpoint);
|
||||
bool pv_make_start_json(char* out, size_t out_size, const char* session_id, const char* device_id);
|
||||
bool pv_valid_pcm_chunk(size_t bytes);
|
||||
bool pv_valid_downstream_audio(const char* format, int sample_rate, int channels, int sample_width, size_t byte_length);
|
||||
bool pv_binary_matches_metadata(size_t expected_bytes, size_t received_bytes);
|
||||
uint32_t pv_retry_delay_seconds(unsigned attempt);
|
||||
PvState pv_disconnect_state(bool endpoint_valid);
|
||||
const char* pv_state_label(PvState state);
|
||||
@@ -0,0 +1,167 @@
|
||||
#include "websocket.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <esp_log.h>
|
||||
#include <esp_random.h>
|
||||
#include <lwip/inet.h>
|
||||
#include <lwip/sockets.h>
|
||||
|
||||
#define TAG "PipecatVoiceWs"
|
||||
#define WS_HEADER_LIMIT 1024U
|
||||
#define WS_CONTROL_LIMIT 125U
|
||||
|
||||
static int send_all(int fd, const uint8_t* data, size_t length) {
|
||||
size_t sent = 0;
|
||||
while (sent < length) {
|
||||
int result = lwip_send(fd, data + sent, length - sent, 0);
|
||||
if (result <= 0) return -1;
|
||||
sent += (size_t)result;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int recv_all(int fd, uint8_t* data, size_t length) {
|
||||
size_t received = 0;
|
||||
while (received < length) {
|
||||
int result = lwip_recv(fd, data + received, length - received, 0);
|
||||
if (result <= 0) return -1;
|
||||
received += (size_t)result;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int discard(int fd, uint64_t length) {
|
||||
uint8_t buffer[256];
|
||||
while (length > 0) {
|
||||
size_t chunk = length > sizeof(buffer) ? sizeof(buffer) : (size_t)length;
|
||||
if (recv_all(fd, buffer, chunk) < 0) return -1;
|
||||
length -= chunk;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int send_frame(int fd, uint8_t opcode, const uint8_t* payload, size_t length) {
|
||||
if (length > 65535U || ((opcode & 0x08U) && length > WS_CONTROL_LIMIT)) return -1;
|
||||
uint8_t header[8];
|
||||
size_t header_length = 2;
|
||||
header[0] = 0x80U | opcode;
|
||||
if (length < 126U) {
|
||||
header[1] = 0x80U | (uint8_t)length;
|
||||
} else {
|
||||
header[1] = 0x80U | 126U;
|
||||
header[2] = (uint8_t)(length >> 8U);
|
||||
header[3] = (uint8_t)length;
|
||||
header_length = 4;
|
||||
}
|
||||
uint8_t mask[4];
|
||||
uint32_t random = esp_random();
|
||||
memcpy(mask, &random, sizeof(mask));
|
||||
memcpy(header + header_length, mask, sizeof(mask));
|
||||
header_length += sizeof(mask);
|
||||
if (send_all(fd, header, header_length) < 0) return -1;
|
||||
|
||||
uint8_t chunk[512];
|
||||
size_t offset = 0;
|
||||
while (offset < length) {
|
||||
size_t count = length - offset > sizeof(chunk) ? sizeof(chunk) : length - offset;
|
||||
for (size_t i = 0; i < count; ++i) chunk[i] = payload[offset + i] ^ mask[(offset + i) % sizeof(mask)];
|
||||
if (send_all(fd, chunk, count) < 0) return -1;
|
||||
offset += count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ws_connect(const char* host, int port, const char* path, const char* device_id, const char* api_key) {
|
||||
if (host == NULL || path == NULL || device_id == NULL || api_key == NULL || port < 1 || port > 65535) return -1;
|
||||
int fd = lwip_socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
ESP_LOGW(TAG, "socket create failed");
|
||||
return -1;
|
||||
}
|
||||
struct sockaddr_in address = {0};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons((uint16_t)port);
|
||||
address.sin_addr.s_addr = ipaddr_addr(host);
|
||||
if (address.sin_addr.s_addr == IPADDR_NONE) {
|
||||
ESP_LOGW(TAG, "endpoint address parse failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
if (lwip_connect(fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
|
||||
ESP_LOGW(TAG, "TCP connect failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
struct timeval timeout = {.tv_sec = 15, .tv_usec = 0};
|
||||
lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
char request[WS_HEADER_LIMIT];
|
||||
int request_length = snprintf(request, sizeof(request),
|
||||
"GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: MDEyMzQ1Njc4OWFiY2RlZg==\r\nSec-WebSocket-Version: 13\r\n"
|
||||
"Authorization: Bearer %s\r\nX-Device-ID: %s\r\n\r\n",
|
||||
path, host, port, api_key, device_id);
|
||||
if (request_length < 0 || (size_t)request_length >= sizeof(request) || send_all(fd, (const uint8_t*)request, (size_t)request_length) < 0) {
|
||||
ESP_LOGW(TAG, "WebSocket upgrade request failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
char response[WS_HEADER_LIMIT];
|
||||
size_t length = 0;
|
||||
while (length + 1 < sizeof(response)) {
|
||||
if (recv_all(fd, (uint8_t*)&response[length], 1) < 0) {
|
||||
ESP_LOGW(TAG, "WebSocket upgrade response failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
response[++length] = '\0';
|
||||
if (length >= 4 && memcmp(response + length - 4, "\r\n\r\n", 4) == 0) break;
|
||||
}
|
||||
if (length + 1 >= sizeof(response) || strstr(response, " 101 ") == NULL) {
|
||||
ESP_LOGW(TAG, "WebSocket upgrade rejected");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
ESP_LOGI(TAG, "WebSocket upgrade accepted");
|
||||
return fd;
|
||||
}
|
||||
|
||||
int ws_send(int fd, const uint8_t* data, size_t length, bool binary) {
|
||||
if (fd < 0 || data == NULL || length == 0) return -1;
|
||||
return send_frame(fd, binary ? 0x02U : 0x01U, data, length);
|
||||
}
|
||||
|
||||
int ws_recv(int fd, int* opcode, bool* final, uint8_t* payload, size_t maximum) {
|
||||
uint8_t header[2];
|
||||
if (fd < 0 || recv_all(fd, header, sizeof(header)) < 0) return -1;
|
||||
uint64_t length = header[1] & 0x7fU;
|
||||
if (length == 126U) {
|
||||
uint8_t extended[2];
|
||||
if (recv_all(fd, extended, sizeof(extended)) < 0) return -1;
|
||||
length = ((uint64_t)extended[0] << 8U) | extended[1];
|
||||
} else if (length == 127U) {
|
||||
uint8_t extended[8];
|
||||
if (recv_all(fd, extended, sizeof(extended)) < 0) return -1;
|
||||
length = 0;
|
||||
for (size_t i = 0; i < sizeof(extended); ++i) length = (length << 8U) | extended[i];
|
||||
}
|
||||
bool masked = (header[1] & 0x80U) != 0;
|
||||
uint8_t mask[4] = {0};
|
||||
if (masked && recv_all(fd, mask, sizeof(mask)) < 0) return -1;
|
||||
uint8_t frame_opcode = header[0] & 0x0fU;
|
||||
if (((frame_opcode & 0x08U) && (length > WS_CONTROL_LIMIT || !(header[0] & 0x80U))) || length > maximum) {
|
||||
if (discard(fd, length) < 0) return -1;
|
||||
return -2;
|
||||
}
|
||||
if (length > 0 && recv_all(fd, payload, (size_t)length) < 0) return -1;
|
||||
if (masked) for (size_t i = 0; i < (size_t)length; ++i) payload[i] ^= mask[i % sizeof(mask)];
|
||||
if (opcode) *opcode = frame_opcode;
|
||||
if (final) *final = (header[0] & 0x80U) != 0;
|
||||
return (int)length;
|
||||
}
|
||||
|
||||
int ws_send_pong(int fd, const uint8_t* payload, size_t length) { return send_frame(fd, 0x0aU, payload, length); }
|
||||
int ws_send_close(int fd) { return send_frame(fd, 0x08U, NULL, 0); }
|
||||
void ws_close(int fd) { if (fd >= 0) close(fd); }
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Connect to a WebSocket server.
|
||||
* @param host Server IP address (e.g. "192.168.68.126")
|
||||
* @param port Port number (e.g. 8642)
|
||||
* @param path WebSocket path (e.g. "/api/esp32/voice/ws")
|
||||
* @param device_id Unique device identifier
|
||||
* @param api_key Optional profile API key; never compiled into firmware
|
||||
* @return Socket file descriptor on success, or -1 on failure
|
||||
*/
|
||||
int ws_connect(const char* host, int port, const char* path, const char* device_id, const char* api_key);
|
||||
|
||||
/**
|
||||
* Send a WebSocket frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param data Data payload to send
|
||||
* @param len Length of the data payload
|
||||
* @param binary True for binary frame, false for text frame
|
||||
* @return 0 on success, or -1 on failure
|
||||
*/
|
||||
int ws_send(int fd, const uint8_t* data, size_t len, bool binary);
|
||||
|
||||
/**
|
||||
* Receive a WebSocket frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param out_opcode Pointer to store the received opcode (e.g. 0x01 text, 0x02 binary)
|
||||
* @param payload Buffer to store the received payload
|
||||
* @param max_len Maximum length of the payload buffer
|
||||
* @return Received payload length on success, -1 on connection failure, or -2 on buffer overflow
|
||||
*/
|
||||
int ws_recv(int fd, int* out_opcode, bool* out_final, uint8_t* payload, size_t max_len);
|
||||
|
||||
/**
|
||||
* Close a WebSocket connection.
|
||||
* @param fd Socket file descriptor
|
||||
*/
|
||||
void ws_close(int fd);
|
||||
|
||||
/**
|
||||
* Send a WebSocket PONG frame.
|
||||
* @param fd Socket file descriptor
|
||||
* @param payload Payload to reflect
|
||||
* @param len Length of payload
|
||||
* @return 0 on success, or -1 on failure
|
||||
*/
|
||||
int ws_send_pong(int fd, const uint8_t* payload, size_t len);
|
||||
|
||||
/** Send a clean WebSocket close control frame before closing the socket. */
|
||||
int ws_send_close(int fd);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,7 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.pipecatvoice
|
||||
app.version.name=1.0.0
|
||||
app.version.code=1
|
||||
app.name=Pipecat Voice
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "voice_protocol.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static void test_endpoint_validation(void) {
|
||||
PvEndpoint endpoint;
|
||||
assert(pv_parse_endpoint("ws://192.168.68.102:8644/api/esp32/voice/ws", &endpoint));
|
||||
assert(strcmp(endpoint.host, "192.168.68.102") == 0);
|
||||
assert(endpoint.port == 8644);
|
||||
assert(strcmp(endpoint.path, "/api/esp32/voice/ws") == 0);
|
||||
assert(!pv_parse_endpoint("ws://127.0.0.1:8642/api", &endpoint));
|
||||
assert(!pv_parse_endpoint("ws://localhost:8642/api", &endpoint));
|
||||
assert(!pv_parse_endpoint("wss://192.168.68.102/api", &endpoint));
|
||||
assert(!pv_parse_endpoint("ws://192.168.68.102:0/api", &endpoint));
|
||||
}
|
||||
|
||||
static void test_start_and_pcm_boundaries(void) {
|
||||
char json[256];
|
||||
assert(pv_make_start_json(json, sizeof(json), "session-01", "tactility-14c19d1a790"));
|
||||
assert(strstr(json, "\"v\":1") != NULL);
|
||||
assert(strstr(json, "\"pcm_s16le\"") != NULL);
|
||||
assert(!pv_make_start_json(json, sizeof(json), "bad session", "device"));
|
||||
assert(pv_valid_pcm_chunk(2));
|
||||
assert(pv_valid_pcm_chunk(PV_PCM_CHUNK_MAX));
|
||||
assert(!pv_valid_pcm_chunk(0));
|
||||
assert(!pv_valid_pcm_chunk(3));
|
||||
assert(!pv_valid_pcm_chunk(PV_PCM_CHUNK_MAX + 2));
|
||||
assert(pv_valid_downstream_audio("pcm_s16le", 24000, 1, 2, 48000));
|
||||
assert(!pv_valid_downstream_audio("wav", 24000, 1, 2, 48000));
|
||||
assert(!pv_valid_downstream_audio("pcm_s16le", 24000, 2, 2, 48000));
|
||||
assert(!pv_valid_downstream_audio("pcm_s16le", 24000, 1, 2, PV_DOWNSTREAM_MAX + 2));
|
||||
assert(pv_binary_matches_metadata(48000, 48000));
|
||||
assert(!pv_binary_matches_metadata(48000, 47998));
|
||||
}
|
||||
|
||||
static void test_retry_and_state(void) {
|
||||
const uint32_t expected[] = {1, 2, 4, 8, 16, 30, 30};
|
||||
for (unsigned i = 0; i < sizeof(expected) / sizeof(expected[0]); ++i) assert(pv_retry_delay_seconds(i) == expected[i]);
|
||||
assert(pv_disconnect_state(true) == PV_RECONNECTING);
|
||||
assert(pv_disconnect_state(false) == PV_FAILED);
|
||||
assert(strcmp(pv_state_label(PV_STREAMING), "STREAMING") == 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_endpoint_validation();
|
||||
test_start_and_pcm_boundaries();
|
||||
test_retry_and_state();
|
||||
puts("voice_protocol tests passed");
|
||||
return 0;
|
||||
}
|
||||
@@ -10,5 +10,5 @@ idf_component_register(
|
||||
../../../Libraries/TactilityCpp/Include
|
||||
../../../Libraries/GameKit/Include
|
||||
../../../Libraries/SfxEngine/Include
|
||||
REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio
|
||||
REQUIRES TactilitySDK
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#include "DungeonModel.h"
|
||||
|
||||
#ifndef LV_ABS
|
||||
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
|
||||
#endif
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
uint32_t DungeonModel::nextRandom() {
|
||||
@@ -11,48 +9,75 @@ uint32_t DungeonModel::nextRandom() {
|
||||
return (seed >> 16u) & 0x7FFFu;
|
||||
}
|
||||
|
||||
void DungeonModel::reset(uint32_t seedValue) {
|
||||
void DungeonModel::reset(uint32_t seedValue, bool tutorials) {
|
||||
seed = seedValue;
|
||||
state = DungeonState {};
|
||||
state.floor = 1;
|
||||
state.floor = tutorials ? 0 : 2;
|
||||
state.hp = 5;
|
||||
state.gold = 0;
|
||||
state.gameOver = false;
|
||||
generateFloor();
|
||||
}
|
||||
|
||||
void DungeonModel::clearAllTiles() {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
|
||||
state.tiles[y][x] = Tile::Floor;
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { state.tiles[0][x] = Tile::Wall; state.tiles[DUNGEON_ROWS-1][x] = Tile::Wall; }
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { state.tiles[y][0] = Tile::Wall; state.tiles[y][DUNGEON_COLS-1] = Tile::Wall; }
|
||||
}
|
||||
|
||||
void DungeonModel::addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp) {
|
||||
if (state.entityCount >= MAX_ENTITIES) return;
|
||||
state.entities[state.entityCount++] = Entity { type, pos, hp, true };
|
||||
}
|
||||
|
||||
void DungeonModel::generateTutorialFloor(uint8_t tId) {
|
||||
clearAllTiles();
|
||||
state.entityCount = 0;
|
||||
state.renderRows = TUTORIAL_ROWS;
|
||||
for (uint8_t y = TUTORIAL_ROWS + 1; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
|
||||
state.tiles[y][x] = Tile::Wall;
|
||||
state.playerPos = {1, 1};
|
||||
if (tId == 0) {
|
||||
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
|
||||
} else {
|
||||
state.tiles[1][3] = Tile::Treasure;
|
||||
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
|
||||
addEnemy(EntityType::Bat, {5, 1}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonModel::generateFloor() {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
if (state.floor < 2) { generateTutorialFloor(static_cast<uint8_t>(state.floor)); return; }
|
||||
state.renderRows = DUNGEON_ROWS;
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
const bool edge = (x == 0 || y == 0 || x == DUNGEON_COLS - 1 || y == DUNGEON_ROWS - 1);
|
||||
state.tiles[y][x] = edge ? Tile::Wall : Tile::Floor;
|
||||
}
|
||||
}
|
||||
state.playerPos = {1, 1};
|
||||
state.entityCount = 0;
|
||||
state.tiles[2][4] = Tile::Wall;
|
||||
state.tiles[3][4] = Tile::Wall;
|
||||
state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure;
|
||||
state.tiles[5][7] = Tile::Stairs;
|
||||
if (state.floor > 1) {
|
||||
if (state.floor > 2) {
|
||||
auto x = static_cast<uint8_t>(2 + (nextRandom() % 5));
|
||||
auto y = static_cast<uint8_t>(1 + (nextRandom() % 5));
|
||||
if (!(x == 1 && y == 1) && !(x == 7 && y == 5)) state.tiles[y][x] = Tile::Wall;
|
||||
}
|
||||
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(state.floor / 3));
|
||||
uint16_t realFloor = state.floor - 1;
|
||||
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
|
||||
addEnemy(EntityType::Bat, {3, 5}, 1);
|
||||
if (state.floor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
||||
if (realFloor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
||||
}
|
||||
|
||||
int DungeonModel::findEnemyAt(GameKit::GridPos pos) const {
|
||||
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||
const auto& entity = state.entities[i];
|
||||
if (entity.alive && entity.pos == pos) return i;
|
||||
const auto& e = state.entities[i];
|
||||
if (e.alive && e.pos == pos) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -70,11 +95,8 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
||||
if (enemyIdx >= 0) {
|
||||
Entity& enemy = state.entities[enemyIdx];
|
||||
enemy.hp -= 1;
|
||||
if (enemy.hp <= 0) {
|
||||
enemy.alive = false;
|
||||
state.gold += enemy.type == EntityType::Bat ? 2 : 1;
|
||||
}
|
||||
moveMonsters();
|
||||
if (enemy.hp <= 0) { enemy.alive = false; state.gold += enemy.type == EntityType::Bat ? 2 : 1; }
|
||||
if (!isTutorial()) moveMonsters();
|
||||
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||
return MoveResult::Attacked;
|
||||
}
|
||||
@@ -87,15 +109,19 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
||||
result = MoveResult::Treasure;
|
||||
} else if (tile == Tile::Stairs) {
|
||||
state.floor += 1;
|
||||
if (state.floor > bestFloor) bestFloor = state.floor;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
if (state.floor >= 2) {
|
||||
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
}
|
||||
generateFloor();
|
||||
return MoveResult::Stairs;
|
||||
}
|
||||
moveMonsters();
|
||||
if (!isTutorial()) moveMonsters();
|
||||
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||
if (state.floor > bestFloor) bestFloor = state.floor;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
if (state.floor >= 2) {
|
||||
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -105,26 +131,19 @@ void DungeonModel::moveMonsters() {
|
||||
if (!enemy.alive) continue;
|
||||
const int16_t dx = state.playerPos.x - enemy.pos.x;
|
||||
const int16_t dy = state.playerPos.y - enemy.pos.y;
|
||||
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) {
|
||||
state.hp -= 1;
|
||||
continue;
|
||||
}
|
||||
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) { state.hp -= 1; continue; }
|
||||
GameKit::GridPos target = enemy.pos;
|
||||
if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) {
|
||||
const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
|
||||
const int* dir = dirs[nextRandom() % 4];
|
||||
target.x += dir[0];
|
||||
target.y += dir[1];
|
||||
target.x += dir[0]; target.y += dir[1];
|
||||
} else if (LV_ABS(dx) > LV_ABS(dy)) {
|
||||
target.x += dx > 0 ? 1 : -1;
|
||||
} else if (dy != 0) {
|
||||
target.y += dy > 0 ? 1 : -1;
|
||||
}
|
||||
if (target == state.playerPos) {
|
||||
state.hp -= 1;
|
||||
} else if (isWalkable(target) && findEnemyAt(target) < 0) {
|
||||
enemy.pos = target;
|
||||
}
|
||||
if (target == state.playerPos) state.hp -= 1;
|
||||
else if (isWalkable(target) && findEnemyAt(target) < 0) enemy.pos = target;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#include <cstdint>
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
static constexpr uint8_t DUNGEON_COLS = 9;
|
||||
static constexpr uint8_t DUNGEON_ROWS = 7;
|
||||
static constexpr uint8_t TUTORIAL_ROWS = 2;
|
||||
static constexpr uint8_t MAX_ENTITIES = 8;
|
||||
|
||||
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
|
||||
@@ -25,10 +25,11 @@ struct DungeonState {
|
||||
Entity entities[MAX_ENTITIES] {};
|
||||
uint8_t entityCount = 0;
|
||||
GameKit::GridPos playerPos {1, 1};
|
||||
uint16_t floor = 1;
|
||||
uint16_t floor = 0; // 0,1 tutorial, 2+ real
|
||||
uint16_t gold = 0;
|
||||
int8_t hp = 5;
|
||||
bool gameOver = false;
|
||||
uint8_t renderRows = DUNGEON_ROWS;
|
||||
};
|
||||
|
||||
class DungeonModel {
|
||||
@@ -42,9 +43,11 @@ class DungeonModel {
|
||||
int findEnemyAt(GameKit::GridPos pos) const;
|
||||
bool isWalkable(GameKit::GridPos pos) const;
|
||||
void moveMonsters();
|
||||
void generateTutorialFloor(uint8_t tId);
|
||||
void clearAllTiles();
|
||||
|
||||
public:
|
||||
void reset(uint32_t seedValue = 0xC0FFEE);
|
||||
void reset(uint32_t seedValue = 0xC0FFEE, bool tutorials = true);
|
||||
void generateFloor();
|
||||
MoveResult movePlayer(int dx, int dy);
|
||||
|
||||
@@ -52,6 +55,9 @@ public:
|
||||
uint16_t getBestFloor() const { return bestFloor; }
|
||||
uint16_t getBestGold() const { return bestGold; }
|
||||
void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; }
|
||||
bool isTutorial() const { return state.floor < 2; }
|
||||
uint8_t tutorialId() const { return static_cast<uint8_t>(state.floor); }
|
||||
uint16_t displayFloor() const { return state.floor < 2 ? 1 : state.floor - 1; }
|
||||
};
|
||||
|
||||
} // namespace PocketDungeon
|
||||
|
||||
@@ -19,7 +19,7 @@ void DungeonRenderer::create(lv_obj_t* parent) {
|
||||
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||
status = lv_label_create(root);
|
||||
lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN);
|
||||
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 8);
|
||||
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 6);
|
||||
const lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
|
||||
const lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
|
||||
const uint16_t maxCellW = static_cast<uint16_t>((screenW - 22) / DUNGEON_COLS);
|
||||
@@ -48,8 +48,7 @@ lv_color_t DungeonRenderer::tileColor(Tile tile) {
|
||||
case Tile::Wall: return lv_color_hex(COLOR_WALL);
|
||||
case Tile::Stairs: return lv_color_hex(COLOR_STAIRS);
|
||||
case Tile::Treasure: return lv_color_hex(COLOR_TREASURE);
|
||||
case Tile::Floor:
|
||||
default: return lv_color_hex(COLOR_FLOOR);
|
||||
case Tile::Floor: default: return lv_color_hex(COLOR_FLOOR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,48 +64,64 @@ lv_color_t DungeonRenderer::entityColor(EntityType type) {
|
||||
switch (type) {
|
||||
case EntityType::Player: return lv_color_hex(COLOR_PLAYER);
|
||||
case EntityType::Bat: return lv_color_hex(COLOR_BAT);
|
||||
case EntityType::Slime:
|
||||
default: return lv_color_hex(COLOR_SLIME);
|
||||
case EntityType::Slime: default: return lv_color_hex(COLOR_SLIME);
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonRenderer::clearEntityLabels() {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
lv_label_set_text(labels[y][x], "");
|
||||
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) {
|
||||
const DungeonState& state = model.getState();
|
||||
char buf[96];
|
||||
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", state.floor, state.hp, state.gold, model.getBestFloor(), model.getBestGold());
|
||||
if (model.isTutorial()) {
|
||||
std::snprintf(buf, sizeof(buf), "TUTORIAL %u/2 HP:%d Gold:%u", model.tutorialId()+1, state.hp, state.gold);
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", model.displayFloor(), state.hp, state.gold, model.getBestFloor(), model.getBestGold());
|
||||
}
|
||||
lv_label_set_text(status, buf);
|
||||
clearEntityLabels();
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
bool visible = y < state.renderRows;
|
||||
if (!visible) {
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) lv_obj_add_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
|
||||
continue;
|
||||
}
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
lv_obj_clear_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
|
||||
const Tile tile = state.tiles[y][x];
|
||||
lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN);
|
||||
lv_label_set_text(labels[y][x], tileText(tile));
|
||||
}
|
||||
}
|
||||
if (state.renderRows == TUTORIAL_ROWS) {
|
||||
lv_obj_set_height(board, grid.cellPx * TUTORIAL_ROWS);
|
||||
lv_obj_align(board, LV_ALIGN_CENTER, 0, -10);
|
||||
} else {
|
||||
lv_obj_set_height(board, grid.rows * grid.cellPx);
|
||||
lv_obj_align(board, LV_ALIGN_CENTER, 0, 8);
|
||||
}
|
||||
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||
const Entity& entity = state.entities[i];
|
||||
if (!entity.alive) continue;
|
||||
const char* symbol = entity.type == EntityType::Bat ? "b" : "s";
|
||||
lv_label_set_text(labels[entity.pos.y][entity.pos.x], symbol);
|
||||
lv_obj_set_style_text_color(labels[entity.pos.y][entity.pos.x], entityColor(entity.type), LV_PART_MAIN);
|
||||
const Entity& e = state.entities[i];
|
||||
if (!e.alive) continue;
|
||||
const char* sym = e.type == EntityType::Bat ? "b" : "s";
|
||||
lv_label_set_text(labels[e.pos.y][e.pos.x], sym);
|
||||
lv_obj_set_style_text_color(labels[e.pos.y][e.pos.x], entityColor(e.type), LV_PART_MAIN);
|
||||
}
|
||||
lv_label_set_text(labels[state.playerPos.y][state.playerPos.x], "@");
|
||||
lv_obj_set_style_text_color(labels[state.playerPos.y][state.playerPos.x], lv_color_hex(COLOR_PLAYER), LV_PART_MAIN);
|
||||
const char* text = "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) {
|
||||
case MoveResult::Blocked: text = "A wall blocks the way"; break;
|
||||
case MoveResult::Attacked: text = "You strike the monster"; break;
|
||||
case MoveResult::Treasure: text = "Treasure found!"; break;
|
||||
case MoveResult::Stairs: text = "Down the stairs..."; break;
|
||||
case MoveResult::Blocked: text = "Wall blocks"; break;
|
||||
case MoveResult::Attacked: text = "You strike!"; break;
|
||||
case MoveResult::Treasure: text = "Treasure! +5"; break;
|
||||
case MoveResult::Stairs: text = model.isTutorial() ? "Tutorial done!" : "Down..."; break;
|
||||
case MoveResult::Dead: text = "Game over - tap to restart"; break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "PocketDungeon.h"
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <cstdio>
|
||||
extern "C" {
|
||||
#include <tt_app.h>
|
||||
}
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
@@ -7,19 +10,26 @@ static constexpr uint32_t TICK_MS = 200;
|
||||
static constexpr const char* PREF_BEST_FLOOR = "bestFloor";
|
||||
static constexpr const char* PREF_BEST_GOLD = "bestGold";
|
||||
|
||||
static constexpr uint32_t COLOR_BG = 0x10111A;
|
||||
static constexpr uint32_t COLOR_PANEL = 0x1D2030;
|
||||
static constexpr uint32_t COLOR_ACCENT = 0x365CF5;
|
||||
static constexpr uint32_t COLOR_OVERLAY = 0x000000;
|
||||
|
||||
void PocketDungeon::onShow(AppHandle app, lv_obj_t* parent) {
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||
root = parent;
|
||||
(void) tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
model.setBests(static_cast<uint16_t>(prefs.getInt(PREF_BEST_FLOOR, 1)), static_cast<uint16_t>(prefs.getInt(PREF_BEST_GOLD, 0)));
|
||||
model.reset(lv_tick_get());
|
||||
model.reset(lv_tick_get(), true);
|
||||
if (sfx == nullptr) {
|
||||
sfx = new SfxEngine();
|
||||
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
||||
}
|
||||
phase = AppPhase::Intro;
|
||||
onEnter(parent);
|
||||
loop.start(TICK_MS, this);
|
||||
(void) app;
|
||||
}
|
||||
|
||||
void PocketDungeon::onHide(AppHandle app) {
|
||||
@@ -27,63 +37,300 @@ void PocketDungeon::onHide(AppHandle app) {
|
||||
loop.stop();
|
||||
onExit();
|
||||
saveBests();
|
||||
if (sfx != nullptr) {
|
||||
sfx->stop();
|
||||
delete sfx;
|
||||
sfx = nullptr;
|
||||
}
|
||||
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; }
|
||||
root = nullptr;
|
||||
}
|
||||
|
||||
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
|
||||
renderer.create(sceneRoot);
|
||||
GameKit::attachInput(sceneRoot, this);
|
||||
root = sceneRoot;
|
||||
GameKit::attachInput(root, this);
|
||||
showIntro();
|
||||
lastResult = MoveResult::None;
|
||||
}
|
||||
|
||||
void PocketDungeon::onExit() { clearIntro(); clearGame(); }
|
||||
|
||||
void PocketDungeon::attachInputToCurrent() {
|
||||
if (root) GameKit::attachInput(root, this);
|
||||
if (introContainer) {
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
GameKit::attachInput(introContainer, this);
|
||||
}
|
||||
if (gameContainer) {
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
GameKit::attachInput(gameContainer, this);
|
||||
lv_obj_add_event_cb(gameContainer, onGameContainerLongPress, LV_EVENT_LONG_PRESSED, this);
|
||||
}
|
||||
if (pauseOverlay) {
|
||||
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
|
||||
GameKit::attachInput(pauseOverlay, this);
|
||||
}
|
||||
}
|
||||
|
||||
void PocketDungeon::clearIntro() { if (introContainer) { lv_obj_delete(introContainer); introContainer = nullptr; } }
|
||||
void PocketDungeon::clearGame() {
|
||||
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
|
||||
if (gameContainer) { lv_obj_delete(gameContainer); gameContainer = nullptr; }
|
||||
}
|
||||
|
||||
void PocketDungeon::showIntro() {
|
||||
clearGame(); clearIntro();
|
||||
phase = AppPhase::Intro;
|
||||
introContainer = lv_obj_create(root);
|
||||
lv_obj_set_size(introContainer, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(introContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(introContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(introContainer, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||
lv_obj_remove_flag(introContainer, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(introContainer, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(introContainer, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(introContainer, 6, LV_PART_MAIN);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
renderIntro();
|
||||
attachInputToCurrent();
|
||||
}
|
||||
|
||||
void PocketDungeon::showGame() {
|
||||
clearIntro(); clearGame();
|
||||
phase = AppPhase::Playing;
|
||||
gameContainer = lv_obj_create(root);
|
||||
lv_obj_set_size(gameContainer, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(gameContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(gameContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(gameContainer, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(gameContainer, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
renderer.create(gameContainer);
|
||||
lv_obj_t* pauseBtn = lv_btn_create(gameContainer);
|
||||
lv_obj_set_size(pauseBtn, 32, 26);
|
||||
lv_obj_set_style_bg_color(pauseBtn, lv_color_hex(0x2A2D40), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(pauseBtn, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(pauseBtn, 0, LV_PART_MAIN);
|
||||
lv_obj_align(pauseBtn, LV_ALIGN_TOP_RIGHT, -6, 4);
|
||||
lv_obj_add_event_cb(pauseBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* pauseLbl = lv_label_create(pauseBtn);
|
||||
lv_label_set_text(pauseLbl, LV_SYMBOL_CLOSE);
|
||||
lv_obj_center(pauseLbl);
|
||||
lastResult = MoveResult::None;
|
||||
attachInputToCurrent();
|
||||
render();
|
||||
if (sfx) sfx->play(SfxId::Warp);
|
||||
}
|
||||
|
||||
void PocketDungeon::showPause() {
|
||||
if (pauseOverlay) return;
|
||||
phase = AppPhase::Paused;
|
||||
pauseOverlay = lv_obj_create(root);
|
||||
lv_obj_set_size(pauseOverlay, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(pauseOverlay, 12, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(pauseOverlay, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(pauseOverlay, lv_color_hex(COLOR_OVERLAY), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(pauseOverlay, LV_OPA_70, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(pauseOverlay, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(pauseOverlay, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(pauseOverlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(pauseOverlay, 10, LV_PART_MAIN);
|
||||
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_t* box = lv_obj_create(pauseOverlay);
|
||||
lv_obj_set_width(box, LV_PCT(80));
|
||||
lv_obj_set_height(box, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(box, 14, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(box, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(box, 12, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(box, 10, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* t = lv_label_create(box);
|
||||
lv_label_set_text(t, "Paused");
|
||||
lv_obj_set_style_text_color(t, lv_color_white(), LV_PART_MAIN);
|
||||
lv_obj_t* resumeBtn = lv_btn_create(box);
|
||||
lv_obj_set_width(resumeBtn, LV_PCT(100));
|
||||
lv_obj_set_height(resumeBtn, 38);
|
||||
lv_obj_set_style_bg_color(resumeBtn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(resumeBtn, 10, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(resumeBtn, onResumeButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* rl = lv_label_create(resumeBtn);
|
||||
lv_label_set_text(rl, "Resume");
|
||||
lv_obj_center(rl);
|
||||
lv_obj_t* exitBtn = lv_btn_create(box);
|
||||
lv_obj_set_width(exitBtn, LV_PCT(100));
|
||||
lv_obj_set_height(exitBtn, 36);
|
||||
lv_obj_set_style_bg_color(exitBtn, lv_color_hex(0x2E303F), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(exitBtn, 10, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(exitBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* el = lv_label_create(exitBtn);
|
||||
lv_label_set_text(el, "Exit Game");
|
||||
lv_obj_center(el);
|
||||
if (sfx) sfx->play(SfxId::MenuOpen);
|
||||
attachInputToCurrent();
|
||||
}
|
||||
|
||||
void PocketDungeon::hidePause() {
|
||||
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
|
||||
phase = AppPhase::Playing;
|
||||
if (sfx) sfx->play(SfxId::MenuClose);
|
||||
attachInputToCurrent();
|
||||
render();
|
||||
}
|
||||
|
||||
void PocketDungeon::onExit() {}
|
||||
void PocketDungeon::exitGame() {
|
||||
saveBests();
|
||||
if (sfx) sfx->play(SfxId::Cancel);
|
||||
tt_app_stop();
|
||||
}
|
||||
|
||||
void PocketDungeon::renderIntro() {
|
||||
if (!introContainer) return;
|
||||
lv_obj_t* titleRow = lv_obj_create(introContainer);
|
||||
lv_obj_set_width(titleRow, LV_PCT(100));
|
||||
lv_obj_set_height(titleRow, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(titleRow, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(titleRow, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(titleRow, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(titleRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(titleRow, 2, LV_PART_MAIN);
|
||||
lv_obj_t* title = lv_label_create(titleRow);
|
||||
lv_label_set_text(title, "POCKET DUNGEON");
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(0xF8F3E6), LV_PART_MAIN);
|
||||
lv_obj_t* subtitle = lv_label_create(titleRow);
|
||||
lv_label_set_text(subtitle, "Tiny paper roguelike");
|
||||
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x8A9FBF), LV_PART_MAIN);
|
||||
char bestBuf[64];
|
||||
std::snprintf(bestBuf, sizeof(bestBuf), "Best F%u G%u", model.getBestFloor(), model.getBestGold());
|
||||
lv_obj_t* best = lv_label_create(titleRow);
|
||||
lv_label_set_text(best, bestBuf);
|
||||
lv_obj_set_style_text_color(best, lv_color_hex(0x7A8196), LV_PART_MAIN);
|
||||
|
||||
lv_obj_t* mainRow = lv_obj_create(introContainer);
|
||||
lv_obj_set_width(mainRow, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(mainRow, 1);
|
||||
lv_obj_set_style_pad_all(mainRow, 4, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_column(mainRow, 8, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_bottom(mainRow, 8, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(mainRow, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(mainRow, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(mainRow, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(mainRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
lv_obj_remove_flag(mainRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* leftCol = lv_obj_create(mainRow);
|
||||
lv_obj_set_flex_grow(leftCol, 1);
|
||||
lv_obj_set_height(leftCol, LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(leftCol, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_right(leftCol, 4, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_bottom(leftCol, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(leftCol, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(leftCol, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(leftCol, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(leftCol, 8, LV_PART_MAIN);
|
||||
lv_obj_add_flag(leftCol, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
auto makeCard = [](lv_obj_t* parent, const char* tTitle, const char* body, uint32_t col) {
|
||||
lv_obj_t* card = lv_obj_create(parent);
|
||||
lv_obj_set_width(card, LV_PCT(100));
|
||||
lv_obj_set_height(card, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(card, 9, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(card, 3, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(card, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(card, 10, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* t = lv_label_create(card);
|
||||
lv_label_set_text(t, tTitle);
|
||||
lv_obj_set_style_text_color(t, lv_color_hex(col), LV_PART_MAIN);
|
||||
lv_obj_t* b = lv_label_create(card);
|
||||
lv_label_set_text(b, body);
|
||||
lv_obj_set_style_text_color(b, lv_color_hex(0xD9DDE8), LV_PART_MAIN);
|
||||
lv_label_set_long_mode(b, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(b, LV_PCT(100));
|
||||
return card;
|
||||
};
|
||||
|
||||
makeCard(leftCol, "GOAL", "Reach deepest floor.\nCollect gold, survive.", 0xD99B23);
|
||||
makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit", 0x6D8CFF);
|
||||
|
||||
lv_obj_t* rightRail = lv_obj_create(mainRow);
|
||||
lv_obj_set_width(rightRail, 44);
|
||||
lv_obj_set_height(rightRail, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(rightRail, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(rightRail, 10, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(rightRail, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(rightRail, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(rightRail, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(rightRail, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_remove_flag(rightRail, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
auto makeIconBtn = [](lv_obj_t* parent, const char* symbol, uint32_t bg, lv_event_cb_t cb, void* user, int s) -> lv_obj_t* {
|
||||
lv_obj_t* btn = lv_btn_create(parent);
|
||||
lv_obj_set_size(btn, s, s);
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(btn, s / 3, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user);
|
||||
lv_obj_t* lbl = lv_label_create(btn);
|
||||
lv_label_set_text(lbl, symbol);
|
||||
lv_obj_center(lbl);
|
||||
lv_obj_set_style_text_color(lbl, lv_color_white(), LV_PART_MAIN);
|
||||
return btn;
|
||||
};
|
||||
|
||||
makeIconBtn(rightRail, LV_SYMBOL_PLAY, COLOR_ACCENT, onStartButtonClicked, this, 38);
|
||||
makeIconBtn(rightRail, LV_SYMBOL_CLOSE, 0x252836, onExitButtonClicked, this, 30);
|
||||
}
|
||||
|
||||
void PocketDungeon::onStartButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->showGame(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onResumeButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->hidePause(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onExitButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->exitGame(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onGameContainerLongPress(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s && s->phase==AppPhase::Playing) s->showPause(); }
|
||||
|
||||
void PocketDungeon::onInput(const GameKit::InputEvent& input) {
|
||||
int dx = 0;
|
||||
int dy = 0;
|
||||
if (input.kind == GameKit::InputKind::Cancel || input.kind == GameKit::InputKind::Menu) { exitGame(); return; }
|
||||
if (phase == AppPhase::Intro) { if (input.kind != GameKit::InputKind::Unknown) showGame(); return; }
|
||||
if (phase == AppPhase::Paused) { if (input.kind != GameKit::InputKind::Unknown) hidePause(); return; }
|
||||
int dx=0, dy=0;
|
||||
switch (input.kind) {
|
||||
case GameKit::InputKind::Up: dy = -1; break;
|
||||
case GameKit::InputKind::Down: dy = 1; break;
|
||||
case GameKit::InputKind::Left: dx = -1; break;
|
||||
case GameKit::InputKind::Right: dx = 1; break;
|
||||
case GameKit::InputKind::Up: dy=-1; break;
|
||||
case GameKit::InputKind::Down: dy=1; break;
|
||||
case GameKit::InputKind::Left: dx=-1; break;
|
||||
case GameKit::InputKind::Right: dx=1; break;
|
||||
default: break;
|
||||
}
|
||||
if (model.getState().gameOver) {
|
||||
model.reset(lv_tick_get());
|
||||
lastResult = MoveResult::None;
|
||||
render();
|
||||
if (dx!=0||dy!=0||input.kind==GameKit::InputKind::Confirm||input.kind==GameKit::InputKind::Touch) {
|
||||
model.reset(lv_tick_get(), true); lastResult=MoveResult::None; render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dx != 0 || dy != 0) {
|
||||
lastResult = model.movePlayer(dx, dy);
|
||||
if (dx!=0||dy!=0) {
|
||||
lastResult=model.movePlayer(dx,dy);
|
||||
playResultSound(lastResult);
|
||||
if (lastResult == MoveResult::Dead) saveBests();
|
||||
if (lastResult==MoveResult::Dead) saveBests();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
void PocketDungeon::onTick(const GameKit::Tick& tick) {
|
||||
(void) tick;
|
||||
}
|
||||
|
||||
void PocketDungeon::render() {
|
||||
void PocketDungeon::onTick(const GameKit::Tick& tick){ (void)tick; }
|
||||
void PocketDungeon::render(){
|
||||
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return;
|
||||
if (!gameContainer) return;
|
||||
renderer.render(model, lastResult);
|
||||
}
|
||||
|
||||
void PocketDungeon::saveBests() {
|
||||
void PocketDungeon::saveBests(){
|
||||
prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor());
|
||||
prefs.putInt(PREF_BEST_GOLD, model.getBestGold());
|
||||
}
|
||||
|
||||
void PocketDungeon::playResultSound(MoveResult result) {
|
||||
if (sfx == nullptr) return;
|
||||
switch (result) {
|
||||
void PocketDungeon::playResultSound(MoveResult r){
|
||||
if (!sfx) return;
|
||||
switch(r){
|
||||
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
|
||||
case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
|
||||
case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
enum class AppPhase : uint8_t { Intro, Playing, Paused };
|
||||
|
||||
class PocketDungeon final : public App, public GameKit::Scene {
|
||||
lv_obj_t* root = nullptr;
|
||||
lv_obj_t* introContainer = nullptr;
|
||||
lv_obj_t* gameContainer = nullptr;
|
||||
lv_obj_t* pauseOverlay = nullptr;
|
||||
AppPhase phase = AppPhase::Intro;
|
||||
|
||||
GameKit::Loop loop;
|
||||
GameKit::Prefs prefs {"PocketDungeon"};
|
||||
DungeonModel model;
|
||||
@@ -20,6 +27,20 @@ class PocketDungeon final : public App, public GameKit::Scene {
|
||||
|
||||
void playResultSound(MoveResult result);
|
||||
void saveBests();
|
||||
void showIntro();
|
||||
void showGame();
|
||||
void showPause();
|
||||
void hidePause();
|
||||
void clearIntro();
|
||||
void clearGame();
|
||||
void renderIntro();
|
||||
void attachInputToCurrent();
|
||||
void exitGame();
|
||||
|
||||
static void onStartButtonClicked(lv_event_t* e);
|
||||
static void onResumeButtonClicked(lv_event_t* e);
|
||||
static void onExitButtonClicked(lv_event_t* e);
|
||||
static void onGameContainerLongPress(lv_event_t* e);
|
||||
|
||||
public:
|
||||
void onShow(AppHandle app, lv_obj_t* parent) override;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.pocketdungeon
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Pocket Dungeon
|
||||
description=Tiny paper-inspired dungeon crawler
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3,esp32p4
|
||||
app.id=one.tactility.pocketdungeon
|
||||
app.version.name=0.1.2
|
||||
app.version.code=3
|
||||
app.name=Pocket Dungeon
|
||||
app.description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
|
||||
app.flags=HideStatusBar
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tt_wifi.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
#include "websocket.h"
|
||||
#include <cJSON.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
|
||||
/* Exported by Tactility firmware 0.8.0-dev; absent from the CDN SDK header. */
|
||||
struct Device* device_find_by_name(const char* name);
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_random.h"
|
||||
|
||||
#define TAG "ReynaBot"
|
||||
#define AUDIO_SAMPLE_RATE 16000U
|
||||
#define AUDIO_BITS_PER_SAMPLE 16U
|
||||
#define MAX_PCM_CHUNK_BYTES 16384U
|
||||
#define MAX_RESPONSE_AUDIO_BYTES 65536U
|
||||
|
||||
typedef enum {
|
||||
STATE_IDLE,
|
||||
@@ -64,10 +72,19 @@ typedef struct {
|
||||
TaskHandle_t worker_task;
|
||||
TaskHandle_t rx_task;
|
||||
|
||||
// Hardware
|
||||
struct Device* i2s_dev;
|
||||
|
||||
// WebSocket
|
||||
// Hardware/audio service
|
||||
struct Device* audio_stream_dev;
|
||||
AudioStreamHandle input_handle;
|
||||
AudioStreamHandle output_handle;
|
||||
uint32_t output_sample_rate;
|
||||
uint8_t output_channels;
|
||||
uint8_t output_bits_per_sample;
|
||||
bool output_stream_pending;
|
||||
size_t expected_audio_bytes;
|
||||
uint32_t expected_audio_rate;
|
||||
uint8_t expected_audio_channels;
|
||||
uint8_t expected_audio_bits;
|
||||
bool stop_sent;
|
||||
int ws_fd;
|
||||
bool ws_connected;
|
||||
bool ws_done;
|
||||
@@ -81,6 +98,77 @@ static void reynabot_rx_task(void* arg);
|
||||
static void update_ui(ReynaBotCtx* ctx);
|
||||
static void load_config(ReynaBotCtx* ctx);
|
||||
|
||||
static bool find_audio_stream_device(ReynaBotCtx* ctx) {
|
||||
struct Device* dev = device_find_by_name("audio-stream");
|
||||
if (dev != NULL) {
|
||||
ctx->audio_stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
// The 0.8.0-dev firmware registers this shared service by name. The
|
||||
// CDN SDK headers do not expose the deprecated type-search helper, so do
|
||||
// not reference it from a dynamically linked app.
|
||||
return false;
|
||||
}
|
||||
|
||||
static void close_input_stream(ReynaBotCtx* ctx) {
|
||||
if (ctx->input_handle != NULL) {
|
||||
audio_stream_close(ctx->input_handle);
|
||||
ctx->input_handle = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void close_output_stream(ReynaBotCtx* ctx) {
|
||||
if (ctx->output_handle != NULL) {
|
||||
audio_stream_close(ctx->output_handle);
|
||||
ctx->output_handle = NULL;
|
||||
}
|
||||
ctx->output_stream_pending = false;
|
||||
}
|
||||
|
||||
static bool open_input_stream(ReynaBotCtx* ctx) {
|
||||
if (ctx->audio_stream_dev == NULL) return false;
|
||||
close_input_stream(ctx);
|
||||
struct AudioStreamConfig config = {
|
||||
.sample_rate = 16000,
|
||||
.bits_per_sample = 16,
|
||||
.channels = 1,
|
||||
};
|
||||
error_t err = audio_stream_open_input(ctx->audio_stream_dev, &config, &ctx->input_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ctx->input_handle = NULL;
|
||||
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
|
||||
return false;
|
||||
}
|
||||
audio_stream_set_mute(ctx->audio_stream_dev, AUDIO_CODEC_DIR_INPUT, false);
|
||||
audio_stream_set_volume(ctx->audio_stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
|
||||
ESP_LOGI(TAG, "Microphone opened via audio-stream at 16000 Hz mono");
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool open_output_stream(ReynaBotCtx* ctx, uint32_t sample_rate, uint8_t channels, uint8_t bits_per_sample) {
|
||||
if (ctx->audio_stream_dev == NULL || sample_rate == 0 || channels == 0 || bits_per_sample != 16) return false;
|
||||
close_output_stream(ctx);
|
||||
struct AudioStreamConfig config = {
|
||||
.sample_rate = sample_rate,
|
||||
.bits_per_sample = bits_per_sample,
|
||||
.channels = channels,
|
||||
};
|
||||
error_t err = audio_stream_open_output(ctx->audio_stream_dev, &config, &ctx->output_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ctx->output_handle = NULL;
|
||||
ESP_LOGE(TAG, "audio_stream_open_output failed: %d rate=%u ch=%u", err, (unsigned)sample_rate, channels);
|
||||
return false;
|
||||
}
|
||||
audio_stream_set_mute(ctx->audio_stream_dev, AUDIO_CODEC_DIR_OUTPUT, false);
|
||||
audio_stream_set_volume(ctx->audio_stream_dev, AUDIO_CODEC_DIR_OUTPUT, 100.0f);
|
||||
ctx->output_sample_rate = sample_rate;
|
||||
ctx->output_channels = channels;
|
||||
ctx->output_bits_per_sample = bits_per_sample;
|
||||
ctx->output_stream_pending = true;
|
||||
ESP_LOGI(TAG, "Speaker opened via audio-stream at %u Hz mono", (unsigned)sample_rate);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ─── Helper for URL parsing ─── */
|
||||
static bool parse_ws_url(const char* url, char* host, int* port, char* path) {
|
||||
if (strncmp(url, "ws://", 5) != 0) return false;
|
||||
@@ -111,9 +199,9 @@ static bool parse_ws_url(const char* url, char* host, int* port, char* path) {
|
||||
/* ─── Config Loading/Saving ─── */
|
||||
static void load_config(ReynaBotCtx* ctx) {
|
||||
// Default fallback config
|
||||
snprintf(ctx->server_url, sizeof(ctx->server_url), "ws://192.168.68.126:8643/api/esp32/voice/ws");
|
||||
snprintf(ctx->server_url, sizeof(ctx->server_url), "ws://192.168.68.112:8643/api/esp32/voice/ws");
|
||||
snprintf(ctx->device_id, sizeof(ctx->device_id), "reynabot_screen");
|
||||
snprintf(ctx->api_key, sizeof(ctx->api_key), "hmek_sXB7921bZ9FXTVKARExqUZ7ttBxtEoURHRU0JCB-gNY");
|
||||
ctx->api_key[0] = '\0';
|
||||
|
||||
char path[256];
|
||||
size_t path_size = sizeof(path);
|
||||
@@ -130,7 +218,7 @@ static void load_config(ReynaBotCtx* ctx) {
|
||||
// Write default config file
|
||||
file = fopen(path, "w");
|
||||
if (file != NULL) {
|
||||
fprintf(file, "{\n \"server_url\": \"ws://192.168.68.126:8643/api/esp32/voice/ws\",\n \"device_id\": \"reynabot_screen\",\n \"api_key\": \"mcT1YA1vOr9wXSiHpCYalweEGGZKX-PIfZv2drp8BSg\"\n}\n");
|
||||
fprintf(file, "{\n \"server_url\": \"ws://192.168.68.112:8643/api/esp32/voice/ws\",\n \"device_id\": \"reynabot_screen\",\n \"api_key\": \"\"\n}\n");
|
||||
fclose(file);
|
||||
}
|
||||
ESP_LOGI(TAG, "Created default config.json at %s", path);
|
||||
@@ -181,6 +269,21 @@ static void parse_json_message(ReynaBotCtx* ctx, const char* json_str) {
|
||||
|
||||
if (strcmp(evt, "ready") == 0) {
|
||||
ESP_LOGI(TAG, "Server ready");
|
||||
} else if (strcmp(evt, "state") == 0) {
|
||||
cJSON* state_item = cJSON_GetObjectItem(json, "state");
|
||||
if (state_item != NULL && cJSON_IsString(state_item)) {
|
||||
if (strcmp(state_item->valuestring, "listening") == 0) {
|
||||
if (ctx->stop_sent) {
|
||||
ctx->ws_done = true;
|
||||
} else {
|
||||
ctx->state = STATE_LISTENING;
|
||||
}
|
||||
ctx->ui_update_pending = true;
|
||||
} else if (strcmp(state_item->valuestring, "processing") == 0) {
|
||||
ctx->state = STATE_THINKING;
|
||||
ctx->ui_update_pending = true;
|
||||
}
|
||||
}
|
||||
} else if (strcmp(evt, "listening") == 0) {
|
||||
ctx->state = STATE_LISTENING;
|
||||
ctx->ui_update_pending = true;
|
||||
@@ -199,11 +302,42 @@ static void parse_json_message(ReynaBotCtx* ctx, const char* json_str) {
|
||||
strncpy(ctx->last_response, txt_item->valuestring, sizeof(ctx->last_response) - 1);
|
||||
ctx->ui_update_pending = true;
|
||||
}
|
||||
} else if (strcmp(evt, "audio") == 0) {
|
||||
cJSON* format_item = cJSON_GetObjectItem(json, "format");
|
||||
cJSON* rate_item = cJSON_GetObjectItem(json, "sample_rate");
|
||||
cJSON* channels_item = cJSON_GetObjectItem(json, "channels");
|
||||
cJSON* width_item = cJSON_GetObjectItem(json, "sample_width");
|
||||
cJSON* length_item = cJSON_GetObjectItem(json, "byte_length");
|
||||
bool valid = format_item != NULL && cJSON_IsString(format_item) &&
|
||||
strcmp(format_item->valuestring, "pcm_s16le") == 0 &&
|
||||
rate_item != NULL && cJSON_IsNumber(rate_item) && rate_item->valueint > 0 && rate_item->valueint <= 48000 &&
|
||||
channels_item != NULL && cJSON_IsNumber(channels_item) && channels_item->valueint == 1 &&
|
||||
width_item != NULL && cJSON_IsNumber(width_item) && width_item->valueint == 2 &&
|
||||
length_item != NULL && cJSON_IsNumber(length_item) && length_item->valueint > 0 &&
|
||||
length_item->valueint <= MAX_RESPONSE_AUDIO_BYTES && (length_item->valueint % 2) == 0;
|
||||
if (valid) {
|
||||
ctx->expected_audio_rate = (uint32_t)rate_item->valueint;
|
||||
ctx->expected_audio_channels = (uint8_t)channels_item->valueint;
|
||||
ctx->expected_audio_bits = (uint8_t)width_item->valueint;
|
||||
ctx->expected_audio_bytes = (size_t)length_item->valueint;
|
||||
if (!open_output_stream(ctx, ctx->expected_audio_rate, ctx->expected_audio_channels, ctx->expected_audio_bits)) {
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Audio output unavailable");
|
||||
ctx->state = STATE_ERROR;
|
||||
ctx->expected_audio_bytes = 0;
|
||||
} else {
|
||||
ctx->state = STATE_SPEAKING;
|
||||
}
|
||||
ctx->ui_update_pending = true;
|
||||
} else {
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Invalid audio metadata");
|
||||
ctx->state = STATE_ERROR;
|
||||
ctx->expected_audio_bytes = 0;
|
||||
ctx->ui_update_pending = true;
|
||||
}
|
||||
} else if (strcmp(evt, "audio_start") == 0) {
|
||||
/* Legacy event: metadata must still arrive as `audio` before binary PCM. */
|
||||
ctx->state = STATE_SPEAKING;
|
||||
ctx->ui_update_pending = true;
|
||||
|
||||
// I2S is already configured globally at session startup
|
||||
} else if (strcmp(evt, "audio_end") == 0) {
|
||||
ESP_LOGI(TAG, "Audio response ended");
|
||||
} else if (strcmp(evt, "done") == 0) {
|
||||
@@ -225,7 +359,7 @@ static void parse_json_message(ReynaBotCtx* ctx, const char* json_str) {
|
||||
/* ─── WebSocket RX (Receive) Task ─── */
|
||||
static void reynabot_rx_task(void* arg) {
|
||||
ReynaBotCtx* ctx = (ReynaBotCtx*)arg;
|
||||
uint8_t* rx_buf = malloc(8192);
|
||||
uint8_t* rx_buf = malloc(MAX_RESPONSE_AUDIO_BYTES + 1U);
|
||||
if (rx_buf == NULL) {
|
||||
ESP_LOGE(TAG, "Failed to allocate RX buffer");
|
||||
ctx->ws_done = true;
|
||||
@@ -238,7 +372,7 @@ static void reynabot_rx_task(void* arg) {
|
||||
ESP_LOGI(TAG, "WS Receive task started");
|
||||
|
||||
while (ctx->ws_fd >= 0) {
|
||||
int r = ws_recv(ctx->ws_fd, &opcode, rx_buf, 8191);
|
||||
int r = ws_recv(ctx->ws_fd, &opcode, rx_buf, MAX_RESPONSE_AUDIO_BYTES);
|
||||
if (r < 0) {
|
||||
if (r == -1) {
|
||||
ESP_LOGI(TAG, "WS connection closed or read error, errno=%d", errno);
|
||||
@@ -252,21 +386,31 @@ static void reynabot_rx_task(void* arg) {
|
||||
if (opcode == 0x01) { // Text frame (JSON)
|
||||
rx_buf[r] = '\0';
|
||||
parse_json_message(ctx, (char*)rx_buf);
|
||||
} else if (opcode == 0x02) { // Binary frame (Audio data)
|
||||
if (ctx->state == STATE_SPEAKING && ctx->i2s_dev != NULL) {
|
||||
const uint8_t* payload_ptr = rx_buf;
|
||||
size_t payload_len = r;
|
||||
|
||||
// Skip WAV header if present in the first chunk
|
||||
if (payload_len > 44 && memcmp(payload_ptr, "RIFF", 4) == 0) {
|
||||
payload_ptr += 44;
|
||||
payload_len -= 44;
|
||||
} else if (opcode == 0x02) { // Audio response PCM
|
||||
if (ctx->output_handle == NULL || ctx->expected_audio_bytes == 0 || (size_t)r != ctx->expected_audio_bytes) {
|
||||
ESP_LOGE(TAG, "Audio frame does not match metadata: got=%d expected=%u", r, (unsigned)ctx->expected_audio_bytes);
|
||||
ctx->state = STATE_ERROR;
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Invalid audio frame");
|
||||
ctx->ui_update_pending = true;
|
||||
} else {
|
||||
size_t written_total = 0;
|
||||
while (written_total < (size_t)r) {
|
||||
size_t written = 0;
|
||||
error_t err = audio_stream_write(ctx->output_handle, rx_buf + written_total,
|
||||
(size_t)r - written_total, &written, pdMS_TO_TICKS(3000));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "audio_stream_write failed: %d written=%u", err, (unsigned)written);
|
||||
ctx->state = STATE_ERROR;
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Audio playback failed");
|
||||
ctx->ui_update_pending = true;
|
||||
break;
|
||||
}
|
||||
written_total += written;
|
||||
}
|
||||
|
||||
size_t written = 0;
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_write(ctx->i2s_dev, payload_ptr, payload_len, &written, pdMS_TO_TICKS(100));
|
||||
device_unlock(ctx->i2s_dev);
|
||||
close_output_stream(ctx);
|
||||
ctx->expected_audio_bytes = 0;
|
||||
ctx->state = STATE_THINKING;
|
||||
ctx->ui_update_pending = true;
|
||||
}
|
||||
} else if (opcode == 0x09) { // PING frame
|
||||
ESP_LOGI(TAG, "WS PING received, sending PONG");
|
||||
@@ -408,8 +552,11 @@ static void reynabot_task(void* arg) {
|
||||
ctx->start_session = false;
|
||||
ctx->stop_session = false;
|
||||
ctx->cancel_session = false;
|
||||
ctx->stop_sent = false;
|
||||
ctx->expected_audio_bytes = 0;
|
||||
close_input_stream(ctx);
|
||||
close_output_stream(ctx);
|
||||
|
||||
ctx->state = STATE_CONNECTING;
|
||||
ctx->last_transcript[0] = '\0';
|
||||
ctx->last_response[0] = '\0';
|
||||
ctx->error_message[0] = '\0';
|
||||
@@ -435,28 +582,17 @@ static void reynabot_task(void* arg) {
|
||||
ctx->ws_fd = fd;
|
||||
ctx->ws_connected = true;
|
||||
|
||||
// Configure I2S on demand for this session (16 kHz, 16-bit, mono)
|
||||
struct I2sConfig session_cfg = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
.sample_rate = 16000,
|
||||
.bits_per_sample = 16,
|
||||
.channel_left = 0,
|
||||
.channel_right = I2S_CHANNEL_NONE
|
||||
};
|
||||
if (ctx->i2s_dev != NULL) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_set_config(ctx->i2s_dev, &session_cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
|
||||
// Spawn background RX task to read and parse events
|
||||
// Spawn background RX task to read and parse events.
|
||||
xTaskCreate(reynabot_rx_task, "reynabot_rx", 4096, ctx, 6, &ctx->rx_task);
|
||||
|
||||
// Send start event handshake
|
||||
char start_json[256];
|
||||
// The Kids LAN adapter requires protocol v1 and a fresh session id.
|
||||
char session_id[80];
|
||||
snprintf(session_id, sizeof(session_id), "reynabot-%08x%08x",
|
||||
(unsigned)esp_random(), (unsigned)esp_random());
|
||||
char start_json[384];
|
||||
snprintf(start_json, sizeof(start_json),
|
||||
"{\"event\":\"start\",\"device_id\":\"%s\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2,\"format\":\"pcm_s16le\"}",
|
||||
ctx->device_id);
|
||||
"{\"v\":1,\"event\":\"start\",\"session_id\":\"%s\",\"device_id\":\"%s\",\"audio\":{\"format\":\"pcm_s16le\",\"sample_rate\":16000,\"channels\":1,\"sample_width\":2}}",
|
||||
session_id, ctx->device_id);
|
||||
if (ws_send(ctx->ws_fd, (const uint8_t*)start_json, strlen(start_json), false) < 0) {
|
||||
ctx->state = STATE_ERROR;
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Handshake send failed");
|
||||
@@ -486,11 +622,20 @@ static void reynabot_task(void* arg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// I2S is already configured globally at session startup
|
||||
if (!open_input_stream(ctx)) {
|
||||
ctx->state = STATE_ERROR;
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Microphone unavailable");
|
||||
ws_close(ctx->ws_fd);
|
||||
ctx->ws_fd = -1;
|
||||
ctx->ws_connected = false;
|
||||
update_ui(ctx);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint8_t* buffer = malloc(1024);
|
||||
uint8_t* buffer = malloc(MAX_PCM_CHUNK_BYTES);
|
||||
if (buffer == NULL) {
|
||||
ESP_LOGE(TAG, "Failed to allocate record buffer");
|
||||
close_input_stream(ctx);
|
||||
ctx->state = STATE_ERROR;
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "Out of memory");
|
||||
ws_close(ctx->ws_fd);
|
||||
@@ -501,11 +646,12 @@ static void reynabot_task(void* arg) {
|
||||
}
|
||||
size_t total_sent_bytes = 0;
|
||||
|
||||
// Stream audio loop while PTT is held
|
||||
// Stream 16 kHz mono PCM while PTT is held.
|
||||
while (ctx->is_pressed && !ctx->stop_session && !ctx->cancel_session && !ctx->ws_done && total_sent_bytes < 320000) {
|
||||
size_t bytes_read = 0;
|
||||
error_t r = i2s_controller_read(ctx->i2s_dev, buffer, 1024, &bytes_read, pdMS_TO_TICKS(100));
|
||||
if (r == ERROR_NONE && bytes_read > 0) {
|
||||
error_t r = audio_stream_read(ctx->input_handle, buffer, MAX_PCM_CHUNK_BYTES,
|
||||
&bytes_read, pdMS_TO_TICKS(200));
|
||||
if (r == ERROR_NONE && bytes_read > 0 && bytes_read <= MAX_PCM_CHUNK_BYTES && (bytes_read % 2U) == 0) {
|
||||
if (ws_send(ctx->ws_fd, buffer, bytes_read, true) < 0) {
|
||||
ESP_LOGE(TAG, "Audio stream send failed");
|
||||
break;
|
||||
@@ -514,16 +660,19 @@ static void reynabot_task(void* arg) {
|
||||
}
|
||||
}
|
||||
|
||||
close_input_stream(ctx);
|
||||
free(buffer);
|
||||
|
||||
if (ctx->cancel_session || total_sent_bytes < 3200) {
|
||||
ESP_LOGI(TAG, "Cancelling audio session");
|
||||
const char* cancel_json = "{\"event\":\"cancel\"}";
|
||||
ws_send(ctx->ws_fd, (const uint8_t*)cancel_json, strlen(cancel_json), false);
|
||||
ctx->stop_sent = true;
|
||||
ctx->state = STATE_IDLE;
|
||||
} else {
|
||||
const char* stop_json = "{\"event\":\"stop\"}";
|
||||
ws_send(ctx->ws_fd, (const uint8_t*)stop_json, strlen(stop_json), false);
|
||||
ctx->stop_sent = true;
|
||||
ctx->state = STATE_THINKING;
|
||||
update_ui(ctx);
|
||||
|
||||
@@ -550,12 +699,8 @@ static void reynabot_task(void* arg) {
|
||||
ctx->ws_connected = false;
|
||||
ws_close(fd_to_close);
|
||||
|
||||
// Reset I2S controller to release DMA and stop white noise
|
||||
if (ctx->i2s_dev != NULL) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
close_input_stream(ctx);
|
||||
close_output_stream(ctx);
|
||||
|
||||
// Wait for receive task to exit
|
||||
int rx_timeout = 100;
|
||||
@@ -611,10 +756,9 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
|
||||
load_config(ctx);
|
||||
|
||||
// Find I2S controller
|
||||
ctx->i2s_dev = device_find_by_name("i2s0");
|
||||
if (ctx->i2s_dev == NULL) {
|
||||
ESP_LOGE(TAG, "I2S controller 'i2s0' not found!");
|
||||
// Find the firmware Audio System service; it owns codec/native-rate conversion.
|
||||
if (!find_audio_stream_device(ctx)) {
|
||||
ESP_LOGE(TAG, "audio-stream device not found!");
|
||||
}
|
||||
|
||||
// Style the parent screen
|
||||
@@ -800,12 +944,8 @@ static void on_hide(AppHandle app, void* data) {
|
||||
ws_close(fd_to_close);
|
||||
}
|
||||
|
||||
// Reset I2S controller to stop DMA and looping noise
|
||||
if (ctx->i2s_dev != NULL) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
close_input_stream(ctx);
|
||||
close_output_stream(ctx);
|
||||
|
||||
// Wait briefly for tasks to exit
|
||||
int timeout = 100;
|
||||
|
||||
@@ -56,8 +56,8 @@ int ws_connect(const char* host, int port, const char* path, const char* device_
|
||||
"Connection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"Authorization: Bearer %s\r\n"
|
||||
"X-Device-ID: %s\r\n"
|
||||
"Authorization: Bearer %s\\r\\n"
|
||||
"X-Device-ID: %s\\r\\n"
|
||||
"\r\n",
|
||||
path, host, port, auth_key, device_id);
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ extern "C" {
|
||||
|
||||
/**
|
||||
* Connect to a WebSocket server.
|
||||
* @param host Server IP address (e.g. "192.168.68.126")
|
||||
* @param port Port number (e.g. 8642)
|
||||
* @param host Server IP address (for example, the Mac LAN host 192.168.68.112)
|
||||
* @param port Port number (e.g. 8643 for the Kids gateway)
|
||||
* @param path WebSocket path (e.g. "/api/esp32/voice/ws")
|
||||
* @param device_id Unique device identifier
|
||||
* @param auth_key Hermes Bearer API key
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.reynabot
|
||||
versionName=1.0.0
|
||||
versionCode=1
|
||||
name=ReynaBot
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.reynabot
|
||||
app.version.name=1.0.0
|
||||
app.version.code=1
|
||||
app.name=ReynaBot
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.serialconsole
|
||||
versionName=0.4.0
|
||||
versionCode=4
|
||||
name=Serial Console
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.serialconsole
|
||||
app.version.name=0.7.0
|
||||
app.version.code=7
|
||||
app.name=Serial Console
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.snake
|
||||
versionName=0.5.0
|
||||
versionCode=5
|
||||
name=Snake
|
||||
description=Classic Snake game
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.snake
|
||||
app.version.name=0.8.0
|
||||
app.version.code=8
|
||||
app.name=Snake
|
||||
app.description=Classic Snake game
|
||||
|
||||
@@ -57,7 +57,6 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
|
||||
if (sfxEngine == nullptr) {
|
||||
sfxEngine = new SfxEngine();
|
||||
sfxEngine->start();
|
||||
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
|
||||
|
||||
// Load settings
|
||||
bool soundEnabled;
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.tamatac
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=TamaTac
|
||||
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.tamatac
|
||||
app.version.name=0.4.0
|
||||
app.version.code=4
|
||||
app.name=TamaTac
|
||||
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.todolist
|
||||
versionName=0.2.0
|
||||
versionCode=2
|
||||
name=Todo List
|
||||
description=Simple task list manager
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.todolist
|
||||
app.version.name=0.5.0
|
||||
app.version.code=5
|
||||
app.name=Todo List
|
||||
app.description=Simple task list manager
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.twoeleven
|
||||
versionName=0.4.0
|
||||
versionCode=4
|
||||
name=2048
|
||||
description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.twoeleven
|
||||
app.version.name=0.7.0
|
||||
app.version.code=7
|
||||
app.name=2048
|
||||
app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||
else()
|
||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
endif()
|
||||
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(VoiceRecorder)
|
||||
tactility_project(VoiceRecorder)
|
||||
@@ -1,5 +1,6 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
REQUIRES TactilitySDK esp_http_client json
|
||||
REQUIRES TactilitySDK
|
||||
)
|
||||
@@ -1,16 +1,15 @@
|
||||
/**
|
||||
* Voice Recorder App for Tactility OS
|
||||
*
|
||||
* Records voice memos (16kHz 16-bit Mono WAV format) to the SD card (/sdcard/memos/)
|
||||
* and plays them back.
|
||||
* Records voice memos (16kHz 16-bit Mono WAV) to /sdcard/memos/
|
||||
* Uses audio-stream API with resampling (native codec 44100 -> app 16000)
|
||||
*/
|
||||
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -42,7 +41,9 @@ typedef enum {
|
||||
} AudioState;
|
||||
|
||||
typedef struct {
|
||||
struct Device* i2s_dev;
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle input_handle;
|
||||
AudioStreamHandle output_handle;
|
||||
uint8_t* audio_buf;
|
||||
AudioState state;
|
||||
int volume;
|
||||
@@ -65,19 +66,19 @@ typedef struct {
|
||||
} AppCtx;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
char riff[4]; // "RIFF"
|
||||
uint32_t overall_size; // file size - 8
|
||||
char wave[4]; // "WAVE"
|
||||
char fmt_chunk_marker[4]; // "fmt "
|
||||
uint32_t length_of_fmt; // 16 for PCM
|
||||
uint16_t format_type; // 1 for PCM
|
||||
uint16_t channels; // 1 for mono
|
||||
uint32_t sample_rate; // 16000
|
||||
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
|
||||
uint16_t block_align; // channels * (bits_per_sample / 8)
|
||||
uint16_t bits_per_sample; // 16
|
||||
char data_chunk_header[4]; // "data"
|
||||
uint32_t data_size; // data size in bytes
|
||||
char riff[4];
|
||||
uint32_t overall_size;
|
||||
char wave[4];
|
||||
char fmt_chunk_marker[4];
|
||||
uint32_t length_of_fmt;
|
||||
uint16_t format_type;
|
||||
uint16_t channels;
|
||||
uint32_t sample_rate;
|
||||
uint32_t byterate;
|
||||
uint16_t block_align;
|
||||
uint16_t bits_per_sample;
|
||||
char data_chunk_header[4];
|
||||
uint32_t data_size;
|
||||
} WavHeader;
|
||||
|
||||
static AppCtx g_ctx;
|
||||
@@ -87,6 +88,20 @@ static void update_ui(AppCtx* ctx);
|
||||
static void refresh_memo_list(AppCtx* ctx);
|
||||
static void on_memo_selected(lv_event_t* e);
|
||||
|
||||
static bool find_audio_stream_device(AppCtx* ctx) {
|
||||
struct Device* dev = device_find_by_name("audio-stream");
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ─── WAV Header Writer ─── */
|
||||
static void write_wav_header(FILE* f, uint32_t sample_rate, uint16_t bits_per_sample, uint16_t channels, uint32_t data_size) {
|
||||
WavHeader header;
|
||||
@@ -114,10 +129,8 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
||||
struct tm timeinfo;
|
||||
localtime_r(&rawtime, &timeinfo);
|
||||
|
||||
// Format: YYYYMMDD_HHMMSS (e.g., 20260629_232202)
|
||||
strftime(datetime_buf, sizeof(datetime_buf), "%Y%m%d_%H%M%S", &timeinfo);
|
||||
|
||||
// Default unique name candidate
|
||||
snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf);
|
||||
|
||||
char filepath[256];
|
||||
@@ -125,24 +138,21 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
||||
|
||||
struct stat st;
|
||||
if (stat(filepath, &st) != 0) {
|
||||
// File does not exist, safe to use
|
||||
return;
|
||||
}
|
||||
|
||||
// File exists, let's search for a unique name by appending _N
|
||||
int suffix = 1;
|
||||
while (1) {
|
||||
snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix);
|
||||
snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename);
|
||||
if (stat(filepath, &st) != 0) {
|
||||
// Found a unique name!
|
||||
return;
|
||||
}
|
||||
suffix++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Record Task ─── */
|
||||
/* ─── Record Task (audio-stream input, resampled to 16k mono) ─── */
|
||||
static void record_task(void* arg) {
|
||||
AppCtx* ctx = (AppCtx*)arg;
|
||||
char filepath[256];
|
||||
@@ -164,29 +174,24 @@ static void record_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write default header
|
||||
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
|
||||
|
||||
/* Configure I2S for recording - RLCD fix: ES7210 outputs stereo (2 mics)
|
||||
* even when we want mono file. Working MicroPython uses I2S.STEREO for RX
|
||||
* then extracts left channel. If we request MONO, driver uses MONO slot mode
|
||||
* but ES7210 still sends 2 slots, causing channel mismatch / low pitch.
|
||||
* So request STEREO from I2S and downmix to mono in the loop.
|
||||
* See audio_util.py: i2s = I2S(..., format=I2S.STEREO, rate=16000) */
|
||||
struct I2sConfig cfg = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
// Open input stream via audio-stream (resampler provides 16k mono regardless of codec native rate)
|
||||
struct AudioStreamConfig in_cfg = {
|
||||
.sample_rate = SAMPLE_RATE,
|
||||
.bits_per_sample = BITS_PER_SAMPLE,
|
||||
.channel_left = 0,
|
||||
.channel_right = 1 // STEREO for ES7210 dual mic - we downmix to mono below
|
||||
.channels = 1
|
||||
};
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
error_t err = audio_stream_open_input(ctx->stream_dev, &in_cfg, &ctx->input_handle);
|
||||
if (err == ERROR_NONE) {
|
||||
// Ensure mic is unmuted and at max gain (ES8311 0..24dB)
|
||||
audio_stream_set_mute(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, false);
|
||||
audio_stream_set_volume(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
|
||||
ESP_LOGI(TAG, "Mic unmuted, gain 100%%");
|
||||
}
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to set I2S config: %d", err);
|
||||
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
|
||||
fclose(f);
|
||||
unlink(filepath);
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
@@ -199,68 +204,47 @@ static void record_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Recording started -> %s", filepath);
|
||||
ESP_LOGI(TAG, "Recording started -> %s (via audio-stream %u Hz)", filepath, (unsigned)SAMPLE_RATE);
|
||||
|
||||
size_t total_written = 0;
|
||||
uint32_t start_time = xTaskGetTickCount();
|
||||
|
||||
while (ctx->state == STATE_RECORDING) {
|
||||
size_t bytes_read = 0;
|
||||
err = i2s_controller_read(ctx->i2s_dev, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(100));
|
||||
err = audio_stream_read(ctx->input_handle, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(200));
|
||||
if (err == ERROR_NONE && bytes_read > 0) {
|
||||
// ES7210 stereo -> mono downmix: extract left channel (every other 16-bit sample)
|
||||
// Working MP: for i in 0..bytes_read step 4: mono[j:j+2]=buffer[i:i+2]
|
||||
// CHUNK_BYTES=1024 mono, but we read AUDIO_BUF_SIZE stereo (4096) then downmix to half
|
||||
size_t mono_bytes = bytes_read / 2;
|
||||
// In-place downmix using separate temp? Use static tmp buffer via audio_buf + mono offset
|
||||
// Easiest: convert in-place from end to start to avoid overwrite
|
||||
// Stereo layout: L0 R0 L1 R1 ... each 2 bytes, total bytes_read
|
||||
// We want L0 L1 L2 ... -> mono_bytes
|
||||
// Do backward copy: dest index = mono_bytes-2, src left = bytes_read-4
|
||||
// This avoids needing second buffer
|
||||
for (int src = (int)bytes_read - 4, dst = (int)mono_bytes - 2; src >= 0 && dst >= 0; src -= 4, dst -= 2) {
|
||||
ctx->audio_buf[dst] = ctx->audio_buf[src];
|
||||
ctx->audio_buf[dst+1] = ctx->audio_buf[src+1];
|
||||
}
|
||||
size_t written = fwrite(ctx->audio_buf, 1, mono_bytes, f);
|
||||
if (written != mono_bytes) {
|
||||
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)mono_bytes);
|
||||
// Data already 16k mono from audio-stream resampler, no downmix needed
|
||||
size_t written = fwrite(ctx->audio_buf, 1, bytes_read, f);
|
||||
if (written != bytes_read) {
|
||||
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)bytes_read);
|
||||
break;
|
||||
}
|
||||
total_written += written;
|
||||
}
|
||||
|
||||
// Update elapsed time in UI
|
||||
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
|
||||
char status_buf[64];
|
||||
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);
|
||||
lv_label_set_text(ctx->lbl_status, status_buf);
|
||||
lv_bar_set_value(ctx->bar_progress, bar_val % 100, LV_ANIM_OFF);
|
||||
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
|
||||
tt_lvgl_unlock();
|
||||
}
|
||||
|
||||
// Rewrite WAV header with actual recorded size
|
||||
audio_stream_close(ctx->input_handle);
|
||||
ctx->input_handle = NULL;
|
||||
|
||||
fseek(f, 0, SEEK_SET);
|
||||
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
|
||||
fclose(f);
|
||||
|
||||
ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written);
|
||||
|
||||
// Reset I2S controller to stop/release DMA channel
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
refresh_memo_list(ctx);
|
||||
|
||||
// Select the new file automatically
|
||||
ctx->selected_index = -1;
|
||||
for (int i = 0; i < ctx->memo_count; i++) {
|
||||
if (strcmp(ctx->memos[i], filename) == 0) {
|
||||
@@ -276,7 +260,7 @@ static void record_task(void* arg) {
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ─── Playback Task ─── */
|
||||
/* ─── Playback Task (audio-stream output with resampling) ─── */
|
||||
static void play_task(void* arg) {
|
||||
AppCtx* ctx = (AppCtx*)arg;
|
||||
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
|
||||
@@ -305,7 +289,6 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read and parse WAV header
|
||||
WavHeader header;
|
||||
if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) {
|
||||
ESP_LOGE(TAG, "Failed to read WAV header");
|
||||
@@ -320,7 +303,6 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify file structure
|
||||
if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) {
|
||||
ESP_LOGE(TAG, "Invalid WAV format");
|
||||
fclose(f);
|
||||
@@ -334,21 +316,16 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Configure I2S based on file metadata */
|
||||
struct I2sConfig cfg = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
/* Open output stream via audio-stream (resampler converts file rate -> native 44100) */
|
||||
struct AudioStreamConfig out_cfg = {
|
||||
.sample_rate = header.sample_rate,
|
||||
.bits_per_sample = header.bits_per_sample,
|
||||
.channel_left = 0,
|
||||
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
||||
.channels = header.channels
|
||||
};
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
error_t err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
|
||||
ESP_LOGE(TAG, "audio_stream_open_output failed for playback: %d (rate=%u)", err, (unsigned)header.sample_rate);
|
||||
fclose(f);
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
@@ -360,7 +337,7 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Playing: %s, rate=%u Hz, channels=%u, size=%u bytes",
|
||||
ESP_LOGI(TAG, "Playing via audio-stream: %s, rate=%u Hz, ch=%u, size=%u",
|
||||
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
|
||||
|
||||
size_t total_played = 0;
|
||||
@@ -371,40 +348,35 @@ static void play_task(void* arg) {
|
||||
fseek(f, sizeof(WavHeader), SEEK_SET);
|
||||
}
|
||||
|
||||
bool i2s_configured = true;
|
||||
bool out_open = true;
|
||||
|
||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||
if (ctx->state == STATE_PAUSED) {
|
||||
if (i2s_configured) {
|
||||
// Reset I2S controller to stop DMA immediately when pausing
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
i2s_configured = false;
|
||||
if (out_open) {
|
||||
audio_stream_close(ctx->output_handle);
|
||||
ctx->output_handle = NULL;
|
||||
out_open = false;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!i2s_configured) {
|
||||
// Reconfigure I2S when resuming
|
||||
device_lock(ctx->i2s_dev);
|
||||
err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
if (!out_open) {
|
||||
err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to reconfigure I2S on resume: %d", err);
|
||||
ESP_LOGE(TAG, "Failed to reopen audio_stream on resume: %d", err);
|
||||
break;
|
||||
}
|
||||
i2s_configured = true;
|
||||
out_open = true;
|
||||
}
|
||||
|
||||
size_t to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES;
|
||||
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f);
|
||||
if (read_bytes == 0) {
|
||||
break; // EOF
|
||||
break;
|
||||
}
|
||||
|
||||
// Scaling Volume
|
||||
// Volume scaling
|
||||
int vol = ctx->volume;
|
||||
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
|
||||
size_t sample_count = read_bytes / sizeof(int16_t);
|
||||
@@ -417,22 +389,19 @@ static void play_task(void* arg) {
|
||||
bool write_err = false;
|
||||
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
||||
size_t written = 0;
|
||||
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
|
||||
err = audio_stream_write(ctx->output_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "I2S playback write error: %d", err);
|
||||
ESP_LOGE(TAG, "audio_stream write error: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
|
||||
if (write_err) {
|
||||
break;
|
||||
}
|
||||
if (write_err) break;
|
||||
|
||||
total_played += read_bytes;
|
||||
|
||||
// UI progress update
|
||||
int pct = (int)((total_played * 100) / data_size);
|
||||
uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8);
|
||||
uint32_t elapsed_sec = total_played / bytes_per_sec;
|
||||
@@ -452,10 +421,10 @@ static void play_task(void* arg) {
|
||||
fclose(f);
|
||||
ESP_LOGI(TAG, "Playback task complete");
|
||||
|
||||
// Reset I2S controller to stop/release DMA channel and prevent looping noise
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
if (out_open && ctx->output_handle) {
|
||||
audio_stream_close(ctx->output_handle);
|
||||
ctx->output_handle = NULL;
|
||||
}
|
||||
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
@@ -470,12 +439,12 @@ static void play_task(void* arg) {
|
||||
static void on_record_click(lv_event_t* e) {
|
||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||
if (ctx->state != STATE_IDLE) return;
|
||||
if (ctx->i2s_dev == NULL) return;
|
||||
if (ctx->stream_dev == NULL) return;
|
||||
|
||||
ctx->state = STATE_RECORDING;
|
||||
update_ui(ctx);
|
||||
|
||||
xTaskCreate(record_task, "voice_rec", 4096, ctx, 5, &ctx->task_handle);
|
||||
xTaskCreate(record_task, "voice_rec", 8192, ctx, 5, &ctx->task_handle);
|
||||
}
|
||||
|
||||
static void on_play_pause_click(lv_event_t* e) {
|
||||
@@ -485,7 +454,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
||||
if (ctx->state == STATE_IDLE) {
|
||||
ctx->state = STATE_PLAYING;
|
||||
update_ui(ctx);
|
||||
xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle);
|
||||
xTaskCreate(play_task, "voice_play", 8192, ctx, 5, &ctx->task_handle);
|
||||
} else if (ctx->state == STATE_PLAYING) {
|
||||
ctx->state = STATE_PAUSED;
|
||||
update_ui(ctx);
|
||||
@@ -523,14 +492,12 @@ static void on_memo_selected(lv_event_t* e) {
|
||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn = lv_event_get_target(e);
|
||||
|
||||
// Clear check states from other entries
|
||||
uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
|
||||
for (uint32_t i = 0; i < child_cnt; i++) {
|
||||
lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i);
|
||||
lv_obj_clear_state(child, LV_STATE_CHECKED);
|
||||
}
|
||||
|
||||
// Apply check state to the clicked memo
|
||||
lv_obj_add_state(btn, LV_STATE_CHECKED);
|
||||
|
||||
const char* text = lv_list_get_btn_text(ctx->lst_memos, btn);
|
||||
@@ -568,7 +535,6 @@ static void refresh_memo_list(AppCtx* ctx) {
|
||||
}
|
||||
closedir(dir);
|
||||
|
||||
// Simple bubble sort
|
||||
for (int i = 0; i < ctx->memo_count - 1; i++) {
|
||||
for (int j = 0; j < ctx->memo_count - i - 1; j++) {
|
||||
if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) {
|
||||
@@ -580,7 +546,6 @@ static void refresh_memo_list(AppCtx* ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// Populate UI List
|
||||
for (int i = 0; i < ctx->memo_count; i++) {
|
||||
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
|
||||
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
|
||||
@@ -647,115 +612,100 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
g_ctx.volume = 80;
|
||||
g_ctx.selected_index = -1;
|
||||
|
||||
// Create the memos storage directory if missing
|
||||
mkdir("/sdcard/memos", 0755);
|
||||
|
||||
// Allocate audio buffer
|
||||
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
|
||||
if (g_ctx.audio_buf == NULL) {
|
||||
ESP_LOGE(TAG, "Failed to allocate audio buffer");
|
||||
}
|
||||
|
||||
// Locate I2S hardware channel
|
||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
||||
if (g_ctx.i2s_dev == NULL) {
|
||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
||||
if (!find_audio_stream_device(&g_ctx)) {
|
||||
ESP_LOGE(TAG, "audio-stream device not found!");
|
||||
}
|
||||
|
||||
// ─── UI Setup ───
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
// Premium Player Card
|
||||
lv_obj_t* card = lv_obj_create(parent);
|
||||
lv_obj_set_size(card, lv_pct(95), lv_pct(88));
|
||||
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
|
||||
lv_obj_set_style_radius(card, 15, 0);
|
||||
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); // mocha base
|
||||
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); // mocha surface0
|
||||
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
|
||||
lv_obj_set_style_border_width(card, 2, 0);
|
||||
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(card, 10, 0);
|
||||
lv_obj_set_style_pad_gap(card, 8, 0);
|
||||
|
||||
// Status Area
|
||||
g_ctx.lbl_status = lv_label_create(card);
|
||||
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
|
||||
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); // mocha text
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
|
||||
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
|
||||
|
||||
// Progress Bar
|
||||
g_ctx.bar_progress = lv_bar_create(card);
|
||||
lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8);
|
||||
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
|
||||
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); // mocha surface1
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); // mocha blue
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||
|
||||
// Memos List
|
||||
g_ctx.lst_memos = lv_list_create(card);
|
||||
lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85);
|
||||
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0); // mocha mantle
|
||||
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0);
|
||||
lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0);
|
||||
lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0);
|
||||
lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0);
|
||||
|
||||
// Controls Box
|
||||
lv_obj_t* ctrl_box = lv_obj_create(card);
|
||||
lv_obj_remove_style_all(ctrl_box);
|
||||
lv_obj_set_size(ctrl_box, lv_pct(95), 40);
|
||||
lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
// Rec Button
|
||||
g_ctx.btn_record = lv_btn_create(ctrl_box);
|
||||
lv_obj_set_size(g_ctx.btn_record, 45, 32);
|
||||
lv_obj_set_style_radius(g_ctx.btn_record, 8, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); // mocha red
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0);
|
||||
lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
|
||||
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
|
||||
lv_obj_center(lbl_rec);
|
||||
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Play/Pause Button
|
||||
g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
|
||||
lv_obj_set_size(g_ctx.btn_play_pause, 45, 32);
|
||||
lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); // mocha blue
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0);
|
||||
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
|
||||
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
|
||||
lv_obj_center(lbl_play);
|
||||
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Stop Button
|
||||
g_ctx.btn_stop = lv_btn_create(ctrl_box);
|
||||
lv_obj_set_size(g_ctx.btn_stop, 45, 32);
|
||||
lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0); // mocha subtext1
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0);
|
||||
lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
|
||||
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
|
||||
lv_obj_center(lbl_stop);
|
||||
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Delete Button
|
||||
g_ctx.btn_delete = lv_btn_create(ctrl_box);
|
||||
lv_obj_set_size(g_ctx.btn_delete, 45, 32);
|
||||
lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); // mocha sapphire
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0);
|
||||
lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete);
|
||||
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
|
||||
lv_obj_center(lbl_del);
|
||||
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Initialize list and control state
|
||||
if (g_ctx.i2s_dev == NULL) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S 'i2s0' missing");
|
||||
if (g_ctx.stream_dev == NULL) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "ERROR: audio-stream missing");
|
||||
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
|
||||
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
|
||||
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
||||
@@ -778,16 +728,17 @@ static void onHideApp(AppHandle app, void* data) {
|
||||
g_ctx.state = STATE_IDLE;
|
||||
}
|
||||
|
||||
// Wait for task completion
|
||||
while (g_ctx.task_handle != NULL) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
|
||||
// Reset I2S to ensure DMA channel is stopped
|
||||
if (g_ctx.i2s_dev != NULL) {
|
||||
device_lock(g_ctx.i2s_dev);
|
||||
i2s_controller_reset(g_ctx.i2s_dev);
|
||||
device_unlock(g_ctx.i2s_dev);
|
||||
if (g_ctx.input_handle) {
|
||||
audio_stream_close(g_ctx.input_handle);
|
||||
g_ctx.input_handle = NULL;
|
||||
}
|
||||
if (g_ctx.output_handle) {
|
||||
audio_stream_close(g_ctx.output_handle);
|
||||
g_ctx.output_handle = NULL;
|
||||
}
|
||||
|
||||
if (g_ctx.audio_buf != NULL) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.voicerecorder
|
||||
app.version.name=1.0.0
|
||||
app.version.code=1
|
||||
app.name=Voice Recorder
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
import sys
|
||||
import boto3
|
||||
|
||||
SHELL_COLOR_RED = "\033[91m"
|
||||
SHELL_COLOR_ORANGE = "\033[93m"
|
||||
SHELL_COLOR_RESET = "\033[m"
|
||||
|
||||
def print_warning(message):
|
||||
print(f"{SHELL_COLOR_ORANGE}WARNING: {message}{SHELL_COLOR_RESET}")
|
||||
|
||||
def print_error(message):
|
||||
print(f"{SHELL_COLOR_RED}ERROR: {message}{SHELL_COLOR_RESET}")
|
||||
|
||||
def print_help():
|
||||
print("Usage: python upload-app-files.py [path] [sdkVersion] [cloudflareAccountId] [cloudflareTokenName] [cloudflareTokenValue]")
|
||||
print("")
|
||||
print("Options:")
|
||||
print(" --index-only Upload only apps.json")
|
||||
|
||||
def exit_with_error(message):
|
||||
print_error(message)
|
||||
sys.exit(1)
|
||||
|
||||
def main(path: str, sdk_version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool):
|
||||
if not os.path.exists(path):
|
||||
exit_with_error(f"Path not found: {path}")
|
||||
s3 = boto3.client(
|
||||
service_name="s3",
|
||||
endpoint_url=f"https://{cloudflare_account_id}.r2.cloudflarestorage.com",
|
||||
aws_access_key_id=cloudflare_token_name,
|
||||
aws_secret_access_key=cloudflare_token_value,
|
||||
region_name="auto"
|
||||
)
|
||||
files_to_upload = os.listdir(path)
|
||||
if index_only:
|
||||
files_to_upload = [f for f in files_to_upload if f == 'apps.json']
|
||||
else:
|
||||
# Ensure apps.json is uploaded last so it never references files that
|
||||
# haven't finished uploading yet.
|
||||
files_to_upload.sort(key=lambda f: f == 'apps.json')
|
||||
counter = 1
|
||||
total = len(files_to_upload)
|
||||
for file_name in files_to_upload:
|
||||
object_path = f"apps/{sdk_version}/{file_name}"
|
||||
print(f"[{counter}/{total}] Uploading {file_name} to {object_path}")
|
||||
file_path = os.path.join(path, file_name)
|
||||
try:
|
||||
s3.upload_file(file_path, "tactility", object_path)
|
||||
except Exception as e:
|
||||
exit_with_error(f"Failed to upload {file_name}: {str(e)}")
|
||||
counter += 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Tactility CDN Apps Uploader")
|
||||
if "--help" in sys.argv:
|
||||
print_help()
|
||||
sys.exit()
|
||||
# Argument validation
|
||||
if len(sys.argv) < 6:
|
||||
print_help()
|
||||
sys.exit(1)
|
||||
main(
|
||||
path=sys.argv[1],
|
||||
sdk_version=sys.argv[2],
|
||||
cloudflare_account_id=sys.argv[3],
|
||||
cloudflare_token_name=sys.argv[4],
|
||||
cloudflare_token_value=sys.argv[5],
|
||||
index_only="--index-only" in sys.argv
|
||||
)
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/queue.h"
|
||||
@@ -277,7 +278,8 @@ private:
|
||||
// State
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
Device* i2sDevice_ = nullptr;
|
||||
Device* streamDevice_ = nullptr;
|
||||
AudioStreamHandle streamHandle_ = nullptr;
|
||||
TaskHandle_t task_ = nullptr;
|
||||
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
|
||||
QueueHandle_t msgQueue_ = nullptr;
|
||||
|
||||
@@ -35,11 +35,8 @@ if (!engine->start()) {
|
||||
ESP_LOGE(TAG, "Failed to start SfxEngine");
|
||||
return;
|
||||
}
|
||||
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
|
||||
|
||||
engine->play(SfxId::Coin); // Predefined SFX
|
||||
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
|
||||
engine->setVolume(0.7f); // Volume control
|
||||
|
||||
engine->stop();
|
||||
delete engine;
|
||||
@@ -87,9 +84,11 @@ idf_component_register(
|
||||
- `void stopVoice(voice)` - Stop specific voice
|
||||
|
||||
### Settings
|
||||
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
|
||||
- `void setEnabled(bool)` - Mute/unmute
|
||||
- `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)
|
||||
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
|
||||
|
||||
@@ -9,11 +9,16 @@
|
||||
#include "SfxEngine.h"
|
||||
#include "SfxDefinitions.h"
|
||||
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include "esp_log.h"
|
||||
|
||||
// The deployed 0.8.0-dev firmware still exports this legacy lookup API, while
|
||||
// the current SDK headers only expose device_get_by_name().
|
||||
extern "C" Device* device_find_by_name(const char* name);
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846f
|
||||
#endif
|
||||
@@ -424,7 +429,7 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
size_t written;
|
||||
QueueMsg msg;
|
||||
|
||||
ESP_LOGI(TAG, "Audio task started");
|
||||
ESP_LOGI(TAG, "Audio task started (audio-stream)");
|
||||
|
||||
while (self->running_) {
|
||||
// Process queued messages (non-blocking)
|
||||
@@ -463,19 +468,26 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
// Fill audio buffer (member buffer to avoid stack pressure)
|
||||
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
|
||||
|
||||
// Write to I2S
|
||||
error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
|
||||
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
|
||||
// Write via audio-stream (resampled to native codec rate, e.g. 44100)
|
||||
if (self->streamHandle_ == nullptr) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
continue;
|
||||
}
|
||||
error_t error = audio_stream_write(self->streamHandle_, self->audioBuffer_,
|
||||
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(200));
|
||||
if (error != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "I2S write error");
|
||||
ESP_LOGE(TAG, "audio_stream_write error %d", error);
|
||||
self->running_ = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush silence
|
||||
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
||||
i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
|
||||
// Flush silence to avoid pop
|
||||
if (self->streamHandle_ != nullptr) {
|
||||
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
||||
size_t dummy = 0;
|
||||
audio_stream_write(self->streamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &dummy, pdMS_TO_TICKS(100));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Audio task exiting");
|
||||
|
||||
@@ -494,33 +506,38 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
bool SfxEngine::start() {
|
||||
if (running_) return true;
|
||||
|
||||
// Find I2S device
|
||||
i2sDevice_ = nullptr;
|
||||
device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) {
|
||||
if (!device_is_ready(device)) return true;
|
||||
Device** devicePtr = static_cast<Device**>(context);
|
||||
*devicePtr = device;
|
||||
return false;
|
||||
});
|
||||
// Find audio-stream device (new audio provision)
|
||||
streamDevice_ = nullptr;
|
||||
streamHandle_ = nullptr;
|
||||
|
||||
if (i2sDevice_ == nullptr) {
|
||||
ESP_LOGW(TAG, "No I2S device found");
|
||||
streamDevice_ = device_find_by_name("audio-stream");
|
||||
if (streamDevice_ == nullptr) {
|
||||
// Fallback: find first device of AUDIO_STREAM_TYPE
|
||||
device_for_each_of_type(&AUDIO_STREAM_TYPE, &streamDevice_, [](Device* device, void* context) {
|
||||
if (!device_is_ready(device)) return true;
|
||||
Device** devPtr = static_cast<Device**>(context);
|
||||
*devPtr = device;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (streamDevice_ == nullptr) {
|
||||
ESP_LOGW(TAG, "No audio-stream device found - audio provision missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Configure I2S
|
||||
I2sConfig config = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
// Open output stream with resampling (app wants 16k stereo, codec native is 44100)
|
||||
struct AudioStreamConfig cfg = {
|
||||
.sample_rate = SAMPLE_RATE,
|
||||
.bits_per_sample = 16,
|
||||
.channel_left = 0,
|
||||
.channel_right = 0
|
||||
.channels = 2
|
||||
};
|
||||
|
||||
error_t error = i2s_controller_set_config(i2sDevice_, &config);
|
||||
error_t error = audio_stream_open_output(streamDevice_, &cfg, &streamHandle_);
|
||||
if (error != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
|
||||
i2sDevice_ = nullptr;
|
||||
ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error);
|
||||
streamDevice_ = nullptr;
|
||||
streamHandle_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -528,12 +545,13 @@ bool SfxEngine::start() {
|
||||
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
|
||||
if (msgQueue_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create message queue");
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
streamDevice_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start audio task
|
||||
// Start audio task (needs slightly larger stack for audio_stream write path)
|
||||
running_ = true;
|
||||
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
|
||||
if (result != pdPASS) {
|
||||
@@ -541,12 +559,13 @@ bool SfxEngine::start() {
|
||||
running_ = false;
|
||||
vQueueDelete(msgQueue_);
|
||||
msgQueue_ = nullptr;
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
streamDevice_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "SfxEngine started (voices=%d, sampleRate=%d)", NUM_VOICES, SAMPLE_RATE);
|
||||
ESP_LOGI(TAG, "SfxEngine started via audio-stream (voices=%d, sampleRate=%d -> resampled to native)", NUM_VOICES, SAMPLE_RATE);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -575,11 +594,13 @@ void SfxEngine::stop() {
|
||||
msgQueue_ = nullptr;
|
||||
}
|
||||
|
||||
if (i2sDevice_ != nullptr) {
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
if (streamHandle_ != nullptr) {
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
}
|
||||
|
||||
streamDevice_ = nullptr;
|
||||
|
||||
ESP_LOGI(TAG, "SfxEngine stopped");
|
||||
}
|
||||
|
||||
|
||||
+55
-40
@@ -1,14 +1,23 @@
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
import os
|
||||
import tempfile
|
||||
import configparser
|
||||
import sys
|
||||
from datetime import datetime, UTC
|
||||
|
||||
def read_properties_file(path):
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(path)
|
||||
return config
|
||||
properties = {}
|
||||
with open(path, "r") as file:
|
||||
for line in file:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
key, sep, value = line.partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
properties[key.strip()] = value.strip()
|
||||
return properties
|
||||
|
||||
def get_manifest(appPath):
|
||||
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz
|
||||
@@ -62,51 +71,49 @@ def get_manifest(appPath):
|
||||
return None
|
||||
|
||||
def get_versioned_file_name(manifest):
|
||||
app_id = manifest["app"]["id"]
|
||||
version_code = manifest["app"]["versionCode"]
|
||||
app_id = manifest["app.id"]
|
||||
version_code = manifest["app.version.code"]
|
||||
return f"{app_id}-{version_code}.app"
|
||||
|
||||
def get_os_version(manifest):
|
||||
sdk = manifest["target"]["sdk"]
|
||||
sdk = manifest["target.sdk"]
|
||||
# Remove trailing hyphen suffix if present
|
||||
if "-" in sdk:
|
||||
return sdk.rsplit("-", 1)[0].strip()
|
||||
else:
|
||||
return sdk
|
||||
|
||||
def manifest_config_to_flat_json(manifest):
|
||||
"""Convert a ConfigParser manifest into a flat JSON-like dict.
|
||||
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))
|
||||
|
||||
Expected sections/keys (case-insensitive for keys):
|
||||
- [app]
|
||||
id -> appId
|
||||
versionName -> appVersionName
|
||||
versionCode -> appVersionCode (int)
|
||||
name -> appName
|
||||
description -> appDescription (optional; default "")
|
||||
- [target]
|
||||
sdk -> targetSdk
|
||||
platforms -> targetPlatforms (comma-separated list)
|
||||
def get_git_commit_hash():
|
||||
return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
|
||||
|
||||
def manifest_config_to_flat_json(manifest):
|
||||
"""Convert a flat (V2) manifest dict into a flat JSON-like dict.
|
||||
|
||||
Expected keys:
|
||||
app.id -> appId
|
||||
app.version.name -> appVersionName
|
||||
app.version.code -> appVersionCode (int)
|
||||
app.name -> appName
|
||||
app.description -> appDescription (optional; default "")
|
||||
target.sdk -> targetSdk
|
||||
target.platforms -> targetPlatforms (comma-separated list)
|
||||
|
||||
Unknown/missing values fall back to sensible defaults per requirements.
|
||||
"""
|
||||
def get_opt(section, option, default=None):
|
||||
if not manifest.has_section(section):
|
||||
return default
|
||||
# try exact option then lowercase (RawConfigParser lowercases by default)
|
||||
if manifest.has_option(section, option):
|
||||
return manifest.get(section, option)
|
||||
low = option.lower()
|
||||
if manifest.has_option(section, low):
|
||||
return manifest.get(section, low)
|
||||
return default
|
||||
|
||||
# Map values
|
||||
app_id = get_opt("app", "id", "")
|
||||
app_version_name = get_opt("app", "versionName", "")
|
||||
app_version_code_raw = get_opt("app", "versionCode", "0")
|
||||
app_name = get_opt("app", "name", "")
|
||||
app_description = get_opt("app", "description", "") or ""
|
||||
app_id = manifest.get("app.id", "")
|
||||
app_version_name = manifest.get("app.version.name", "")
|
||||
app_version_code_raw = manifest.get("app.version.code", "0")
|
||||
app_name = manifest.get("app.name", "")
|
||||
app_description = manifest.get("app.description", "") or ""
|
||||
|
||||
# Coerce version code to int safely
|
||||
try:
|
||||
@@ -114,8 +121,8 @@ def manifest_config_to_flat_json(manifest):
|
||||
except Exception:
|
||||
app_version_code = 0
|
||||
|
||||
target_sdk = get_opt("target", "sdk", "")
|
||||
platforms_raw = get_opt("target", "platforms", "")
|
||||
target_sdk = manifest.get("target.sdk", "")
|
||||
platforms_raw = manifest.get("target.platforms", "")
|
||||
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
|
||||
|
||||
filename = get_versioned_file_name(manifest)
|
||||
@@ -142,15 +149,23 @@ if __name__ == "__main__":
|
||||
sys.exit()
|
||||
app_directory = sys.argv[1]
|
||||
manifest_map = {}
|
||||
output_json = {
|
||||
"apps": []
|
||||
}
|
||||
any_manifest = None
|
||||
if os.path.exists(app_directory):
|
||||
for file in os.listdir(app_directory):
|
||||
if file.endswith(".app"):
|
||||
file_path = os.path.join(app_directory, file)
|
||||
manifest_map[file_path] = get_manifest(file_path)
|
||||
# All bundled apps must target the same SDK version; this becomes the CDN path segment
|
||||
sdk_version = check_and_get_sdk_version(manifest_map)
|
||||
with open("sdk_version.txt", "w") as f:
|
||||
f.write(sdk_version)
|
||||
print(f"SDK version: {sdk_version}")
|
||||
output_json = {
|
||||
"sdkVersion": sdk_version,
|
||||
"created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
"gitCommit": get_git_commit_hash(),
|
||||
"apps": []
|
||||
}
|
||||
# Rename files and collect manifest data into output json object
|
||||
for file_path in manifest_map.keys():
|
||||
print(f"Processing {file_path}: {manifest_map[file_path]}")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate MP3 Player close-and-resume evidence captured from serial."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def evaluate_resume_trace(trace: str, fixture: str, minimum_position: int = 6) -> dict[str, int]:
|
||||
"""Require real playback, persisted position, and same-file relaunch evidence."""
|
||||
escaped = re.escape(fixture)
|
||||
initial = re.search(rf"Starting MP3 playback: {escaped} .* resume=0\b", trace)
|
||||
stream_opened = re.search(r"Audio stream opened: \d+ Hz, \d+ channels", trace)
|
||||
persisted = re.search(rf"History saved on hide: {escaped} pos (\d+) total \d+", trace)
|
||||
resumed = re.search(rf"Resuming last play {escaped} at (\d+) sec from history", trace)
|
||||
playback_matches = list(re.finditer(rf"Starting MP3 playback: {escaped} .* resume=(\d+)\b", trace))
|
||||
resumed_playback = playback_matches[-1] if playback_matches else None
|
||||
|
||||
if "Playback task stuck, force deleting" in trace:
|
||||
raise RuntimeError("forced playback task termination invalidates the device test")
|
||||
|
||||
if not initial or not stream_opened:
|
||||
raise RuntimeError("missing verified playback evidence for the fixture")
|
||||
if not persisted or not resumed or not resumed_playback:
|
||||
raise RuntimeError("missing persisted or resumed playback evidence")
|
||||
|
||||
persisted_position = int(persisted.group(1))
|
||||
resumed_position = int(resumed.group(1))
|
||||
resumed_playback_position = int(resumed_playback.group(1))
|
||||
if persisted_position < minimum_position:
|
||||
raise RuntimeError(f"persisted position {persisted_position} is below {minimum_position}")
|
||||
if resumed_position != persisted_position or resumed_playback_position != persisted_position:
|
||||
raise RuntimeError("relaunch did not resume the persisted position on the same file")
|
||||
return {"persisted_position": persisted_position, "resumed_position": resumed_position}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--trace", type=Path, required=True, help="Captured serial log")
|
||||
parser.add_argument("--fixture", required=True, help="Absolute fixture path logged by MP3 Player")
|
||||
parser.add_argument("--minimum-position", type=int, default=6)
|
||||
args = parser.parse_args()
|
||||
result = evaluate_resume_trace(args.trace.read_text(encoding="utf-8", errors="replace"), args.fixture, args.minimum_position)
|
||||
print(f"PASS persisted_position={result['persisted_position']} resumed_position={result['resumed_position']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+23
-36
@@ -1,4 +1,3 @@
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -13,7 +12,7 @@ import tarfile
|
||||
from urllib.parse import urlparse
|
||||
|
||||
ttbuild_path = ".tactility"
|
||||
ttbuild_version = "3.5.1"
|
||||
ttbuild_version = "4.1.0"
|
||||
ttbuild_cdn = "https://cdn.tactilityproject.org"
|
||||
ttbuild_sdk_json_validity = 3600 # seconds
|
||||
ttport = 6666
|
||||
@@ -106,9 +105,17 @@ def get_url(ip, path):
|
||||
return f"http://{ip}:{ttport}{path}"
|
||||
|
||||
def read_properties_file(path):
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(path)
|
||||
return config
|
||||
properties = {}
|
||||
with open(path, "r") as file:
|
||||
for line in file:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
key, sep, value = line.partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
properties[key.strip()] = value.strip()
|
||||
return properties
|
||||
|
||||
#endregion Core
|
||||
|
||||
@@ -185,7 +192,7 @@ def fetch_sdkconfig_files(platform_targets):
|
||||
for platform in platform_targets:
|
||||
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
||||
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
|
||||
if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path):
|
||||
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
|
||||
exit_with_error(f"Failed to download sdkconfig file for {platform}")
|
||||
|
||||
#endregion SDK helpers
|
||||
@@ -231,32 +238,12 @@ def read_manifest():
|
||||
return read_properties_file("manifest.properties")
|
||||
|
||||
def validate_manifest(manifest):
|
||||
# [manifest]
|
||||
if not "manifest" in manifest:
|
||||
exit_with_error("Invalid manifest format: [manifest] not found")
|
||||
if not "version" in manifest["manifest"]:
|
||||
exit_with_error("Invalid manifest format: [manifest] version not found")
|
||||
# [target]
|
||||
if not "target" in manifest:
|
||||
exit_with_error("Invalid manifest format: [target] not found")
|
||||
if not "sdk" in manifest["target"]:
|
||||
exit_with_error("Invalid manifest format: [target] sdk not found")
|
||||
if not "platforms" in manifest["target"]:
|
||||
exit_with_error("Invalid manifest format: [target] platforms not found")
|
||||
# [app]
|
||||
if not "app" in manifest:
|
||||
exit_with_error("Invalid manifest format: [app] not found")
|
||||
if not "id" in manifest["app"]:
|
||||
exit_with_error("Invalid manifest format: [app] id not found")
|
||||
if not "versionName" in manifest["app"]:
|
||||
exit_with_error("Invalid manifest format: [app] versionName not found")
|
||||
if not "versionCode" in manifest["app"]:
|
||||
exit_with_error("Invalid manifest format: [app] versionCode not found")
|
||||
if not "name" in manifest["app"]:
|
||||
exit_with_error("Invalid manifest format: [app] name not found")
|
||||
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"):
|
||||
if key not in manifest:
|
||||
exit_with_error(f"Invalid manifest format: {key} not found")
|
||||
|
||||
def is_valid_manifest_platform(manifest, platform):
|
||||
manifest_platforms = manifest["target"]["platforms"].split(",")
|
||||
manifest_platforms = manifest["target.platforms"].split(",")
|
||||
return platform in manifest_platforms
|
||||
|
||||
def validate_manifest_platform(manifest, platform):
|
||||
@@ -265,7 +252,7 @@ def validate_manifest_platform(manifest, platform):
|
||||
|
||||
def get_manifest_target_platforms(manifest, requested_platform):
|
||||
if requested_platform == "" or requested_platform is None:
|
||||
return manifest["target"]["platforms"].split(",")
|
||||
return manifest["target.platforms"].split(",")
|
||||
else:
|
||||
validate_manifest_platform(manifest, requested_platform)
|
||||
return [requested_platform]
|
||||
@@ -512,7 +499,7 @@ def build_action(manifest, platform_arg, skip_build):
|
||||
if use_local_sdk:
|
||||
global local_base_path
|
||||
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
|
||||
validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
|
||||
validate_local_sdks(platforms_to_build, manifest["target.sdk"])
|
||||
|
||||
if should_fetch_sdkconfig_files(platforms_to_build):
|
||||
fetch_sdkconfig_files(platforms_to_build)
|
||||
@@ -521,7 +508,7 @@ def build_action(manifest, platform_arg, skip_build):
|
||||
sdk_json = read_sdk_json()
|
||||
validate_self(sdk_json)
|
||||
# Build
|
||||
sdk_version = manifest["target"]["sdk"]
|
||||
sdk_version = manifest["target.sdk"]
|
||||
if not use_local_sdk:
|
||||
if not sdk_download_all(sdk_version, platforms_to_build):
|
||||
exit_with_error("Failed to download one or more SDKs")
|
||||
@@ -570,7 +557,7 @@ def get_device_info(ip):
|
||||
print_status_error(f"Device info request failed: {e}")
|
||||
|
||||
def run_action(manifest, ip):
|
||||
app_id = manifest["app"]["id"]
|
||||
app_id = manifest["app.id"]
|
||||
print_status_busy("Running")
|
||||
url = get_url(ip, "/app/run")
|
||||
params = {'id': app_id}
|
||||
@@ -614,7 +601,7 @@ def install_action(ip, platforms):
|
||||
return False
|
||||
|
||||
def uninstall_action(manifest, ip):
|
||||
app_id = manifest["app"]["id"]
|
||||
app_id = manifest["app.id"]
|
||||
print_status_busy("Uninstalling")
|
||||
url = get_url(ip, "/app/uninstall")
|
||||
params = {'id': app_id}
|
||||
@@ -670,7 +657,7 @@ if __name__ == "__main__":
|
||||
exit_with_error("manifest.properties not found")
|
||||
manifest = read_manifest()
|
||||
validate_manifest(manifest)
|
||||
all_platform_targets = manifest["target"]["platforms"].split(",")
|
||||
all_platform_targets = manifest["target.platforms"].split(",")
|
||||
# Update SDK cache (tool.json)
|
||||
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
|
||||
exit_with_error("Failed to retrieve SDK info")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Source-level contract for the Live Captions external Tactility app.
|
||||
|
||||
The app is ELF-loaded firmware code, so this test guards the device protocol and
|
||||
persistence requirements before an ESP32-S3 build/install test is available.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "Apps" / "LiveCaptions"
|
||||
|
||||
|
||||
class LiveCaptionsContractTests(unittest.TestCase):
|
||||
def test_manifest_identifies_the_captions_app(self):
|
||||
manifest = (APP / "manifest.properties").read_text()
|
||||
self.assertIn("app.id=one.tactility.livecaptions", manifest)
|
||||
self.assertIn("app.name=Live Captions", manifest)
|
||||
self.assertIn("target.platforms=esp32s3", manifest)
|
||||
|
||||
def test_app_streams_audio_and_logs_final_caption_to_daily_text_file(self):
|
||||
source = (APP / "main" / "Source" / "main.c").read_text()
|
||||
self.assertIn("audio_stream_open_input", source)
|
||||
self.assertIn("/sdcard/captions", source)
|
||||
self.assertIn('"%Y-%m-%d.txt"', source)
|
||||
self.assertIn("append_final_caption", source)
|
||||
self.assertIn('"draft"', source)
|
||||
self.assertIn('"interim_transcript"', source)
|
||||
self.assertIn('"transcript"', source)
|
||||
self.assertIn('"final"', source)
|
||||
self.assertIn("const char* stop", source)
|
||||
self.assertNotIn("i2s_controller_", source)
|
||||
|
||||
def test_ui_autostarts_and_keeps_only_the_latest_thirty_words(self):
|
||||
source = (APP / "main" / "Source" / "main.c").read_text()
|
||||
self.assertIn("#define DISPLAY_WORD_LIMIT 50", source)
|
||||
self.assertIn("copy_recent_words", source)
|
||||
self.assertIn("tt_lvgl_toolbar_create_for_app", source)
|
||||
self.assertNotIn("lv_btn_create", source)
|
||||
self.assertNotIn("start_button", source)
|
||||
self.assertIn("xTaskCreate(worker_task", source)
|
||||
|
||||
def test_connection_failure_stays_failed_until_user_starts_again(self):
|
||||
source = (APP / "main" / "Source" / "main.c").read_text()
|
||||
self.assertIn("CAPTION_FAILED", source)
|
||||
self.assertIn("Fail to connect", source)
|
||||
self.assertNotIn("retry_attempt", source)
|
||||
self.assertNotIn("retry scheduled", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MP3_SOURCE = Path(__file__).parents[1] / "Apps" / "Mp3Player" / "main" / "Source" / "main.c"
|
||||
|
||||
|
||||
def test_mp3_player_uses_the_firmware_audio_stream_device_name():
|
||||
source = MP3_SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
assert 'device_find_by_name("audio-stream0")' in source
|
||||
assert 'device_find_by_name("audio-stream")' not in source
|
||||
assert "device_get_first_by_type" not in source
|
||||
assert "device_find_first_by_type" not in source
|
||||
|
||||
|
||||
def test_mp3_player_restores_a_visible_persisted_volume_control_above_the_six_button_row():
|
||||
source = MP3_SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
assert "lv_obj_t* card = lv_obj_create(parent);" not in source
|
||||
assert "lv_label_set_text(icon, LV_SYMBOL_AUDIO);" not in source
|
||||
assert "lv_obj_t* vol_box = lv_obj_create(parent);" in source
|
||||
assert "g_ctx.slider_volume = lv_slider_create(vol_box);" in source
|
||||
assert "lv_slider_set_value(g_ctx.slider_volume, g_ctx.volume, LV_ANIM_OFF);" in source
|
||||
assert "on_volume_slider_changed" in source
|
||||
assert "save_volume(ctx);" in source
|
||||
assert "lv_obj_set_size(bottom_box, lv_pct(100), 52);" in source
|
||||
assert "lv_obj_t* btn_close = lv_btn_create(ctrl_box);" in source
|
||||
assert "lv_obj_t* btn_hist = lv_btn_create(ctrl_box);" in source
|
||||
assert "lv_label_set_text(lbl_close, LV_SYMBOL_CLOSE);" in source
|
||||
assert "lv_label_set_text(lbl_hist_btn, LV_SYMBOL_DIRECTORY);" in source
|
||||
assert "lv_label_set_text(lbl_back, \"-15s\");" in source
|
||||
assert "lv_label_set_text(lbl_fwd, \"+15s\");" in source
|
||||
|
||||
|
||||
def test_mp3_player_folder_button_returns_to_the_files_list():
|
||||
source = MP3_SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
library_callback = source.split("static void on_library_click", 1)[1].split("/* ─── App Lifecycle", 1)[0]
|
||||
assert "tt_app_stop();" not in library_callback
|
||||
assert "show_history_screen(&g_ctx);" in library_callback
|
||||
assert "load_first_sd_mp3" not in library_callback
|
||||
|
||||
|
||||
def test_mp3_player_history_view_and_fifteen_second_seek_are_present():
|
||||
source = MP3_SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
assert "#define SEEK_SECONDS 15" in source
|
||||
assert 'tt_app_get_user_data_child_path(app, "play_history.txt"' in source
|
||||
assert "static void show_history_screen" in source
|
||||
assert "static void on_history_selected" in source
|
||||
assert "char label[640];" in source
|
||||
assert "request_seek(ctx, -SEEK_SECONDS);" in source
|
||||
assert "request_seek(ctx, SEEK_SECONDS);" in source
|
||||
|
||||
|
||||
def test_mp3_player_persists_and_resumes_short_tracks_too():
|
||||
source = MP3_SOURCE.read_text(encoding="utf-8")
|
||||
history_selector = source.split("static void on_history_selected", 1)[1].split("static void refresh_history_list", 1)[0]
|
||||
|
||||
assert "Skip history: audio too short" not in source
|
||||
assert "Resuming last 10min+ play" not in source
|
||||
assert "Resuming last play %s at %d sec from history" in source
|
||||
assert "if (he->total_sec >= 600)" not in history_selector
|
||||
assert "resume_pos = he->pos_sec;" in history_selector
|
||||
|
||||
|
||||
def test_mp3_player_close_waits_for_playback_before_stopping_the_app():
|
||||
source = MP3_SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
close_callback = source.split("static void on_close_click", 1)[1].split("static void on_library_click", 1)[0]
|
||||
assert "wait_for_playback_task_to_exit(&g_ctx);" in close_callback
|
||||
assert close_callback.index("wait_for_playback_task_to_exit(&g_ctx);") < close_callback.index("tt_app_stop();")
|
||||
assert "tt_lvgl_unlock();" in source
|
||||
assert "tt_lvgl_lock(portMAX_DELAY);" in source
|
||||
|
||||
|
||||
def test_mp3_player_has_a_consumed_dev_autoclose_hook_for_device_tests():
|
||||
source = MP3_SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
assert '"mp3player_dev_autoclose_ms"' in source
|
||||
assert "on_close_click(NULL);" in source
|
||||
assert "unlink(marker_path);" in source
|
||||
@@ -0,0 +1,69 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNNER_PATH = Path(__file__).parents[1] / "scripts" / "mp3_player_device_runner.py"
|
||||
|
||||
|
||||
def load_runner():
|
||||
spec = importlib.util.spec_from_file_location("mp3_player_device_runner", RUNNER_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_resume_trace_requires_playback_persistence_and_same_path_resume():
|
||||
runner = load_runner()
|
||||
fixture = "/sdcard/download/test.mp3"
|
||||
trace = "\n".join(
|
||||
[
|
||||
"I Mp3Player: Starting MP3 playback: /sdcard/download/test.mp3 (size: 480000 bytes) resume=0",
|
||||
"I Mp3Player: Audio stream opened: 16000 Hz, 1 channels",
|
||||
"I Mp3Player: History saved on hide: /sdcard/download/test.mp3 pos 9 total 30",
|
||||
"I Mp3Player: Resuming last play /sdcard/download/test.mp3 at 9 sec from history",
|
||||
"I Mp3Player: Starting MP3 playback: /sdcard/download/test.mp3 (size: 480000 bytes) resume=9",
|
||||
]
|
||||
)
|
||||
|
||||
result = runner.evaluate_resume_trace(trace, fixture, minimum_position=6)
|
||||
|
||||
assert result == {"persisted_position": 9, "resumed_position": 9}
|
||||
|
||||
|
||||
def test_resume_trace_rejects_a_different_file_or_no_playback_evidence():
|
||||
runner = load_runner()
|
||||
fixture = "/sdcard/download/test.mp3"
|
||||
|
||||
try:
|
||||
runner.evaluate_resume_trace(
|
||||
"I Mp3Player: Starting MP3 playback: /sdcard/other.mp3 (size: 1 bytes) resume=0",
|
||||
fixture,
|
||||
minimum_position=6,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
assert "playback" in str(exc).lower()
|
||||
else:
|
||||
raise AssertionError("missing playback evidence must fail")
|
||||
|
||||
|
||||
def test_resume_trace_rejects_forced_playback_task_termination():
|
||||
runner = load_runner()
|
||||
fixture = "/sdcard/download/test.mp3"
|
||||
trace = "\n".join(
|
||||
[
|
||||
f"I Mp3Player: Starting MP3 playback: {fixture} (size: 480000 bytes) resume=0",
|
||||
"I Mp3Player: Audio stream opened: 16000 Hz, 1 channels",
|
||||
f"I Mp3Player: History saved on hide: {fixture} pos 9 total 180",
|
||||
"W Mp3Player: Playback task stuck, force deleting",
|
||||
f"I Mp3Player: Resuming last play {fixture} at 9 sec from history",
|
||||
f"I Mp3Player: Starting MP3 playback: {fixture} (size: 480000 bytes) resume=9",
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
runner.evaluate_resume_trace(trace, fixture, minimum_position=6)
|
||||
except RuntimeError as exc:
|
||||
assert "forced" in str(exc).lower()
|
||||
else:
|
||||
raise AssertionError("forced task termination must fail the device test")
|
||||
Reference in New Issue
Block a user