Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fb3c22f79 | |||
| ac9d6dc0d3 | |||
| fb800bb5bd | |||
| e5233efdf3 | |||
| ca55f16085 | |||
| 3c6acf47a5 | |||
| 23e969c1b6 |
@@ -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,10 +1,5 @@
|
||||
name: Release Apps
|
||||
|
||||
outputs:
|
||||
sdk_version:
|
||||
description: 'Common SDK version shared by all bundled apps'
|
||||
value: ${{ steps.release.outputs.sdk_version }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -16,11 +11,8 @@ runs:
|
||||
run: rsync -av downloaded_apps/*/*.app cdn_files/
|
||||
shell: bash
|
||||
- name: 'Create CDN release files'
|
||||
id: release
|
||||
run: python release.py cdn_files/
|
||||
shell: bash
|
||||
run: |
|
||||
python release.py cdn_files/
|
||||
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Build"
|
||||
uses: ./.github/actions/build-app
|
||||
with:
|
||||
@@ -25,26 +23,7 @@ jobs:
|
||||
Bundle:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Build]
|
||||
outputs:
|
||||
sdk_version: ${{ steps.release.outputs.sdk_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Build"
|
||||
id: release
|
||||
uses: ./.github/actions/release-apps
|
||||
PublishApps:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Bundle]
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Publish Apps"
|
||||
env:
|
||||
CDN_ID: ${{ secrets.CDN_ID }}
|
||||
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
|
||||
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
|
||||
uses: ./.github/actions/publish-apps
|
||||
with:
|
||||
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
|
||||
|
||||
@@ -12,5 +12,5 @@ endif()
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(RobotArm)
|
||||
tactility_project(RobotArm)
|
||||
project(AudioTest)
|
||||
tactility_project(AudioTest)
|
||||
@@ -0,0 +1,6 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
REQUIRES TactilitySDK
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,8 +1,11 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.brainfuck
|
||||
app.version.name=0.5.0
|
||||
app.version.code=5
|
||||
app.name=Brainfuck interpreter
|
||||
app.description=Brainfuck esoteric language interpreter
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.brainfuck
|
||||
versionName=0.2.0
|
||||
versionCode=2
|
||||
name=Brainfuck interpreter
|
||||
description=Brainfuck esoteric language interpreter
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.breakout
|
||||
app.version.name=0.5.0
|
||||
app.version.code=5
|
||||
app.name=Breakout
|
||||
app.description=Classic brick-breaking arcade game
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.breakout
|
||||
versionName=0.2.0
|
||||
versionCode=2
|
||||
name=Breakout
|
||||
description=Classic brick-breaking arcade game
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.calculator
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Calculator
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.calculator
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Calculator
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.diceware
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Diceware
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.diceware
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Diceware
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3,esp32p4
|
||||
app.id=one.tactility.epubreader
|
||||
app.version.name=0.4.0
|
||||
app.version.code=4
|
||||
app.name=Epub Reader
|
||||
app.description=Epub and text file reader. Requires PSRAM!
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
[app]
|
||||
id=one.tactility.epubreader
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Epub Reader
|
||||
description=Epub and text file reader. Requires PSRAM!
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.gpio
|
||||
app.version.name=0.7.0
|
||||
app.version.code=7
|
||||
app.name=GPIO
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.gpio
|
||||
versionName=0.4.0
|
||||
versionCode=4
|
||||
name=GPIO
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.graphicsdemo
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Graphics Demo
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.graphicsdemo
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Graphics Demo
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
#include <tt_app.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
|
||||
* Only C is supported for now (C++ symbols fail to link)
|
||||
@@ -10,13 +27,37 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
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_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[]) {
|
||||
tt_app_register((AppRegistration) {
|
||||
.onShow = onShowApp
|
||||
.onShow = onShowApp,
|
||||
.onHide = onHideApp,
|
||||
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.helloworld
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Hello World
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.helloworld
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Hello World
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3,esp32p4
|
||||
app.id=one.tactility.m5unittest
|
||||
app.version.name=0.4.0
|
||||
app.version.code=4
|
||||
app.name=M5 Unit Test
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
[app]
|
||||
id=one.tactility.m5unittest
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=M5 Unit Test
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.magic8ball
|
||||
app.version.name=0.5.0
|
||||
app.version.code=5
|
||||
app.name=Magic 8-Ball
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.magic8ball
|
||||
versionName=0.2.0
|
||||
versionCode=2
|
||||
name=Magic 8-Ball
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||
else()
|
||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||
message(WARNING "TACTILITY_SDK_PATH is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
endif()
|
||||
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(McpScreen)
|
||||
tactility_project(McpScreen)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||
REQUIRES TactilitySDK lwip
|
||||
)
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -1,8 +1,11 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3,esp32p4
|
||||
app.id=one.tactility.mediakeys
|
||||
app.version.name=0.4.0
|
||||
app.version.code=4
|
||||
app.name=Media Keys
|
||||
app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
[app]
|
||||
id=one.tactility.mediakeys
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Media Keys
|
||||
description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.mystifydemo
|
||||
app.version.name=0.6.0
|
||||
app.version.code=6
|
||||
app.name=Mystify Demo
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.mystifydemo
|
||||
versionName=0.3.0
|
||||
versionCode=3
|
||||
name=Mystify Demo
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
REQUIRES TactilitySDK lwip
|
||||
)
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function)
|
||||
@@ -1,429 +0,0 @@
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tactility/lvgl_fonts.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
#include <lwip/sockets.h>
|
||||
#include <lwip/inet.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
#define TAG "RobotArm"
|
||||
#define ARM_HOST "192.168.68.103"
|
||||
#define ARM_PORT 80
|
||||
#define ARM_PATH "/api/mcp"
|
||||
#define NUM_JOINTS 6
|
||||
#define SEQ_MAX 16
|
||||
|
||||
typedef struct {
|
||||
const char* name;
|
||||
const char* cute;
|
||||
uint32_t bg;
|
||||
uint32_t accent;
|
||||
int home, rmin, rmax;
|
||||
int value, last_sent;
|
||||
} Joint;
|
||||
|
||||
static Joint joints[NUM_JOINTS] = {
|
||||
{"base","Base",0xFFD6E0,0xFF8FA8,70,0,180,70,-1},
|
||||
{"shoulder","Shldr",0xD6E8FF,0x8FB6FF,40,0,70,40,-1},
|
||||
{"elbow","Elbow",0xFFE8C5,0xFFB86A,20,0,120,20,-1},
|
||||
{"pitch","Pitch",0xD5F0D5,0x88D488,90,30,150,90,-1},
|
||||
{"roll","Roll",0xE8D5FF,0xB088FF,90,30,150,90,-1},
|
||||
{"gripper","Grip",0xFFF0B3,0xFFD060,90,0,180,90,-1},
|
||||
};
|
||||
|
||||
typedef struct { int v[NUM_JOINTS]; } Frame;
|
||||
static Frame seq[SEQ_MAX];
|
||||
static int seq_len = 0, seq_idx = -1;
|
||||
static bool seq_playing = false;
|
||||
static int seq_play_pos = 0;
|
||||
|
||||
typedef struct {
|
||||
AppHandle app;
|
||||
lv_obj_t* status;
|
||||
lv_obj_t* sliders[6];
|
||||
lv_obj_t* vals[6];
|
||||
lv_obj_t* seq_label;
|
||||
lv_obj_t* play_label;
|
||||
lv_obj_t* blocks[SEQ_MAX];
|
||||
int pend[6];
|
||||
bool has[6];
|
||||
int ticks[6];
|
||||
lv_timer_t* poll;
|
||||
lv_timer_t* seq_timer;
|
||||
} Ctx;
|
||||
static Ctx* g = NULL;
|
||||
|
||||
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
||||
|
||||
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
|
||||
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||
if(fd<0) return -1;
|
||||
struct sockaddr_in s; memset(&s,0,sizeof(s));
|
||||
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ipaddr_addr(host);
|
||||
struct timeval tv={5,0};
|
||||
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
|
||||
char hdr[256];
|
||||
int bl=strlen(body);
|
||||
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
|
||||
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
|
||||
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
|
||||
int tot=0;
|
||||
while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
||||
out[tot]='\0'; close(fd);
|
||||
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
|
||||
return tot>0?0:-4;
|
||||
}
|
||||
|
||||
static bool jget(const char* js,const char* key,int* out){
|
||||
const char* p=strstr(js,key);
|
||||
if(!p) return false;
|
||||
p+=strlen(key);
|
||||
while(*p && *p!=':'){
|
||||
p++;
|
||||
if(!*p) return false;
|
||||
}
|
||||
p++;
|
||||
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
|
||||
int sign=1;
|
||||
if(*p=='-'){sign=-1;p++;}
|
||||
float v=0,frac=0.1f;
|
||||
bool dot=false,got=false;
|
||||
while(*p){
|
||||
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
|
||||
else if(*p=='.'&&!dot) dot=true;
|
||||
else break;
|
||||
p++;
|
||||
}
|
||||
if(!got) return false;
|
||||
*out=(int)(v*sign+0.5f);
|
||||
return true;
|
||||
}
|
||||
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||
int v; bool ok=true;
|
||||
if(jget(r,"base",&v)) *b=v; else ok=false;
|
||||
if(jget(r,"shoulder",&v)) *s=v; else ok=false;
|
||||
if(jget(r,"elbow",&v)) *e=v; else ok=false;
|
||||
if(jget(r,"pitch",&v)) *p=v; else ok=false;
|
||||
if(jget(r,"roll",&v)) *ro=v; else ok=false;
|
||||
if(jget(r,"gripper",&v)) *gr=v; else ok=false;
|
||||
return ok;
|
||||
}
|
||||
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
|
||||
char resp[2048]; memset(resp,0,sizeof(resp));
|
||||
if(http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
||||
return parse_state(resp,b,s,e,p,ro,gr);
|
||||
}
|
||||
static bool rpc_move(const char* j,int a){
|
||||
char body[300]; snprintf(body,sizeof(body),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",
|
||||
j,a);
|
||||
char r[1024]; memset(r,0,sizeof(r));
|
||||
return http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||
}
|
||||
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
|
||||
char body[420]; snprintf(body,sizeof(body),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
|
||||
b,s,e,p,ro,gr,dur);
|
||||
char r[1024]; memset(r,0,sizeof(r));
|
||||
int rc=http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r));
|
||||
ESP_LOGI(TAG,"move_all %d %d %d rc=%d",b,s,e,rc);
|
||||
return rc==0;
|
||||
}
|
||||
static bool rpc_home(void){
|
||||
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
|
||||
char r[512]; memset(r,0,sizeof(r)); return http_post(ARM_HOST,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
||||
}
|
||||
|
||||
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
|
||||
static void apply_ui(void){
|
||||
if(!g) return;
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
if(g->sliders[i]) lv_slider_set_value(g->sliders[i],joints[i].value,LV_ANIM_OFF);
|
||||
if(g->vals[i]){char buf[8]; snprintf(buf,sizeof(buf),"%d",joints[i].value); lv_label_set_text(g->vals[i],buf);}
|
||||
}
|
||||
}
|
||||
static void refresh_arm(void){
|
||||
set_status("Reading .103...");
|
||||
int b,s,e,p,ro,gr;
|
||||
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
|
||||
joints[0].value=b; joints[1].value=s; joints[2].value=e;
|
||||
joints[3].value=p; joints[4].value=ro; joints[5].value=gr;
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
|
||||
apply_ui();
|
||||
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
|
||||
} else set_status("No .103");
|
||||
}
|
||||
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||
static void poll_cb(lv_timer_t* t){
|
||||
(void)t; if(!g) return;
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
if(!g->has[i]) continue;
|
||||
g->ticks[i]++; if(g->ticks[i]<3) continue;
|
||||
g->has[i]=false; g->ticks[i]=0;
|
||||
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
|
||||
joints[i].last_sent=tgt; joints[i].value=tgt;
|
||||
char buf[20]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
||||
bool ok=rpc_move(joints[i].name,tgt);
|
||||
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"ok":"fail"); set_status(buf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
static void slider_cb(lv_event_t* e){
|
||||
int idx=(int)(intptr_t)lv_event_get_user_data(e);
|
||||
int v=(int)lv_slider_get_value(lv_event_get_target(e));
|
||||
joints[idx].value=v;
|
||||
if(g){
|
||||
if(g->vals[idx]){char b[8]; snprintf(b,sizeof(b),"%d",v); lv_label_set_text(g->vals[idx],b);}
|
||||
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── sequencer ── */
|
||||
static void update_seq_ui(void){
|
||||
if(!g) return;
|
||||
if(g->seq_label){
|
||||
if(seq_len==0) lv_label_set_text(g->seq_label,"empty");
|
||||
else {char b[16]; snprintf(b,sizeof(b),"%d/%d", (seq_idx>=0?seq_idx+1:seq_len), seq_len); lv_label_set_text(g->seq_label,b);}
|
||||
}
|
||||
for(int i=0;i<SEQ_MAX;i++){
|
||||
if(!g->blocks[i]) continue;
|
||||
if(i<seq_len){
|
||||
lv_obj_set_style_bg_opa(g->blocks[i],LV_OPA_COVER,0);
|
||||
if(i==seq_idx){
|
||||
lv_obj_set_style_border_width(g->blocks[i],3,0);
|
||||
lv_obj_set_style_border_color(g->blocks[i],lv_color_hex(0xFFFFFF),0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(g->blocks[i],2,0);
|
||||
lv_obj_set_style_border_color(g->blocks[i],lv_color_hex(0x3A3A3A),0);
|
||||
}
|
||||
} else {
|
||||
lv_obj_set_style_bg_opa(g->blocks[i],LV_OPA_30,0);
|
||||
lv_obj_set_style_border_width(g->blocks[i],0,0);
|
||||
}
|
||||
}
|
||||
}
|
||||
static void load_frame(int fidx){
|
||||
if(fidx<0||fidx>=seq_len) return;
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=seq[fidx].v[i]; joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false;}}
|
||||
apply_ui(); seq_idx=fidx; update_seq_ui();
|
||||
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
|
||||
}
|
||||
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Seq full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
||||
static void seq_rem_cb(void){
|
||||
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
|
||||
int rem=seq_idx;
|
||||
for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
||||
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
|
||||
if(seq_idx>=seq_len) seq_idx=seq_len-1;
|
||||
load_frame(seq_idx);
|
||||
}
|
||||
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
|
||||
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
|
||||
|
||||
static void seq_timer_cb(lv_timer_t* t){
|
||||
(void)t; if(!seq_playing||seq_len==0) return;
|
||||
int f=seq_play_pos%seq_len;
|
||||
int* v=seq[f].v;
|
||||
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
|
||||
set_status("Seq fail"); seq_playing=false;
|
||||
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play");
|
||||
return;
|
||||
}
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
|
||||
apply_ui(); seq_idx=f; update_seq_ui();
|
||||
char b[20]; snprintf(b,sizeof(b),"Play %d/%d",f+1,seq_len); set_status(b);
|
||||
seq_play_pos++; if(seq_play_pos>=seq_len) seq_play_pos=0;
|
||||
}
|
||||
static void seq_play_cb(lv_event_t* e){
|
||||
(void)e; if(seq_len==0){set_status("No frames"); return;}
|
||||
seq_playing=!seq_playing;
|
||||
if(g&&g->play_label) lv_label_set_text(g->play_label, seq_playing?"Pause":"Play");
|
||||
if(seq_playing){seq_play_pos=(seq_idx>=0?seq_idx:0); set_status("Playing");} else set_status("Paused");
|
||||
}
|
||||
static void seq_add_ev(lv_event_t* e){(void)e; seq_add_cb();}
|
||||
static void seq_rem_ev(lv_event_t* e){(void)e; seq_rem_cb();}
|
||||
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
|
||||
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
|
||||
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
|
||||
|
||||
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
|
||||
static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
|
||||
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
|
||||
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
|
||||
|
||||
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
(void)data;
|
||||
Ctx* c=(Ctx*)calloc(1,sizeof(Ctx)); if(!c) return;
|
||||
c->app=app; g=c;
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
|
||||
|
||||
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
|
||||
|
||||
lv_obj_t* root=lv_obj_create(parent);
|
||||
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,34,0);
|
||||
lv_obj_set_style_border_width(root,0,0);
|
||||
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
|
||||
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
|
||||
lv_obj_set_scroll_dir(root, LV_DIR_VER);
|
||||
|
||||
lv_obj_t* hdr=lv_obj_create(root); lv_obj_set_size(hdr,LV_PCT(100),16);
|
||||
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(hdr,0,0); lv_obj_set_pos(hdr,0,0);
|
||||
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
|
||||
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(title,2,0);
|
||||
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn .103...");
|
||||
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0); lv_obj_set_pos(c->status,90,0);
|
||||
|
||||
lv_obj_t* brow=lv_obj_create(root); lv_obj_set_size(brow,LV_PCT(100),20);
|
||||
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(brow,0,0); lv_obj_set_pos(brow,0,16);
|
||||
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
|
||||
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
|
||||
for(int i=0;i<4;i++){
|
||||
lv_obj_t* b=lv_btn_create(brow); lv_obj_set_size(b,56,18);
|
||||
lv_obj_set_style_bg_color(b,lv_color_hex(btns[i].col),0); lv_obj_set_style_radius(b,8,0);
|
||||
lv_obj_set_pos(b,i*62+2,0);
|
||||
lv_obj_t* l=lv_label_create(b); lv_label_set_text(l,btns[i].t);
|
||||
lv_obj_set_style_text_font(l,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(l,lv_color_hex(0x4A356A),0); lv_obj_center(l);
|
||||
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||
}
|
||||
|
||||
/* ── large nice vertical sliders: 3 cols, 2x height 22x72 per request ── */
|
||||
lv_obj_t* grid=lv_obj_create(root);
|
||||
lv_obj_set_size(grid,LV_PCT(100),196);
|
||||
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(grid,2,0); lv_obj_set_pos(grid,0,36);
|
||||
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
int col=i%3, row=i/3;
|
||||
lv_obj_t* card=lv_obj_create(grid);
|
||||
lv_obj_set_size(card,102,96);
|
||||
lv_obj_set_pos(card,col*104+2,row*98);
|
||||
lv_obj_set_style_bg_color(card,lv_color_hex(joints[i].bg),0);
|
||||
lv_obj_set_style_bg_opa(card,LV_OPA_90,0);
|
||||
lv_obj_set_style_radius(card,12,0);
|
||||
lv_obj_set_style_border_width(card,0,0);
|
||||
lv_obj_set_style_pad_all(card,3,0);
|
||||
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* name=lv_label_create(card);
|
||||
lv_label_set_text(name,joints[i].cute);
|
||||
lv_obj_set_style_text_font(name,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(name,lv_color_hex(0x4A356A),0); lv_obj_set_pos(name,2,0);
|
||||
|
||||
c->vals[i]=lv_label_create(card);
|
||||
char vb[8]; snprintf(vb,sizeof(vb),"%d",joints[i].value);
|
||||
lv_label_set_text(c->vals[i],vb);
|
||||
lv_obj_set_style_text_font(c->vals[i],lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->vals[i],lv_color_hex(0xC04060),0); lv_obj_set_pos(c->vals[i],40,0);
|
||||
|
||||
lv_obj_t* rlbl=lv_label_create(card);
|
||||
char rb[12]; snprintf(rb,sizeof(rb),"%d-%d",joints[i].rmin,joints[i].rmax);
|
||||
lv_label_set_text(rlbl,rb);
|
||||
lv_obj_set_style_text_font(rlbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(rlbl,lv_color_hex(0xA090A0),0); lv_obj_set_pos(rlbl,58,0);
|
||||
|
||||
lv_obj_t* sl=lv_slider_create(card);
|
||||
c->sliders[i]=sl;
|
||||
lv_obj_set_size(sl,22,72);
|
||||
lv_obj_set_pos(sl,40,20);
|
||||
lv_slider_set_range(sl,joints[i].rmin,joints[i].rmax);
|
||||
lv_slider_set_value(sl,joints[i].value,LV_ANIM_OFF);
|
||||
lv_obj_set_style_bg_color(sl,lv_color_hex(0xFFFFFF),LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(sl,LV_OPA_80,LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(sl,10,LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(sl,lv_color_hex(joints[i].accent),LV_PART_INDICATOR);
|
||||
lv_obj_set_style_radius(sl,10,LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_color(sl,lv_color_hex(0xFFFFFF),LV_PART_KNOB);
|
||||
lv_obj_set_style_border_color(sl,lv_color_hex(joints[i].accent),LV_PART_KNOB);
|
||||
lv_obj_set_style_border_width(sl,2,LV_PART_KNOB);
|
||||
lv_obj_set_style_radius(sl,12,LV_PART_KNOB);
|
||||
lv_obj_set_style_pad_all(sl,3,LV_PART_KNOB);
|
||||
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
|
||||
}
|
||||
|
||||
/* ── sequencer at bottom, NOT fixed — scrollable with content ──
|
||||
visual: colored blocks, no info, current highlighted, larger buttons */
|
||||
lv_obj_t* seqbar=lv_obj_create(root);
|
||||
lv_obj_set_size(seqbar,316,96);
|
||||
lv_obj_set_pos(seqbar,2,232);
|
||||
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
|
||||
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
|
||||
lv_obj_set_style_radius(seqbar,12,0);
|
||||
lv_obj_set_style_border_width(seqbar,1,0);
|
||||
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
|
||||
lv_obj_set_style_pad_all(seqbar,4,0);
|
||||
|
||||
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
|
||||
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
|
||||
|
||||
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
|
||||
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
|
||||
|
||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
|
||||
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
|
||||
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
|
||||
for(int i=0;i<5;i++){
|
||||
lv_obj_t* b=lv_btn_create(seqbar);
|
||||
lv_obj_set_size(b,(i==2)?56:38,32);
|
||||
lv_obj_set_style_bg_color(b,lv_color_hex(sbtns[i].col),0);
|
||||
lv_obj_set_style_radius(b,8,0);
|
||||
int xs[5]={64,110,158,220,260};
|
||||
lv_obj_set_pos(b,xs[i],0);
|
||||
lv_obj_t* l=lv_label_create(b); if(sbtns[i].play) c->play_label=l;
|
||||
lv_label_set_text(l,sbtns[i].t);
|
||||
lv_obj_set_style_text_font(l,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
|
||||
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||
}
|
||||
|
||||
uint32_t blk_cols[16]={
|
||||
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
|
||||
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
|
||||
};
|
||||
for(int i=0;i<SEQ_MAX;i++){
|
||||
lv_obj_t* blk=lv_btn_create(seqbar);
|
||||
lv_obj_set_size(blk,22,22);
|
||||
lv_obj_set_pos(blk,(i%8)*24+2,(i/8)*24+38);
|
||||
lv_obj_set_style_bg_color(blk,lv_color_hex(blk_cols[i]),0);
|
||||
lv_obj_set_style_radius(blk,6,0);
|
||||
lv_obj_set_style_border_width(blk,0,0);
|
||||
lv_obj_set_style_bg_opa(blk,LV_OPA_30,0);
|
||||
c->blocks[i]=blk;
|
||||
lv_obj_add_event_cb(blk,block_click_cb,LV_EVENT_CLICKED,(void*)(intptr_t)i);
|
||||
}
|
||||
update_seq_ui();
|
||||
|
||||
c->poll=lv_timer_create(poll_cb,100,NULL);
|
||||
c->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
||||
lv_timer_create(init_cb,600,NULL);
|
||||
}
|
||||
|
||||
static void onHide(AppHandle app,void* data){
|
||||
(void)app;(void)data;
|
||||
if(g){
|
||||
if(g->poll) lv_timer_delete(g->poll);
|
||||
if(g->seq_timer) lv_timer_delete(g->seq_timer);
|
||||
free(g); g=NULL;
|
||||
}
|
||||
seq_playing=false;
|
||||
}
|
||||
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
|
||||
@@ -1,13 +0,0 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3
|
||||
|
||||
[app]
|
||||
id=one.tactility.robotarm
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Robot Arm
|
||||
description=Cute controller for MCP robot arm
|
||||
@@ -1,7 +1,10 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.serialconsole
|
||||
app.version.name=0.7.0
|
||||
app.version.code=7
|
||||
app.name=Serial Console
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.serialconsole
|
||||
versionName=0.4.0
|
||||
versionCode=4
|
||||
name=Serial Console
|
||||
|
||||
@@ -245,6 +245,41 @@ static void game_play_event(lv_event_t* e) {
|
||||
default:
|
||||
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) {
|
||||
uint32_t key = lv_event_get_key(e);
|
||||
// Arrow keys, WASD, and punctuation keys for cardputer
|
||||
@@ -394,6 +429,7 @@ lv_obj_t* snake_create(lv_obj_t* parent, uint16_t cell_size, bool wall_collision
|
||||
// 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_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);
|
||||
|
||||
// Set up keyboard focus if available
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.snake
|
||||
app.version.name=0.8.0
|
||||
app.version.code=8
|
||||
app.name=Snake
|
||||
app.description=Classic Snake game
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.snake
|
||||
versionName=0.5.0
|
||||
versionCode=5
|
||||
name=Snake
|
||||
description=Classic Snake game
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.tamatac
|
||||
app.version.name=0.4.0
|
||||
app.version.code=4
|
||||
app.name=TamaTac
|
||||
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.tamatac
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=TamaTac
|
||||
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.todolist
|
||||
app.version.name=0.5.0
|
||||
app.version.code=5
|
||||
app.name=Todo List
|
||||
app.description=Simple task list manager
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.todolist
|
||||
versionName=0.2.0
|
||||
versionCode=2
|
||||
name=Todo List
|
||||
description=Simple task list manager
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
app.id=one.tactility.twoeleven
|
||||
app.version.name=0.7.0
|
||||
app.version.code=7
|
||||
app.name=2048
|
||||
app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.twoeleven
|
||||
versionName=0.4.0
|
||||
versionCode=4
|
||||
name=2048
|
||||
description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
+39
-54
@@ -1,23 +1,14 @@
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
import os
|
||||
import tempfile
|
||||
import configparser
|
||||
import sys
|
||||
from datetime import datetime, UTC
|
||||
|
||||
def read_properties_file(path):
|
||||
properties = {}
|
||||
with open(path, "r") as file:
|
||||
for line in file:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
key, sep, value = line.partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
properties[key.strip()] = value.strip()
|
||||
return properties
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(path)
|
||||
return config
|
||||
|
||||
def get_manifest(appPath):
|
||||
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz
|
||||
@@ -71,49 +62,51 @@ def get_manifest(appPath):
|
||||
return None
|
||||
|
||||
def get_versioned_file_name(manifest):
|
||||
app_id = manifest["app.id"]
|
||||
version_code = manifest["app.version.code"]
|
||||
app_id = manifest["app"]["id"]
|
||||
version_code = manifest["app"]["versionCode"]
|
||||
return f"{app_id}-{version_code}.app"
|
||||
|
||||
def get_os_version(manifest):
|
||||
sdk = manifest["target.sdk"]
|
||||
sdk = manifest["target"]["sdk"]
|
||||
# Remove trailing hyphen suffix if present
|
||||
if "-" in sdk:
|
||||
return sdk.rsplit("-", 1)[0].strip()
|
||||
else:
|
||||
return sdk
|
||||
|
||||
def check_and_get_sdk_version(manifest_map):
|
||||
"""Ensure all apps target the same (simplified) SDK version and return it."""
|
||||
versions = {get_os_version(manifest) for manifest in manifest_map.values()}
|
||||
if len(versions) != 1:
|
||||
print(f"ERROR: Apps target multiple SDK versions: {sorted(versions)}. All apps must target the same SDK version.")
|
||||
sys.exit(1)
|
||||
return next(iter(versions))
|
||||
|
||||
def get_git_commit_hash():
|
||||
return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
|
||||
|
||||
def manifest_config_to_flat_json(manifest):
|
||||
"""Convert a flat (V2) manifest dict into a flat JSON-like dict.
|
||||
"""Convert a ConfigParser manifest into a flat JSON-like dict.
|
||||
|
||||
Expected keys:
|
||||
app.id -> appId
|
||||
app.version.name -> appVersionName
|
||||
app.version.code -> appVersionCode (int)
|
||||
app.name -> appName
|
||||
app.description -> appDescription (optional; default "")
|
||||
target.sdk -> targetSdk
|
||||
target.platforms -> targetPlatforms (comma-separated list)
|
||||
Expected sections/keys (case-insensitive for keys):
|
||||
- [app]
|
||||
id -> appId
|
||||
versionName -> appVersionName
|
||||
versionCode -> appVersionCode (int)
|
||||
name -> appName
|
||||
description -> appDescription (optional; default "")
|
||||
- [target]
|
||||
sdk -> targetSdk
|
||||
platforms -> targetPlatforms (comma-separated list)
|
||||
|
||||
Unknown/missing values fall back to sensible defaults per requirements.
|
||||
"""
|
||||
def get_opt(section, option, default=None):
|
||||
if not manifest.has_section(section):
|
||||
return default
|
||||
# try exact option then lowercase (RawConfigParser lowercases by default)
|
||||
if manifest.has_option(section, option):
|
||||
return manifest.get(section, option)
|
||||
low = option.lower()
|
||||
if manifest.has_option(section, low):
|
||||
return manifest.get(section, low)
|
||||
return default
|
||||
|
||||
# Map values
|
||||
app_id = manifest.get("app.id", "")
|
||||
app_version_name = manifest.get("app.version.name", "")
|
||||
app_version_code_raw = manifest.get("app.version.code", "0")
|
||||
app_name = manifest.get("app.name", "")
|
||||
app_description = manifest.get("app.description", "") or ""
|
||||
app_id = get_opt("app", "id", "")
|
||||
app_version_name = get_opt("app", "versionName", "")
|
||||
app_version_code_raw = get_opt("app", "versionCode", "0")
|
||||
app_name = get_opt("app", "name", "")
|
||||
app_description = get_opt("app", "description", "") or ""
|
||||
|
||||
# Coerce version code to int safely
|
||||
try:
|
||||
@@ -121,8 +114,8 @@ def manifest_config_to_flat_json(manifest):
|
||||
except Exception:
|
||||
app_version_code = 0
|
||||
|
||||
target_sdk = manifest.get("target.sdk", "")
|
||||
platforms_raw = manifest.get("target.platforms", "")
|
||||
target_sdk = get_opt("target", "sdk", "")
|
||||
platforms_raw = get_opt("target", "platforms", "")
|
||||
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
|
||||
|
||||
filename = get_versioned_file_name(manifest)
|
||||
@@ -149,23 +142,15 @@ if __name__ == "__main__":
|
||||
sys.exit()
|
||||
app_directory = sys.argv[1]
|
||||
manifest_map = {}
|
||||
output_json = {
|
||||
"apps": []
|
||||
}
|
||||
any_manifest = None
|
||||
if os.path.exists(app_directory):
|
||||
for file in os.listdir(app_directory):
|
||||
if file.endswith(".app"):
|
||||
file_path = os.path.join(app_directory, file)
|
||||
manifest_map[file_path] = get_manifest(file_path)
|
||||
# All bundled apps must target the same SDK version; this becomes the CDN path segment
|
||||
sdk_version = check_and_get_sdk_version(manifest_map)
|
||||
with open("sdk_version.txt", "w") as f:
|
||||
f.write(sdk_version)
|
||||
print(f"SDK version: {sdk_version}")
|
||||
output_json = {
|
||||
"sdkVersion": sdk_version,
|
||||
"created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
"gitCommit": get_git_commit_hash(),
|
||||
"apps": []
|
||||
}
|
||||
# Rename files and collect manifest data into output json object
|
||||
for file_path in manifest_map.keys():
|
||||
print(f"Processing {file_path}: {manifest_map[file_path]}")
|
||||
|
||||
+36
-23
@@ -1,3 +1,4 @@
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -12,7 +13,7 @@ import tarfile
|
||||
from urllib.parse import urlparse
|
||||
|
||||
ttbuild_path = ".tactility"
|
||||
ttbuild_version = "4.1.0"
|
||||
ttbuild_version = "3.5.1"
|
||||
ttbuild_cdn = "https://cdn.tactilityproject.org"
|
||||
ttbuild_sdk_json_validity = 3600 # seconds
|
||||
ttport = 6666
|
||||
@@ -105,17 +106,9 @@ def get_url(ip, path):
|
||||
return f"http://{ip}:{ttport}{path}"
|
||||
|
||||
def read_properties_file(path):
|
||||
properties = {}
|
||||
with open(path, "r") as file:
|
||||
for line in file:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
key, sep, value = line.partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
properties[key.strip()] = value.strip()
|
||||
return properties
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(path)
|
||||
return config
|
||||
|
||||
#endregion Core
|
||||
|
||||
@@ -192,7 +185,7 @@ def fetch_sdkconfig_files(platform_targets):
|
||||
for platform in platform_targets:
|
||||
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
||||
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
|
||||
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
|
||||
if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path):
|
||||
exit_with_error(f"Failed to download sdkconfig file for {platform}")
|
||||
|
||||
#endregion SDK helpers
|
||||
@@ -238,12 +231,32 @@ def read_manifest():
|
||||
return read_properties_file("manifest.properties")
|
||||
|
||||
def validate_manifest(manifest):
|
||||
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"):
|
||||
if key not in manifest:
|
||||
exit_with_error(f"Invalid manifest format: {key} not found")
|
||||
# [manifest]
|
||||
if not "manifest" in manifest:
|
||||
exit_with_error("Invalid manifest format: [manifest] not found")
|
||||
if not "version" in manifest["manifest"]:
|
||||
exit_with_error("Invalid manifest format: [manifest] version not found")
|
||||
# [target]
|
||||
if not "target" in manifest:
|
||||
exit_with_error("Invalid manifest format: [target] not found")
|
||||
if not "sdk" in manifest["target"]:
|
||||
exit_with_error("Invalid manifest format: [target] sdk not found")
|
||||
if not "platforms" in manifest["target"]:
|
||||
exit_with_error("Invalid manifest format: [target] platforms not found")
|
||||
# [app]
|
||||
if not "app" in manifest:
|
||||
exit_with_error("Invalid manifest format: [app] not found")
|
||||
if not "id" in manifest["app"]:
|
||||
exit_with_error("Invalid manifest format: [app] id not found")
|
||||
if not "versionName" in manifest["app"]:
|
||||
exit_with_error("Invalid manifest format: [app] versionName not found")
|
||||
if not "versionCode" in manifest["app"]:
|
||||
exit_with_error("Invalid manifest format: [app] versionCode not found")
|
||||
if not "name" in manifest["app"]:
|
||||
exit_with_error("Invalid manifest format: [app] name not found")
|
||||
|
||||
def is_valid_manifest_platform(manifest, platform):
|
||||
manifest_platforms = manifest["target.platforms"].split(",")
|
||||
manifest_platforms = manifest["target"]["platforms"].split(",")
|
||||
return platform in manifest_platforms
|
||||
|
||||
def validate_manifest_platform(manifest, platform):
|
||||
@@ -252,7 +265,7 @@ def validate_manifest_platform(manifest, platform):
|
||||
|
||||
def get_manifest_target_platforms(manifest, requested_platform):
|
||||
if requested_platform == "" or requested_platform is None:
|
||||
return manifest["target.platforms"].split(",")
|
||||
return manifest["target"]["platforms"].split(",")
|
||||
else:
|
||||
validate_manifest_platform(manifest, requested_platform)
|
||||
return [requested_platform]
|
||||
@@ -499,7 +512,7 @@ def build_action(manifest, platform_arg, skip_build):
|
||||
if use_local_sdk:
|
||||
global local_base_path
|
||||
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
|
||||
validate_local_sdks(platforms_to_build, manifest["target.sdk"])
|
||||
validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
|
||||
|
||||
if should_fetch_sdkconfig_files(platforms_to_build):
|
||||
fetch_sdkconfig_files(platforms_to_build)
|
||||
@@ -508,7 +521,7 @@ def build_action(manifest, platform_arg, skip_build):
|
||||
sdk_json = read_sdk_json()
|
||||
validate_self(sdk_json)
|
||||
# Build
|
||||
sdk_version = manifest["target.sdk"]
|
||||
sdk_version = manifest["target"]["sdk"]
|
||||
if not use_local_sdk:
|
||||
if not sdk_download_all(sdk_version, platforms_to_build):
|
||||
exit_with_error("Failed to download one or more SDKs")
|
||||
@@ -557,7 +570,7 @@ def get_device_info(ip):
|
||||
print_status_error(f"Device info request failed: {e}")
|
||||
|
||||
def run_action(manifest, ip):
|
||||
app_id = manifest["app.id"]
|
||||
app_id = manifest["app"]["id"]
|
||||
print_status_busy("Running")
|
||||
url = get_url(ip, "/app/run")
|
||||
params = {'id': app_id}
|
||||
@@ -601,7 +614,7 @@ def install_action(ip, platforms):
|
||||
return False
|
||||
|
||||
def uninstall_action(manifest, ip):
|
||||
app_id = manifest["app.id"]
|
||||
app_id = manifest["app"]["id"]
|
||||
print_status_busy("Uninstalling")
|
||||
url = get_url(ip, "/app/uninstall")
|
||||
params = {'id': app_id}
|
||||
@@ -657,7 +670,7 @@ if __name__ == "__main__":
|
||||
exit_with_error("manifest.properties not found")
|
||||
manifest = read_manifest()
|
||||
validate_manifest(manifest)
|
||||
all_platform_targets = manifest["target.platforms"].split(",")
|
||||
all_platform_targets = manifest["target"]["platforms"].split(",")
|
||||
# Update SDK cache (tool.json)
|
||||
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
|
||||
exit_with_error("Failed to retrieve SDK info")
|
||||
|
||||
Reference in New Issue
Block a user