7 Commits

Author SHA1 Message Date
Adolfo Reyna 0fb3c22f79 feat: add McpScreen phase 2 display tools
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
2026-06-25 12:43:08 -04:00
Adolfo Reyna ac9d6dc0d3 feat: add Tactility MCP Screen foundation 2026-06-24 23:48:21 -04:00
Adolfo Reyna fb800bb5bd docs: add McpScreen implementation plan 2026-06-24 23:19:03 -04:00
Adolfo Reyna e5233efdf3 chore: save Tactility app experiments 2026-06-24 23:17:40 -04:00
Adolfo Reyna ca55f16085 feat(audiotest): add AudioTest application for testing recording and playback 2026-06-23 18:32:44 -04:00
Adolfo Reyna 3c6acf47a5 Add touch support to Snake game by dividing the screen into Up/Down/Left/Right zones
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
2026-06-23 12:16:33 -04:00
Adolfo Reyna 23e969c1b6 Configure HelloWorld manifest target platforms for esp32s3
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
2026-06-23 11:59:51 -04:00
94 changed files with 3454 additions and 1629 deletions
-21
View File
@@ -1,21 +0,0 @@
name: Publish Apps
inputs:
sdk_version:
description: The SDK version that determines the path on the CDN
required: true
runs:
using: 'composite'
steps:
- name: 'Download cdn-files'
uses: actions/download-artifact@v4
with:
name: 'cdn-files'
path: cdn_files
- name: 'Install boto3'
shell: bash
run: pip install boto3
- name: 'Upload files'
shell: bash
run: python Buildscripts/CDN/upload-app-files.py cdn_files ${{ inputs.sdk_version }} ${{ env.CDN_ID }} ${{ env.CDN_TOKEN_NAME }} ${{ env.CDN_TOKEN_VALUE }}
+1 -9
View File
@@ -1,10 +1,5 @@
name: Release Apps name: Release Apps
outputs:
sdk_version:
description: 'Common SDK version shared by all bundled apps'
value: ${{ steps.release.outputs.sdk_version }}
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
@@ -16,11 +11,8 @@ runs:
run: rsync -av downloaded_apps/*/*.app cdn_files/ run: rsync -av downloaded_apps/*/*.app cdn_files/
shell: bash shell: bash
- name: 'Create CDN release files' - name: 'Create CDN release files'
id: release run: python release.py cdn_files/
shell: bash shell: bash
run: |
python release.py cdn_files/
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
- name: 'Upload Artifact' - name: 'Upload Artifact'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
+1 -22
View File
@@ -12,12 +12,10 @@ jobs:
Build: Build:
strategy: strategy:
matrix: matrix:
app_name: [Brainfuck, Breakout, Calculator, Diceware, EpubReader, EspNowBridge, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven] app_name: [Brainfuck, Breakout, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Build" - name: "Build"
uses: ./.github/actions/build-app uses: ./.github/actions/build-app
with: with:
@@ -25,26 +23,7 @@ jobs:
Bundle: Bundle:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Build] needs: [Build]
outputs:
sdk_version: ${{ steps.release.outputs.sdk_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: "Build" - name: "Build"
id: release
uses: ./.github/actions/release-apps uses: ./.github/actions/release-apps
PublishApps:
runs-on: ubuntu-latest
needs: [Bundle]
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Publish Apps"
env:
CDN_ID: ${{ secrets.CDN_ID }}
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
uses: ./.github/actions/publish-apps
with:
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
+16
View File
@@ -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(AudioTest)
tactility_project(AudioTest)
+6
View File
@@ -0,0 +1,6 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK
)
+337
View File
@@ -0,0 +1,337 @@
/**
* Audio Test - Record & Playback
*
* Records from the ES8311 microphone via I2S and plays it back through
* the FM8002E speaker amplifier. Uses the Tactility kernel I2S controller API.
*
* UI: Two buttons - Record and Play
* Record: holds recording for up to ~3 seconds (16kHz 16-bit mono ≈ 96KB)
* Play: plays back the last recorded buffer
*/
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/device.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#define TAG "AudioTest"
/* ─── Audio params ─── */
#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE 16
#define RECORD_SECONDS 3
/* 16-bit mono → 2 bytes per sample */
#define AUDIO_BUF_SIZE (SAMPLE_RATE * (BITS_PER_SAMPLE / 8) * RECORD_SECONDS)
/* Chunk size for read/write calls (512 samples = 1024 bytes at 16-bit) */
#define CHUNK_SAMPLES 512
#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8))
/* ─── App state ─── */
typedef enum {
STATE_IDLE,
STATE_RECORDING,
STATE_PLAYING
} AudioState;
typedef struct {
struct Device* i2s_dev;
uint8_t* audio_buf;
size_t recorded_bytes;
AudioState state;
lv_obj_t* btn_record;
lv_obj_t* btn_play;
lv_obj_t* lbl_status;
lv_obj_t* bar_progress;
TaskHandle_t task_handle;
} AppCtx;
/* ─── Forward decls ─── */
static void record_task(void* arg);
static void play_task(void* arg);
static void update_ui(AppCtx* ctx);
/* ─── Callbacks ─── */
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;
ctx->state = STATE_RECORDING;
ctx->recorded_bytes = 0;
update_ui(ctx);
xTaskCreate(record_task, "audio_rec", 4096, ctx, 5, &ctx->task_handle);
}
static void on_play_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->state != STATE_IDLE) return;
if (ctx->recorded_bytes == 0) return;
if (ctx->i2s_dev == NULL) return;
ctx->state = STATE_PLAYING;
update_ui(ctx);
xTaskCreate(play_task, "audio_play", 4096, ctx, 5, &ctx->task_handle);
}
/* ─── Update UI labels/buttons from any context ─── */
static void update_ui(AppCtx* ctx) {
if (ctx->lbl_status == NULL) return;
switch (ctx->state) {
case STATE_RECORDING:
lv_label_set_text(ctx->lbl_status, "🔴 Recording...");
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
break;
case STATE_PLAYING:
lv_label_set_text(ctx->lbl_status, "🔊 Playing...");
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
break;
case STATE_IDLE:
default:
if (ctx->recorded_bytes > 0) {
char buf[64];
snprintf(buf, sizeof(buf), "Ready (%u bytes recorded)", (unsigned)ctx->recorded_bytes);
lv_label_set_text(ctx->lbl_status, buf);
} else {
lv_label_set_text(ctx->lbl_status, "Press Record to capture audio");
}
lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED);
if (ctx->recorded_bytes > 0) {
lv_obj_clear_state(ctx->btn_play, LV_STATE_DISABLED);
} else {
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
}
break;
}
}
/* ─── Record task ─── */
static void record_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
size_t total = 0;
size_t bytes_read = 0;
/* Configure I2S for recording */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channel_left = 0,
.channel_right = I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to set I2S config for recording: %d", err);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Recording started (max %d bytes)", AUDIO_BUF_SIZE);
while (total < AUDIO_BUF_SIZE && ctx->state == STATE_RECORDING) {
size_t remaining = AUDIO_BUF_SIZE - total;
size_t to_read = (remaining < CHUNK_BYTES) ? remaining : CHUNK_BYTES;
err = i2s_controller_read(ctx->i2s_dev,
ctx->audio_buf + total,
to_read,
&bytes_read,
pdMS_TO_TICKS(1000));
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "I2S read error: %d", err);
break;
}
total += bytes_read;
/* Update progress bar */
int pct = (int)((total * 100) / AUDIO_BUF_SIZE);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
ctx->recorded_bytes = total;
ctx->state = STATE_IDLE;
ctx->task_handle = NULL;
ESP_LOGI(TAG, "Recording complete: %u bytes", (unsigned)total);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF);
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
}
/* ─── Play task ─── */
static void play_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
size_t total = 0;
size_t bytes_written = 0;
/* Configure I2S for playback */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channel_left = 0,
.channel_right = I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Playback started (%u bytes)", (unsigned)ctx->recorded_bytes);
while (total < ctx->recorded_bytes && ctx->state == STATE_PLAYING) {
size_t remaining = ctx->recorded_bytes - total;
size_t to_write = (remaining < CHUNK_BYTES) ? remaining : CHUNK_BYTES;
err = i2s_controller_write(ctx->i2s_dev,
ctx->audio_buf + total,
to_write,
&bytes_written,
pdMS_TO_TICKS(1000));
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "I2S write error: %d", err);
break;
}
total += bytes_written;
/* Update progress bar */
int pct = (int)((total * 100) / ctx->recorded_bytes);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
ctx->state = STATE_IDLE;
ctx->task_handle = NULL;
ESP_LOGI(TAG, "Playback complete");
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF);
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
}
/* ─── App lifecycle ─── */
static AppCtx g_ctx;
static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
memset(&g_ctx, 0, sizeof(g_ctx));
/* 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 (%d bytes)", AUDIO_BUF_SIZE);
}
/* Find I2S device */
g_ctx.i2s_dev = device_find_by_name("i2s0");
if (g_ctx.i2s_dev == NULL) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
} else {
ESP_LOGI(TAG, "Found I2S device: %s", g_ctx.i2s_dev->name);
}
/* ─── UI ─── */
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
/* Status label */
g_ctx.lbl_status = lv_label_create(parent);
lv_label_set_text(g_ctx.lbl_status, "Initializing...");
lv_obj_set_width(g_ctx.lbl_status, lv_pct(90));
lv_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(g_ctx.lbl_status, LV_ALIGN_TOP_MID, 0, 50);
/* Progress bar */
g_ctx.bar_progress = lv_bar_create(parent);
lv_obj_set_size(g_ctx.bar_progress, lv_pct(80), 10);
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
lv_obj_align(g_ctx.bar_progress, LV_ALIGN_CENTER, 0, -10);
/* Button container */
lv_obj_t* btn_container = lv_obj_create(parent);
lv_obj_remove_style_all(btn_container);
lv_obj_set_size(btn_container, lv_pct(90), 50);
lv_obj_set_flex_flow(btn_container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btn_container, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_align(btn_container, LV_ALIGN_CENTER, 0, 30);
/* Record button */
g_ctx.btn_record = lv_btn_create(btn_container);
lv_obj_set_size(g_ctx.btn_record, 100, 40);
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO " Record");
lv_obj_center(lbl_rec);
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
/* Play button */
g_ctx.btn_play = lv_btn_create(btn_container);
lv_obj_set_size(g_ctx.btn_play, 100, 40);
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play);
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY " Play");
lv_obj_center(lbl_play);
lv_obj_add_event_cb(g_ctx.btn_play, on_play_click, LV_EVENT_CLICKED, &g_ctx);
/* Check initialization status */
if (g_ctx.i2s_dev == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S device not found");
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play, LV_STATE_DISABLED);
} else if (g_ctx.audio_buf == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: Out of memory");
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play, LV_STATE_DISABLED);
} else {
g_ctx.state = STATE_IDLE;
update_ui(&g_ctx);
}
}
int main(int argc, char* argv[]) {
tt_app_register((AppRegistration) {
.onShow = onShowApp
});
return 0;
}
+10
View File
@@ -0,0 +1,10 @@
[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
+4 -4
View File
@@ -1,6 +1,6 @@
#include "Brainfuck.h" #include "Brainfuck.h"
#include <tt_app.h> #include <tt_app.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <dirent.h> #include <dirent.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -399,11 +399,11 @@ void Brainfuck::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Brainfuck interpreter"); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
clrBtn = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onClearClicked, nullptr); clrBtn = tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onClearClicked, nullptr);
lv_obj_add_flag(clrBtn, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(clrBtn, LV_OBJ_FLAG_HIDDEN);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onExamplesClicked, nullptr); tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onExamplesClicked, nullptr);
lv_obj_t* cont = lv_obj_create(parent); lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100)); lv_obj_set_width(cont, LV_PCT(100));
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.brainfuck sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Brainfuck interpreter id=one.tactility.brainfuck
app.description=Brainfuck esoteric language interpreter versionName=0.2.0
versionCode=2
name=Brainfuck interpreter
description=Brainfuck esoteric language interpreter
+7 -7
View File
@@ -7,14 +7,13 @@
#include <cstdio> #include <cstdio>
#include <cmath> #include <cmath>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tt_preferences.h> #include <tt_preferences.h>
#include <esp_random.h> #include <esp_random.h>
#include <tactility/device.h> #include <tt_lvgl_keyboard.h>
#include <tactility/drivers/keyboard.h>
#include <lvgl/lvgl.h> #include <tactility/lvgl_module.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
constexpr auto* TAG = "Breakout"; constexpr auto* TAG = "Breakout";
@@ -125,11 +124,12 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
if (!sfxEngine) { if (!sfxEngine) {
sfxEngine = new SfxEngine(); sfxEngine = new SfxEngine();
sfxEngine->start(); sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
sfxEngine->setEnabled(soundEnabled); sfxEngine->setEnabled(soundEnabled);
} }
// Toolbar // Toolbar
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Breakout"); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
// Score wrapper in toolbar // Score wrapper in toolbar
lv_obj_t* scoreWrap = lv_obj_create(toolbar); lv_obj_t* scoreWrap = lv_obj_create(toolbar);
@@ -1330,7 +1330,7 @@ void Breakout::updateMessage() {
case GameState::Ready: { case GameState::Ready: {
char buf[64]; char buf[64];
const char* input_hint = "Touch"; const char* input_hint = "Touch";
if (device_has_active_by_type(&KEYBOARD_TYPE)) { if (tt_lvgl_hardware_keyboard_is_available()) {
input_hint = "Space"; input_hint = "Space";
} }
if (level > 1) { if (level > 1) {
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.breakout sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=Breakout id=one.tactility.breakout
app.description=Classic brick-breaking arcade game versionName=0.2.0
versionCode=2
name=Breakout
description=Classic brick-breaking arcade game
+2 -2
View File
@@ -2,7 +2,7 @@
#include <cstdio> #include <cstdio>
#include <ctype.h> #include <ctype.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <stack> #include <stack>
#include <cstring> #include <cstring>
@@ -148,7 +148,7 @@ void Calculator::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Calculator"); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_t* wrapper = lv_obj_create(parent); lv_obj_t* wrapper = lv_obj_create(parent);
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.calculator sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=Calculator id=one.tactility.calculator
versionName=0.3.0
versionCode=3
name=Calculator
+12 -11
View File
@@ -1,9 +1,9 @@
#include "Diceware.h" #include "Diceware.h"
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <tactility/filesystem/file_mutex.h> #include <tt_lock.h>
#include <lvgl/lvgl.h> #include <tt_lvgl.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <esp_random.h> #include <esp_random.h>
#include <esp_log.h> #include <esp_log.h>
@@ -39,17 +39,18 @@ static std::string readWordAtLine(const AppHandle handle, const int lineIndex) {
return ""; return "";
} }
struct FileMutex mutex; auto lock = tt_lock_alloc_for_path(path);
file_mutex_get(&mutex, path);
std::string word; std::string word;
file_mutex_lock(&mutex); if (tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) {
FILE* file = fopen(path, "r"); FILE* file = fopen(path, "r");
if (file != nullptr) { if (file != nullptr) {
skipNewlines(file, lineIndex); skipNewlines(file, lineIndex);
word = readWord(file); word = readWord(file);
fclose(file); fclose(file);
} else { ESP_LOGE(TAG, "Failed to open %s", path); } } else { ESP_LOGE(TAG, "Failed to open %s", path); }
file_mutex_unlock(&mutex); tt_lock_release(lock);
} else { ESP_LOGE(TAG, "Failed to acquire lock for %s", path); }
tt_lock_free(lock);
return word; return word;
} }
@@ -86,9 +87,9 @@ void Diceware::startJob(uint32_t jobWordCount) {
} }
void Diceware::onFinishJob(std::string result) { void Diceware::onFinishJob(std::string result) {
lvgl_lock(); tt_lvgl_lock(tt::kernel::MAX_TICKS);
lv_label_set_text(resultLabel, result.c_str()); lv_label_set_text(resultLabel, result.c_str());
lvgl_unlock(); tt_lvgl_unlock();
} }
void Diceware::onClickGenerate(lv_event_t* e) { void Diceware::onClickGenerate(lv_event_t* e) {
@@ -122,8 +123,8 @@ void Diceware::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* toolbar = lvgl_toolbar_create(parent, "Diceware"); auto* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
lvgl_toolbar_add_text_button_action(toolbar, "?", onHelpClicked, nullptr); tt_lvgl_toolbar_add_text_button_action(toolbar, "?", onHelpClicked, nullptr);
auto* wrapper = lv_obj_create(parent); auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.diceware sdk=0.7.0-dev
app.version.name=0.8.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=8 [app]
app.name=Diceware id=one.tactility.diceware
versionName=0.3.0
versionCode=3
name=Diceware
+2 -2
View File
@@ -1,7 +1,7 @@
#include "EpubReader.h" #include "EpubReader.h"
#include "HtmlStrip.h" // stripHtmlToText #include "HtmlStrip.h" // stripHtmlToText
#include <tt_bundle.h> #include <tt_bundle.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <tt_app_selectiondialog.h> #include <tt_app_selectiondialog.h>
#include <tactility/log.h> #include <tactility/log.h>
@@ -239,7 +239,7 @@ void EpubReader::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
toolbar_ = lvgl_toolbar_create(parent, "Epub Reader"); toolbar_ = tt_lvgl_toolbar_create_for_app(parent, app);
wrapperWidget_ = lv_obj_create(parent); wrapperWidget_ = lv_obj_create(parent);
lv_obj_set_width(wrapperWidget_, LV_PCT(100)); lv_obj_set_width(wrapperWidget_, LV_PCT(100));
@@ -1,6 +1,6 @@
#include "EpubReader.h" #include "EpubReader.h"
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tactility/filesystem/file_mutex.h> #include <tt_lock.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <Tactility/kernel/Kernel.h> #include <Tactility/kernel/Kernel.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
@@ -43,7 +43,7 @@ void EpubReader::spawnOpenTask(EpubReader* self, bool restore) {
// Show a brief placeholder so old content doesn't linger during the open // Show a brief placeholder so old content doesn't linger during the open
lv_obj_clean(self->wrapperWidget_); lv_obj_clean(self->wrapperWidget_);
lvgl_toolbar_clear_actions(self->toolbar_); tt_lvgl_toolbar_clear_actions(self->toolbar_);
lv_obj_t* lbl = lv_label_create(self->wrapperWidget_); lv_obj_t* lbl = lv_label_create(self->wrapperWidget_);
lv_obj_set_style_pad_all(lbl, 8, 0); lv_obj_set_style_pad_all(lbl, 8, 0);
lv_label_set_text(lbl, restore ? "Loading..." : "Opening..."); lv_label_set_text(lbl, restore ? "Loading..." : "Opening...");
@@ -115,9 +115,14 @@ void EpubReader::backgroundOpenTask(void* data) {
// Acquire the filesystem lock before any SD card I/O - prevents concurrent // Acquire the filesystem lock before any SD card I/O - prevents concurrent
// SDMMC access from the background and LVGL tasks (bus errors 0x107/0x108). // SDMMC access from the background and LVGL tasks (bus errors 0x107/0x108).
struct FileMutex mutex; auto lock = tt_lock_alloc_for_path(a->filePath.c_str());
file_mutex_get(&mutex, a->filePath.c_str()); if (!tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) {
file_mutex_lock(&mutex); LOG_E(TAG, "FS lock timed out, skipping open: %s", a->filePath.c_str());
tt_lock_free(lock);
lv_async_call(asyncOpenComplete, a);
vTaskDelete(nullptr);
return;
}
if (isTextFile(a->filePath)) { if (isTextFile(a->filePath)) {
// Read the entire text file here (under the lock) so asyncOpenComplete // Read the entire text file here (under the lock) so asyncOpenComplete
@@ -141,7 +146,8 @@ void EpubReader::backgroundOpenTask(void* data) {
a->epub = EpubService::open(a->filePath); a->epub = EpubService::open(a->filePath);
} }
file_mutex_unlock(&mutex); tt_lock_release(lock);
tt_lock_free(lock);
// Signal the LVGL task that the work is done // Signal the LVGL task that the work is done
lv_async_call(asyncOpenComplete, a); lv_async_call(asyncOpenComplete, a);
+8 -8
View File
@@ -1,5 +1,5 @@
#include "EpubReader.h" #include "EpubReader.h"
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
#include <dirent.h> #include <dirent.h>
@@ -37,20 +37,20 @@ static void setListBtnLongMode(lv_obj_t* btn, lv_label_long_mode_t mode) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void EpubReader::setReaderToolbarButtons() { void EpubReader::setReaderToolbarButtons() {
lvgl_toolbar_clear_actions(toolbar_); tt_lvgl_toolbar_clear_actions(toolbar_);
lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_PREV, onPrevPressed, this); tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_PREV, onPrevPressed, this);
if (!textMode_) { if (!textMode_) {
lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_LIST, onTocPressed, this); tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_LIST, onTocPressed, this);
} }
lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_NEXT, onNextPressed, this); tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_NEXT, onNextPressed, this);
lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onBrowsePressed, this); tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onBrowsePressed, this);
} }
void EpubReader::setBrowserToolbarButtons() { void EpubReader::setBrowserToolbarButtons() {
lvgl_toolbar_clear_actions(toolbar_); tt_lvgl_toolbar_clear_actions(toolbar_);
// Show "Use Folder" button when the current browse path isn't already the saved books folder // Show "Use Folder" button when the current browse path isn't already the saved books folder
if (browsePath_ != booksPath_) { if (browsePath_ != booksPath_) {
lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onSetBooksFolder, this); tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onSetBooksFolder, this);
} }
} }
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.epubreader sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32s3,esp32p4
app.version.code=5 [app]
app.name=Epub Reader id=one.tactility.epubreader
app.description=Epub and text file reader. Requires PSRAM! versionName=0.1.0
versionCode=1
name=Epub Reader
description=Epub and text file reader. Requires PSRAM!
Binary file not shown.
-11
View File
@@ -1,11 +0,0 @@
file(GLOB_RECURSE SOURCE_FILES
Source/*.c*
)
idf_component_register(
SRCS ${SOURCE_FILES}
# Library headers must be included directly,
# because all regular dependencies get stripped by elf_loader's cmake script
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
REQUIRES TactilitySDK bootloader_support esp_app_format
)
@@ -1,740 +0,0 @@
#include "EspNowBridge.h"
#include <tactility/device.h>
#include <tactility/drivers/wifi.h>
#include <tactility/wifi_auto_scan.h>
#include <tactility/firmware/firmware.h>
#include <tt_app.h>
#include <tt_app_fileselection.h>
#include <tt_bundle.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <esp_app_desc.h>
#include <esp_app_format.h>
#include <esp_system.h>
#include <tactility/log.h>
constexpr TickType_t LVGL_DEFAULT_LOCK_TIME = 500; // 500 ticks = 500 ms
#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 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 = lvgl_try_lock(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);
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 = lvgl_toolbar_create(parent, "ESP-NOW Bridge");
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(wrapper, 8, LV_STATE_DEFAULT);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
currentVersionLabel_ = lv_label_create(wrapper);
lv_obj_set_style_pad_bottom(currentVersionLabel_, 12, LV_STATE_DEFAULT);
enableWifiButton_ = lv_button_create(wrapper);
lv_obj_add_event_cb(enableWifiButton_, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr);
auto* enableWifiButtonLabel = lv_label_create(enableWifiButton_);
lv_label_set_text(enableWifiButtonLabel, "Enable WiFi (required for co-processor link)");
lv_obj_set_style_pad_bottom(enableWifiButton_, 12, LV_STATE_DEFAULT);
updateBundledButton_ = lv_button_create(wrapper);
lv_obj_add_event_cb(updateBundledButton_, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr);
auto* updateBundledButtonLabel = lv_label_create(updateBundledButton_);
lv_label_set_text(updateBundledButtonLabel, "Update to bundled firmware");
lv_obj_set_style_pad_bottom(updateBundledButton_, 12, LV_STATE_DEFAULT);
updateButton_ = lv_button_create(wrapper);
lv_obj_add_event_cb(updateButton_, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr);
auto* updateButtonLabel = lv_label_create(updateButton_);
lv_label_set_text(updateButtonLabel, "Update from SD card...");
lv_obj_set_style_pad_bottom(updateButton_, 12, LV_STATE_DEFAULT);
progressBar_ = lv_bar_create(wrapper);
lv_obj_set_size(progressBar_, LV_PCT(100), LV_PCT(6));
lv_bar_set_range(progressBar_, 0, 100);
lv_bar_set_value(progressBar_, 0, LV_ANIM_OFF);
statusLabel_ = lv_label_create(wrapper);
lv_label_set_text(statusLabel_, "Ready");
wifiDevice_ = wifi_find_first_registered_device();
if (wifiDevice_ != nullptr) {
wifi_add_event_callback(wifiDevice_, this, onWifiEvent);
if (wifi_get_firmware_ops(wifiDevice_, &firmwareOps_, &firmwareCtx_) != ERROR_NONE) {
firmwareOps_ = nullptr;
firmwareCtx_ = nullptr;
}
}
refreshCurrentVersion();
refreshWifiPrompt();
// If an SD-card file was picked before this onShow() ran (FileSelection tears down and
// rebuilds this app's whole widget tree), perform the update now that widgets are valid
// again. The bundled-firmware button doesn't go through this path - it calls
// startUpdateTask() directly since there's no separate app launch/result round trip involved.
if (!pendingUpdateFilePath_.empty()) {
std::string path = std::move(pendingUpdateFilePath_);
pendingUpdateFilePath_.clear();
startUpdateTask(path);
}
}
void EspNowBridge::onHide(AppHandle /*app*/) {
isShown_ = false;
if (wifiDevice_ != nullptr) {
wifi_remove_event_callback(wifiDevice_, onWifiEvent);
wifiDevice_ = nullptr;
}
}
void EspNowBridge::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, AppResult result, BundleHandle resultData) {
if (launchId != pickFileLaunchId_) {
return;
}
pickFileLaunchId_ = 0;
if (result == APP_RESULT_OK && resultData != nullptr) {
char pathBuf[256] = {};
if (tt_app_fileselection_get_result_path(resultData, pathBuf, sizeof(pathBuf))) {
pendingUpdateFilePath_ = pathBuf;
}
}
}
@@ -1,108 +0,0 @@
#pragma once
#include <TactilityCpp/App.h>
#include <atomic>
#include <optional>
#include <string>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lvgl.h>
#include <tactility/drivers/wifi.h>
/** RAII guard: pauses WifiService's background auto-connect scan for the guard's lifetime. See
* tactility/wifi_auto_scan.h - belt-and-suspenders measure, not sufficient on its own (see the
* REBOOT comment in EspNowBridge.cpp). */
class AutoScanPauseGuard {
public:
AutoScanPauseGuard();
~AutoScanPauseGuard();
AutoScanPauseGuard(const AutoScanPauseGuard&) = delete;
AutoScanPauseGuard& operator=(const AutoScanPauseGuard&) = delete;
};
class EspNowBridge final : public App {
public:
EspNowBridge() = default;
EspNowBridge(const EspNowBridge&) = delete;
EspNowBridge& operator=(const EspNowBridge&) = delete;
void onCreate(AppHandle app) override;
void onDestroy(AppHandle app) override;
void onShow(AppHandle app, lv_obj_t* parent) override;
void onHide(AppHandle app) override;
void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
// Public so the free-function dispatchToUi() work callbacks in EspNowBridge.cpp (which run
// outside any member-function's lexical scope, unlike the inline lambdas in performUpdate())
// can call them.
void setStatus(const std::string& text);
void setProgress(int percent);
private:
AppHandle appHandle_ = nullptr;
AppLaunchId pickFileLaunchId_ = 0;
std::string pendingUpdateFilePath_;
Device* wifiDevice_ = nullptr;
// Resolved once in onShow() via wifi_get_firmware_ops() - null on a WiFi device with no
// updatable co-processor (e.g. a native, non-hosted chip). All OTA/version-query calls go
// through this generic interface, not any esp_hosted-specific API directly.
const FirmwareOps* firmwareOps_ = nullptr;
void* firmwareCtx_ = nullptr;
// Set once in onShow(), false once onHide() tears the widget tree down - checked (via
// dispatchToUi(), below) before touching any lv_obj_t*, since the OTA worker task and the
// WiFi-event callback can both outlive a hide/app-switch.
std::atomic<bool> isShown_{false};
// Only one EspNowBridge instance is ever live at a time (app loader owns a single instance
// per running app), so a single static "is this instance still current" pointer, guarded by
// an atomic, substitutes for the internal app's shared_ptr-based lifetime guard - the OTA
// worker task and dispatchToUi()'s lv_async_call closures check liveInstance_ == this before
// touching any member, instead of holding a shared_ptr to keep `this` alive.
static std::atomic<EspNowBridge*> liveInstance_;
TaskHandle_t updateTask_ = nullptr;
// Number of background tasks (updateTaskEntry, waitForTransportTaskEntry) currently running
// against this instance's members. onDestroy() must wait for this to hit 0 before returning -
// the app framework frees this instance shortly after onDestroy() returns (see Loader.cpp),
// so any task still touching `this` past that point is a use-after-free.
std::atomic<int> outstandingTasks_{0};
SemaphoreHandle_t taskDoneSemaphore_ = nullptr;
// Outlives performUpdate() deliberately, so auto-scan stays paused across the async gap
// between performUpdate() returning and the automatic restart - see performUpdate().
std::optional<AutoScanPauseGuard> heldAutoScanPauseGuard_;
lv_obj_t* currentVersionLabel_ = nullptr;
lv_obj_t* statusLabel_ = nullptr;
lv_obj_t* progressBar_ = nullptr;
lv_obj_t* updateButton_ = nullptr;
lv_obj_t* updateBundledButton_ = nullptr;
lv_obj_t* enableWifiButton_ = nullptr;
void refreshCurrentVersion();
bool isWifiRadioOn();
void refreshWifiPrompt();
/** Enables/disables both update-trigger buttons together - only one performUpdate() can run
* at a time (see updateTask_), regardless of which button started it. */
void setUpdateButtonsDisabled(bool disabled);
/** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance_ is
* still this instance (checked at dispatch time and again right before running, on the LVGL
* task) and isShown_ is true (this app's widget tree exists). */
void dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*));
void performUpdate(const std::string& filePath);
void startUpdateTask(const std::string& filePath);
static void updateTaskEntry(void* arg);
static void onUpdateButtonClicked(lv_event_t* event);
static void onUpdateBundledButtonClicked(lv_event_t* event);
static void onEnableWifiButtonClicked(lv_event_t* event);
static void onWifiEvent(Device* device, void* callbackContext, WifiEvent event);
static void waitForTransportTaskEntry(void* arg);
};
-11
View File
@@ -1,11 +0,0 @@
#include "EspNowBridge.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<EspNowBridge>();
return 0;
}
}
-8
View File
@@ -1,8 +0,0 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32p4
app.id=one.tactility.espnowbridge
app.version.name=0.3.0
app.version.code=3
app.name=ESP-NOW Bridge
app.description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support.
+6 -5
View File
@@ -2,9 +2,10 @@
#include <Tactility/kernel/Kernel.h> #include <Tactility/kernel/Kernel.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <lvgl/lvgl.h> #include <tactility/lvgl_module.h>
#include <esp_log.h> #include <esp_log.h>
#include <driver/gpio.h> #include <driver/gpio.h>
@@ -19,7 +20,7 @@ void Gpio::updatePinStates() {
} }
void Gpio::updatePinWidgets() { void Gpio::updatePinWidgets() {
lvgl_lock(); tt_lvgl_lock(tt::kernel::MAX_TICKS);
for (int j = 0; j < pinStates.size(); ++j) { for (int j = 0; j < pinStates.size(); ++j) {
int level = pinStates[j]; int level = pinStates[j];
lv_obj_t* label = pinWidgets[j]; lv_obj_t* label = pinWidgets[j];
@@ -34,7 +35,7 @@ void Gpio::updatePinWidgets() {
} }
} }
} }
lvgl_unlock(); tt_lvgl_unlock();
} }
lv_obj_t* Gpio::createGpioRowWrapper(lv_obj_t* parent) { lv_obj_t* Gpio::createGpioRowWrapper(lv_obj_t* parent) {
@@ -78,7 +79,7 @@ void Gpio::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* toolbar = lvgl_toolbar_create(parent, "GPIO"); auto* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Main content wrapper, enables scrolling content without scrolling the toolbar // Main content wrapper, enables scrolling content without scrolling the toolbar
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.gpio sdk=0.7.0-dev
app.version.name=0.9.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=9 [app]
app.name=GPIO id=one.tactility.gpio
versionName=0.4.0
versionCode=4
name=GPIO
+16 -16
View File
@@ -4,17 +4,17 @@
#include "drivers/Colors.h" #include "drivers/Colors.h"
#include <cstring> #include <cstring>
#include <tactility/drivers/display.h> #include <tt_hal_display.h>
class PixelBuffer { class PixelBuffer {
uint16_t pixelWidth; uint16_t pixelWidth;
uint16_t pixelHeight; uint16_t pixelHeight;
enum DisplayColorFormat colorFormat; ColorFormat colorFormat;
uint8_t* data; uint8_t* data;
public: public:
PixelBuffer(uint16_t pixelWidth, uint16_t pixelHeight, enum DisplayColorFormat colorFormat) : PixelBuffer(uint16_t pixelWidth, uint16_t pixelHeight, ColorFormat colorFormat) :
pixelWidth(pixelWidth), pixelWidth(pixelWidth),
pixelHeight(pixelHeight), pixelHeight(pixelHeight),
colorFormat(colorFormat) colorFormat(colorFormat)
@@ -35,7 +35,7 @@ public:
return pixelHeight; return pixelHeight;
} }
enum DisplayColorFormat getColorFormat() const { ColorFormat getColorFormat() const {
return colorFormat; return colorFormat;
} }
@@ -58,14 +58,14 @@ public:
uint8_t getPixelSize() const { uint8_t getPixelSize() const {
switch (colorFormat) { switch (colorFormat) {
case DISPLAY_COLOR_FORMAT_MONOCHROME: case COLOR_FORMAT_MONOCHROME:
return 1; return 1;
case DISPLAY_COLOR_FORMAT_BGR565: case COLOR_FORMAT_BGR565:
case DISPLAY_COLOR_FORMAT_BGR565_SWAPPED: case COLOR_FORMAT_BGR565_SWAPPED:
case DISPLAY_COLOR_FORMAT_RGB565: case COLOR_FORMAT_RGB565:
case DISPLAY_COLOR_FORMAT_RGB565_SWAPPED: case COLOR_FORMAT_RGB565_SWAPPED:
return 2; return 2;
case DISPLAY_COLOR_FORMAT_RGB888: case COLOR_FORMAT_RGB888:
return 3; return 3;
default: default:
// TODO: Crash with error // TODO: Crash with error
@@ -82,13 +82,13 @@ public:
void setPixel(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b) const { void setPixel(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b) const {
auto address = getPixelAddress(x, y); auto address = getPixelAddress(x, y);
switch (colorFormat) { switch (colorFormat) {
case DISPLAY_COLOR_FORMAT_MONOCHROME: case COLOR_FORMAT_MONOCHROME:
*address = (uint8_t)((uint16_t)r + (uint16_t)g + (uint16_t)b / 3); *address = (uint8_t)((uint16_t)r + (uint16_t)g + (uint16_t)b / 3);
break; break;
case DISPLAY_COLOR_FORMAT_BGR565: case COLOR_FORMAT_BGR565:
Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address)); Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address));
break; break;
case DISPLAY_COLOR_FORMAT_BGR565_SWAPPED: { case COLOR_FORMAT_BGR565_SWAPPED: {
// TODO: Make proper conversion function // TODO: Make proper conversion function
Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address)); Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address));
uint8_t temp = *address; uint8_t temp = *address;
@@ -96,11 +96,11 @@ public:
*(address + 1) = temp; *(address + 1) = temp;
break; break;
} }
case DISPLAY_COLOR_FORMAT_RGB565: { case COLOR_FORMAT_RGB565: {
Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address)); Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address));
break; break;
} }
case DISPLAY_COLOR_FORMAT_RGB565_SWAPPED: { case COLOR_FORMAT_RGB565_SWAPPED: {
// TODO: Make proper conversion function // TODO: Make proper conversion function
Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address)); Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address));
uint8_t temp = *address; uint8_t temp = *address;
@@ -108,7 +108,7 @@ public:
*(address + 1) = temp; *(address + 1) = temp;
break; break;
} }
case DISPLAY_COLOR_FORMAT_RGB888: { case COLOR_FORMAT_RGB888: {
uint8_t pixel[3] = { r, g, b }; uint8_t pixel[3] = { r, g, b };
memcpy(address, pixel, 3); memcpy(address, pixel, 3);
break; break;
@@ -1,47 +1,49 @@
#pragma once #pragma once
#include <tactility/device.h> #include <cassert>
#include <tactility/drivers/display.h> #include <tt_hal_display.h>
#include <Tactility/kernel/Kernel.h> #include <Tactility/kernel/Kernel.h>
/** /**
* Wrapper for display_* device driver functions * Wrapper for tt_hal_display_driver_*
*/ */
class DisplayDriver { class DisplayDriver {
struct Device* device; DisplayDriverHandle handle = nullptr;
public: public:
explicit DisplayDriver(struct Device* device) : device(device) { explicit DisplayDriver(DeviceId id) {
device_get(device); assert(tt_hal_display_driver_supported(id));
handle = tt_hal_display_driver_alloc(id);
assert(handle != nullptr);
} }
~DisplayDriver() { ~DisplayDriver() {
device_put(device); tt_hal_display_driver_free(handle);
} }
bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const { bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const {
return device_try_lock(device, timeout); return tt_hal_display_driver_lock(handle, timeout);
} }
void unlock() const { void unlock() const {
device_unlock(device); tt_hal_display_driver_unlock(handle);
} }
uint16_t getWidth() const { uint16_t getWidth() const {
return display_get_resolution_x(device); return tt_hal_display_driver_get_pixel_width(handle);
} }
uint16_t getHeight() const { uint16_t getHeight() const {
return display_get_resolution_y(device); return tt_hal_display_driver_get_pixel_height(handle);
} }
enum DisplayColorFormat getColorFormat() const { ColorFormat getColorFormat() const {
return display_get_color_format(device); return tt_hal_display_driver_get_colorformat(handle);
} }
void drawBitmap(int xStart, int yStart, int xEnd, int yEnd, const void* pixelData) const { void drawBitmap(int xStart, int yStart, int xEnd, int yEnd, const void* pixelData) const {
display_draw_bitmap(device, xStart, yStart, xEnd, yEnd, pixelData); tt_hal_display_driver_draw_bitmap(handle, xStart, yStart, xEnd, yEnd, pixelData);
} }
}; };
@@ -1,28 +1,28 @@
#pragma once #pragma once
#include <tactility/device.h> #include <cassert>
#include <tactility/drivers/pointer.h> #include <tt_hal_touch.h>
/** /**
* Wrapper for pointer_* device driver functions * Wrapper for tt_hal_touch_driver_*
*/ */
class TouchDriver { class TouchDriver {
struct Device* device; TouchDriverHandle handle = nullptr;
public: public:
explicit TouchDriver(struct Device* device) : device(device) { explicit TouchDriver(DeviceId id) {
device_get(device); assert(tt_hal_touch_driver_supported(id));
handle = tt_hal_touch_driver_alloc(id);
assert(handle != nullptr);
} }
~TouchDriver() { ~TouchDriver() {
device_put(device); tt_hal_touch_driver_free(handle);
} }
bool getTouchedPoints(uint16_t* x, uint16_t* y, uint16_t* strength, uint8_t* count, uint8_t maxCount) const { bool getTouchedPoints(uint16_t* x, uint16_t* y, uint16_t* strength, uint8_t* count, uint8_t maxCount) const {
// Poll without blocking: perform one read attempt, then report whatever is cached. return tt_hal_touch_driver_get_touched_points(handle, x, y, strength, count, maxCount);
pointer_read_data(device, 0);
return pointer_get_touched_points(device, x, y, strength, count, maxCount);
} }
}; };
+44 -23
View File
@@ -6,44 +6,65 @@
#include <tt_app.h> #include <tt_app.h>
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <tt_lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/display.h>
#include <tactility/drivers/pointer.h>
#include <lvgl/lvgl.h>
#include <lvgl/module.h>
#include <tactility/module.h>
constexpr auto TAG = "Main"; constexpr auto TAG = "Main";
static void onCreate(AppHandle appHandle, void* data) { /** Find a DisplayDevice that supports the DisplayDriver interface */
struct Device* display_device; static bool findUsableDisplay(DeviceId& deviceId) {
if (device_get_first_active_by_type(&DISPLAY_TYPE, &display_device) != ERROR_NONE) { uint16_t display_count = 0;
if (!tt_hal_device_find(DEVICE_TYPE_DISPLAY, &deviceId, &display_count, 1)) {
ESP_LOGE(TAG, "No display device found"); ESP_LOGE(TAG, "No display device found");
return false;
}
if (!tt_hal_display_driver_supported(deviceId)) {
ESP_LOGE(TAG, "Display doesn't support driver mode");
return false;
}
return true;
}
/** Find a TouchDevice that supports the TouchDriver interface */
static bool findUsableTouch(DeviceId& deviceId) {
uint16_t touch_count = 0;
if (!tt_hal_device_find(DEVICE_TYPE_TOUCH, &deviceId, &touch_count, 1)) {
ESP_LOGE(TAG, "No touch device found");
return false;
}
if (!tt_hal_touch_driver_supported(deviceId)) {
ESP_LOGE(TAG, "Touch doesn't support driver mode");
return false;
}
return true;
}
static void onCreate(AppHandle appHandle, void* data) {
DeviceId display_id;
if (!findUsableDisplay(display_id)) {
tt_app_stop(); tt_app_stop();
tt_app_alertdialog_start("Error", "No display device was found.", nullptr, 0); tt_app_alertdialog_start("Error", "The display doesn't support the required features.", nullptr, 0);
return; return;
} }
struct Device* touch_device; DeviceId touch_id;
if (device_get_first_active_by_type(&POINTER_TYPE, &touch_device) != ERROR_NONE) { if (!findUsableTouch(touch_id)) {
ESP_LOGE(TAG, "No touch device found");
device_put(display_device);
tt_app_stop(); tt_app_stop();
tt_app_alertdialog_start("Error", "No touch device was found.", nullptr, 0); tt_app_alertdialog_start("Error", "The touch driver doesn't support the required features.", nullptr, 0);
return; return;
} }
// Stop LVGL first (because it's currently using the drivers we want to use) // Stop LVGL first (because it's currently using the drivers we want to use)
module_stop(&lvgl_module); tt_lvgl_stop();
ESP_LOGI(TAG, "Creating display driver"); ESP_LOGI(TAG, "Creating display driver");
auto display = new DisplayDriver(display_device); auto display = new DisplayDriver(display_id);
device_put(display_device);
ESP_LOGI(TAG, "Creating touch driver"); ESP_LOGI(TAG, "Creating touch driver");
auto touch = new TouchDriver(touch_device); auto touch = new TouchDriver(touch_id);
device_put(touch_device);
// Run the main logic // Run the main logic
ESP_LOGI(TAG, "Running application"); ESP_LOGI(TAG, "Running application");
@@ -61,9 +82,9 @@ static void onCreate(AppHandle appHandle, void* data) {
static void onDestroy(AppHandle appHandle, void* data) { static void onDestroy(AppHandle appHandle, void* data) {
// Restart LVGL to resume rendering of regular apps // Restart LVGL to resume rendering of regular apps
if (!module_is_started(&lvgl_module)) { if (!tt_lvgl_is_started()) {
ESP_LOGI(TAG, "Restarting LVGL"); ESP_LOGI(TAG, "Restarting LVGL");
module_start(&lvgl_module); tt_lvgl_start();
} }
} }
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.graphicsdemo sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=Graphics Demo id=one.tactility.graphicsdemo
versionName=0.3.0
versionCode=3
name=Graphics Demo
+45 -4
View File
@@ -1,22 +1,63 @@
#include <tt_app.h> #include <tt_app.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#define TICK_MS 25
lv_timer_t* gameTimer;
static void onTick(lv_timer_t* timer) {
// 1. Retrieve the progress bar using the official getter function
lv_obj_t* bar_progress = (lv_obj_t*) lv_timer_get_user_data(timer);
int32_t current_val = lv_bar_get_value(bar_progress);
// 4. Stop and delete the timer when it reaches 100
if (current_val + 1 >= 100) {
lv_bar_set_value(bar_progress, 0, LV_ANIM_ON);
} else {
lv_bar_set_value(bar_progress, current_val + 1, LV_ANIM_OFF);
}
}
/** /**
* Note: LVGL and Tactility methods need to be exposed manually from TactilityC/Source/tt_init.cpp * Note: LVGL and Tactility methods need to be exposed manually from TactilityC/Source/tt_init.cpp
* Only C is supported for now (C++ symbols fail to link) * Only C is supported for now (C++ symbols fail to link)
*/ */
static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Hello World"); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_t* label = lv_label_create(parent); lv_obj_t* label = lv_label_create(parent);
lv_label_set_text(label, "Hello, world!"); lv_label_set_text(label, "Hello, world!\n(Adolfo Made It2!)");
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
lv_obj_t* label2 = lv_label_create(parent);
lv_label_set_text(label2, "Hello, There!");
lv_obj_align(label2, LV_ALIGN_BOTTOM_MID, 0, 0);
/* Progress bar */
lv_obj_t* bar_progress = lv_bar_create(parent);
lv_obj_set_size(bar_progress, lv_pct(80), 10);
lv_bar_set_range(bar_progress, 0, 100);
lv_bar_set_value(bar_progress, 50, LV_ANIM_ON);
lv_obj_align(bar_progress, LV_ALIGN_CENTER, 0, -30);
// Start game timer wiht the progress bar as user data
gameTimer = lv_timer_create(onTick, TICK_MS, bar_progress);
}
//on hide
static void onHideApp(AppHandle app, void* data) {
if (gameTimer) {
lv_timer_delete(gameTimer);
gameTimer = NULL;
}
} }
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
tt_app_register((AppRegistration) { tt_app_register((AppRegistration) {
.onShow = onShowApp .onShow = onShowApp,
.onHide = onHideApp,
}); });
return 0; return 0;
} }
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.helloworld sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32s3
app.version.code=7 [app]
app.name=Hello World id=one.tactility.helloworld
versionName=0.3.0
versionCode=3
name=Hello World
@@ -14,6 +14,7 @@
#include "TestUnitLcdGfx.h" #include "TestUnitLcdGfx.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <tt_lvgl_toolbar.h>
#include <esp_log.h> #include <esp_log.h>
constexpr auto* TAG = "M5UnitTest"; constexpr auto* TAG = "M5UnitTest";
+3 -3
View File
@@ -1,13 +1,13 @@
#include "TestListView.h" #include "TestListView.h"
#include "M5UnitTest.h" #include "M5UnitTest.h"
#include "UiScale.h" #include "UiScale.h"
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
void TestListView::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { void TestListView::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app; app_ = app;
lvgl_toolbar_create(parent, "M5 Unit Test"); tt_lvgl_toolbar_create_for_app(parent, handle);
list_ = lv_list_create(parent); list_ = lv_list_create(parent);
lv_obj_set_width(list_, LV_PCT(100)); lv_obj_set_width(list_, LV_PCT(100));
+1 -1
View File
@@ -2,7 +2,7 @@
#include <array> #include <array>
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/icons/shared.h> #include <tactility/lvgl_icon_shared.h>
#include <tt_app.h> #include <tt_app.h>
class M5UnitTest; class M5UnitTest;
@@ -2,7 +2,7 @@
#include "GroveLookup.h" #include "GroveLookup.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
#include <cstring> #include <cstring>
@@ -2,7 +2,7 @@
#include "GroveLookup.h" #include "GroveLookup.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
#include <cstring> #include <cstring>
void TestUnitByteButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { void TestUnitByteButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
@@ -3,7 +3,7 @@
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/uart_controller.h> #include <tactility/drivers/uart_controller.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
#include <cstring> #include <cstring>
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
#include "TestUnitDualButton.h" #include "TestUnitDualButton.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
static constexpr gpio_pin_t PIN_MIN = 0; static constexpr gpio_pin_t PIN_MIN = 0;
static constexpr gpio_pin_t PIN_MAX = 57; static constexpr gpio_pin_t PIN_MAX = 57;
@@ -2,7 +2,7 @@
#include "GroveLookup.h" #include "GroveLookup.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
+1 -1
View File
@@ -2,7 +2,7 @@
#include "GroveLookup.h" #include "GroveLookup.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
void TestUnitLcd::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { void TestUnitLcd::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app; app_ = app;
@@ -2,7 +2,7 @@
#include "GroveLookup.h" #include "GroveLookup.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
#include <esp_timer.h> #include <esp_timer.h>
#include <cstring> #include <cstring>
#include <cstdio> #include <cstdio>
+1 -1
View File
@@ -2,7 +2,7 @@
#include "GroveLookup.h" #include "GroveLookup.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
void TestUnitMidi::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { void TestUnitMidi::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app; app_ = app;
@@ -3,7 +3,7 @@
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/i2c_controller.h> #include <tactility/drivers/i2c_controller.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
void TestUnitPaHub::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { void TestUnitPaHub::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app; app_ = app;
@@ -2,7 +2,8 @@
#include "GroveLookup.h" #include "GroveLookup.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
#include <tt_lvgl_toolbar.h>
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@@ -2,7 +2,7 @@
#include "GroveLookup.h" #include "GroveLookup.h"
#include "UiScale.h" #include "UiScale.h"
#include <tactility/device.h> #include <tactility/device.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
void TestUnitScroll::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { void TestUnitScroll::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app; app_ = app;
+5 -4
View File
@@ -1,12 +1,13 @@
#include "TestViewBase.h" #include "TestViewBase.h"
#include "M5UnitTest.h" #include "M5UnitTest.h"
#include "UiScale.h" #include "UiScale.h"
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
lv_obj_t* TestViewBase::createToolbar(lv_obj_t* parent, AppHandle handle, const char* title) { lv_obj_t* TestViewBase::createToolbar(lv_obj_t* parent, AppHandle handle, const char* title) {
lv_obj_t* toolbar = lvgl_toolbar_create(parent, title); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, handle);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LEFT, onBackClicked, this); tt_lvgl_toolbar_set_title(toolbar, title);
tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LEFT, onBackClicked, this);
return toolbar; return toolbar;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
// Device screen widths in default (portrait) orientation: // Device screen widths in default (portrait) orientation:
// tiny < 200 : small OLEDs, custom breadboard devices // tiny < 200 : small OLEDs, custom breadboard devices
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.m5unittest sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32s3,esp32p4
app.version.code=5 [app]
app.name=M5 Unit Test id=one.tactility.m5unittest
versionName=0.1.0
versionCode=1
name=M5 Unit Test
+6 -7
View File
@@ -1,7 +1,6 @@
#include "Magic8Ball.h" #include "Magic8Ball.h"
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tactility/device.h> #include <tt_lvgl_keyboard.h>
#include <tactility/drivers/keyboard.h>
#include <stdlib.h> #include <stdlib.h>
#include <time.h> #include <time.h>
@@ -36,7 +35,7 @@ static const char* responses[] = {
#define NUM_RESPONSES (sizeof(responses) / sizeof(responses[0])) #define NUM_RESPONSES (sizeof(responses) / sizeof(responses[0]))
static const char* getInputHint() { static const char* getInputHint() {
if (device_has_active_by_type(&KEYBOARD_TYPE)) { if (tt_lvgl_hardware_keyboard_is_available()) {
return "Touch or Space to ask Q to exit"; return "Touch or Space to ask Q to exit";
} }
return "Touch the ball to ask"; return "Touch the ball to ask";
@@ -93,7 +92,7 @@ void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
/* Toolbar */ /* Toolbar */
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Magic 8-Ball"); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
/* Main container */ /* Main container */
@@ -142,7 +141,7 @@ void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_add_event_cb(ballObj, onBallClick, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(ballObj, onBallClick, LV_EVENT_CLICKED, this);
/* Keyboard support - no editing mode needed, just focus the ball */ /* Keyboard support - no editing mode needed, just focus the ball */
if (device_has_active_by_type(&KEYBOARD_TYPE)) { if (tt_lvgl_hardware_keyboard_is_available()) {
lv_group_t* grp = lv_group_get_default(); lv_group_t* grp = lv_group_get_default();
if (grp) { if (grp) {
lv_group_add_obj(grp, ballObj); lv_group_add_obj(grp, ballObj);
@@ -153,7 +152,7 @@ void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) {
} }
void Magic8Ball::onHide(AppHandle app) { void Magic8Ball::onHide(AppHandle app) {
if (device_has_active_by_type(&KEYBOARD_TYPE) && ballObj) { if (tt_lvgl_hardware_keyboard_is_available() && ballObj) {
lv_group_remove_obj(ballObj); lv_group_remove_obj(ballObj);
} }
answerLabel = nullptr; answerLabel = nullptr;
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.magic8ball sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Magic 8-Ball id=one.tactility.magic8ball
versionName=0.2.0
versionCode=2
name=Magic 8-Ball
@@ -6,11 +6,11 @@ if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH}) set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else() else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK") set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}") message(WARNING "TACTILITY_SDK_PATH is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif() endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(EspNowBridge) project(McpScreen)
tactility_project(EspNowBridge) tactility_project(McpScreen)
+428
View File
@@ -0,0 +1,428 @@
# McpScreen for Tactility — Implementation Plan
## Goal
Port the hardware-agnostic MCP Screen design from:
`/Users/adolforeyna/Projects/MicroPython/test1/Screen`
into a native Tactility application while preserving compatibility with the
existing host-side MCP bridge and Hermes voice service.
The original reference documents and implementations are:
- `system_design_specs.md`
- `mcp_server.py`
- `mcp_bridge.py`
- `main.py`
- `video_stream.py`
- `voice_assistant.py`
- `lib/audio_util.py`
## Target application
- Source folder: `Apps/McpScreen`
- App ID: `one.tactility.mcpscreen`
- Initial platform: ESP32-S3
- Initial Tactility SDK: `0.7.0-dev`
- Language: C/C++ with ESP-IDF, FreeRTOS, Tactility SDK, and LVGL
## Current implementation status
Phase 1 is implemented, builds successfully for ESP32-S3, and has been tested
on a 320x240 Tactility device at runtime.
Completed:
- Tactility app lifecycle and app-owned drawing area
- HTTP JSON-RPC endpoint on port 80
- `tools/list`
- `get_capabilities`
- `clear_screen`
- `draw_text`
- request-size limits and JSON-RPC error responses
- live `tools/list` and `get_capabilities` calls
- live `clear_screen` and `draw_text` calls
- live malformed-JSON error handling
- crash-safe HTTP worker shutdown during app destruction
- three forced stop/start cycles with the management and MCP endpoints still
responsive afterward
Blocked by the current external-app ABI:
- UDP discovery requires `lwip_recvfrom` and `lwip_sendto`, which the running
Tactility 0.7.0-dev firmware does not export to ELF apps. The existing bridge
falls back to HTTP subnet scanning, so MCP connectivity remains available.
Pending verification:
- verify service behavior while the app is genuinely hidden behind another
running application. A remote HelloWorld launch did not trigger `onHide`, so
this needs a manual launcher/device interaction test.
### Shutdown implementation note
Tactility unloads external-app ELF memory immediately after `onDestroy`
returns. An app task blocked in `accept()` therefore cannot be allowed to
survive destruction. The HTTP listener is non-blocking and client reads have
short timeouts. `onHide` only clears the run flag and returns immediately so
it does not block Tactility's GUI lifecycle or close an lwIP descriptor from
the wrong task. The worker owns the socket shutdown, then suspends itself
asynchronously. The next `onShow` reaps it before starting a new worker, while
`onDestroy` waits for and deletes it before returning.
## Design principles
1. Preserve the existing MCP tool names and JSON-RPC contract wherever the
underlying Tactility hardware supports them.
2. Keep network, audio, and long-running work outside the LVGL task.
3. Perform all LVGL operations under the Tactility LVGL lock or dispatch them
onto the LVGL task.
4. Use Tactility device and HAL interfaces instead of board-specific GPIO
assumptions wherever possible.
5. Return structured unsupported-capability errors instead of silently
emulating unavailable hardware.
6. Treat files supplied over MCP as untrusted input and constrain file access
to the application's user-data directory.
7. Do not port `execute_python` literally. Native firmware must not expose
arbitrary code execution.
## Proposed application structure
```text
Apps/McpScreen/
├── CMakeLists.txt
├── manifest.properties
├── IMPLEMENTATION_PLAN.md
└── main/
├── CMakeLists.txt
└── Source/
├── main.cpp
├── McpScreenApp.cpp
├── McpScreenApp.h
├── McpServer.cpp
├── McpServer.h
├── DiscoveryService.cpp
├── DiscoveryService.h
├── ToolRegistry.cpp
├── ToolRegistry.h
├── ToolDispatcher.cpp
├── ToolDispatcher.h
├── DisplayTools.cpp
├── DisplayTools.h
├── AudioTools.cpp
├── AudioTools.h
├── SystemTools.cpp
├── SystemTools.h
├── DashboardView.cpp
└── DashboardView.h
```
The file boundaries may be consolidated during the first slice if a smaller
implementation is easier to validate.
## Runtime architecture
### Application lifecycle
- `onCreate`
- Allocate application state.
- `onShow`
- Build the dashboard and MCP-controlled display canvas.
- Attach the current UI objects to the shared application state.
- Start the HTTP MCP service.
- `onHide`
- Signal the HTTP MCP service to stop without blocking the GUI lifecycle.
- Detach and invalidate UI object pointers.
- `onDestroy`
- Join/delete any remaining worker and close sockets as a safety fallback.
- Release buffers, devices, and application state.
The MCP server is deliberately foreground-only. Tactility does not need to
keep the listener or display-control task active while another app owns the
screen.
### FreeRTOS responsibilities
- MCP HTTP task
- Listen for HTTP requests.
- Parse JSON-RPC requests.
- Dispatch tools.
- Serialize JSON-RPC responses.
- UDP discovery task
- Bind UDP port 5000.
- Reply to discovery packets.
- Display work
- Marshal changes through the LVGL lock or an LVGL async callback.
- Audio tasks
- Record and play I2S data without blocking the UI or MCP listener.
- Future streaming tasks
- Own TCP/UDP frame sockets and preallocated frame buffers.
## Network compatibility
### UDP discovery
- Port: `5000`
- Request: exact bytes `DISCOVER_SCREEN`
- Response: exact bytes `SCREEN_IP_80`
### MCP HTTP endpoint
- Port: `80`
- Method and path: `POST /api/mcp`
- Content type: `application/json`
- Protocol: JSON-RPC 2.0
- Required methods:
- `tools/list`
- `tools/call`
### Raw display endpoint
Added after the basic MCP slice:
- Method and path: `POST /api/screen/raw`
- Query parameters: `x`, `y`, `w`, `h`
- Body: raw big-endian RGB565 bytes
## MCP response behavior
Successful tool calls retain the existing shape:
```json
{
"jsonrpc": "2.0",
"result": {
"content": [
{
"type": "text",
"text": "..."
}
]
},
"id": 1
}
```
Errors use JSON-RPC error objects:
```json
{
"jsonrpc": "2.0",
"error": {
"code": -32000,
"message": "..."
},
"id": 1
}
```
## Phase 1 — MCP foundation
### Scope
1. Scaffold `Apps/McpScreen`.
2. Add a status/dashboard view showing:
- Wi-Fi state
- server state
- port
- last tool
- last error
3. Start the HTTP server on port 80.
4. Implement `POST /api/mcp`.
5. Implement JSON-RPC request validation and error handling.
6. Implement `tools/list`.
7. Implement UDP discovery on port 5000.
8. Implement the first three tools:
- `get_capabilities`
- `clear_screen`
- `draw_text`
9. Test with the existing `mcp_bridge.py`.
### Initial tool semantics
#### `get_capabilities`
Return live display information:
```json
{
"color": true,
"width": 320,
"height": 240,
"formats": ["rgb565_base64", "bmp_base64"],
"platform": "tactility",
"appVersion": "0.1.0"
}
```
Width, height, and color format must be queried at runtime.
#### `clear_screen`
- Accept `color` values compatible with the original API:
- `0`: white
- `1`: black
- Mark MCP display override active.
- Clear the app-owned canvas/display area.
#### `draw_text`
- Accept `text`, `x`, `y`, and optional `size`.
- Mark MCP display override active.
- Render inside the app-owned canvas.
- Clamp coordinates to the drawable area.
### Phase 1 acceptance criteria
- The app builds and packages for ESP32-S3.
- Opening the app displays its server status.
- UDP discovery returns `SCREEN_IP_80`.
- The existing bridge can discover the device.
- `tools/list` returns valid schemas for the three initial tools.
- `get_capabilities` reports the actual Tactility display dimensions.
- `clear_screen` and `draw_text` visibly update the app.
- Malformed JSON and unknown methods return JSON-RPC errors without crashing.
- Repeated requests do not leak tasks, sockets, or request buffers.
- App hiding/showing behavior is documented from a device test.
## Phase 2 — Display tools
Add:
- `draw_raw_rgb565`
- `POST /api/screen/raw`
- `draw_color_bmp`
- `draw_image`
- `get_screenshot`
- `set_backlight`
- `set_screen_power`
Display commands should target an app-owned LVGL canvas or image buffer. Direct
panel access should only be used when the Tactility display HAL guarantees safe
ownership and synchronization.
Image preprocessing may remain in `mcp_bridge.py` initially. That avoids large
PNG/JPEG decoders and unnecessary memory pressure on the ESP32-S3.
## Phase 3 — Audio tools
Port the working implementation from `Apps/AudioTest`:
- `play_tone`
- `record_voice`
- `play_audio`
- `play_audio_base64`
Audio baseline:
- 16 kHz
- 16-bit signed PCM
- mono
- I2S device discovered through `device_find_by_name("i2s0")`
Recordings should be streamed to the application's user-data directory instead
of requiring one large fixed-duration RAM allocation.
## Phase 4 — Hardware and system tools
Add where supported:
- `get_touch`
- `get_battery`
- `set_led`
- `get_sensors`
- `scan_ble`
- `sync_time`
- `read_file`
- `write_file`
- `download_file`
File operations must:
- resolve paths under the app user-data directory;
- reject absolute paths and traversal such as `../`;
- enforce practical request and file-size limits.
`execute_python` is intentionally excluded. A future allow-listed diagnostic
command tool may replace it if needed.
## Phase 5 — Video streaming
Add:
- TCP frame server
- UDP chunked frame server
- stream activity timeout
- `get_video_streaming_instructions`
- `get_stream_stats`
The new protocol should advertise the actual Tactility display dimensions and
native color format. The old RLCD-specific 15,000-byte monochrome mapping may
be offered only as an optional compatibility mode.
Use preallocated buffers and avoid per-frame heap allocation.
## Phase 6 — Dashboard and Hermes voice assistant
Add:
- five-second dashboard refresh;
- MCP override mode;
- an explicit local action to leave override mode;
- Hermes WebSocket client;
- touch-to-talk;
- 16 kHz, 16-bit mono microphone streaming;
- incoming PCM/WAV playback;
- transcript, thinking, listening, and speaking UI states.
## Compatibility notes
- Keep the existing host bridge usable throughout development.
- Preserve tool names and argument names unless a compatibility defect is
documented.
- `draw_image` can continue to be host-preprocessed into PBM or RGB565.
- Screenshot output may change from PBM to PNG/RGB565 if the bridge is updated
to understand both.
- Hardware-specific tools should report support through `get_capabilities`.
## Security and robustness limits
The first implementation should define conservative limits for:
- HTTP header size
- JSON body size
- base64 payload size
- socket read timeout
- simultaneous clients
- filename length
- file size
- text length
- drawing bounds
- audio duration
Only one display mutation should run at a time. Audio operations should also be
serialized around the shared I2S device.
## Build workflow
From the TactilityApps repository:
```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/.gemini/antigravity/scratch/tactility/release/TactilitySDK
python3 tactility.py Apps/McpScreen build esp32s3 --local-sdk
```
## Immediate next task
Implement Phase 1 as a minimal vertical slice:
1. Scaffold the app and lifecycle.
2. Add a small server-status UI.
3. Add UDP discovery.
4. Add HTTP and JSON-RPC parsing.
5. Add `tools/list`.
6. Add `get_capabilities`, `clear_screen`, and `draw_text`.
7. Build.
8. Run a host smoke test through the existing bridge.
+76
View File
@@ -0,0 +1,76 @@
# MCP Screen
Native Tactility port of the MicroPython MCP Screen firmware.
Phase 1 provides:
- JSON-RPC 2.0 over `POST /api/mcp` on port 80
- `tools/list`
- `get_capabilities`
- `clear_screen`
- `draw_text`
Phase 2 adds:
- `draw_raw_rgb565`
- `POST /api/screen/raw`
- `draw_color_bmp`
- bridge-assisted PBM `draw_image`
- PBM `get_screenshot`
Backlight and screen-power controls are intentionally excluded.
See `IMPLEMENTATION_PLAN.md` for the full roadmap.
UDP discovery is temporarily unavailable to an external app because the
Tactility 0.7.0-dev firmware does not export `lwip_recvfrom` and
`lwip_sendto`. The existing MicroPython bridge remains compatible through its
HTTP subnet-scan fallback.
The HTTP worker uses a non-blocking listener and short client timeouts.
`onHide` signals it and returns immediately; the worker owns the socket
shutdown and then suspends itself outside the GUI lifecycle. It is reaped by
the next `onShow`, or by `onDestroy` before Tactility unloads the external ELF.
The MCP server is foreground-only: it starts in `onShow`, and port 80 closes
within one short worker poll after `onHide`. It is intentionally unavailable
whenever McpScreen is not the active screen.
## Build
```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/.gemini/antigravity/scratch/tactility/release/TactilitySDK
python3 tactility.py Apps/McpScreen build esp32s3 --local-sdk
```
## Direct smoke test
Replace the IP address with the device address:
```bash
curl -s http://DEVICE_IP/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
```bash
curl -s http://DEVICE_IP/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"draw_text","arguments":{"text":"Hello from MCP","x":20,"y":30,"size":2}}}'
```
The existing MicroPython host bridge can also be used unchanged:
```bash
python3 /Users/adolforeyna/Projects/MicroPython/test1/Screen/mcp_bridge.py
```
Or run the included Phase 2 smoke test:
```bash
python3 Apps/McpScreen/tools/smoke_test.py --ip DEVICE_IP --draw
```
The `--ip` argument is required until UDP discovery is available to external
apps.
+8
View File
@@ -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
)
+337
View File
@@ -0,0 +1,337 @@
#include "McpScreen.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <esp_log.h>
#include <tt_lvgl.h>
static const char* TAG = "DisplayTools";
static bool ascii_space(uint8_t value) {
return value == ' ' || value == '\t' || value == '\r' ||
value == '\n' || value == '\f' || value == '\v';
}
static bool ascii_digit(uint8_t value) {
return value >= '0' && value <= '9';
}
static uint16_t read_le16(const uint8_t* value) {
return (uint16_t)value[0] | ((uint16_t)value[1] << 8);
}
static uint32_t read_le32(const uint8_t* value) {
return (uint32_t)value[0] |
((uint32_t)value[1] << 8) |
((uint32_t)value[2] << 16) |
((uint32_t)value[3] << 24);
}
static bool display_ready(McpScreenState* state) {
return state != NULL &&
state->visible &&
state->draw_area != NULL &&
state->framebuffer != NULL &&
state->draw_width > 0 &&
state->draw_height > 0;
}
static uint16_t rgb888_to_rgb565(uint8_t red, uint8_t green, uint8_t blue) {
return (uint16_t)(((red & 0xF8) << 8) |
((green & 0xFC) << 3) |
(blue >> 3));
}
static void put_pixel(McpScreenState* state, int x, int y, uint16_t color) {
if (x >= 0 && y >= 0 && x < state->draw_width && y < state->draw_height) {
state->framebuffer[(size_t)y * state->draw_width + x] = color;
}
}
bool mcp_ui_draw_rgb565_be(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y,
int width,
int height
) {
if (data == NULL || width <= 0 || height <= 0 ||
data_size < (size_t)width * height * 2 || !display_ready(state)) {
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(2000))) {
if (display_ready(state)) {
for (int source_y = 0; source_y < height; ++source_y) {
int destination_y = y + source_y;
if (destination_y < 0 || destination_y >= state->draw_height) {
continue;
}
for (int source_x = 0; source_x < width; ++source_x) {
int destination_x = x + source_x;
if (destination_x < 0 || destination_x >= state->draw_width) {
continue;
}
size_t offset = ((size_t)source_y * width + source_x) * 2;
uint16_t color = ((uint16_t)data[offset] << 8) | data[offset + 1];
put_pixel(state, destination_x, destination_y, color);
}
}
lv_obj_invalidate(state->draw_area);
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
}
return success;
}
bool mcp_ui_draw_bmp(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y
) {
if (data == NULL || data_size < 54 || !display_ready(state) ||
data[0] != 'B' || data[1] != 'M') {
return false;
}
uint32_t pixel_offset = read_le32(data + 10);
uint32_t dib_size = read_le32(data + 14);
int32_t width = (int32_t)read_le32(data + 18);
int32_t signed_height = (int32_t)read_le32(data + 22);
uint16_t planes = read_le16(data + 26);
uint16_t bits_per_pixel = read_le16(data + 28);
uint32_t compression = read_le32(data + 30);
if (dib_size < 40 || width <= 0 || signed_height == 0 || planes != 1 ||
(bits_per_pixel != 24 && bits_per_pixel != 32) || compression != 0) {
return false;
}
int height = signed_height < 0 ? -signed_height : signed_height;
bool top_down = signed_height < 0;
size_t row_stride = (((size_t)width * bits_per_pixel + 31) / 32) * 4;
if (pixel_offset > data_size ||
row_stride > data_size ||
(size_t)height > (data_size - pixel_offset) / row_stride) {
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(2500))) {
if (display_ready(state)) {
size_t bytes_per_pixel = bits_per_pixel / 8;
for (int source_y = 0; source_y < height; ++source_y) {
int file_y = top_down ? source_y : (height - 1 - source_y);
const uint8_t* row = data + pixel_offset + (size_t)file_y * row_stride;
for (int source_x = 0; source_x < width; ++source_x) {
const uint8_t* pixel = row + (size_t)source_x * bytes_per_pixel;
put_pixel(
state,
x + source_x,
y + source_y,
rgb888_to_rgb565(pixel[2], pixel[1], pixel[0])
);
}
}
lv_obj_invalidate(state->draw_area);
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
}
return success;
}
static bool pbm_next_number(
const uint8_t* data,
size_t data_size,
size_t* offset,
int* result
) {
while (*offset < data_size) {
if (data[*offset] == '#') {
while (*offset < data_size && data[*offset] != '\n') {
(*offset)++;
}
} else if (ascii_space(data[*offset])) {
(*offset)++;
} else {
break;
}
}
if (*offset >= data_size || !ascii_digit(data[*offset])) {
return false;
}
int value = 0;
while (*offset < data_size && ascii_digit(data[*offset])) {
value = value * 10 + (data[*offset] - '0');
(*offset)++;
}
*result = value;
return true;
}
bool mcp_ui_draw_pbm(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y
) {
if (data == NULL || data_size < 8 || !display_ready(state) ||
data[0] != 'P' || data[1] != '4') {
ESP_LOGE(
TAG,
"PBM precondition failed data=%p size=%u ready=%d magic=%02x%02x",
data,
(unsigned)data_size,
display_ready(state),
data_size > 0 ? data[0] : 0,
data_size > 1 ? data[1] : 0
);
return false;
}
size_t offset = 2;
int width = 0;
int height = 0;
if (!pbm_next_number(data, data_size, &offset, &width) ||
!pbm_next_number(data, data_size, &offset, &height) ||
width <= 0 || height <= 0) {
ESP_LOGE(TAG, "PBM dimension parse failed offset=%u w=%d h=%d", (unsigned)offset, width, height);
return false;
}
if (offset >= data_size || !ascii_space(data[offset])) {
ESP_LOGE(
TAG,
"PBM missing header separator offset=%u size=%u byte=%02x",
(unsigned)offset,
(unsigned)data_size,
offset < data_size ? data[offset] : 0
);
return false;
}
if (data[offset] == '\r' && offset + 1 < data_size && data[offset + 1] == '\n') {
offset += 2;
} else {
offset++;
}
size_t row_bytes = ((size_t)width + 7) / 8;
if (offset > data_size ||
(size_t)height > (data_size - offset) / row_bytes) {
ESP_LOGE(
TAG,
"PBM payload too small offset=%u size=%u row=%u h=%d",
(unsigned)offset,
(unsigned)data_size,
(unsigned)row_bytes,
height
);
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(2000))) {
if (display_ready(state)) {
for (int source_y = 0; source_y < height; ++source_y) {
const uint8_t* row = data + offset + (size_t)source_y * row_bytes;
for (int source_x = 0; source_x < width; ++source_x) {
bool black = (row[source_x >> 3] & (0x80 >> (source_x & 7))) != 0;
put_pixel(state, x + source_x, y + source_y, black ? 0x0000 : 0xFFFF);
}
}
lv_obj_invalidate(state->draw_area);
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
} else {
ESP_LOGE(TAG, "PBM LVGL lock timed out");
}
if (!success) ESP_LOGE(TAG, "PBM draw failed after lock ready=%d", display_ready(state));
return success;
}
static char* base64_encode(const uint8_t* input, size_t input_size) {
static const char alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t output_size = ((input_size + 2) / 3) * 4;
char* output = malloc(output_size + 1);
if (output == NULL) {
return NULL;
}
size_t in = 0;
size_t out = 0;
while (in < input_size) {
uint32_t value = (uint32_t)input[in++] << 16;
bool have_second = in < input_size;
if (have_second) value |= (uint32_t)input[in++] << 8;
bool have_third = in < input_size;
if (have_third) value |= input[in++];
output[out++] = alphabet[(value >> 18) & 0x3F];
output[out++] = alphabet[(value >> 12) & 0x3F];
output[out++] = have_second ? alphabet[(value >> 6) & 0x3F] : '=';
output[out++] = have_third ? alphabet[value & 0x3F] : '=';
}
output[out] = '\0';
return output;
}
char* mcp_ui_get_screenshot_pbm_base64(McpScreenState* state) {
if (!display_ready(state)) {
return NULL;
}
size_t row_bytes = ((size_t)state->draw_width + 7) / 8;
char header[40];
int header_size = snprintf(
header,
sizeof(header),
"P4\n%u %u\n",
state->draw_width,
state->draw_height
);
size_t pbm_size = (size_t)header_size + row_bytes * state->draw_height;
uint8_t* pbm = calloc(1, pbm_size);
if (pbm == NULL) {
return NULL;
}
memcpy(pbm, header, header_size);
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(2000))) {
if (display_ready(state)) {
for (int y = 0; y < state->draw_height; ++y) {
uint8_t* row = pbm + header_size + (size_t)y * row_bytes;
for (int x = 0; x < state->draw_width; ++x) {
uint16_t color = state->framebuffer[(size_t)y * state->draw_width + x];
int red = (color >> 11) & 0x1F;
int green = (color >> 5) & 0x3F;
int blue = color & 0x1F;
int luminance = red * 2 + green * 3 + blue;
if (luminance < 128) {
row[x >> 3] |= (0x80 >> (x & 7));
}
}
}
success = true;
}
tt_lvgl_unlock();
}
char* encoded = success ? base64_encode(pbm, pbm_size) : NULL;
free(pbm);
return encoded;
}
+287
View File
@@ -0,0 +1,287 @@
#include "McpScreen.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <esp_log.h>
#include <esp_heap_caps.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_wifi.h>
static const char* TAG = "McpScreen";
static void set_label_text_locked(lv_obj_t* label, const char* text) {
if (label != NULL && lv_obj_is_valid(label)) {
lv_label_set_text(label, text);
}
}
void mcp_ui_set_server_status(McpScreenState* state, const char* status) {
if (state == NULL || !state->visible) {
return;
}
if (tt_lvgl_lock(pdMS_TO_TICKS(1000))) {
if (state->visible) {
set_label_text_locked(state->server_label, status);
}
tt_lvgl_unlock();
}
}
void mcp_ui_set_last_tool(McpScreenState* state, const char* tool) {
if (state == NULL || !state->visible) {
return;
}
if (tt_lvgl_lock(pdMS_TO_TICKS(1000))) {
if (state->visible && state->tool_label != NULL && lv_obj_is_valid(state->tool_label)) {
lv_label_set_text_fmt(state->tool_label, "Last: %s", tool);
}
tt_lvgl_unlock();
}
}
bool mcp_ui_clear(McpScreenState* state, int color) {
if (state == NULL || !state->visible || state->framebuffer == NULL) {
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(1500))) {
if (state->visible && state->draw_area != NULL && lv_obj_is_valid(state->draw_area)) {
lv_obj_clean(state->draw_area);
uint16_t fill = color == 1 ? 0x0000 : 0xFFFF;
size_t pixel_count = (size_t)state->draw_width * state->draw_height;
for (size_t i = 0; i < pixel_count; ++i) {
state->framebuffer[i] = fill;
}
lv_obj_invalidate(state->draw_area);
state->draw_color = color;
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
}
return success;
}
bool mcp_ui_draw_text(
McpScreenState* state,
const char* text,
int x,
int y,
int size
) {
if (state == NULL || text == NULL || !state->visible) {
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(1500))) {
if (state->visible && state->draw_area != NULL && lv_obj_is_valid(state->draw_area)) {
int max_x = state->draw_width > 0 ? state->draw_width - 1 : 0;
int max_y = state->draw_height > 0 ? state->draw_height - 1 : 0;
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x > max_x) x = max_x;
if (y > max_y) y = max_y;
lv_obj_t* label = lv_label_create(state->draw_area);
lv_label_set_text(label, text);
lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
lv_obj_set_width(label, LV_MAX(1, state->draw_width - x));
lv_obj_set_pos(label, x, y);
lv_obj_set_style_text_color(
label,
state->draw_color == 0 ? lv_color_black() : lv_color_white(),
LV_PART_MAIN
);
#if LV_FONT_MONTSERRAT_24
if (size >= 2) {
lv_obj_set_style_text_font(label, &lv_font_montserrat_24, LV_PART_MAIN);
}
#else
(void)size;
#endif
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
}
return success;
}
static void* create_data(void) {
McpScreenState* state = calloc(1, sizeof(McpScreenState));
if (state != NULL) {
state->http_socket = -1;
state->client_socket = -1;
state->discovery_socket = -1;
}
return state;
}
static void destroy_data(void* data) {
McpScreenState* state = data;
if (state != NULL && state->framebuffer != NULL) {
heap_caps_free(state->framebuffer);
}
free(state);
}
static void on_create(AppHandle app, void* data) {
McpScreenState* state = data;
state->app = app;
}
static void on_destroy(AppHandle app, void* data) {
(void)app;
mcp_services_stop((McpScreenState*)data);
}
static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
McpScreenState* state = data;
ESP_LOGI(TAG, "onShow: starting foreground UI and MCP service");
state->visible = true;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(parent, 0, LV_PART_MAIN);
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
state->server_label = lv_label_create(toolbar);
lv_label_set_text(state->server_label, "MCP starting");
lv_obj_set_style_text_color(
state->server_label,
lv_palette_main(LV_PALETTE_ORANGE),
LV_PART_MAIN
);
state->tool_label = lv_label_create(toolbar);
lv_label_set_text(state->tool_label, "Last: none");
if (!mcp_services_start(state)) {
ESP_LOGE(TAG, "Failed to start foreground MCP service");
lv_label_set_text(state->server_label, "MCP start failed");
lv_obj_set_style_text_color(
state->server_label,
lv_palette_main(LV_PALETTE_RED),
LV_PART_MAIN
);
}
state->draw_area = lv_canvas_create(parent);
lv_obj_set_width(state->draw_area, LV_PCT(100));
lv_obj_set_flex_grow(state->draw_area, 1);
lv_obj_set_style_radius(state->draw_area, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(state->draw_area, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(state->draw_area, 0, LV_PART_MAIN);
lv_obj_remove_flag(state->draw_area, LV_OBJ_FLAG_SCROLLABLE);
lv_display_t* display = lv_obj_get_display(parent);
state->display_width = lv_display_get_horizontal_resolution(display);
state->display_height = lv_display_get_vertical_resolution(display);
lv_obj_update_layout(parent);
state->draw_width = lv_obj_get_content_width(state->draw_area);
state->draw_height = lv_obj_get_content_height(state->draw_area);
size_t required_size = (size_t)state->draw_width * state->draw_height * sizeof(uint16_t);
if (state->framebuffer == NULL || state->framebuffer_size != required_size) {
if (state->framebuffer != NULL) {
heap_caps_free(state->framebuffer);
state->framebuffer = NULL;
}
state->framebuffer = heap_caps_malloc(required_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (state->framebuffer == NULL) {
state->framebuffer = heap_caps_malloc(required_size, MALLOC_CAP_8BIT);
}
state->framebuffer_size = state->framebuffer == NULL ? 0 : required_size;
}
if (state->framebuffer == NULL) {
ESP_LOGE(TAG, "Failed to allocate %u-byte framebuffer", (unsigned)required_size);
lv_obj_t* error = lv_label_create(state->draw_area);
lv_label_set_text(error, "Framebuffer allocation failed");
lv_obj_center(error);
return;
}
lv_canvas_set_buffer(
state->draw_area,
state->framebuffer,
state->draw_width,
state->draw_height,
LV_COLOR_FORMAT_RGB565
);
if (!state->override_active) {
for (size_t i = 0; i < (size_t)state->draw_width * state->draw_height; ++i) {
state->framebuffer[i] = 0x10C3;
}
state->draw_color = 1;
lv_obj_t* title = lv_label_create(state->draw_area);
lv_label_set_text(title, "MCP Screen");
lv_obj_set_style_text_color(title, lv_color_white(), LV_PART_MAIN);
#if LV_FONT_MONTSERRAT_24
lv_obj_set_style_text_font(title, &lv_font_montserrat_24, LV_PART_MAIN);
#endif
lv_obj_align(title, LV_ALIGN_CENTER, 0, -55);
lv_obj_t* dimensions = lv_label_create(state->draw_area);
lv_label_set_text_fmt(
dimensions,
"%ux%u display\nHTTP :80\nDiscovery: bridge subnet scan",
state->display_width,
state->display_height
);
lv_obj_set_style_text_align(dimensions, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_style_text_color(dimensions, lv_palette_lighten(LV_PALETTE_BLUE, 3), LV_PART_MAIN);
lv_obj_align(dimensions, LV_ALIGN_CENTER, 0, 0);
lv_obj_t* wifi = lv_label_create(state->draw_area);
lv_label_set_text_fmt(
wifi,
"Wi-Fi: %s",
tt_wifi_radio_state_to_string(tt_wifi_get_radio_state())
);
lv_obj_set_style_text_color(wifi, lv_color_white(), LV_PART_MAIN);
lv_obj_align(wifi, LV_ALIGN_CENTER, 0, 48);
}
}
static void on_hide(AppHandle app, void* data) {
(void)app;
McpScreenState* state = data;
ESP_LOGI(TAG, "onHide: stopping foreground MCP service");
state->visible = false;
mcp_services_hide(state);
state->draw_area = NULL;
state->server_label = NULL;
state->tool_label = 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;
}
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lvgl.h>
#include <tt_app.h>
typedef struct {
volatile bool running;
volatile bool http_ready;
volatile bool http_exited;
volatile bool discovery_ready;
volatile bool visible;
volatile bool override_active;
int http_socket;
int client_socket;
int discovery_socket;
TaskHandle_t http_task;
TaskHandle_t discovery_task;
AppHandle app;
lv_obj_t* draw_area;
lv_obj_t* server_label;
lv_obj_t* tool_label;
uint16_t* framebuffer;
size_t framebuffer_size;
uint16_t display_width;
uint16_t display_height;
uint16_t draw_width;
uint16_t draw_height;
int draw_color;
} McpScreenState;
bool mcp_services_start(McpScreenState* state);
void mcp_services_hide(McpScreenState* state);
void mcp_services_stop(McpScreenState* state);
void mcp_ui_set_server_status(McpScreenState* state, const char* status);
void mcp_ui_set_last_tool(McpScreenState* state, const char* tool);
bool mcp_ui_clear(McpScreenState* state, int color);
bool mcp_ui_draw_text(
McpScreenState* state,
const char* text,
int x,
int y,
int size
);
bool mcp_ui_draw_rgb565_be(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y,
int width,
int height
);
bool mcp_ui_draw_bmp(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y
);
bool mcp_ui_draw_pbm(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y
);
char* mcp_ui_get_screenshot_pbm_base64(McpScreenState* state);
+820
View File
@@ -0,0 +1,820 @@
#include "McpScreen.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <cJSON.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lwip/inet.h>
#include <lwip/sockets.h>
#define MCP_HTTP_PORT 80
#define MCP_HEADER_LIMIT 2048
#define MCP_JSON_BODY_LIMIT (256 * 1024)
#define MCP_RAW_BODY_LIMIT (512 * 1024)
#define MCP_RESPONSE_LIMIT (64 * 1024)
static const char* TAG = "McpServer";
static const char* TOOLS_JSON =
"["
"{"
"\"name\":\"get_capabilities\","
"\"description\":\"Get the Tactility display capabilities.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}"
"},"
"{"
"\"name\":\"clear_screen\","
"\"description\":\"Clear the MCP drawing area to white (0) or black (1).\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{\"color\":{\"type\":\"integer\",\"enum\":[0,1]}},"
"\"required\":[\"color\"]"
"}"
"},"
"{"
"\"name\":\"draw_text\","
"\"description\":\"Draw text in the MCP drawing area at x,y coordinates.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"text\":{\"type\":\"string\"},"
"\"x\":{\"type\":\"integer\"},"
"\"y\":{\"type\":\"integer\"},"
"\"size\":{\"type\":\"integer\",\"enum\":[1,2],\"default\":1}"
"},"
"\"required\":[\"text\",\"x\",\"y\"]"
"}"
"},"
"{"
"\"name\":\"draw_image\","
"\"description\":\"Draw a bridge-preprocessed binary PBM image.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"pbm_base64\":{\"type\":\"string\"},"
"\"x\":{\"type\":\"integer\",\"default\":0},"
"\"y\":{\"type\":\"integer\",\"default\":0},"
"\"dither\":{\"type\":\"boolean\",\"default\":true}"
"},"
"\"required\":[\"pbm_base64\"]"
"}"
"},"
"{"
"\"name\":\"draw_color_bmp\","
"\"description\":\"Draw an uncompressed 24-bit or 32-bit BMP image.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"bmp_base64\":{\"type\":\"string\"},"
"\"x\":{\"type\":\"integer\",\"default\":0},"
"\"y\":{\"type\":\"integer\",\"default\":0}"
"},"
"\"required\":[\"bmp_base64\"]"
"}"
"},"
"{"
"\"name\":\"draw_raw_rgb565\","
"\"description\":\"Draw big-endian raw RGB565 pixels.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"rgb565_base64\":{\"type\":\"string\"},"
"\"x\":{\"type\":\"integer\"},"
"\"y\":{\"type\":\"integer\"},"
"\"w\":{\"type\":\"integer\"},"
"\"h\":{\"type\":\"integer\"}"
"},"
"\"required\":[\"rgb565_base64\",\"x\",\"y\",\"w\",\"h\"]"
"}"
"},"
"{"
"\"name\":\"get_screenshot\","
"\"description\":\"Capture the MCP framebuffer as a PBM image.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}"
"}"
"]";
static int base64_value(char character) {
if (character >= 'A' && character <= 'Z') return character - 'A';
if (character >= 'a' && character <= 'z') return character - 'a' + 26;
if (character >= '0' && character <= '9') return character - '0' + 52;
if (character == '+') return 62;
if (character == '/') return 63;
return -1;
}
static uint8_t* base64_decode(const char* input, size_t* output_size) {
if (input == NULL || output_size == NULL) {
return NULL;
}
const char* payload = input;
const char* marker = strstr(input, ";base64,");
if (marker != NULL) {
payload = marker + 8;
}
size_t input_size = strlen(payload);
uint8_t* output = malloc((input_size / 4) * 3 + 3);
if (output == NULL) {
return NULL;
}
uint32_t accumulator = 0;
int bits = 0;
size_t written = 0;
for (size_t index = 0; index < input_size; ++index) {
char character = payload[index];
if (character == '=') {
break;
}
int value = base64_value(character);
if (value < 0) {
if (character == '\r' || character == '\n' ||
character == ' ' || character == '\t') {
continue;
}
free(output);
return NULL;
}
accumulator = (accumulator << 6) | (uint32_t)value;
bits += 6;
if (bits >= 8) {
bits -= 8;
output[written++] = (uint8_t)((accumulator >> bits) & 0xFF);
}
}
*output_size = written;
return output;
}
static void close_socket(int* socket_fd) {
if (socket_fd != NULL && *socket_fd >= 0) {
close(*socket_fd);
*socket_fd = -1;
}
}
static bool send_all(int socket_fd, const char* data, size_t length) {
size_t sent_total = 0;
while (sent_total < length) {
int sent = send(socket_fd, data + sent_total, length - sent_total, 0);
if (sent <= 0) {
return false;
}
sent_total += (size_t)sent;
}
return true;
}
static cJSON* make_error(cJSON* id, int code, const char* message) {
cJSON* root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "jsonrpc", "2.0");
cJSON* error = cJSON_AddObjectToObject(root, "error");
cJSON_AddNumberToObject(error, "code", code);
cJSON_AddStringToObject(error, "message", message);
if (id != NULL) {
cJSON_AddItemToObject(root, "id", cJSON_Duplicate(id, true));
} else {
cJSON_AddNullToObject(root, "id");
}
return root;
}
static cJSON* make_tool_result(cJSON* id, const char* text) {
cJSON* root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "jsonrpc", "2.0");
cJSON* result = cJSON_AddObjectToObject(root, "result");
cJSON* content = cJSON_AddArrayToObject(result, "content");
cJSON* item = cJSON_CreateObject();
cJSON_AddStringToObject(item, "type", "text");
cJSON_AddStringToObject(item, "text", text);
cJSON_AddItemToArray(content, item);
if (id != NULL) {
cJSON_AddItemToObject(root, "id", cJSON_Duplicate(id, true));
} else {
cJSON_AddNullToObject(root, "id");
}
return root;
}
static cJSON* handle_tool_call(McpScreenState* state, cJSON* id, cJSON* params) {
if (!cJSON_IsObject(params)) {
return make_error(id, -32602, "Invalid params");
}
cJSON* name_item = cJSON_GetObjectItemCaseSensitive(params, "name");
cJSON* arguments = cJSON_GetObjectItemCaseSensitive(params, "arguments");
if (!cJSON_IsString(name_item) || name_item->valuestring == NULL) {
return make_error(id, -32602, "Missing tool name");
}
if (arguments != NULL && !cJSON_IsObject(arguments)) {
return make_error(id, -32602, "Tool arguments must be an object");
}
const char* name = name_item->valuestring;
mcp_ui_set_last_tool(state, name);
if (strcmp(name, "get_capabilities") == 0) {
char capabilities[320];
uint16_t width = state->draw_width > 0 ? state->draw_width : state->display_width;
uint16_t height = state->draw_height > 0 ? state->draw_height : state->display_height;
snprintf(
capabilities,
sizeof(capabilities),
"{\"color\":true,\"width\":%u,\"height\":%u,"
"\"formats\":[\"rgb565_base64\",\"bmp_base64\",\"pbm_base64\"],"
"\"platform\":\"tactility\",\"appVersion\":\"0.1.0\","
"\"uiVisible\":%s}",
width,
height,
state->visible ? "true" : "false"
);
return make_tool_result(id, capabilities);
}
if (strcmp(name, "clear_screen") == 0) {
cJSON* color_item = cJSON_GetObjectItemCaseSensitive(arguments, "color");
if (!cJSON_IsNumber(color_item) ||
(color_item->valueint != 0 && color_item->valueint != 1)) {
return make_error(id, -32602, "color must be 0 or 1");
}
if (!mcp_ui_clear(state, color_item->valueint)) {
return make_error(id, -32010, "MCP Screen app is not visible");
}
return make_tool_result(id, "Screen cleared.");
}
if (strcmp(name, "draw_text") == 0) {
cJSON* text_item = cJSON_GetObjectItemCaseSensitive(arguments, "text");
cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x");
cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y");
cJSON* size_item = cJSON_GetObjectItemCaseSensitive(arguments, "size");
if (!cJSON_IsString(text_item) || text_item->valuestring == NULL ||
!cJSON_IsNumber(x_item) || !cJSON_IsNumber(y_item)) {
return make_error(id, -32602, "draw_text requires text, x, and y");
}
if (strlen(text_item->valuestring) > 512) {
return make_error(id, -32602, "text exceeds 512 characters");
}
int size = cJSON_IsNumber(size_item) ? size_item->valueint : 1;
if (size != 1 && size != 2) {
return make_error(id, -32602, "size must be 1 or 2");
}
if (!mcp_ui_draw_text(
state,
text_item->valuestring,
x_item->valueint,
y_item->valueint,
size)) {
return make_error(id, -32010, "MCP Screen app is not visible");
}
char message[160];
snprintf(
message,
sizeof(message),
"Successfully drew text at (%d, %d) with size %d.",
x_item->valueint,
y_item->valueint,
size
);
return make_tool_result(id, message);
}
if (strcmp(name, "draw_raw_rgb565") == 0) {
cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "rgb565_base64");
cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x");
cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y");
cJSON* width_item = cJSON_GetObjectItemCaseSensitive(arguments, "w");
cJSON* height_item = cJSON_GetObjectItemCaseSensitive(arguments, "h");
if (!cJSON_IsString(encoded_item) || !cJSON_IsNumber(x_item) ||
!cJSON_IsNumber(y_item) || !cJSON_IsNumber(width_item) ||
!cJSON_IsNumber(height_item)) {
return make_error(id, -32602, "draw_raw_rgb565 requires rgb565_base64, x, y, w, and h");
}
size_t decoded_size = 0;
uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size);
if (decoded == NULL) {
return make_error(id, -32602, "Invalid RGB565 base64 data");
}
bool drawn = mcp_ui_draw_rgb565_be(
state,
decoded,
decoded_size,
x_item->valueint,
y_item->valueint,
width_item->valueint,
height_item->valueint
);
free(decoded);
return drawn
? make_tool_result(id, "Raw RGB565 data displayed successfully.")
: make_error(id, -32010, "Failed to draw RGB565 data");
}
if (strcmp(name, "draw_color_bmp") == 0) {
cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "bmp_base64");
cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x");
cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y");
if (!cJSON_IsString(encoded_item)) {
return make_error(id, -32602, "Missing bmp_base64");
}
size_t decoded_size = 0;
uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size);
if (decoded == NULL) {
return make_error(id, -32602, "Invalid BMP base64 data");
}
bool drawn = mcp_ui_draw_bmp(
state,
decoded,
decoded_size,
cJSON_IsNumber(x_item) ? x_item->valueint : 0,
cJSON_IsNumber(y_item) ? y_item->valueint : 0
);
free(decoded);
return drawn
? make_tool_result(id, "Color BMP image displayed successfully.")
: make_error(id, -32602, "Unsupported or invalid BMP image");
}
if (strcmp(name, "draw_image") == 0) {
cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "pbm_base64");
cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x");
cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y");
if (!cJSON_IsString(encoded_item)) {
return make_error(
id,
-32602,
"draw_image requires bridge-preprocessed pbm_base64"
);
}
size_t decoded_size = 0;
uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size);
if (decoded == NULL) {
return make_error(id, -32602, "Invalid PBM base64 data");
}
bool drawn = mcp_ui_draw_pbm(
state,
decoded,
decoded_size,
cJSON_IsNumber(x_item) ? x_item->valueint : 0,
cJSON_IsNumber(y_item) ? y_item->valueint : 0
);
free(decoded);
return drawn
? make_tool_result(id, "Image displayed successfully.")
: make_error(id, -32602, "Unsupported or invalid PBM image");
}
if (strcmp(name, "get_screenshot") == 0) {
char* encoded = mcp_ui_get_screenshot_pbm_base64(state);
if (encoded == NULL) {
return make_error(id, -32010, "Screenshot capture failed");
}
size_t result_size = strlen(encoded) + 17;
char* result = malloc(result_size);
if (result == NULL) {
free(encoded);
return make_error(id, -32603, "Out of memory");
}
snprintf(result, result_size, "__PBM_BASE64__:%s", encoded);
cJSON* response = make_tool_result(id, result);
free(result);
free(encoded);
return response;
}
return make_error(id, -32602, "Unknown tool");
}
static cJSON* handle_rpc(McpScreenState* state, const char* body) {
cJSON* request = cJSON_Parse(body);
if (request == NULL) {
return make_error(NULL, -32700, "Parse error");
}
cJSON* id = cJSON_GetObjectItemCaseSensitive(request, "id");
cJSON* jsonrpc = cJSON_GetObjectItemCaseSensitive(request, "jsonrpc");
cJSON* method = cJSON_GetObjectItemCaseSensitive(request, "method");
cJSON* params = cJSON_GetObjectItemCaseSensitive(request, "params");
if (!cJSON_IsObject(request) ||
!cJSON_IsString(jsonrpc) ||
strcmp(jsonrpc->valuestring, "2.0") != 0 ||
!cJSON_IsString(method)) {
cJSON* response = make_error(id, -32600, "Invalid Request");
cJSON_Delete(request);
return response;
}
cJSON* response = NULL;
if (strcmp(method->valuestring, "tools/list") == 0) {
cJSON* tools = cJSON_Parse(TOOLS_JSON);
if (tools == NULL) {
response = make_error(id, -32603, "Failed to construct tool list");
} else {
response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "jsonrpc", "2.0");
cJSON* result = cJSON_AddObjectToObject(response, "result");
cJSON_AddItemToObject(result, "tools", tools);
if (id != NULL) {
cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true));
} else {
cJSON_AddNullToObject(response, "id");
}
}
} else if (strcmp(method->valuestring, "tools/call") == 0) {
response = handle_tool_call(state, id, params);
} else {
response = make_error(id, -32601, "Method not found");
}
cJSON_Delete(request);
return response;
}
static int find_header_end(const char* buffer, int length) {
for (int i = 0; i <= length - 4; ++i) {
if (buffer[i] == '\r' && buffer[i + 1] == '\n' &&
buffer[i + 2] == '\r' && buffer[i + 3] == '\n') {
return i + 4;
}
}
return -1;
}
static int parse_content_length(const char* headers) {
const char* cursor = headers;
while ((cursor = strstr(cursor, "\r\n")) != NULL) {
cursor += 2;
if (strncasecmp(cursor, "Content-Length:", 15) == 0) {
return atoi(cursor + 15);
}
}
return 0;
}
static void send_http_response(int client, int status, const char* content_type, const char* body) {
const char* status_text = status == 200 ? "OK" :
status == 404 ? "Not Found" :
status == 405 ? "Method Not Allowed" :
status == 413 ? "Payload Too Large" :
"Bad Request";
char header[320];
int body_length = body == NULL ? 0 : (int)strlen(body);
int header_length = snprintf(
header,
sizeof(header),
"HTTP/1.1 %d %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n"
"Access-Control-Allow-Origin: *\r\n"
"\r\n",
status,
status_text,
content_type,
body_length
);
send_all(client, header, (size_t)header_length);
if (body_length > 0) {
send_all(client, body, (size_t)body_length);
}
}
static int query_integer(const char* path, const char* name, int default_value) {
const char* query = strchr(path, '?');
if (query == NULL) {
return default_value;
}
query++;
size_t name_size = strlen(name);
while (*query != '\0') {
if (strncmp(query, name, name_size) == 0 && query[name_size] == '=') {
return atoi(query + name_size + 1);
}
query = strchr(query, '&');
if (query == NULL) {
break;
}
query++;
}
return default_value;
}
static void handle_http_client(McpScreenState* state, int client) {
struct timeval timeout = { .tv_sec = 0, .tv_usec = 250000 };
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
char* headers = calloc(1, MCP_HEADER_LIMIT + 1);
if (headers == NULL) {
send_http_response(client, 400, "text/plain", "Out of memory");
return;
}
int received = 0;
int header_end = -1;
while (state->running && received < MCP_HEADER_LIMIT) {
int count = recv(client, headers + received, MCP_HEADER_LIMIT - received, 0);
if (count <= 0) {
free(headers);
return;
}
received += count;
header_end = find_header_end(headers, received);
if (header_end >= 0) {
break;
}
}
if (!state->running) {
free(headers);
return;
}
if (header_end < 0) {
send_http_response(client, 400, "text/plain", "Invalid HTTP headers");
free(headers);
return;
}
headers[header_end - 4] = '\0';
char method[12] = {0};
char path[128] = {0};
if (sscanf(headers, "%11s %127s", method, path) != 2) {
send_http_response(client, 400, "text/plain", "Invalid request line");
free(headers);
return;
}
if (strcmp(method, "POST") != 0) {
send_http_response(client, 405, "text/plain", "POST required");
free(headers);
return;
}
bool is_mcp = strcmp(path, "/api/mcp") == 0;
bool is_raw = strncmp(path, "/api/screen/raw", 15) == 0;
if (!is_mcp && !is_raw) {
send_http_response(client, 404, "text/plain", "Not found");
free(headers);
return;
}
int body_limit = is_raw ? MCP_RAW_BODY_LIMIT : MCP_JSON_BODY_LIMIT;
int content_length = parse_content_length(headers);
if (content_length <= 0 || content_length > body_limit) {
send_http_response(
client,
content_length > body_limit ? 413 : 400,
"text/plain",
"Invalid Content-Length"
);
free(headers);
return;
}
uint8_t* body = malloc((size_t)content_length + (is_mcp ? 1 : 0));
if (body == NULL) {
send_http_response(client, 400, "text/plain", "Out of memory");
free(headers);
return;
}
int body_received = received - header_end;
if (body_received > content_length) {
body_received = content_length;
}
memcpy(body, headers + header_end, body_received);
free(headers);
while (state->running && body_received < content_length) {
int count = recv(client, body + body_received, content_length - body_received, 0);
if (count <= 0) {
send_http_response(client, 400, "text/plain", "Incomplete request body");
free(body);
return;
}
body_received += count;
}
if (!state->running || body_received < content_length) {
free(body);
return;
}
if (is_raw) {
int x = query_integer(path, "x", 0);
int y = query_integer(path, "y", 0);
int width = query_integer(path, "w", state->draw_width);
int height = query_integer(path, "h", state->draw_height);
bool valid_size = width > 0 && height > 0 &&
(size_t)width * height * 2 == (size_t)content_length;
bool drawn = valid_size && mcp_ui_draw_rgb565_be(
state,
body,
content_length,
x,
y,
width,
height
);
send_http_response(
client,
drawn ? 200 : 400,
"text/plain",
drawn ? "OK" : "Invalid RGB565 payload"
);
} else {
body[content_length] = '\0';
cJSON* response = handle_rpc(state, (const char*)body);
char* response_text = cJSON_PrintUnformatted(response);
if (response_text != NULL && strlen(response_text) <= MCP_RESPONSE_LIMIT) {
send_http_response(client, 200, "application/json", response_text);
} else {
send_http_response(client, 400, "text/plain", "Response serialization failed");
}
cJSON_free(response_text);
cJSON_Delete(response);
}
free(body);
}
static void http_task(void* argument) {
McpScreenState* state = argument;
state->http_exited = false;
int server = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
if (server < 0) {
ESP_LOGE(TAG, "Failed to create HTTP socket: errno=%d", errno);
state->http_ready = false;
mcp_ui_set_server_status(state, "MCP socket error");
goto suspend_for_owner;
}
state->http_socket = server;
int reuse = 1;
setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
struct sockaddr_in address = {
.sin_family = AF_INET,
.sin_port = htons(MCP_HTTP_PORT),
.sin_addr.s_addr = htonl(INADDR_ANY)
};
if (bind(server, (struct sockaddr*)&address, sizeof(address)) != 0 ||
listen(server, 3) != 0) {
ESP_LOGE(TAG, "Failed to bind/listen on TCP %d: errno=%d", MCP_HTTP_PORT, errno);
state->http_ready = false;
mcp_ui_set_server_status(state, "MCP bind error");
close_socket(&state->http_socket);
goto suspend_for_owner;
}
int flags = fcntl(server, F_GETFL, 0);
if (flags < 0 || fcntl(server, F_SETFL, flags | O_NONBLOCK) < 0) {
ESP_LOGE(TAG, "Failed to make HTTP listener non-blocking: errno=%d", errno);
state->http_ready = false;
mcp_ui_set_server_status(state, "MCP socket config error");
close_socket(&state->http_socket);
goto suspend_for_owner;
}
ESP_LOGI(TAG, "HTTP MCP server listening on port %d", MCP_HTTP_PORT);
state->http_ready = true;
mcp_ui_set_server_status(state, "MCP :80 ready");
while (state->running) {
struct sockaddr_storage source;
socklen_t source_length = sizeof(source);
int client = accept(server, (struct sockaddr*)&source, &source_length);
if (client < 0) {
if (!state->running) {
break;
}
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
state->client_socket = client;
handle_http_client(state, client);
close_socket(&state->client_socket);
}
close_socket(&state->client_socket);
close_socket(&state->http_socket);
state->http_ready = false;
suspend_for_owner:
/*
* External-app code must not continue after Tactility unloads the ELF.
* Signal completion, then suspend in FreeRTOS code. onDestroy owns the
* final vTaskDelete() and will not return until the worker is gone.
*/
state->http_exited = true;
vTaskSuspend(NULL);
vTaskDelete(NULL);
}
bool mcp_services_start(McpScreenState* state) {
if (state == NULL || state->running) {
ESP_LOGE(TAG, "Service start rejected state=%p running=%d", state, state != NULL && state->running);
return false;
}
/*
* onHide only requests shutdown because it runs in Tactility's GUI
* lifecycle. Reap the worker after it has reached its safe suspended
* state before starting a new foreground server.
*/
if (state->http_task != NULL) {
if (!state->http_exited) {
ESP_LOGE(TAG, "Previous HTTP worker is still stopping");
return false;
}
vTaskDelete(state->http_task);
state->http_task = NULL;
}
state->running = true;
state->http_exited = false;
state->http_socket = -1;
state->client_socket = -1;
BaseType_t http_result = xTaskCreate(
http_task,
"mcp_http",
8192,
state,
4,
&state->http_task
);
/*
* UDP discovery requires lwip_recvfrom/lwip_sendto. Tactility 0.7.0-dev
* currently does not export those symbols to external ELF apps. The
* existing host bridge remains compatible through its HTTP subnet-scan
* fallback. Re-enable discovery when those symbols are exported.
*/
state->discovery_ready = false;
state->discovery_task = NULL;
if (http_result != pdPASS) {
ESP_LOGE(TAG, "Failed to create MCP service tasks");
mcp_services_stop(state);
return false;
}
return true;
}
void mcp_services_hide(McpScreenState* state) {
if (state == NULL) {
return;
}
state->running = false;
state->http_ready = false;
state->discovery_ready = false;
}
void mcp_services_stop(McpScreenState* state) {
if (state == NULL) {
return;
}
mcp_services_hide(state);
for (int attempt = 0; attempt < 80; ++attempt) {
if (state->http_task == NULL || state->http_exited) {
break;
}
vTaskDelay(pdMS_TO_TICKS(10));
}
if (state->http_task != NULL) {
/*
* Normal path: the non-blocking worker has closed its sockets and
* suspended itself. The bounded fallback guarantees that no
* external-ELF instruction survives onDestroy.
*/
vTaskDelete(state->http_task);
state->http_task = NULL;
}
close_socket(&state->http_socket);
close_socket(&state->client_socket);
close_socket(&state->discovery_socket);
state->http_exited = true;
}
+10
View File
@@ -0,0 +1,10 @@
[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
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Host smoke test for the Tactility MCP Screen Phase 2 API."""
import argparse
import base64
import json
import socket
import struct
import urllib.request
def discover(timeout: float = 2.0) -> str | None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.settimeout(timeout)
try:
sock.sendto(b"DISCOVER_SCREEN", ("255.255.255.255", 5000))
data, address = sock.recvfrom(128)
if data == b"SCREEN_IP_80":
return address[0]
except TimeoutError:
return None
finally:
sock.close()
return None
def rpc(ip: str, request_id: int, method: str, params: dict | None = None) -> dict:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
}
if params is not None:
payload["params"] = params
request = urllib.request.Request(
f"http://{ip}/api/mcp",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
return json.loads(response.read())
def require_result(response: dict, label: str) -> None:
if "error" in response:
raise RuntimeError(f"{label} failed: {response['error']}")
if "result" not in response:
raise RuntimeError(f"{label} returned no result: {response}")
def make_bmp() -> bytes:
width = 4
height = 4
row_size = width * 3
pixels = bytearray()
colors = [
(0, 0, 255),
(0, 255, 0),
(255, 0, 0),
(255, 255, 255),
]
for y in range(height):
for x in range(width):
blue, green, red = colors[(x + y) % len(colors)]
pixels.extend((blue, green, red))
pixels.extend(b"\0" * ((4 - row_size % 4) % 4))
offset = 54
file_size = offset + len(pixels)
header = (
b"BM"
+ struct.pack("<IHHI", file_size, 0, 0, offset)
+ struct.pack("<IIIHHIIIIII", 40, width, height, 1, 24, 0, len(pixels), 0, 0, 0, 0)
)
return header + pixels
def raw_endpoint(ip: str) -> None:
pixels = bytes(
[
0xF8, 0x00,
0x07, 0xE0,
0x00, 0x1F,
0xFF, 0xFF,
]
)
request = urllib.request.Request(
f"http://{ip}/api/screen/raw?x=2&y=2&w=2&h=2",
data=pixels,
headers={"Content-Type": "application/octet-stream"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
if response.read() != b"OK":
raise RuntimeError("Raw endpoint returned an unexpected response")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--ip", help="Device IP; omit to use UDP discovery")
parser.add_argument(
"--draw",
action="store_true",
help="Also clear the app canvas and draw visible test text",
)
args = parser.parse_args()
ip = args.ip or discover()
if not ip:
raise SystemExit("MCP Screen was not discovered. Pass --ip DEVICE_IP to test directly.")
print(f"Testing MCP Screen at {ip}")
tools = rpc(ip, 1, "tools/list")
require_result(tools, "tools/list")
names = [tool["name"] for tool in tools["result"]["tools"]]
expected = {"get_capabilities", "clear_screen", "draw_text"}
if not expected.issubset(names):
raise RuntimeError(f"Missing tools: {sorted(expected - set(names))}")
print(f"tools/list: {', '.join(names)}")
capabilities = rpc(
ip,
2,
"tools/call",
{"name": "get_capabilities", "arguments": {}},
)
require_result(capabilities, "get_capabilities")
print("get_capabilities:", capabilities["result"]["content"][0]["text"])
if args.draw:
cleared = rpc(
ip,
3,
"tools/call",
{"name": "clear_screen", "arguments": {"color": 1}},
)
require_result(cleared, "clear_screen")
drawn = rpc(
ip,
4,
"tools/call",
{
"name": "draw_text",
"arguments": {
"text": "Tactility MCP is alive",
"x": 20,
"y": 30,
"size": 2,
},
},
)
require_result(drawn, "draw_text")
raw_rpc = rpc(
ip,
5,
"tools/call",
{
"name": "draw_raw_rgb565",
"arguments": {
"rgb565_base64": base64.b64encode(
bytes([0xFF, 0xE0, 0xF8, 0x1F, 0x07, 0xFF, 0x00, 0x00])
).decode(),
"x": 20,
"y": 70,
"w": 4,
"h": 1,
},
},
)
require_result(raw_rpc, "draw_raw_rgb565")
bmp = rpc(
ip,
6,
"tools/call",
{
"name": "draw_color_bmp",
"arguments": {
"bmp_base64": base64.b64encode(make_bmp()).decode(),
"x": 30,
"y": 90,
},
},
)
require_result(bmp, "draw_color_bmp")
pbm_bytes = b"P4\n8 2\n" + bytes([0b10101010, 0b01010101])
pbm = rpc(
ip,
7,
"tools/call",
{
"name": "draw_image",
"arguments": {
"pbm_base64": base64.b64encode(pbm_bytes).decode(),
"x": 50,
"y": 110,
},
},
)
require_result(pbm, "draw_image")
raw_endpoint(ip)
screenshot = rpc(
ip,
8,
"tools/call",
{"name": "get_screenshot", "arguments": {}},
)
require_result(screenshot, "get_screenshot")
screenshot_text = screenshot["result"]["content"][0]["text"]
if not screenshot_text.startswith("__PBM_BASE64__:"):
raise RuntimeError("Screenshot did not return PBM data")
base64.b64decode(screenshot_text.split(":", 1)[1], validate=True)
print("Visible Phase 2 draw and screenshot tests sent.")
print("Phase 2 smoke test passed.")
if __name__ == "__main__":
main()
+28 -60
View File
@@ -4,9 +4,9 @@
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/task.h> #include <freertos/task.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
#include <lvgl/lvgl.h> #include <tt_lvgl.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
static const char* TAG = "MediaKeys"; static const char* TAG = "MediaKeys";
@@ -154,24 +154,24 @@ void MediaKeys::btEventCallback(struct Device* /*device*/, void* context, struct
if (event.radio_state == BT_RADIO_STATE_ON) { if (event.radio_state == BT_RADIO_STATE_ON) {
// Radio is now up - start HID (needs LVGL lock for UI update) // Radio is now up - start HID (needs LVGL lock for UI update)
if (lvgl_try_lock(1000)) { if (tt_lvgl_lock(1000)) {
// Re-check inside lock to avoid TOCTOU race with handleSwitchToggle(false) // Re-check inside lock to avoid TOCTOU race with handleSwitchToggle(false)
if (self->_radioEnabling) { if (self->_radioEnabling) {
self->startHid(); self->startHid();
} }
lvgl_unlock(); tt_lvgl_unlock();
} }
} else if (event.radio_state == BT_RADIO_STATE_OFF && self->_isEnabled) { } else if (event.radio_state == BT_RADIO_STATE_OFF && self->_isEnabled) {
// Radio dropped while we were active - revert UI // Radio dropped while we were active - revert UI
LOG_I(TAG, "BT radio turned off, disabling HID"); LOG_I(TAG, "BT radio turned off, disabling HID");
if (lvgl_try_lock(1000)) { if (tt_lvgl_lock(1000)) {
if (device_has_active_by_type(&KEYBOARD_TYPE)) self->exitKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) self->exitKeyMode();
self->_hidDevice = nullptr; self->_hidDevice = nullptr;
self->_isEnabled = false; self->_isEnabled = false;
self->_radioEnabling = false; self->_radioEnabling = false;
if (self->_switchWidget) lv_obj_remove_state(self->_switchWidget, LV_STATE_CHECKED); if (self->_switchWidget) lv_obj_remove_state(self->_switchWidget, LV_STATE_CHECKED);
if (self->_mainWrapper) lv_obj_add_flag(self->_mainWrapper, LV_OBJ_FLAG_HIDDEN); if (self->_mainWrapper) lv_obj_add_flag(self->_mainWrapper, LV_OBJ_FLAG_HIDDEN);
lvgl_unlock(); tt_lvgl_unlock();
} }
} }
} else if (event.type == BT_EVENT_PROFILE_STATE_CHANGED && event.profile_state.profile == BT_PROFILE_HID_DEVICE) { } else if (event.type == BT_EVENT_PROFILE_STATE_CHANGED && event.profile_state.profile == BT_PROFILE_HID_DEVICE) {
@@ -208,24 +208,7 @@ void MediaKeys::startHid() {
} }
if (_mainWrapper) lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); if (_mainWrapper) lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
}
void MediaKeys::teardownBt() {
// Remove callback FIRST - stops any in-flight BT events from firing against
// our (possibly already freed) UI widget pointers after this returns.
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
// Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() /
// ble_gatts_start() which corrupts NimBLE heap while the host task is still
// running. HID device is a persistent kernel device; hid_device_start() cleans
// up stale context on next use. Explicit stop is handled by handleSwitchToggle.
// Restore the radio/device to the state we found them in.
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
if (_btDevice && _deviceWasStarted) device_stop(_btDevice);
_btDevice = nullptr;
_hidDevice = nullptr;
_radioWasOff = false;
_deviceWasStarted = false;
} }
void MediaKeys::handleSwitchToggle(bool enabled) { void MediaKeys::handleSwitchToggle(bool enabled) {
@@ -233,7 +216,7 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
_isEnabled = enabled; _isEnabled = enabled;
if (enabled) { if (enabled) {
_btDevice = device_find_first_by_type(&BLUETOOTH_TYPE); _btDevice = bluetooth_find_first_ready_device();
if (!_btDevice) { if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found"); LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false; _isEnabled = false;
@@ -241,19 +224,6 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
return; return;
} }
// Device may not be started yet (BT disabled in DTS by default to save memory).
if (!device_is_ready(_btDevice)) {
LOG_I(TAG, "BT device not started, starting now");
if (device_start(_btDevice) != ERROR_NONE) {
LOG_E(TAG, "Failed to start BT device");
_btDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
_deviceWasStarted = true;
}
bluetooth_set_device_name(_btDevice, "Tactility Media Keys"); bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
// Register callback before enabling radio so we don't miss the state-change event. // Register callback before enabling radio so we don't miss the state-change event.
@@ -276,11 +246,13 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
} }
} else { } else {
_radioEnabling = false; _radioEnabling = false;
if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
// Explicit user toggle-off: stop HID cleanly (safe here since we're on the
// LVGL task and the user intentionally disabled, so no race with app teardown).
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice); if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
teardownBt(); if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_radioWasOff = false;
_btDevice = nullptr;
_hidDevice = nullptr;
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
} }
} }
@@ -307,10 +279,10 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Media Keys"); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
_switchWidget = lvgl_toolbar_add_switch_action(toolbar); _switchWidget = tt_lvgl_toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(_switchWidget, onSwitchToggled, LV_EVENT_VALUE_CHANGED, this); lv_obj_add_event_cb(_switchWidget, onSwitchToggled, LV_EVENT_VALUE_CHANGED, this);
_mainWrapper = lv_obj_create(parent); _mainWrapper = lv_obj_create(parent);
@@ -342,30 +314,26 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_add_event_cb(_buttonMatrix, onButtonPressed, LV_EVENT_VALUE_CHANGED, this); lv_obj_add_event_cb(_buttonMatrix, onButtonPressed, LV_EVENT_VALUE_CHANGED, this);
// Physical keyboard support: key events on the matrix (entered when BT enabled, Q/Esc exits) // Physical keyboard support: key events on the matrix (entered when BT enabled, Q/Esc exits)
if (device_has_active_by_type(&KEYBOARD_TYPE)) { if (tt_lvgl_hardware_keyboard_is_available()) {
lv_obj_add_event_cb(_buttonMatrix, onKeyEvent, LV_EVENT_KEY, this); lv_obj_add_event_cb(_buttonMatrix, onKeyEvent, LV_EVENT_KEY, this);
_keyHighlightTimer = lv_timer_create(onKeyHighlightTimer, 150, this); _keyHighlightTimer = lv_timer_create(onKeyHighlightTimer, 150, this);
lv_timer_pause(_keyHighlightTimer); lv_timer_pause(_keyHighlightTimer);
} }
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
// Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app).
struct Device* btDev = device_find_first_by_type(&BLUETOOTH_TYPE);
if (btDev && device_is_ready(btDev)) {
enum BtRadioState radioState;
if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) {
lv_obj_add_state(_switchWidget, LV_STATE_CHECKED);
handleSwitchToggle(true);
}
}
} }
void MediaKeys::onHide(AppHandle /*appHandle*/) { void MediaKeys::onHide(AppHandle /*appHandle*/) {
_radioEnabling = false; if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_btDevice = nullptr;
_hidDevice = nullptr;
_isEnabled = false; _isEnabled = false;
if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(); _radioEnabling = false;
teardownBt(); _radioWasOff = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
if (_keyHighlightTimer) { if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer); lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr; _keyHighlightTimer = nullptr;
+3 -6
View File
@@ -2,11 +2,10 @@
#include <TactilityCpp/App.h> #include <TactilityCpp/App.h>
#include <lvgl.h> #include <lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h> #include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h> #include <tactility/drivers/bluetooth_hid_device.h>
#include <tactility/drivers/keyboard.h>
#include <tt_app.h> #include <tt_app.h>
#include <tt_lvgl_keyboard.h>
#include <atomic> #include <atomic>
class MediaKeys final : public App { class MediaKeys final : public App {
@@ -25,9 +24,8 @@ class MediaKeys final : public App {
// State - accessed from both LVGL thread and BT callback thread // State - accessed from both LVGL thread and BT callback thread
std::atomic<bool> _isEnabled {false}; std::atomic<bool> _isEnabled {false};
std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON
std::atomic<bool> _radioWasOff {false}; // true if we turned the radio on (restore on exit) std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off)
std::atomic<bool> _deviceWasStarted{false}; // true if we called device_start (restore on exit)
// Static event callbacks // Static event callbacks
static void onSwitchToggled(lv_event_t* e); static void onSwitchToggled(lv_event_t* e);
@@ -38,7 +36,6 @@ class MediaKeys final : public App {
static void sendKeyTask(void* param); static void sendKeyTask(void* param);
// Instance methods called by static callbacks // Instance methods called by static callbacks
void teardownBt(); // remove callback + stop HID + restore radio/device state
void handleSwitchToggle(bool enabled); void handleSwitchToggle(bool enabled);
void handleButtonPress(uint32_t buttonId); void handleButtonPress(uint32_t buttonId);
void startHid(); // called once radio is confirmed ON void startHid(); // called once radio is confirmed ON
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.mediakeys sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32s3,esp32p4
app.version.code=6 [app]
app.name=Media Keys id=one.tactility.mediakeys
app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus. versionName=0.1.0
versionCode=1
name=Media Keys
description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
+16 -16
View File
@@ -4,17 +4,17 @@
#include "drivers/Colors.h" #include "drivers/Colors.h"
#include <cstring> #include <cstring>
#include <tactility/drivers/display.h> #include <tt_hal_display.h>
class PixelBuffer { class PixelBuffer {
uint16_t pixelWidth; uint16_t pixelWidth;
uint16_t pixelHeight; uint16_t pixelHeight;
enum DisplayColorFormat colorFormat; ColorFormat colorFormat;
uint8_t* data; uint8_t* data;
public: public:
PixelBuffer(uint16_t pixelWidth, uint16_t pixelHeight, enum DisplayColorFormat colorFormat) : PixelBuffer(uint16_t pixelWidth, uint16_t pixelHeight, ColorFormat colorFormat) :
pixelWidth(pixelWidth), pixelWidth(pixelWidth),
pixelHeight(pixelHeight), pixelHeight(pixelHeight),
colorFormat(colorFormat) colorFormat(colorFormat)
@@ -35,7 +35,7 @@ public:
return pixelHeight; return pixelHeight;
} }
enum DisplayColorFormat getColorFormat() const { ColorFormat getColorFormat() const {
return colorFormat; return colorFormat;
} }
@@ -58,14 +58,14 @@ public:
uint8_t getPixelSize() const { uint8_t getPixelSize() const {
switch (colorFormat) { switch (colorFormat) {
case DISPLAY_COLOR_FORMAT_MONOCHROME: case COLOR_FORMAT_MONOCHROME:
return 1; return 1;
case DISPLAY_COLOR_FORMAT_BGR565: case COLOR_FORMAT_BGR565:
case DISPLAY_COLOR_FORMAT_BGR565_SWAPPED: case COLOR_FORMAT_BGR565_SWAPPED:
case DISPLAY_COLOR_FORMAT_RGB565: case COLOR_FORMAT_RGB565:
case DISPLAY_COLOR_FORMAT_RGB565_SWAPPED: case COLOR_FORMAT_RGB565_SWAPPED:
return 2; return 2;
case DISPLAY_COLOR_FORMAT_RGB888: case COLOR_FORMAT_RGB888:
return 3; return 3;
default: default:
// TODO: Crash with error // TODO: Crash with error
@@ -82,13 +82,13 @@ public:
void setPixel(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b) const { void setPixel(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b) const {
auto address = getPixelAddress(x, y); auto address = getPixelAddress(x, y);
switch (colorFormat) { switch (colorFormat) {
case DISPLAY_COLOR_FORMAT_MONOCHROME: case COLOR_FORMAT_MONOCHROME:
*address = (uint8_t)((uint16_t)r + (uint16_t)g + (uint16_t)b / 3); *address = (uint8_t)((uint16_t)r + (uint16_t)g + (uint16_t)b / 3);
break; break;
case DISPLAY_COLOR_FORMAT_BGR565: case COLOR_FORMAT_BGR565:
Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address)); Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address));
break; break;
case DISPLAY_COLOR_FORMAT_BGR565_SWAPPED: { case COLOR_FORMAT_BGR565_SWAPPED: {
// TODO: Make proper conversion function // TODO: Make proper conversion function
Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address)); Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address));
uint8_t temp = *address; uint8_t temp = *address;
@@ -96,11 +96,11 @@ public:
*(address + 1) = temp; *(address + 1) = temp;
break; break;
} }
case DISPLAY_COLOR_FORMAT_RGB565: { case COLOR_FORMAT_RGB565: {
Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address)); Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address));
break; break;
} }
case DISPLAY_COLOR_FORMAT_RGB565_SWAPPED: { case COLOR_FORMAT_RGB565_SWAPPED: {
// TODO: Make proper conversion function // TODO: Make proper conversion function
Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address)); Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address));
uint8_t temp = *address; uint8_t temp = *address;
@@ -108,7 +108,7 @@ public:
*(address + 1) = temp; *(address + 1) = temp;
break; break;
} }
case DISPLAY_COLOR_FORMAT_RGB888: { case COLOR_FORMAT_RGB888: {
uint8_t pixel[3] = { r, g, b }; uint8_t pixel[3] = { r, g, b };
memcpy(address, pixel, 3); memcpy(address, pixel, 3);
break; break;
@@ -1,47 +1,49 @@
#pragma once #pragma once
#include <tactility/device.h> #include <cassert>
#include <tactility/drivers/display.h> #include <tt_hal_display.h>
#include <Tactility/kernel/Kernel.h> #include <Tactility/kernel/Kernel.h>
/** /**
* Wrapper for display_* device driver functions * Wrapper for tt_hal_display_driver_*
*/ */
class DisplayDriver { class DisplayDriver {
struct Device* device; DisplayDriverHandle handle = nullptr;
public: public:
explicit DisplayDriver(struct Device* device) : device(device) { explicit DisplayDriver(DeviceId id) {
device_get(device); assert(tt_hal_display_driver_supported(id));
handle = tt_hal_display_driver_alloc(id);
assert(handle != nullptr);
} }
~DisplayDriver() { ~DisplayDriver() {
device_put(device); tt_hal_display_driver_free(handle);
} }
bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const { bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const {
return device_try_lock(device, timeout); return tt_hal_display_driver_lock(handle, timeout);
} }
void unlock() const { void unlock() const {
device_unlock(device); tt_hal_display_driver_unlock(handle);
} }
uint16_t getWidth() const { uint16_t getWidth() const {
return display_get_resolution_x(device); return tt_hal_display_driver_get_pixel_width(handle);
} }
uint16_t getHeight() const { uint16_t getHeight() const {
return display_get_resolution_y(device); return tt_hal_display_driver_get_pixel_height(handle);
} }
enum DisplayColorFormat getColorFormat() const { ColorFormat getColorFormat() const {
return display_get_color_format(device); return tt_hal_display_driver_get_colorformat(handle);
} }
void drawBitmap(int xStart, int yStart, int xEnd, int yEnd, const void* pixelData) const { void drawBitmap(int xStart, int yStart, int xEnd, int yEnd, const void* pixelData) const {
display_draw_bitmap(device, xStart, yStart, xEnd, yEnd, pixelData); tt_hal_display_driver_draw_bitmap(handle, xStart, yStart, xEnd, yEnd, pixelData);
} }
}; };
@@ -1,28 +1,28 @@
#pragma once #pragma once
#include <tactility/device.h> #include <cassert>
#include <tactility/drivers/pointer.h> #include <tt_hal_touch.h>
/** /**
* Wrapper for pointer_* device driver functions * Wrapper for tt_hal_touch_driver_*
*/ */
class TouchDriver { class TouchDriver {
struct Device* device; TouchDriverHandle handle = nullptr;
public: public:
explicit TouchDriver(struct Device* device) : device(device) { explicit TouchDriver(DeviceId id) {
device_get(device); assert(tt_hal_touch_driver_supported(id));
handle = tt_hal_touch_driver_alloc(id);
assert(handle != nullptr);
} }
~TouchDriver() { ~TouchDriver() {
device_put(device); tt_hal_touch_driver_free(handle);
} }
bool getTouchedPoints(uint16_t* x, uint16_t* y, uint16_t* strength, uint8_t* count, uint8_t maxCount) const { bool getTouchedPoints(uint16_t* x, uint16_t* y, uint16_t* strength, uint8_t* count, uint8_t maxCount) const {
// Poll without blocking: perform one read attempt, then report whatever is cached. return tt_hal_touch_driver_get_touched_points(handle, x, y, strength, count, maxCount);
pointer_read_data(device, 0);
return pointer_get_touched_points(device, x, y, strength, count, maxCount);
} }
}; };
+44 -23
View File
@@ -6,44 +6,65 @@
#include <tt_app.h> #include <tt_app.h>
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <tt_lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/display.h>
#include <tactility/drivers/pointer.h>
#include <lvgl/lvgl.h>
#include <lvgl/module.h>
#include <tactility/module.h>
constexpr auto TAG = "Main"; constexpr auto TAG = "Main";
static void onCreate(AppHandle appHandle, void* data) { /** Find a DisplayDevice that supports the DisplayDriver interface */
struct Device* display_device; static bool findUsableDisplay(DeviceId& deviceId) {
if (device_get_first_active_by_type(&DISPLAY_TYPE, &display_device) != ERROR_NONE) { uint16_t display_count = 0;
if (!tt_hal_device_find(DEVICE_TYPE_DISPLAY, &deviceId, &display_count, 1)) {
ESP_LOGE(TAG, "No display device found"); ESP_LOGE(TAG, "No display device found");
return false;
}
if (!tt_hal_display_driver_supported(deviceId)) {
ESP_LOGE(TAG, "Display doesn't support driver mode");
return false;
}
return true;
}
/** Find a TouchDevice that supports the TouchDriver interface */
static bool findUsableTouch(DeviceId& deviceId) {
uint16_t touch_count = 0;
if (!tt_hal_device_find(DEVICE_TYPE_TOUCH, &deviceId, &touch_count, 1)) {
ESP_LOGE(TAG, "No touch device found");
return false;
}
if (!tt_hal_touch_driver_supported(deviceId)) {
ESP_LOGE(TAG, "Touch doesn't support driver mode");
return false;
}
return true;
}
static void onCreate(AppHandle appHandle, void* data) {
DeviceId display_id;
if (!findUsableDisplay(display_id)) {
tt_app_stop(); tt_app_stop();
tt_app_alertdialog_start("Error", "No display device was found.", nullptr, 0); tt_app_alertdialog_start("Error", "The display doesn't support the required features.", nullptr, 0);
return; return;
} }
struct Device* touch_device; DeviceId touch_id;
if (device_get_first_active_by_type(&POINTER_TYPE, &touch_device) != ERROR_NONE) { if (!findUsableTouch(touch_id)) {
ESP_LOGE(TAG, "No touch device found");
device_put(display_device);
tt_app_stop(); tt_app_stop();
tt_app_alertdialog_start("Error", "No touch device was found.", nullptr, 0); tt_app_alertdialog_start("Error", "The touch driver doesn't support the required features.", nullptr, 0);
return; return;
} }
// Stop LVGL first (because it's currently using the drivers we want to use) // Stop LVGL first (because it's currently using the drivers we want to use)
module_stop(&lvgl_module); tt_lvgl_stop();
ESP_LOGI(TAG, "Creating display driver"); ESP_LOGI(TAG, "Creating display driver");
auto display = new DisplayDriver(display_device); auto display = new DisplayDriver(display_id);
device_put(display_device);
ESP_LOGI(TAG, "Creating touch driver"); ESP_LOGI(TAG, "Creating touch driver");
auto touch = new TouchDriver(touch_device); auto touch = new TouchDriver(touch_id);
device_put(touch_device);
// Run the main logic // Run the main logic
ESP_LOGI(TAG, "Running application"); ESP_LOGI(TAG, "Running application");
@@ -61,9 +82,9 @@ static void onCreate(AppHandle appHandle, void* data) {
static void onDestroy(AppHandle appHandle, void* data) { static void onDestroy(AppHandle appHandle, void* data) {
// Restart LVGL to resume rendering of regular apps // Restart LVGL to resume rendering of regular apps
if (!module_is_started(&lvgl_module)) { if (!tt_lvgl_is_started()) {
ESP_LOGI(TAG, "Restarting LVGL"); ESP_LOGI(TAG, "Restarting LVGL");
module_start(&lvgl_module); tt_lvgl_start();
} }
} }
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.mystifydemo sdk=0.7.0-dev
app.version.name=0.8.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=8 [app]
app.name=Mystify Demo id=one.tactility.mystifydemo
versionName=0.3.0
versionCode=3
name=Mystify Demo
+2 -3
View File
@@ -8,13 +8,12 @@
#include <functional> #include <functional>
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <tt_lvgl.h>
#include <TactilityCpp/LvglLock.h> #include <TactilityCpp/LvglLock.h>
#include <TactilityCpp/Preferences.h> #include <TactilityCpp/Preferences.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/uart_controller.h> #include <tactility/drivers/uart_controller.h>
constexpr TickType_t LVGL_DEFAULT_LOCK_TIME = 500; // 500 ticks = 500 ms
class ConnectView final : public View { class ConnectView final : public View {
public: public:
@@ -46,7 +45,7 @@ private:
void onConnect() { void onConnect() {
auto lock = lvglLock.asScopedLock(); auto lock = lvglLock.asScopedLock();
if (!lock.lock(LVGL_DEFAULT_LOCK_TIME)) { if (!lock.lock(TT_LVGL_DEFAULT_LOCK_TIME)) {
return; return;
} }
@@ -7,6 +7,8 @@
#include <sstream> #include <sstream>
#include <lvgl.h> #include <lvgl.h>
#include <tt_lvgl.h>
#include <Tactility/RecursiveMutex.h> #include <Tactility/RecursiveMutex.h>
#include <Tactility/Thread.h> #include <Tactility/Thread.h>
#include <TactilityCpp/LvglLock.h> #include <TactilityCpp/LvglLock.h>
@@ -1,5 +1,5 @@
#include "SerialConsole.h" #include "SerialConsole.h"
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
constexpr auto* TAG = "SerialMonitor"; constexpr auto* TAG = "SerialMonitor";
@@ -43,9 +43,9 @@ void SerialConsole::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* toolbar = lvgl_toolbar_create(parent, "Serial Console"); auto* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
disconnectButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_POWER, onDisconnectPressed, this); disconnectButton = tt_lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_POWER, onDisconnectPressed, this);
lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
wrapperWidget = lv_obj_create(parent); wrapperWidget = lv_obj_create(parent);
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.serialconsole sdk=0.7.0-dev
app.version.name=0.9.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=9 [app]
app.name=Serial Console id=one.tactility.serialconsole
versionName=0.4.0
versionCode=4
name=Serial Console
+4 -4
View File
@@ -5,15 +5,15 @@
#include "Snake.h" #include "Snake.h"
#include <inttypes.h> #include <inttypes.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <tt_app_selectiondialog.h> #include <tt_app_selectiondialog.h>
#include <tt_preferences.h> #include <tt_preferences.h>
#include <TactilityCpp/LvglLock.h> #include <TactilityCpp/LvglLock.h>
#include <lvgl/lvgl.h> #include <tactility/lvgl_module.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
constexpr auto* TAG = "Snake"; constexpr auto* TAG = "Snake";
@@ -245,7 +245,7 @@ void Snake::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
// Create toolbar // Create toolbar
toolbar = lvgl_toolbar_create(parent, "Snake"); toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Create main wrapper // Create main wrapper
+39 -4
View File
@@ -7,8 +7,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <time.h> #include <time.h>
#include <tactility/device.h> #include <tt_lvgl_keyboard.h>
#include <tactility/drivers/keyboard.h>
// Forward declarations // Forward declarations
static void game_play_event(lv_event_t* e); static void game_play_event(lv_event_t* e);
@@ -37,7 +36,7 @@ static void delete_event(lv_event_t* e) {
} }
// Restore edit mode and remove from group before cleanup // Restore edit mode and remove from group before cleanup
if (device_has_active_by_type(&KEYBOARD_TYPE)) { if (tt_lvgl_hardware_keyboard_is_available()) {
lv_group_t* group = lv_group_get_default(); lv_group_t* group = lv_group_get_default();
if (group) lv_group_set_editing(group, false); if (group) lv_group_set_editing(group, false);
lv_group_remove_obj(game->container); lv_group_remove_obj(game->container);
@@ -246,6 +245,41 @@ static void game_play_event(lv_event_t* e) {
default: default:
break; break;
} }
} else if (code == LV_EVENT_CLICKED) {
lv_indev_t* indev = lv_indev_active();
if (indev) {
lv_point_t point;
lv_indev_get_point(indev, &point);
lv_area_t coords;
lv_obj_get_coords(game->container, &coords);
lv_coord_t w = coords.x2 - coords.x1 + 1;
lv_coord_t h = coords.y2 - coords.y1 + 1;
lv_coord_t cx = coords.x1 + w / 2;
lv_coord_t cy = coords.y1 + h / 2;
lv_coord_t rx = point.x - cx;
lv_coord_t ry = point.y - cy;
if (rx != 0 || ry != 0) {
if (abs(rx) * h > abs(ry) * w) {
// Horizontal touch
if (rx > 0) {
snake_set_direction(game, SNAKE_DIR_RIGHT);
} else {
snake_set_direction(game, SNAKE_DIR_LEFT);
}
} else {
// Vertical touch
if (ry > 0) {
snake_set_direction(game, SNAKE_DIR_DOWN);
} else {
snake_set_direction(game, SNAKE_DIR_UP);
}
}
}
}
} else if (code == LV_EVENT_KEY) { } else if (code == LV_EVENT_KEY) {
uint32_t key = lv_event_get_key(e); uint32_t key = lv_event_get_key(e);
// Arrow keys, WASD, and punctuation keys for cardputer // Arrow keys, WASD, and punctuation keys for cardputer
@@ -395,10 +429,11 @@ lv_obj_t* snake_create(lv_obj_t* parent, uint16_t cell_size, bool wall_collision
// Add event callbacks for touch gestures and keyboard // Add event callbacks for touch gestures and keyboard
lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_GESTURE, obj); lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_GESTURE, obj);
lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_KEY, obj); lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_KEY, obj);
lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_CLICKED, obj);
lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL); lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL);
// Set up keyboard focus if available // Set up keyboard focus if available
if (device_has_active_by_type(&KEYBOARD_TYPE)) { if (tt_lvgl_hardware_keyboard_is_available()) {
lv_group_t* group = lv_group_get_default(); lv_group_t* group = lv_group_get_default();
if (group) { if (group) {
lv_group_add_obj(group, game->container); lv_group_add_obj(group, game->container);
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.snake sdk=0.7.0-dev
app.version.name=0.10.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=10 [app]
app.name=Snake id=one.tactility.snake
app.description=Classic Snake game versionName=0.5.0
versionCode=5
name=Snake
description=Classic Snake game
+6 -5
View File
@@ -5,7 +5,7 @@
#include "TamaTac.h" #include "TamaTac.h"
#include "SpriteData.h" #include "SpriteData.h"
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <Tactility/kernel/Kernel.h> #include <Tactility/kernel/Kernel.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
@@ -57,6 +57,7 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
if (sfxEngine == nullptr) { if (sfxEngine == nullptr) {
sfxEngine = new SfxEngine(); sfxEngine = new SfxEngine();
sfxEngine->start(); sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
// Load settings // Load settings
bool soundEnabled; bool soundEnabled;
@@ -86,11 +87,11 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
lv_obj_set_style_pad_all(parent, 0, 0); lv_obj_set_style_pad_all(parent, 0, 0);
lv_obj_set_style_pad_row(parent, 0, 0); lv_obj_set_style_pad_row(parent, 0, 0);
toolbar = lvgl_toolbar_create(parent, "TamaTac"); toolbar = tt_lvgl_toolbar_create_for_app(parent, context);
menuButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onMenuClicked, this); menuButton = tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onMenuClicked, this);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onCleanClicked, this); tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onCleanClicked, this);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_REFRESH, onResetClicked, this); tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_REFRESH, onResetClicked, this);
wrapperWidget = lv_obj_create(parent); wrapperWidget = lv_obj_create(parent);
lv_obj_set_width(wrapperWidget, LV_PCT(100)); lv_obj_set_width(wrapperWidget, LV_PCT(100));
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.tamatac sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=TamaTac id=one.tactility.tamatac
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM. versionName=0.1.0
versionCode=1
name=TamaTac
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
+18 -13
View File
@@ -1,10 +1,11 @@
#include "TodoList.h" #include "TodoList.h"
#include <tt_app.h> #include <tt_app.h>
#include <tactility/filesystem/file_mutex.h> #include <tt_lock.h>
#include <Tactility/kernel/Kernel.h> #include <Tactility/kernel/Kernel.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <lvgl/lvgl.h> #include <tt_lvgl_keyboard.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_module.h>
#include <tactility/lvgl_fonts.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -59,9 +60,9 @@ void TodoList::saveTodos() {
char savePath[256]; char savePath[256];
if (!getSaveFilePath(savePath, sizeof(savePath))) return; if (!getSaveFilePath(savePath, sizeof(savePath))) return;
struct FileMutex mutex; auto lock = tt_lock_alloc_for_path(savePath);
file_mutex_get(&mutex, savePath); if (!lock) return;
file_mutex_lock(&mutex); if (tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) {
FILE* f = fopen(savePath, "w"); FILE* f = fopen(savePath, "w");
if (f) { if (f) {
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
@@ -69,17 +70,19 @@ void TodoList::saveTodos() {
} }
fclose(f); fclose(f);
} }
file_mutex_unlock(&mutex); tt_lock_release(lock);
}
tt_lock_free(lock);
} }
void TodoList::loadTodos() { void TodoList::loadTodos() {
char savePath[256]; char savePath[256];
if (!getSaveFilePath(savePath, sizeof(savePath))) return; if (!getSaveFilePath(savePath, sizeof(savePath))) return;
struct FileMutex mutex; auto lock = tt_lock_alloc_for_path(savePath);
file_mutex_get(&mutex, savePath); if (!lock) return;
file_mutex_lock(&mutex); if (tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) {
count = 0; count = 0;
FILE* f = fopen(savePath, "r"); FILE* f = fopen(savePath, "r");
if (f) { if (f) {
@@ -100,7 +103,9 @@ void TodoList::loadTodos() {
} }
fclose(f); fclose(f);
} }
file_mutex_unlock(&mutex); tt_lock_release(lock);
}
tt_lock_free(lock);
} }
/* ── UI Helpers ───────────────────────────────────────────────────── */ /* ── UI Helpers ───────────────────────────────────────────────────── */
@@ -272,7 +277,7 @@ void TodoList::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
/* Toolbar */ /* Toolbar */
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Todo List"); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_t* countWrapper = lv_obj_create(toolbar); lv_obj_t* countWrapper = lv_obj_create(toolbar);
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.todolist sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=Todo List id=one.tactility.todolist
app.description=Simple task list manager versionName=0.2.0
versionCode=2
name=Todo List
description=Simple task list manager
+4 -4
View File
@@ -5,12 +5,12 @@
#include "TwoEleven.h" #include "TwoEleven.h"
#include <inttypes.h> #include <inttypes.h>
#include <lvgl/widgets/toolbar.h> #include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h> #include <tt_app_alertdialog.h>
#include <tt_app_selectiondialog.h> #include <tt_app_selectiondialog.h>
#include <tt_preferences.h> #include <tt_preferences.h>
#include <lvgl/lvgl.h> #include <tactility/lvgl_module.h>
#include <lvgl/fonts.h> #include <tactility/lvgl_fonts.h>
#include <TactilityCpp/LvglLock.h> #include <TactilityCpp/LvglLock.h>
constexpr auto* TAG = "TwoEleven"; constexpr auto* TAG = "TwoEleven";
@@ -257,7 +257,7 @@ void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
// Create toolbar // Create toolbar
toolbar = lvgl_toolbar_create(parent, "2048"); toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Create main wrapper // Create main wrapper
+3 -4
View File
@@ -3,8 +3,7 @@
#include "TwoElevenHelpers.h" #include "TwoElevenHelpers.h"
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <tactility/device.h> #include <tt_lvgl_keyboard.h>
#include <tactility/drivers/keyboard.h>
static void game_play_event(lv_event_t * e); static void game_play_event(lv_event_t * e);
static void btnm_event_cb(lv_event_t * e); static void btnm_event_cb(lv_event_t * e);
@@ -19,7 +18,7 @@ static void delete_event(lv_event_t * e)
twoeleven_t * game_2048 = (twoeleven_t *)lv_obj_get_user_data(obj); twoeleven_t * game_2048 = (twoeleven_t *)lv_obj_get_user_data(obj);
if (game_2048) { if (game_2048) {
// Restore edit mode and remove from group before cleanup // Restore edit mode and remove from group before cleanup
if (device_has_active_by_type(&KEYBOARD_TYPE)) { if (tt_lvgl_hardware_keyboard_is_available()) {
lv_group_t* group = lv_group_get_default(); lv_group_t* group = lv_group_get_default();
if (group) lv_group_set_editing(group, false); if (group) lv_group_set_editing(group, false);
lv_group_remove_obj(game_2048->btnm); lv_group_remove_obj(game_2048->btnm);
@@ -141,7 +140,7 @@ lv_obj_t * twoeleven_create(lv_obj_t * parent, uint16_t matrix_size)
lv_obj_add_event_cb(game_2048->btnm, btnm_event_cb, LV_EVENT_DRAW_TASK_ADDED, NULL); lv_obj_add_event_cb(game_2048->btnm, btnm_event_cb, LV_EVENT_DRAW_TASK_ADDED, NULL);
lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL); lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL);
if (device_has_active_by_type(&KEYBOARD_TYPE)) { if (tt_lvgl_hardware_keyboard_is_available()) {
lv_group_t* group = lv_group_get_default(); lv_group_t* group = lv_group_get_default();
if (group) { if (group) {
lv_group_add_obj(group, game_2048->btnm); lv_group_add_obj(group, game_2048->btnm);
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.twoeleven sdk=0.7.0-dev
app.version.name=0.9.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=9 [app]
app.name=2048 id=one.tactility.twoeleven
app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert). versionName=0.4.0
versionCode=4
name=2048
description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
-70
View File
@@ -1,70 +0,0 @@
import os
import sys
import boto3
SHELL_COLOR_RED = "\033[91m"
SHELL_COLOR_ORANGE = "\033[93m"
SHELL_COLOR_RESET = "\033[m"
def print_warning(message):
print(f"{SHELL_COLOR_ORANGE}WARNING: {message}{SHELL_COLOR_RESET}")
def print_error(message):
print(f"{SHELL_COLOR_RED}ERROR: {message}{SHELL_COLOR_RESET}")
def print_help():
print("Usage: python upload-app-files.py [path] [sdkVersion] [cloudflareAccountId] [cloudflareTokenName] [cloudflareTokenValue]")
print("")
print("Options:")
print(" --index-only Upload only apps.json")
def exit_with_error(message):
print_error(message)
sys.exit(1)
def main(path: str, sdk_version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool):
if not os.path.exists(path):
exit_with_error(f"Path not found: {path}")
s3 = boto3.client(
service_name="s3",
endpoint_url=f"https://{cloudflare_account_id}.r2.cloudflarestorage.com",
aws_access_key_id=cloudflare_token_name,
aws_secret_access_key=cloudflare_token_value,
region_name="auto"
)
files_to_upload = os.listdir(path)
if index_only:
files_to_upload = [f for f in files_to_upload if f == 'apps.json']
else:
# Ensure apps.json is uploaded last so it never references files that
# haven't finished uploading yet.
files_to_upload.sort(key=lambda f: f == 'apps.json')
counter = 1
total = len(files_to_upload)
for file_name in files_to_upload:
object_path = f"apps/{sdk_version}/{file_name}"
print(f"[{counter}/{total}] Uploading {file_name} to {object_path}")
file_path = os.path.join(path, file_name)
try:
s3.upload_file(file_path, "tactility", object_path)
except Exception as e:
exit_with_error(f"Failed to upload {file_name}: {str(e)}")
counter += 1
if __name__ == "__main__":
print("Tactility CDN Apps Uploader")
if "--help" in sys.argv:
print_help()
sys.exit()
# Argument validation
if len(sys.argv) < 6:
print_help()
sys.exit(1)
main(
path=sys.argv[1],
sdk_version=sys.argv[2],
cloudflare_account_id=sys.argv[3],
cloudflare_token_name=sys.argv[4],
cloudflare_token_value=sys.argv[5],
index_only="--index-only" in sys.argv
)
+14
View File
@@ -0,0 +1,14 @@
## 4. Workflow: Compiling & Installing Apps
### Step 1: Source Environment & Path
Ensure ESP-IDF environment is sourced and export local SDK path:
```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/.gemini/antigravity/scratch/tactility/release/TactilitySDK
```
### Step 2: Compile the App
Run the Tactility build script, specifying the target platform:
```bash
# Compile and package HelloWorld
python3 tactility.py Apps/HelloWorld build esp32s3 --local-sdk
@@ -10,15 +10,13 @@ UnitDualButton::~UnitDualButton() {
bool UnitDualButton::begin(Device* controller, gpio_pin_t pinA, gpio_pin_t pinB) { bool UnitDualButton::begin(Device* controller, gpio_pin_t pinA, gpio_pin_t pinB) {
if (!controller) return false; if (!controller) return false;
gpio_flags_t flags = GPIO_FLAG_DIRECTION_INPUT | GPIO_FLAG_PULL_UP; descA_ = gpio_descriptor_acquire(controller, pinA, GPIO_OWNER_GPIO);
descA_ = gpio_descriptor_acquire(controller, pinA, flags, GPIO_OWNER_GPIO);
if (!descA_) { if (!descA_) {
ESP_LOGW(TAG, "Failed to acquire pin %d", (int)pinA); ESP_LOGW(TAG, "Failed to acquire pin %d", (int)pinA);
return false; return false;
} }
descB_ = gpio_descriptor_acquire(controller, pinB, flags, GPIO_OWNER_GPIO); descB_ = gpio_descriptor_acquire(controller, pinB, GPIO_OWNER_GPIO);
if (!descB_) { if (!descB_) {
ESP_LOGW(TAG, "Failed to acquire pin %d", (int)pinB); ESP_LOGW(TAG, "Failed to acquire pin %d", (int)pinB);
gpio_descriptor_release(descA_); gpio_descriptor_release(descA_);
@@ -26,6 +24,20 @@ bool UnitDualButton::begin(Device* controller, gpio_pin_t pinA, gpio_pin_t pinB)
return false; return false;
} }
gpio_flags_t flags = GPIO_FLAG_DIRECTION_INPUT | GPIO_FLAG_PULL_UP;
if (gpio_descriptor_set_flags(descA_, flags) != ERROR_NONE) {
ESP_LOGW(TAG, "Failed to configure pin %d flags", (int)pinA);
gpio_descriptor_release(descA_); gpio_descriptor_release(descB_);
descA_ = descB_ = nullptr;
return false;
}
if (gpio_descriptor_set_flags(descB_, flags) != ERROR_NONE) {
ESP_LOGW(TAG, "Failed to configure pin %d flags", (int)pinB);
gpio_descriptor_release(descA_); gpio_descriptor_release(descB_);
descA_ = descB_ = nullptr;
return false;
}
ready_ = true; ready_ = true;
ESP_LOGI(TAG, "DualButton ready on pins %d/%d", (int)pinA, (int)pinB); ESP_LOGI(TAG, "DualButton ready on pins %d/%d", (int)pinA, (int)pinB);
return true; return true;
+8 -10
View File
@@ -26,7 +26,6 @@
#include <cstdint> #include <cstdint>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "freertos/queue.h" #include "freertos/queue.h"
@@ -162,6 +161,12 @@ public:
// Settings // Settings
void setEnabled(bool enabled) { enabled_ = enabled; } void setEnabled(bool enabled) { enabled_ = enabled; }
bool isEnabled() const { return enabled_; } bool isEnabled() const { return enabled_; }
void setVolume(float vol) { masterVolume_ = (vol < 0) ? 0 : (vol > 1.0f) ? 1.0f : vol; }
float getVolume() const { return masterVolume_; }
// Volume presets (consistent with SoundEngine naming)
enum class VolumePreset { Quiet, Normal, Loud };
void applyVolumePreset(VolumePreset preset);
// Polyphonic gate (consistent with SoundEngine) // Polyphonic gate (consistent with SoundEngine)
void setPolyphonicGateEnabled(bool enabled) { polyphonicGateEnabled_ = enabled; } void setPolyphonicGateEnabled(bool enabled) { polyphonicGateEnabled_ = enabled; }
@@ -272,21 +277,14 @@ private:
// State // State
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
Device* audioStreamDevice_ = nullptr; Device* i2sDevice_ = nullptr;
AudioStreamHandle audioStreamHandle_ = nullptr;
TaskHandle_t task_ = nullptr; TaskHandle_t task_ = nullptr;
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
QueueHandle_t msgQueue_ = nullptr; QueueHandle_t msgQueue_ = nullptr;
volatile bool running_ = false; volatile bool running_ = false;
volatile bool enabled_ = true; volatile bool enabled_ = true;
volatile float masterVolume_ = 0.5f;
// Cached system output volume (0..1), refreshed periodically from the shared
// audio_stream device so the preset acts as a relative multiplier on top of it
// rather than an absolute level (a "Quiet" preset should sound quiet relative
// to whatever the user has the system volume set to, not in absolute terms).
float systemVolumeMix_ = 1.0f;
int systemVolumePollCounter_ = 0;
// Polyphonic gate // Polyphonic gate
volatile bool polyphonicGateEnabled_ = true; volatile bool polyphonicGateEnabled_ = true;
+5 -4
View File
@@ -35,8 +35,11 @@ if (!engine->start()) {
ESP_LOGE(TAG, "Failed to start SfxEngine"); ESP_LOGE(TAG, "Failed to start SfxEngine");
return; return;
} }
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
engine->play(SfxId::Coin); // Predefined SFX engine->play(SfxId::Coin); // Predefined SFX
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
engine->setVolume(0.7f); // Volume control
engine->stop(); engine->stop();
delete engine; delete engine;
@@ -84,11 +87,9 @@ idf_component_register(
- `void stopVoice(voice)` - Stop specific voice - `void stopVoice(voice)` - Stop specific voice
### Settings ### Settings
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
- `void setEnabled(bool)` - Mute/unmute - `void setEnabled(bool)` - Mute/unmute
- `void applyVolumePreset(VolumePreset)` - Apply Quiet/Normal/Loud preset (configures volume, gate, normalization)
Loudness is controlled by the system output volume (set via the audio_stream device / Settings UI),
not by SfxEngine itself -- a fixed app-side gain on top of hardware attenuation gets swamped at low
system volumes, so there's no separate volume control here.
### Mixing (consistent with SoundEngine) ### Mixing (consistent with SoundEngine)
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on) - `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
+64 -56
View File
@@ -9,7 +9,7 @@
#include "SfxEngine.h" #include "SfxEngine.h"
#include "SfxDefinitions.h" #include "SfxDefinitions.h"
#include <tactility/drivers/audio_stream.h> #include <tactility/drivers/i2s_controller.h>
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include "esp_log.h" #include "esp_log.h"
@@ -320,17 +320,15 @@ void SfxEngine::fillStereoBuffer(int16_t* buf, int samples) {
// Apply polyphonic soft gate (proportional reduction when clipping threatened) // Apply polyphonic soft gate (proportional reduction when clipping threatened)
mix = applyPolyphonicGate(mix, activeVoices); mix = applyPolyphonicGate(mix, activeVoices);
// Apply master volume (exponential curve for perceptual linearity)
float volCurve = masterVolume_ * masterVolume_;
mix *= volCurve;
// Apply auto-normalization (consistent volume across different SFX) // Apply auto-normalization (consistent volume across different SFX)
mix = applyAutoNormalization(mix); mix = applyAutoNormalization(mix);
// Brick-wall limiter (final safety net before soft clip) // Brick-wall limiter (final safety net before soft clip)
mix = applyBrickWallLimiter(mix); mix = applyBrickWallLimiter(mix);
// The shared system output volume (esp_codec_dev hardware attenuation, set via
// the Settings UI / audio_stream_set_volume) is the sole loudness control here --
// a fixed app-side gain multiplier stacked on top of it just gets swamped at low
// system-volume levels, making any such control feel like it does nothing.
mix *= systemVolumeMix_;
} }
// Cubic soft clip // Cubic soft clip
@@ -462,28 +460,14 @@ void SfxEngine::audioTaskFunc(void* param) {
} }
} }
// Periodically refresh the cached system output volume/enabled state (cheap pass-
// through to the shared kernel device; polled rather than read per-sample since it
// changes rarely and audio_stream_get_volume may take a lock).
if (self->systemVolumePollCounter_-- <= 0) {
self->systemVolumePollCounter_ = 32; // ~0.5s at 256 samples / 16kHz
float systemVolumePercent = 100.0f;
bool systemOutputEnabled = true;
audio_stream_get_volume(self->audioStreamDevice_, AUDIO_CODEC_DIR_OUTPUT, &systemVolumePercent);
audio_stream_get_enabled(self->audioStreamDevice_, AUDIO_CODEC_DIR_OUTPUT, &systemOutputEnabled);
self->systemVolumeMix_ = systemOutputEnabled ? (systemVolumePercent / 100.0f) : 0.0f;
}
// Fill audio buffer (member buffer to avoid stack pressure) // Fill audio buffer (member buffer to avoid stack pressure)
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES); self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
// Write to the audio stream (resampled to the codec's native rate transparently) // Write to I2S
error_t error = audio_stream_write(self->audioStreamHandle_, self->audioBuffer_, error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100)); sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
if (error != ERROR_NONE) { if (error != ERROR_NONE) {
ESP_LOGE(TAG, "Audio stream write error"); ESP_LOGE(TAG, "I2S write error");
self->running_ = false; self->running_ = false;
break; break;
} }
@@ -491,7 +475,7 @@ void SfxEngine::audioTaskFunc(void* param) {
// Flush silence // Flush silence
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_)); memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
audio_stream_write(self->audioStreamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50)); i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
ESP_LOGI(TAG, "Audio task exiting"); ESP_LOGI(TAG, "Audio task exiting");
@@ -510,31 +494,33 @@ void SfxEngine::audioTaskFunc(void* param) {
bool SfxEngine::start() { bool SfxEngine::start() {
if (running_) return true; if (running_) return true;
// Find audio stream device // Find I2S device
audioStreamDevice_ = nullptr; i2sDevice_ = nullptr;
device_for_each_of_type(&AUDIO_STREAM_TYPE, &audioStreamDevice_, [](Device* device, void* context) { device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) {
if (!device_is_ready(device)) return true; if (!device_is_ready(device)) return true;
Device** devicePtr = static_cast<Device**>(context); Device** devicePtr = static_cast<Device**>(context);
*devicePtr = device; *devicePtr = device;
return false; return false;
}); });
if (audioStreamDevice_ == nullptr) { if (i2sDevice_ == nullptr) {
ESP_LOGW(TAG, "No audio stream device found"); ESP_LOGW(TAG, "No I2S device found");
return false; return false;
} }
// Open output stream (the kernel resamples to the codec's native rate transparently) // Configure I2S
AudioStreamConfig config = { I2sConfig config = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE, .sample_rate = SAMPLE_RATE,
.bits_per_sample = 16, .bits_per_sample = 16,
.channels = 2 .channel_left = 0,
.channel_right = 0
}; };
error_t error = audio_stream_open_output(audioStreamDevice_, &config, &audioStreamHandle_); error_t error = i2s_controller_set_config(i2sDevice_, &config);
if (error != ERROR_NONE) { if (error != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to open audio output stream: %s", error_to_string(error)); ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
audioStreamDevice_ = nullptr; i2sDevice_ = nullptr;
return false; return false;
} }
@@ -542,14 +528,12 @@ bool SfxEngine::start() {
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg)); msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
if (msgQueue_ == nullptr) { if (msgQueue_ == nullptr) {
ESP_LOGE(TAG, "Failed to create message queue"); ESP_LOGE(TAG, "Failed to create message queue");
audio_stream_close(audioStreamHandle_); i2s_controller_reset(i2sDevice_);
audioStreamHandle_ = nullptr; i2sDevice_ = nullptr;
audioStreamDevice_ = nullptr;
return false; return false;
} }
// Start audio task // Start audio task
systemVolumePollCounter_ = 0; // poll the system volume immediately on the first iteration
running_ = true; running_ = true;
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_); BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
if (result != pdPASS) { if (result != pdPASS) {
@@ -557,9 +541,8 @@ bool SfxEngine::start() {
running_ = false; running_ = false;
vQueueDelete(msgQueue_); vQueueDelete(msgQueue_);
msgQueue_ = nullptr; msgQueue_ = nullptr;
audio_stream_close(audioStreamHandle_); i2s_controller_reset(i2sDevice_);
audioStreamHandle_ = nullptr; i2sDevice_ = nullptr;
audioStreamDevice_ = nullptr;
return false; return false;
} }
@@ -568,22 +551,19 @@ bool SfxEngine::start() {
} }
void SfxEngine::stop() { void SfxEngine::stop() {
// Guard on msgQueue_ (the resource marker), not running_ - the audio task can clear if (!running_) return;
// running_ itself on a write error and self-delete before stop() is ever called, which
// would otherwise make this early-return and leak audioStreamHandle_/msgQueue_.
if (msgQueue_ == nullptr && audioStreamHandle_ == nullptr) return;
if (running_) { // Create semaphore for deterministic shutdown
// Only wait on the semaphore if the task might still be alive to signal it - if
// running_ is already false, the task already exited (and self-deleted) on its own.
stopSemaphore_ = xSemaphoreCreateBinary(); stopSemaphore_ = xSemaphoreCreateBinary();
running_ = false; running_ = false;
if (task_ != nullptr && stopSemaphore_ != nullptr) { if (task_ != nullptr) {
// Wait for audio task to signal completion (up to 500ms)
if (stopSemaphore_ != nullptr) {
xSemaphoreTake(stopSemaphore_, pdMS_TO_TICKS(500)); xSemaphoreTake(stopSemaphore_, pdMS_TO_TICKS(500));
} }
}
task_ = nullptr; task_ = nullptr;
}
if (stopSemaphore_ != nullptr) { if (stopSemaphore_ != nullptr) {
vSemaphoreDelete(stopSemaphore_); vSemaphoreDelete(stopSemaphore_);
@@ -595,15 +575,43 @@ void SfxEngine::stop() {
msgQueue_ = nullptr; msgQueue_ = nullptr;
} }
if (audioStreamHandle_ != nullptr) { if (i2sDevice_ != nullptr) {
audio_stream_close(audioStreamHandle_); i2s_controller_reset(i2sDevice_);
audioStreamHandle_ = nullptr; i2sDevice_ = nullptr;
} }
audioStreamDevice_ = nullptr;
ESP_LOGI(TAG, "SfxEngine stopped"); ESP_LOGI(TAG, "SfxEngine stopped");
} }
void SfxEngine::applyVolumePreset(VolumePreset preset) {
switch (preset) {
case VolumePreset::Quiet:
masterVolume_ = 0.3f;
autoNormalize_ = true;
targetRms_ = 0.25f;
polyphonicGateEnabled_ = true;
softGateThreshold_ = 0.90f;
ESP_LOGI(TAG, "Applied Quiet preset");
break;
case VolumePreset::Normal:
masterVolume_ = 0.5f;
autoNormalize_ = true;
targetRms_ = 0.35f;
polyphonicGateEnabled_ = true;
softGateThreshold_ = 0.95f;
ESP_LOGI(TAG, "Applied Normal preset");
break;
case VolumePreset::Loud:
masterVolume_ = 0.75f;
autoNormalize_ = true;
targetRms_ = 0.45f;
polyphonicGateEnabled_ = true;
softGateThreshold_ = 0.98f;
ESP_LOGI(TAG, "Applied Loud preset");
break;
}
}
void SfxEngine::play(SfxId sound) { void SfxEngine::play(SfxId sound) {
if (!running_ || msgQueue_ == nullptr) return; if (!running_ || msgQueue_ == nullptr) return;
@@ -1,20 +1,19 @@
#pragma once #pragma once
#include <Tactility/Lock.h> #include <Tactility/Lock.h>
#include <lvgl/lvgl.h> #include <tt_lvgl.h>
class LvglLock final : public tt::Lock { class LvglLock final : public tt::Lock {
public: public:
using tt::Lock::lock; bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const override {
return tt_lvgl_lock(timeout);
bool lock(TickType_t timeout) const override {
return lvgl_try_lock(timeout);
} }
void unlock() const override { void unlock() const override {
lvgl_unlock(); tt_lvgl_unlock();
} }
}; };
+39 -54
View File
@@ -1,23 +1,14 @@
import json import json
import subprocess
import tarfile import tarfile
import os import os
import tempfile import tempfile
import configparser
import sys import sys
from datetime import datetime, UTC
def read_properties_file(path): def read_properties_file(path):
properties = {} config = configparser.RawConfigParser()
with open(path, "r") as file: config.read(path)
for line in file: return config
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
def get_manifest(appPath): def get_manifest(appPath):
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz """Extract only the file named 'manifest.properties' from the given tar/tar.gz
@@ -71,49 +62,51 @@ def get_manifest(appPath):
return None return None
def get_versioned_file_name(manifest): def get_versioned_file_name(manifest):
app_id = manifest["app.id"] app_id = manifest["app"]["id"]
version_code = manifest["app.version.code"] version_code = manifest["app"]["versionCode"]
return f"{app_id}-{version_code}.app" return f"{app_id}-{version_code}.app"
def get_os_version(manifest): def get_os_version(manifest):
sdk = manifest["target.sdk"] sdk = manifest["target"]["sdk"]
# Remove trailing hyphen suffix if present # Remove trailing hyphen suffix if present
if "-" in sdk: if "-" in sdk:
return sdk.rsplit("-", 1)[0].strip() return sdk.rsplit("-", 1)[0].strip()
else: else:
return sdk return sdk
def check_and_get_sdk_version(manifest_map):
"""Ensure all apps target the same (simplified) SDK version and return it."""
versions = {get_os_version(manifest) for manifest in manifest_map.values()}
if len(versions) != 1:
print(f"ERROR: Apps target multiple SDK versions: {sorted(versions)}. All apps must target the same SDK version.")
sys.exit(1)
return next(iter(versions))
def get_git_commit_hash():
return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
def manifest_config_to_flat_json(manifest): def manifest_config_to_flat_json(manifest):
"""Convert a flat (V2) manifest dict into a flat JSON-like dict. """Convert a ConfigParser manifest into a flat JSON-like dict.
Expected keys: Expected sections/keys (case-insensitive for keys):
app.id -> appId - [app]
app.version.name -> appVersionName id -> appId
app.version.code -> appVersionCode (int) versionName -> appVersionName
app.name -> appName versionCode -> appVersionCode (int)
app.description -> appDescription (optional; default "") name -> appName
target.sdk -> targetSdk description -> appDescription (optional; default "")
target.platforms -> targetPlatforms (comma-separated list) - [target]
sdk -> targetSdk
platforms -> targetPlatforms (comma-separated list)
Unknown/missing values fall back to sensible defaults per requirements. Unknown/missing values fall back to sensible defaults per requirements.
""" """
def get_opt(section, option, default=None):
if not manifest.has_section(section):
return default
# try exact option then lowercase (RawConfigParser lowercases by default)
if manifest.has_option(section, option):
return manifest.get(section, option)
low = option.lower()
if manifest.has_option(section, low):
return manifest.get(section, low)
return default
# Map values # Map values
app_id = manifest.get("app.id", "") app_id = get_opt("app", "id", "")
app_version_name = manifest.get("app.version.name", "") app_version_name = get_opt("app", "versionName", "")
app_version_code_raw = manifest.get("app.version.code", "0") app_version_code_raw = get_opt("app", "versionCode", "0")
app_name = manifest.get("app.name", "") app_name = get_opt("app", "name", "")
app_description = manifest.get("app.description", "") or "" app_description = get_opt("app", "description", "") or ""
# Coerce version code to int safely # Coerce version code to int safely
try: try:
@@ -121,8 +114,8 @@ def manifest_config_to_flat_json(manifest):
except Exception: except Exception:
app_version_code = 0 app_version_code = 0
target_sdk = manifest.get("target.sdk", "") target_sdk = get_opt("target", "sdk", "")
platforms_raw = manifest.get("target.platforms", "") platforms_raw = get_opt("target", "platforms", "")
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else [] target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
filename = get_versioned_file_name(manifest) filename = get_versioned_file_name(manifest)
@@ -149,23 +142,15 @@ if __name__ == "__main__":
sys.exit() sys.exit()
app_directory = sys.argv[1] app_directory = sys.argv[1]
manifest_map = {} manifest_map = {}
output_json = {
"apps": []
}
any_manifest = None any_manifest = None
if os.path.exists(app_directory): if os.path.exists(app_directory):
for file in os.listdir(app_directory): for file in os.listdir(app_directory):
if file.endswith(".app"): if file.endswith(".app"):
file_path = os.path.join(app_directory, file) file_path = os.path.join(app_directory, file)
manifest_map[file_path] = get_manifest(file_path) manifest_map[file_path] = get_manifest(file_path)
# All bundled apps must target the same SDK version; this becomes the CDN path segment
sdk_version = check_and_get_sdk_version(manifest_map)
with open("sdk_version.txt", "w") as f:
f.write(sdk_version)
print(f"SDK version: {sdk_version}")
output_json = {
"sdkVersion": sdk_version,
"created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
"gitCommit": get_git_commit_hash(),
"apps": []
}
# Rename files and collect manifest data into output json object # Rename files and collect manifest data into output json object
for file_path in manifest_map.keys(): for file_path in manifest_map.keys():
print(f"Processing {file_path}: {manifest_map[file_path]}") print(f"Processing {file_path}: {manifest_map[file_path]}")
+36 -23
View File
@@ -1,3 +1,4 @@
import configparser
import json import json
import os import os
import re import re
@@ -12,7 +13,7 @@ import tarfile
from urllib.parse import urlparse from urllib.parse import urlparse
ttbuild_path = ".tactility" ttbuild_path = ".tactility"
ttbuild_version = "4.1.0" ttbuild_version = "3.5.1"
ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_cdn = "https://cdn.tactilityproject.org"
ttbuild_sdk_json_validity = 3600 # seconds ttbuild_sdk_json_validity = 3600 # seconds
ttport = 6666 ttport = 6666
@@ -105,17 +106,9 @@ def get_url(ip, path):
return f"http://{ip}:{ttport}{path}" return f"http://{ip}:{ttport}{path}"
def read_properties_file(path): def read_properties_file(path):
properties = {} config = configparser.RawConfigParser()
with open(path, "r") as file: config.read(path)
for line in file: return config
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
#endregion Core #endregion Core
@@ -192,7 +185,7 @@ def fetch_sdkconfig_files(platform_targets):
for platform in platform_targets: for platform in platform_targets:
sdkconfig_filename = f"sdkconfig.app.{platform}" sdkconfig_filename = f"sdkconfig.app.{platform}"
target_path = os.path.join(ttbuild_path, sdkconfig_filename) target_path = os.path.join(ttbuild_path, sdkconfig_filename)
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path): if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path):
exit_with_error(f"Failed to download sdkconfig file for {platform}") exit_with_error(f"Failed to download sdkconfig file for {platform}")
#endregion SDK helpers #endregion SDK helpers
@@ -238,12 +231,32 @@ def read_manifest():
return read_properties_file("manifest.properties") return read_properties_file("manifest.properties")
def validate_manifest(manifest): def validate_manifest(manifest):
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"): # [manifest]
if key not in manifest: if not "manifest" in manifest:
exit_with_error(f"Invalid manifest format: {key} not found") exit_with_error("Invalid manifest format: [manifest] not found")
if not "version" in manifest["manifest"]:
exit_with_error("Invalid manifest format: [manifest] version not found")
# [target]
if not "target" in manifest:
exit_with_error("Invalid manifest format: [target] not found")
if not "sdk" in manifest["target"]:
exit_with_error("Invalid manifest format: [target] sdk not found")
if not "platforms" in manifest["target"]:
exit_with_error("Invalid manifest format: [target] platforms not found")
# [app]
if not "app" in manifest:
exit_with_error("Invalid manifest format: [app] not found")
if not "id" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] id not found")
if not "versionName" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] versionName not found")
if not "versionCode" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] versionCode not found")
if not "name" in manifest["app"]:
exit_with_error("Invalid manifest format: [app] name not found")
def is_valid_manifest_platform(manifest, platform): def is_valid_manifest_platform(manifest, platform):
manifest_platforms = manifest["target.platforms"].split(",") manifest_platforms = manifest["target"]["platforms"].split(",")
return platform in manifest_platforms return platform in manifest_platforms
def validate_manifest_platform(manifest, platform): def validate_manifest_platform(manifest, platform):
@@ -252,7 +265,7 @@ def validate_manifest_platform(manifest, platform):
def get_manifest_target_platforms(manifest, requested_platform): def get_manifest_target_platforms(manifest, requested_platform):
if requested_platform == "" or requested_platform is None: if requested_platform == "" or requested_platform is None:
return manifest["target.platforms"].split(",") return manifest["target"]["platforms"].split(",")
else: else:
validate_manifest_platform(manifest, requested_platform) validate_manifest_platform(manifest, requested_platform)
return [requested_platform] return [requested_platform]
@@ -499,7 +512,7 @@ def build_action(manifest, platform_arg, skip_build):
if use_local_sdk: if use_local_sdk:
global local_base_path global local_base_path
local_base_path = os.environ.get("TACTILITY_SDK_PATH") local_base_path = os.environ.get("TACTILITY_SDK_PATH")
validate_local_sdks(platforms_to_build, manifest["target.sdk"]) validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
if should_fetch_sdkconfig_files(platforms_to_build): if should_fetch_sdkconfig_files(platforms_to_build):
fetch_sdkconfig_files(platforms_to_build) fetch_sdkconfig_files(platforms_to_build)
@@ -508,7 +521,7 @@ def build_action(manifest, platform_arg, skip_build):
sdk_json = read_sdk_json() sdk_json = read_sdk_json()
validate_self(sdk_json) validate_self(sdk_json)
# Build # Build
sdk_version = manifest["target.sdk"] sdk_version = manifest["target"]["sdk"]
if not use_local_sdk: if not use_local_sdk:
if not sdk_download_all(sdk_version, platforms_to_build): if not sdk_download_all(sdk_version, platforms_to_build):
exit_with_error("Failed to download one or more SDKs") exit_with_error("Failed to download one or more SDKs")
@@ -557,7 +570,7 @@ def get_device_info(ip):
print_status_error(f"Device info request failed: {e}") print_status_error(f"Device info request failed: {e}")
def run_action(manifest, ip): def run_action(manifest, ip):
app_id = manifest["app.id"] app_id = manifest["app"]["id"]
print_status_busy("Running") print_status_busy("Running")
url = get_url(ip, "/app/run") url = get_url(ip, "/app/run")
params = {'id': app_id} params = {'id': app_id}
@@ -601,7 +614,7 @@ def install_action(ip, platforms):
return False return False
def uninstall_action(manifest, ip): def uninstall_action(manifest, ip):
app_id = manifest["app.id"] app_id = manifest["app"]["id"]
print_status_busy("Uninstalling") print_status_busy("Uninstalling")
url = get_url(ip, "/app/uninstall") url = get_url(ip, "/app/uninstall")
params = {'id': app_id} params = {'id': app_id}
@@ -657,7 +670,7 @@ if __name__ == "__main__":
exit_with_error("manifest.properties not found") exit_with_error("manifest.properties not found")
manifest = read_manifest() manifest = read_manifest()
validate_manifest(manifest) validate_manifest(manifest)
all_platform_targets = manifest["target.platforms"].split(",") all_platform_targets = manifest["target"]["platforms"].split(",")
# Update SDK cache (tool.json) # Update SDK cache (tool.json)
if not use_local_sdk and should_update_tool_json() and not update_tool_json(): if not use_local_sdk and should_update_tool_json() and not update_tool_json():
exit_with_error("Failed to retrieve SDK info") exit_with_error("Failed to retrieve SDK info")