5 Commits

Author SHA1 Message Date
Adolfo Reyna 0fb3c22f79 feat: add McpScreen phase 2 display tools
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
2026-06-25 12:43:08 -04:00
Adolfo Reyna ac9d6dc0d3 feat: add Tactility MCP Screen foundation 2026-06-24 23:48:21 -04:00
Adolfo Reyna fb800bb5bd docs: add McpScreen implementation plan 2026-06-24 23:19:03 -04:00
Adolfo Reyna e5233efdf3 chore: save Tactility app experiments 2026-06-24 23:17:40 -04:00
Adolfo Reyna ca55f16085 feat(audiotest): add AudioTest application for testing recording and playback 2026-06-23 18:32:44 -04:00
16 changed files with 2712 additions and 2 deletions
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(AudioTest)
tactility_project(AudioTest)
+6
View File
@@ -0,0 +1,6 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK
)
+337
View File
@@ -0,0 +1,337 @@
/**
* Audio Test - Record & Playback
*
* Records from the ES8311 microphone via I2S and plays it back through
* the FM8002E speaker amplifier. Uses the Tactility kernel I2S controller API.
*
* UI: Two buttons - Record and Play
* Record: holds recording for up to ~3 seconds (16kHz 16-bit mono ≈ 96KB)
* Play: plays back the last recorded buffer
*/
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/device.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#define TAG "AudioTest"
/* ─── Audio params ─── */
#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE 16
#define RECORD_SECONDS 3
/* 16-bit mono → 2 bytes per sample */
#define AUDIO_BUF_SIZE (SAMPLE_RATE * (BITS_PER_SAMPLE / 8) * RECORD_SECONDS)
/* Chunk size for read/write calls (512 samples = 1024 bytes at 16-bit) */
#define CHUNK_SAMPLES 512
#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8))
/* ─── App state ─── */
typedef enum {
STATE_IDLE,
STATE_RECORDING,
STATE_PLAYING
} AudioState;
typedef struct {
struct Device* i2s_dev;
uint8_t* audio_buf;
size_t recorded_bytes;
AudioState state;
lv_obj_t* btn_record;
lv_obj_t* btn_play;
lv_obj_t* lbl_status;
lv_obj_t* bar_progress;
TaskHandle_t task_handle;
} AppCtx;
/* ─── Forward decls ─── */
static void record_task(void* arg);
static void play_task(void* arg);
static void update_ui(AppCtx* ctx);
/* ─── Callbacks ─── */
static void on_record_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->state != STATE_IDLE) return;
if (ctx->i2s_dev == NULL) return;
ctx->state = STATE_RECORDING;
ctx->recorded_bytes = 0;
update_ui(ctx);
xTaskCreate(record_task, "audio_rec", 4096, ctx, 5, &ctx->task_handle);
}
static void on_play_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->state != STATE_IDLE) return;
if (ctx->recorded_bytes == 0) return;
if (ctx->i2s_dev == NULL) return;
ctx->state = STATE_PLAYING;
update_ui(ctx);
xTaskCreate(play_task, "audio_play", 4096, ctx, 5, &ctx->task_handle);
}
/* ─── Update UI labels/buttons from any context ─── */
static void update_ui(AppCtx* ctx) {
if (ctx->lbl_status == NULL) return;
switch (ctx->state) {
case STATE_RECORDING:
lv_label_set_text(ctx->lbl_status, "🔴 Recording...");
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
break;
case STATE_PLAYING:
lv_label_set_text(ctx->lbl_status, "🔊 Playing...");
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
break;
case STATE_IDLE:
default:
if (ctx->recorded_bytes > 0) {
char buf[64];
snprintf(buf, sizeof(buf), "Ready (%u bytes recorded)", (unsigned)ctx->recorded_bytes);
lv_label_set_text(ctx->lbl_status, buf);
} else {
lv_label_set_text(ctx->lbl_status, "Press Record to capture audio");
}
lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED);
if (ctx->recorded_bytes > 0) {
lv_obj_clear_state(ctx->btn_play, LV_STATE_DISABLED);
} else {
lv_obj_add_state(ctx->btn_play, LV_STATE_DISABLED);
}
break;
}
}
/* ─── Record task ─── */
static void record_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
size_t total = 0;
size_t bytes_read = 0;
/* Configure I2S for recording */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channel_left = 0,
.channel_right = I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to set I2S config for recording: %d", err);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Recording started (max %d bytes)", AUDIO_BUF_SIZE);
while (total < AUDIO_BUF_SIZE && ctx->state == STATE_RECORDING) {
size_t remaining = AUDIO_BUF_SIZE - total;
size_t to_read = (remaining < CHUNK_BYTES) ? remaining : CHUNK_BYTES;
err = i2s_controller_read(ctx->i2s_dev,
ctx->audio_buf + total,
to_read,
&bytes_read,
pdMS_TO_TICKS(1000));
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "I2S read error: %d", err);
break;
}
total += bytes_read;
/* Update progress bar */
int pct = (int)((total * 100) / AUDIO_BUF_SIZE);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
ctx->recorded_bytes = total;
ctx->state = STATE_IDLE;
ctx->task_handle = NULL;
ESP_LOGI(TAG, "Recording complete: %u bytes", (unsigned)total);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF);
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
}
/* ─── Play task ─── */
static void play_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
size_t total = 0;
size_t bytes_written = 0;
/* Configure I2S for playback */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channel_left = 0,
.channel_right = I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "Playback started (%u bytes)", (unsigned)ctx->recorded_bytes);
while (total < ctx->recorded_bytes && ctx->state == STATE_PLAYING) {
size_t remaining = ctx->recorded_bytes - total;
size_t to_write = (remaining < CHUNK_BYTES) ? remaining : CHUNK_BYTES;
err = i2s_controller_write(ctx->i2s_dev,
ctx->audio_buf + total,
to_write,
&bytes_written,
pdMS_TO_TICKS(1000));
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "I2S write error: %d", err);
break;
}
total += bytes_written;
/* Update progress bar */
int pct = (int)((total * 100) / ctx->recorded_bytes);
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
ctx->state = STATE_IDLE;
ctx->task_handle = NULL;
ESP_LOGI(TAG, "Playback complete");
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF);
update_ui(ctx);
tt_lvgl_unlock();
vTaskDelete(NULL);
}
/* ─── App lifecycle ─── */
static AppCtx g_ctx;
static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
memset(&g_ctx, 0, sizeof(g_ctx));
/* Allocate audio buffer */
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
if (g_ctx.audio_buf == NULL) {
ESP_LOGE(TAG, "Failed to allocate audio buffer (%d bytes)", AUDIO_BUF_SIZE);
}
/* Find I2S device */
g_ctx.i2s_dev = device_find_by_name("i2s0");
if (g_ctx.i2s_dev == NULL) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
} else {
ESP_LOGI(TAG, "Found I2S device: %s", g_ctx.i2s_dev->name);
}
/* ─── UI ─── */
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
/* Status label */
g_ctx.lbl_status = lv_label_create(parent);
lv_label_set_text(g_ctx.lbl_status, "Initializing...");
lv_obj_set_width(g_ctx.lbl_status, lv_pct(90));
lv_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(g_ctx.lbl_status, LV_ALIGN_TOP_MID, 0, 50);
/* Progress bar */
g_ctx.bar_progress = lv_bar_create(parent);
lv_obj_set_size(g_ctx.bar_progress, lv_pct(80), 10);
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
lv_obj_align(g_ctx.bar_progress, LV_ALIGN_CENTER, 0, -10);
/* Button container */
lv_obj_t* btn_container = lv_obj_create(parent);
lv_obj_remove_style_all(btn_container);
lv_obj_set_size(btn_container, lv_pct(90), 50);
lv_obj_set_flex_flow(btn_container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btn_container, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_align(btn_container, LV_ALIGN_CENTER, 0, 30);
/* Record button */
g_ctx.btn_record = lv_btn_create(btn_container);
lv_obj_set_size(g_ctx.btn_record, 100, 40);
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO " Record");
lv_obj_center(lbl_rec);
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
/* Play button */
g_ctx.btn_play = lv_btn_create(btn_container);
lv_obj_set_size(g_ctx.btn_play, 100, 40);
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play);
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY " Play");
lv_obj_center(lbl_play);
lv_obj_add_event_cb(g_ctx.btn_play, on_play_click, LV_EVENT_CLICKED, &g_ctx);
/* Check initialization status */
if (g_ctx.i2s_dev == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S device not found");
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play, LV_STATE_DISABLED);
} else if (g_ctx.audio_buf == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: Out of memory");
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play, LV_STATE_DISABLED);
} else {
g_ctx.state = STATE_IDLE;
update_ui(&g_ctx);
}
}
int main(int argc, char* argv[]) {
tt_app_register((AppRegistration) {
.onShow = onShowApp
});
return 0;
}
+10
View File
@@ -0,0 +1,10 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.audiotest
versionName=0.1.0
versionCode=1
name=Audio Test
+43 -2
View File
@@ -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!\n(Loaded via LAN!)");
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;
}
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH 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)
+428
View File
@@ -0,0 +1,428 @@
# McpScreen for Tactility — Implementation Plan
## Goal
Port the hardware-agnostic MCP Screen design from:
`/Users/adolforeyna/Projects/MicroPython/test1/Screen`
into a native Tactility application while preserving compatibility with the
existing host-side MCP bridge and Hermes voice service.
The original reference documents and implementations are:
- `system_design_specs.md`
- `mcp_server.py`
- `mcp_bridge.py`
- `main.py`
- `video_stream.py`
- `voice_assistant.py`
- `lib/audio_util.py`
## Target application
- Source folder: `Apps/McpScreen`
- App ID: `one.tactility.mcpscreen`
- Initial platform: ESP32-S3
- Initial Tactility SDK: `0.7.0-dev`
- Language: C/C++ with ESP-IDF, FreeRTOS, Tactility SDK, and LVGL
## Current implementation status
Phase 1 is implemented, builds successfully for ESP32-S3, and has been tested
on a 320x240 Tactility device at runtime.
Completed:
- Tactility app lifecycle and app-owned drawing area
- HTTP JSON-RPC endpoint on port 80
- `tools/list`
- `get_capabilities`
- `clear_screen`
- `draw_text`
- request-size limits and JSON-RPC error responses
- live `tools/list` and `get_capabilities` calls
- live `clear_screen` and `draw_text` calls
- live malformed-JSON error handling
- crash-safe HTTP worker shutdown during app destruction
- three forced stop/start cycles with the management and MCP endpoints still
responsive afterward
Blocked by the current external-app ABI:
- UDP discovery requires `lwip_recvfrom` and `lwip_sendto`, which the running
Tactility 0.7.0-dev firmware does not export to ELF apps. The existing bridge
falls back to HTTP subnet scanning, so MCP connectivity remains available.
Pending verification:
- verify service behavior while the app is genuinely hidden behind another
running application. A remote HelloWorld launch did not trigger `onHide`, so
this needs a manual launcher/device interaction test.
### Shutdown implementation note
Tactility unloads external-app ELF memory immediately after `onDestroy`
returns. An app task blocked in `accept()` therefore cannot be allowed to
survive destruction. The HTTP listener is non-blocking and client reads have
short timeouts. `onHide` only clears the run flag and returns immediately so
it does not block Tactility's GUI lifecycle or close an lwIP descriptor from
the wrong task. The worker owns the socket shutdown, then suspends itself
asynchronously. The next `onShow` reaps it before starting a new worker, while
`onDestroy` waits for and deletes it before returning.
## Design principles
1. Preserve the existing MCP tool names and JSON-RPC contract wherever the
underlying Tactility hardware supports them.
2. Keep network, audio, and long-running work outside the LVGL task.
3. Perform all LVGL operations under the Tactility LVGL lock or dispatch them
onto the LVGL task.
4. Use Tactility device and HAL interfaces instead of board-specific GPIO
assumptions wherever possible.
5. Return structured unsupported-capability errors instead of silently
emulating unavailable hardware.
6. Treat files supplied over MCP as untrusted input and constrain file access
to the application's user-data directory.
7. Do not port `execute_python` literally. Native firmware must not expose
arbitrary code execution.
## Proposed application structure
```text
Apps/McpScreen/
├── CMakeLists.txt
├── manifest.properties
├── IMPLEMENTATION_PLAN.md
└── main/
├── CMakeLists.txt
└── Source/
├── main.cpp
├── McpScreenApp.cpp
├── McpScreenApp.h
├── McpServer.cpp
├── McpServer.h
├── DiscoveryService.cpp
├── DiscoveryService.h
├── ToolRegistry.cpp
├── ToolRegistry.h
├── ToolDispatcher.cpp
├── ToolDispatcher.h
├── DisplayTools.cpp
├── DisplayTools.h
├── AudioTools.cpp
├── AudioTools.h
├── SystemTools.cpp
├── SystemTools.h
├── DashboardView.cpp
└── DashboardView.h
```
The file boundaries may be consolidated during the first slice if a smaller
implementation is easier to validate.
## Runtime architecture
### Application lifecycle
- `onCreate`
- Allocate application state.
- `onShow`
- Build the dashboard and MCP-controlled display canvas.
- Attach the current UI objects to the shared application state.
- Start the HTTP MCP service.
- `onHide`
- Signal the HTTP MCP service to stop without blocking the GUI lifecycle.
- Detach and invalidate UI object pointers.
- `onDestroy`
- Join/delete any remaining worker and close sockets as a safety fallback.
- Release buffers, devices, and application state.
The MCP server is deliberately foreground-only. Tactility does not need to
keep the listener or display-control task active while another app owns the
screen.
### FreeRTOS responsibilities
- MCP HTTP task
- Listen for HTTP requests.
- Parse JSON-RPC requests.
- Dispatch tools.
- Serialize JSON-RPC responses.
- UDP discovery task
- Bind UDP port 5000.
- Reply to discovery packets.
- Display work
- Marshal changes through the LVGL lock or an LVGL async callback.
- Audio tasks
- Record and play I2S data without blocking the UI or MCP listener.
- Future streaming tasks
- Own TCP/UDP frame sockets and preallocated frame buffers.
## Network compatibility
### UDP discovery
- Port: `5000`
- Request: exact bytes `DISCOVER_SCREEN`
- Response: exact bytes `SCREEN_IP_80`
### MCP HTTP endpoint
- Port: `80`
- Method and path: `POST /api/mcp`
- Content type: `application/json`
- Protocol: JSON-RPC 2.0
- Required methods:
- `tools/list`
- `tools/call`
### Raw display endpoint
Added after the basic MCP slice:
- Method and path: `POST /api/screen/raw`
- Query parameters: `x`, `y`, `w`, `h`
- Body: raw big-endian RGB565 bytes
## MCP response behavior
Successful tool calls retain the existing shape:
```json
{
"jsonrpc": "2.0",
"result": {
"content": [
{
"type": "text",
"text": "..."
}
]
},
"id": 1
}
```
Errors use JSON-RPC error objects:
```json
{
"jsonrpc": "2.0",
"error": {
"code": -32000,
"message": "..."
},
"id": 1
}
```
## Phase 1 — MCP foundation
### Scope
1. Scaffold `Apps/McpScreen`.
2. Add a status/dashboard view showing:
- Wi-Fi state
- server state
- port
- last tool
- last error
3. Start the HTTP server on port 80.
4. Implement `POST /api/mcp`.
5. Implement JSON-RPC request validation and error handling.
6. Implement `tools/list`.
7. Implement UDP discovery on port 5000.
8. Implement the first three tools:
- `get_capabilities`
- `clear_screen`
- `draw_text`
9. Test with the existing `mcp_bridge.py`.
### Initial tool semantics
#### `get_capabilities`
Return live display information:
```json
{
"color": true,
"width": 320,
"height": 240,
"formats": ["rgb565_base64", "bmp_base64"],
"platform": "tactility",
"appVersion": "0.1.0"
}
```
Width, height, and color format must be queried at runtime.
#### `clear_screen`
- Accept `color` values compatible with the original API:
- `0`: white
- `1`: black
- Mark MCP display override active.
- Clear the app-owned canvas/display area.
#### `draw_text`
- Accept `text`, `x`, `y`, and optional `size`.
- Mark MCP display override active.
- Render inside the app-owned canvas.
- Clamp coordinates to the drawable area.
### Phase 1 acceptance criteria
- The app builds and packages for ESP32-S3.
- Opening the app displays its server status.
- UDP discovery returns `SCREEN_IP_80`.
- The existing bridge can discover the device.
- `tools/list` returns valid schemas for the three initial tools.
- `get_capabilities` reports the actual Tactility display dimensions.
- `clear_screen` and `draw_text` visibly update the app.
- Malformed JSON and unknown methods return JSON-RPC errors without crashing.
- Repeated requests do not leak tasks, sockets, or request buffers.
- App hiding/showing behavior is documented from a device test.
## Phase 2 — Display tools
Add:
- `draw_raw_rgb565`
- `POST /api/screen/raw`
- `draw_color_bmp`
- `draw_image`
- `get_screenshot`
- `set_backlight`
- `set_screen_power`
Display commands should target an app-owned LVGL canvas or image buffer. Direct
panel access should only be used when the Tactility display HAL guarantees safe
ownership and synchronization.
Image preprocessing may remain in `mcp_bridge.py` initially. That avoids large
PNG/JPEG decoders and unnecessary memory pressure on the ESP32-S3.
## Phase 3 — Audio tools
Port the working implementation from `Apps/AudioTest`:
- `play_tone`
- `record_voice`
- `play_audio`
- `play_audio_base64`
Audio baseline:
- 16 kHz
- 16-bit signed PCM
- mono
- I2S device discovered through `device_find_by_name("i2s0")`
Recordings should be streamed to the application's user-data directory instead
of requiring one large fixed-duration RAM allocation.
## Phase 4 — Hardware and system tools
Add where supported:
- `get_touch`
- `get_battery`
- `set_led`
- `get_sensors`
- `scan_ble`
- `sync_time`
- `read_file`
- `write_file`
- `download_file`
File operations must:
- resolve paths under the app user-data directory;
- reject absolute paths and traversal such as `../`;
- enforce practical request and file-size limits.
`execute_python` is intentionally excluded. A future allow-listed diagnostic
command tool may replace it if needed.
## Phase 5 — Video streaming
Add:
- TCP frame server
- UDP chunked frame server
- stream activity timeout
- `get_video_streaming_instructions`
- `get_stream_stats`
The new protocol should advertise the actual Tactility display dimensions and
native color format. The old RLCD-specific 15,000-byte monochrome mapping may
be offered only as an optional compatibility mode.
Use preallocated buffers and avoid per-frame heap allocation.
## Phase 6 — Dashboard and Hermes voice assistant
Add:
- five-second dashboard refresh;
- MCP override mode;
- an explicit local action to leave override mode;
- Hermes WebSocket client;
- touch-to-talk;
- 16 kHz, 16-bit mono microphone streaming;
- incoming PCM/WAV playback;
- transcript, thinking, listening, and speaking UI states.
## Compatibility notes
- Keep the existing host bridge usable throughout development.
- Preserve tool names and argument names unless a compatibility defect is
documented.
- `draw_image` can continue to be host-preprocessed into PBM or RGB565.
- Screenshot output may change from PBM to PNG/RGB565 if the bridge is updated
to understand both.
- Hardware-specific tools should report support through `get_capabilities`.
## Security and robustness limits
The first implementation should define conservative limits for:
- HTTP header size
- JSON body size
- base64 payload size
- socket read timeout
- simultaneous clients
- filename length
- file size
- text length
- drawing bounds
- audio duration
Only one display mutation should run at a time. Audio operations should also be
serialized around the shared I2S device.
## Build workflow
From the TactilityApps repository:
```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/.gemini/antigravity/scratch/tactility/release/TactilitySDK
python3 tactility.py Apps/McpScreen build esp32s3 --local-sdk
```
## Immediate next task
Implement Phase 1 as a minimal vertical slice:
1. Scaffold the app and lifecycle.
2. Add a small server-status UI.
3. Add UDP discovery.
4. Add HTTP and JSON-RPC parsing.
5. Add `tools/list`.
6. Add `get_capabilities`, `clear_screen`, and `draw_text`.
7. Build.
8. Run a host smoke test through the existing bridge.
+76
View File
@@ -0,0 +1,76 @@
# MCP Screen
Native Tactility port of the MicroPython MCP Screen firmware.
Phase 1 provides:
- JSON-RPC 2.0 over `POST /api/mcp` on port 80
- `tools/list`
- `get_capabilities`
- `clear_screen`
- `draw_text`
Phase 2 adds:
- `draw_raw_rgb565`
- `POST /api/screen/raw`
- `draw_color_bmp`
- bridge-assisted PBM `draw_image`
- PBM `get_screenshot`
Backlight and screen-power controls are intentionally excluded.
See `IMPLEMENTATION_PLAN.md` for the full roadmap.
UDP discovery is temporarily unavailable to an external app because the
Tactility 0.7.0-dev firmware does not export `lwip_recvfrom` and
`lwip_sendto`. The existing MicroPython bridge remains compatible through its
HTTP subnet-scan fallback.
The HTTP worker uses a non-blocking listener and short client timeouts.
`onHide` signals it and returns immediately; the worker owns the socket
shutdown and then suspends itself outside the GUI lifecycle. It is reaped by
the next `onShow`, or by `onDestroy` before Tactility unloads the external ELF.
The MCP server is foreground-only: it starts in `onShow`, and port 80 closes
within one short worker poll after `onHide`. It is intentionally unavailable
whenever McpScreen is not the active screen.
## Build
```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/.gemini/antigravity/scratch/tactility/release/TactilitySDK
python3 tactility.py Apps/McpScreen build esp32s3 --local-sdk
```
## Direct smoke test
Replace the IP address with the device address:
```bash
curl -s http://DEVICE_IP/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
```bash
curl -s http://DEVICE_IP/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"draw_text","arguments":{"text":"Hello from MCP","x":20,"y":30,"size":2}}}'
```
The existing MicroPython host bridge can also be used unchanged:
```bash
python3 /Users/adolforeyna/Projects/MicroPython/test1/Screen/mcp_bridge.py
```
Or run the included Phase 2 smoke test:
```bash
python3 Apps/McpScreen/tools/smoke_test.py --ip DEVICE_IP --draw
```
The `--ip` argument is required until UDP discovery is available to external
apps.
+8
View File
@@ -0,0 +1,8 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
idf_component_register(
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
REQUIRES TactilitySDK lwip
)
+337
View File
@@ -0,0 +1,337 @@
#include "McpScreen.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <esp_log.h>
#include <tt_lvgl.h>
static const char* TAG = "DisplayTools";
static bool ascii_space(uint8_t value) {
return value == ' ' || value == '\t' || value == '\r' ||
value == '\n' || value == '\f' || value == '\v';
}
static bool ascii_digit(uint8_t value) {
return value >= '0' && value <= '9';
}
static uint16_t read_le16(const uint8_t* value) {
return (uint16_t)value[0] | ((uint16_t)value[1] << 8);
}
static uint32_t read_le32(const uint8_t* value) {
return (uint32_t)value[0] |
((uint32_t)value[1] << 8) |
((uint32_t)value[2] << 16) |
((uint32_t)value[3] << 24);
}
static bool display_ready(McpScreenState* state) {
return state != NULL &&
state->visible &&
state->draw_area != NULL &&
state->framebuffer != NULL &&
state->draw_width > 0 &&
state->draw_height > 0;
}
static uint16_t rgb888_to_rgb565(uint8_t red, uint8_t green, uint8_t blue) {
return (uint16_t)(((red & 0xF8) << 8) |
((green & 0xFC) << 3) |
(blue >> 3));
}
static void put_pixel(McpScreenState* state, int x, int y, uint16_t color) {
if (x >= 0 && y >= 0 && x < state->draw_width && y < state->draw_height) {
state->framebuffer[(size_t)y * state->draw_width + x] = color;
}
}
bool mcp_ui_draw_rgb565_be(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y,
int width,
int height
) {
if (data == NULL || width <= 0 || height <= 0 ||
data_size < (size_t)width * height * 2 || !display_ready(state)) {
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(2000))) {
if (display_ready(state)) {
for (int source_y = 0; source_y < height; ++source_y) {
int destination_y = y + source_y;
if (destination_y < 0 || destination_y >= state->draw_height) {
continue;
}
for (int source_x = 0; source_x < width; ++source_x) {
int destination_x = x + source_x;
if (destination_x < 0 || destination_x >= state->draw_width) {
continue;
}
size_t offset = ((size_t)source_y * width + source_x) * 2;
uint16_t color = ((uint16_t)data[offset] << 8) | data[offset + 1];
put_pixel(state, destination_x, destination_y, color);
}
}
lv_obj_invalidate(state->draw_area);
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
}
return success;
}
bool mcp_ui_draw_bmp(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y
) {
if (data == NULL || data_size < 54 || !display_ready(state) ||
data[0] != 'B' || data[1] != 'M') {
return false;
}
uint32_t pixel_offset = read_le32(data + 10);
uint32_t dib_size = read_le32(data + 14);
int32_t width = (int32_t)read_le32(data + 18);
int32_t signed_height = (int32_t)read_le32(data + 22);
uint16_t planes = read_le16(data + 26);
uint16_t bits_per_pixel = read_le16(data + 28);
uint32_t compression = read_le32(data + 30);
if (dib_size < 40 || width <= 0 || signed_height == 0 || planes != 1 ||
(bits_per_pixel != 24 && bits_per_pixel != 32) || compression != 0) {
return false;
}
int height = signed_height < 0 ? -signed_height : signed_height;
bool top_down = signed_height < 0;
size_t row_stride = (((size_t)width * bits_per_pixel + 31) / 32) * 4;
if (pixel_offset > data_size ||
row_stride > data_size ||
(size_t)height > (data_size - pixel_offset) / row_stride) {
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(2500))) {
if (display_ready(state)) {
size_t bytes_per_pixel = bits_per_pixel / 8;
for (int source_y = 0; source_y < height; ++source_y) {
int file_y = top_down ? source_y : (height - 1 - source_y);
const uint8_t* row = data + pixel_offset + (size_t)file_y * row_stride;
for (int source_x = 0; source_x < width; ++source_x) {
const uint8_t* pixel = row + (size_t)source_x * bytes_per_pixel;
put_pixel(
state,
x + source_x,
y + source_y,
rgb888_to_rgb565(pixel[2], pixel[1], pixel[0])
);
}
}
lv_obj_invalidate(state->draw_area);
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
}
return success;
}
static bool pbm_next_number(
const uint8_t* data,
size_t data_size,
size_t* offset,
int* result
) {
while (*offset < data_size) {
if (data[*offset] == '#') {
while (*offset < data_size && data[*offset] != '\n') {
(*offset)++;
}
} else if (ascii_space(data[*offset])) {
(*offset)++;
} else {
break;
}
}
if (*offset >= data_size || !ascii_digit(data[*offset])) {
return false;
}
int value = 0;
while (*offset < data_size && ascii_digit(data[*offset])) {
value = value * 10 + (data[*offset] - '0');
(*offset)++;
}
*result = value;
return true;
}
bool mcp_ui_draw_pbm(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y
) {
if (data == NULL || data_size < 8 || !display_ready(state) ||
data[0] != 'P' || data[1] != '4') {
ESP_LOGE(
TAG,
"PBM precondition failed data=%p size=%u ready=%d magic=%02x%02x",
data,
(unsigned)data_size,
display_ready(state),
data_size > 0 ? data[0] : 0,
data_size > 1 ? data[1] : 0
);
return false;
}
size_t offset = 2;
int width = 0;
int height = 0;
if (!pbm_next_number(data, data_size, &offset, &width) ||
!pbm_next_number(data, data_size, &offset, &height) ||
width <= 0 || height <= 0) {
ESP_LOGE(TAG, "PBM dimension parse failed offset=%u w=%d h=%d", (unsigned)offset, width, height);
return false;
}
if (offset >= data_size || !ascii_space(data[offset])) {
ESP_LOGE(
TAG,
"PBM missing header separator offset=%u size=%u byte=%02x",
(unsigned)offset,
(unsigned)data_size,
offset < data_size ? data[offset] : 0
);
return false;
}
if (data[offset] == '\r' && offset + 1 < data_size && data[offset + 1] == '\n') {
offset += 2;
} else {
offset++;
}
size_t row_bytes = ((size_t)width + 7) / 8;
if (offset > data_size ||
(size_t)height > (data_size - offset) / row_bytes) {
ESP_LOGE(
TAG,
"PBM payload too small offset=%u size=%u row=%u h=%d",
(unsigned)offset,
(unsigned)data_size,
(unsigned)row_bytes,
height
);
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(2000))) {
if (display_ready(state)) {
for (int source_y = 0; source_y < height; ++source_y) {
const uint8_t* row = data + offset + (size_t)source_y * row_bytes;
for (int source_x = 0; source_x < width; ++source_x) {
bool black = (row[source_x >> 3] & (0x80 >> (source_x & 7))) != 0;
put_pixel(state, x + source_x, y + source_y, black ? 0x0000 : 0xFFFF);
}
}
lv_obj_invalidate(state->draw_area);
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
} else {
ESP_LOGE(TAG, "PBM LVGL lock timed out");
}
if (!success) ESP_LOGE(TAG, "PBM draw failed after lock ready=%d", display_ready(state));
return success;
}
static char* base64_encode(const uint8_t* input, size_t input_size) {
static const char alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t output_size = ((input_size + 2) / 3) * 4;
char* output = malloc(output_size + 1);
if (output == NULL) {
return NULL;
}
size_t in = 0;
size_t out = 0;
while (in < input_size) {
uint32_t value = (uint32_t)input[in++] << 16;
bool have_second = in < input_size;
if (have_second) value |= (uint32_t)input[in++] << 8;
bool have_third = in < input_size;
if (have_third) value |= input[in++];
output[out++] = alphabet[(value >> 18) & 0x3F];
output[out++] = alphabet[(value >> 12) & 0x3F];
output[out++] = have_second ? alphabet[(value >> 6) & 0x3F] : '=';
output[out++] = have_third ? alphabet[value & 0x3F] : '=';
}
output[out] = '\0';
return output;
}
char* mcp_ui_get_screenshot_pbm_base64(McpScreenState* state) {
if (!display_ready(state)) {
return NULL;
}
size_t row_bytes = ((size_t)state->draw_width + 7) / 8;
char header[40];
int header_size = snprintf(
header,
sizeof(header),
"P4\n%u %u\n",
state->draw_width,
state->draw_height
);
size_t pbm_size = (size_t)header_size + row_bytes * state->draw_height;
uint8_t* pbm = calloc(1, pbm_size);
if (pbm == NULL) {
return NULL;
}
memcpy(pbm, header, header_size);
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(2000))) {
if (display_ready(state)) {
for (int y = 0; y < state->draw_height; ++y) {
uint8_t* row = pbm + header_size + (size_t)y * row_bytes;
for (int x = 0; x < state->draw_width; ++x) {
uint16_t color = state->framebuffer[(size_t)y * state->draw_width + x];
int red = (color >> 11) & 0x1F;
int green = (color >> 5) & 0x3F;
int blue = color & 0x1F;
int luminance = red * 2 + green * 3 + blue;
if (luminance < 128) {
row[x >> 3] |= (0x80 >> (x & 7));
}
}
}
success = true;
}
tt_lvgl_unlock();
}
char* encoded = success ? base64_encode(pbm, pbm_size) : NULL;
free(pbm);
return encoded;
}
+287
View File
@@ -0,0 +1,287 @@
#include "McpScreen.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <esp_log.h>
#include <esp_heap_caps.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_wifi.h>
static const char* TAG = "McpScreen";
static void set_label_text_locked(lv_obj_t* label, const char* text) {
if (label != NULL && lv_obj_is_valid(label)) {
lv_label_set_text(label, text);
}
}
void mcp_ui_set_server_status(McpScreenState* state, const char* status) {
if (state == NULL || !state->visible) {
return;
}
if (tt_lvgl_lock(pdMS_TO_TICKS(1000))) {
if (state->visible) {
set_label_text_locked(state->server_label, status);
}
tt_lvgl_unlock();
}
}
void mcp_ui_set_last_tool(McpScreenState* state, const char* tool) {
if (state == NULL || !state->visible) {
return;
}
if (tt_lvgl_lock(pdMS_TO_TICKS(1000))) {
if (state->visible && state->tool_label != NULL && lv_obj_is_valid(state->tool_label)) {
lv_label_set_text_fmt(state->tool_label, "Last: %s", tool);
}
tt_lvgl_unlock();
}
}
bool mcp_ui_clear(McpScreenState* state, int color) {
if (state == NULL || !state->visible || state->framebuffer == NULL) {
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(1500))) {
if (state->visible && state->draw_area != NULL && lv_obj_is_valid(state->draw_area)) {
lv_obj_clean(state->draw_area);
uint16_t fill = color == 1 ? 0x0000 : 0xFFFF;
size_t pixel_count = (size_t)state->draw_width * state->draw_height;
for (size_t i = 0; i < pixel_count; ++i) {
state->framebuffer[i] = fill;
}
lv_obj_invalidate(state->draw_area);
state->draw_color = color;
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
}
return success;
}
bool mcp_ui_draw_text(
McpScreenState* state,
const char* text,
int x,
int y,
int size
) {
if (state == NULL || text == NULL || !state->visible) {
return false;
}
bool success = false;
if (tt_lvgl_lock(pdMS_TO_TICKS(1500))) {
if (state->visible && state->draw_area != NULL && lv_obj_is_valid(state->draw_area)) {
int max_x = state->draw_width > 0 ? state->draw_width - 1 : 0;
int max_y = state->draw_height > 0 ? state->draw_height - 1 : 0;
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x > max_x) x = max_x;
if (y > max_y) y = max_y;
lv_obj_t* label = lv_label_create(state->draw_area);
lv_label_set_text(label, text);
lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
lv_obj_set_width(label, LV_MAX(1, state->draw_width - x));
lv_obj_set_pos(label, x, y);
lv_obj_set_style_text_color(
label,
state->draw_color == 0 ? lv_color_black() : lv_color_white(),
LV_PART_MAIN
);
#if LV_FONT_MONTSERRAT_24
if (size >= 2) {
lv_obj_set_style_text_font(label, &lv_font_montserrat_24, LV_PART_MAIN);
}
#else
(void)size;
#endif
state->override_active = true;
success = true;
}
tt_lvgl_unlock();
}
return success;
}
static void* create_data(void) {
McpScreenState* state = calloc(1, sizeof(McpScreenState));
if (state != NULL) {
state->http_socket = -1;
state->client_socket = -1;
state->discovery_socket = -1;
}
return state;
}
static void destroy_data(void* data) {
McpScreenState* state = data;
if (state != NULL && state->framebuffer != NULL) {
heap_caps_free(state->framebuffer);
}
free(state);
}
static void on_create(AppHandle app, void* data) {
McpScreenState* state = data;
state->app = app;
}
static void on_destroy(AppHandle app, void* data) {
(void)app;
mcp_services_stop((McpScreenState*)data);
}
static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
McpScreenState* state = data;
ESP_LOGI(TAG, "onShow: starting foreground UI and MCP service");
state->visible = true;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(parent, 0, LV_PART_MAIN);
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
state->server_label = lv_label_create(toolbar);
lv_label_set_text(state->server_label, "MCP starting");
lv_obj_set_style_text_color(
state->server_label,
lv_palette_main(LV_PALETTE_ORANGE),
LV_PART_MAIN
);
state->tool_label = lv_label_create(toolbar);
lv_label_set_text(state->tool_label, "Last: none");
if (!mcp_services_start(state)) {
ESP_LOGE(TAG, "Failed to start foreground MCP service");
lv_label_set_text(state->server_label, "MCP start failed");
lv_obj_set_style_text_color(
state->server_label,
lv_palette_main(LV_PALETTE_RED),
LV_PART_MAIN
);
}
state->draw_area = lv_canvas_create(parent);
lv_obj_set_width(state->draw_area, LV_PCT(100));
lv_obj_set_flex_grow(state->draw_area, 1);
lv_obj_set_style_radius(state->draw_area, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(state->draw_area, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(state->draw_area, 0, LV_PART_MAIN);
lv_obj_remove_flag(state->draw_area, LV_OBJ_FLAG_SCROLLABLE);
lv_display_t* display = lv_obj_get_display(parent);
state->display_width = lv_display_get_horizontal_resolution(display);
state->display_height = lv_display_get_vertical_resolution(display);
lv_obj_update_layout(parent);
state->draw_width = lv_obj_get_content_width(state->draw_area);
state->draw_height = lv_obj_get_content_height(state->draw_area);
size_t required_size = (size_t)state->draw_width * state->draw_height * sizeof(uint16_t);
if (state->framebuffer == NULL || state->framebuffer_size != required_size) {
if (state->framebuffer != NULL) {
heap_caps_free(state->framebuffer);
state->framebuffer = NULL;
}
state->framebuffer = heap_caps_malloc(required_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (state->framebuffer == NULL) {
state->framebuffer = heap_caps_malloc(required_size, MALLOC_CAP_8BIT);
}
state->framebuffer_size = state->framebuffer == NULL ? 0 : required_size;
}
if (state->framebuffer == NULL) {
ESP_LOGE(TAG, "Failed to allocate %u-byte framebuffer", (unsigned)required_size);
lv_obj_t* error = lv_label_create(state->draw_area);
lv_label_set_text(error, "Framebuffer allocation failed");
lv_obj_center(error);
return;
}
lv_canvas_set_buffer(
state->draw_area,
state->framebuffer,
state->draw_width,
state->draw_height,
LV_COLOR_FORMAT_RGB565
);
if (!state->override_active) {
for (size_t i = 0; i < (size_t)state->draw_width * state->draw_height; ++i) {
state->framebuffer[i] = 0x10C3;
}
state->draw_color = 1;
lv_obj_t* title = lv_label_create(state->draw_area);
lv_label_set_text(title, "MCP Screen");
lv_obj_set_style_text_color(title, lv_color_white(), LV_PART_MAIN);
#if LV_FONT_MONTSERRAT_24
lv_obj_set_style_text_font(title, &lv_font_montserrat_24, LV_PART_MAIN);
#endif
lv_obj_align(title, LV_ALIGN_CENTER, 0, -55);
lv_obj_t* dimensions = lv_label_create(state->draw_area);
lv_label_set_text_fmt(
dimensions,
"%ux%u display\nHTTP :80\nDiscovery: bridge subnet scan",
state->display_width,
state->display_height
);
lv_obj_set_style_text_align(dimensions, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_style_text_color(dimensions, lv_palette_lighten(LV_PALETTE_BLUE, 3), LV_PART_MAIN);
lv_obj_align(dimensions, LV_ALIGN_CENTER, 0, 0);
lv_obj_t* wifi = lv_label_create(state->draw_area);
lv_label_set_text_fmt(
wifi,
"Wi-Fi: %s",
tt_wifi_radio_state_to_string(tt_wifi_get_radio_state())
);
lv_obj_set_style_text_color(wifi, lv_color_white(), LV_PART_MAIN);
lv_obj_align(wifi, LV_ALIGN_CENTER, 0, 48);
}
}
static void on_hide(AppHandle app, void* data) {
(void)app;
McpScreenState* state = data;
ESP_LOGI(TAG, "onHide: stopping foreground MCP service");
state->visible = false;
mcp_services_hide(state);
state->draw_area = NULL;
state->server_label = NULL;
state->tool_label = NULL;
}
int main(int argc, char* argv[]) {
(void)argc;
(void)argv;
tt_app_register((AppRegistration) {
.createData = create_data,
.destroyData = destroy_data,
.onCreate = on_create,
.onDestroy = on_destroy,
.onShow = on_show,
.onHide = on_hide
});
return 0;
}
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lvgl.h>
#include <tt_app.h>
typedef struct {
volatile bool running;
volatile bool http_ready;
volatile bool http_exited;
volatile bool discovery_ready;
volatile bool visible;
volatile bool override_active;
int http_socket;
int client_socket;
int discovery_socket;
TaskHandle_t http_task;
TaskHandle_t discovery_task;
AppHandle app;
lv_obj_t* draw_area;
lv_obj_t* server_label;
lv_obj_t* tool_label;
uint16_t* framebuffer;
size_t framebuffer_size;
uint16_t display_width;
uint16_t display_height;
uint16_t draw_width;
uint16_t draw_height;
int draw_color;
} McpScreenState;
bool mcp_services_start(McpScreenState* state);
void mcp_services_hide(McpScreenState* state);
void mcp_services_stop(McpScreenState* state);
void mcp_ui_set_server_status(McpScreenState* state, const char* status);
void mcp_ui_set_last_tool(McpScreenState* state, const char* tool);
bool mcp_ui_clear(McpScreenState* state, int color);
bool mcp_ui_draw_text(
McpScreenState* state,
const char* text,
int x,
int y,
int size
);
bool mcp_ui_draw_rgb565_be(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y,
int width,
int height
);
bool mcp_ui_draw_bmp(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y
);
bool mcp_ui_draw_pbm(
McpScreenState* state,
const uint8_t* data,
size_t data_size,
int x,
int y
);
char* mcp_ui_get_screenshot_pbm_base64(McpScreenState* state);
+820
View File
@@ -0,0 +1,820 @@
#include "McpScreen.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <cJSON.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lwip/inet.h>
#include <lwip/sockets.h>
#define MCP_HTTP_PORT 80
#define MCP_HEADER_LIMIT 2048
#define MCP_JSON_BODY_LIMIT (256 * 1024)
#define MCP_RAW_BODY_LIMIT (512 * 1024)
#define MCP_RESPONSE_LIMIT (64 * 1024)
static const char* TAG = "McpServer";
static const char* TOOLS_JSON =
"["
"{"
"\"name\":\"get_capabilities\","
"\"description\":\"Get the Tactility display capabilities.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}"
"},"
"{"
"\"name\":\"clear_screen\","
"\"description\":\"Clear the MCP drawing area to white (0) or black (1).\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{\"color\":{\"type\":\"integer\",\"enum\":[0,1]}},"
"\"required\":[\"color\"]"
"}"
"},"
"{"
"\"name\":\"draw_text\","
"\"description\":\"Draw text in the MCP drawing area at x,y coordinates.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"text\":{\"type\":\"string\"},"
"\"x\":{\"type\":\"integer\"},"
"\"y\":{\"type\":\"integer\"},"
"\"size\":{\"type\":\"integer\",\"enum\":[1,2],\"default\":1}"
"},"
"\"required\":[\"text\",\"x\",\"y\"]"
"}"
"},"
"{"
"\"name\":\"draw_image\","
"\"description\":\"Draw a bridge-preprocessed binary PBM image.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"pbm_base64\":{\"type\":\"string\"},"
"\"x\":{\"type\":\"integer\",\"default\":0},"
"\"y\":{\"type\":\"integer\",\"default\":0},"
"\"dither\":{\"type\":\"boolean\",\"default\":true}"
"},"
"\"required\":[\"pbm_base64\"]"
"}"
"},"
"{"
"\"name\":\"draw_color_bmp\","
"\"description\":\"Draw an uncompressed 24-bit or 32-bit BMP image.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"bmp_base64\":{\"type\":\"string\"},"
"\"x\":{\"type\":\"integer\",\"default\":0},"
"\"y\":{\"type\":\"integer\",\"default\":0}"
"},"
"\"required\":[\"bmp_base64\"]"
"}"
"},"
"{"
"\"name\":\"draw_raw_rgb565\","
"\"description\":\"Draw big-endian raw RGB565 pixels.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"rgb565_base64\":{\"type\":\"string\"},"
"\"x\":{\"type\":\"integer\"},"
"\"y\":{\"type\":\"integer\"},"
"\"w\":{\"type\":\"integer\"},"
"\"h\":{\"type\":\"integer\"}"
"},"
"\"required\":[\"rgb565_base64\",\"x\",\"y\",\"w\",\"h\"]"
"}"
"},"
"{"
"\"name\":\"get_screenshot\","
"\"description\":\"Capture the MCP framebuffer as a PBM image.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}"
"}"
"]";
static int base64_value(char character) {
if (character >= 'A' && character <= 'Z') return character - 'A';
if (character >= 'a' && character <= 'z') return character - 'a' + 26;
if (character >= '0' && character <= '9') return character - '0' + 52;
if (character == '+') return 62;
if (character == '/') return 63;
return -1;
}
static uint8_t* base64_decode(const char* input, size_t* output_size) {
if (input == NULL || output_size == NULL) {
return NULL;
}
const char* payload = input;
const char* marker = strstr(input, ";base64,");
if (marker != NULL) {
payload = marker + 8;
}
size_t input_size = strlen(payload);
uint8_t* output = malloc((input_size / 4) * 3 + 3);
if (output == NULL) {
return NULL;
}
uint32_t accumulator = 0;
int bits = 0;
size_t written = 0;
for (size_t index = 0; index < input_size; ++index) {
char character = payload[index];
if (character == '=') {
break;
}
int value = base64_value(character);
if (value < 0) {
if (character == '\r' || character == '\n' ||
character == ' ' || character == '\t') {
continue;
}
free(output);
return NULL;
}
accumulator = (accumulator << 6) | (uint32_t)value;
bits += 6;
if (bits >= 8) {
bits -= 8;
output[written++] = (uint8_t)((accumulator >> bits) & 0xFF);
}
}
*output_size = written;
return output;
}
static void close_socket(int* socket_fd) {
if (socket_fd != NULL && *socket_fd >= 0) {
close(*socket_fd);
*socket_fd = -1;
}
}
static bool send_all(int socket_fd, const char* data, size_t length) {
size_t sent_total = 0;
while (sent_total < length) {
int sent = send(socket_fd, data + sent_total, length - sent_total, 0);
if (sent <= 0) {
return false;
}
sent_total += (size_t)sent;
}
return true;
}
static cJSON* make_error(cJSON* id, int code, const char* message) {
cJSON* root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "jsonrpc", "2.0");
cJSON* error = cJSON_AddObjectToObject(root, "error");
cJSON_AddNumberToObject(error, "code", code);
cJSON_AddStringToObject(error, "message", message);
if (id != NULL) {
cJSON_AddItemToObject(root, "id", cJSON_Duplicate(id, true));
} else {
cJSON_AddNullToObject(root, "id");
}
return root;
}
static cJSON* make_tool_result(cJSON* id, const char* text) {
cJSON* root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "jsonrpc", "2.0");
cJSON* result = cJSON_AddObjectToObject(root, "result");
cJSON* content = cJSON_AddArrayToObject(result, "content");
cJSON* item = cJSON_CreateObject();
cJSON_AddStringToObject(item, "type", "text");
cJSON_AddStringToObject(item, "text", text);
cJSON_AddItemToArray(content, item);
if (id != NULL) {
cJSON_AddItemToObject(root, "id", cJSON_Duplicate(id, true));
} else {
cJSON_AddNullToObject(root, "id");
}
return root;
}
static cJSON* handle_tool_call(McpScreenState* state, cJSON* id, cJSON* params) {
if (!cJSON_IsObject(params)) {
return make_error(id, -32602, "Invalid params");
}
cJSON* name_item = cJSON_GetObjectItemCaseSensitive(params, "name");
cJSON* arguments = cJSON_GetObjectItemCaseSensitive(params, "arguments");
if (!cJSON_IsString(name_item) || name_item->valuestring == NULL) {
return make_error(id, -32602, "Missing tool name");
}
if (arguments != NULL && !cJSON_IsObject(arguments)) {
return make_error(id, -32602, "Tool arguments must be an object");
}
const char* name = name_item->valuestring;
mcp_ui_set_last_tool(state, name);
if (strcmp(name, "get_capabilities") == 0) {
char capabilities[320];
uint16_t width = state->draw_width > 0 ? state->draw_width : state->display_width;
uint16_t height = state->draw_height > 0 ? state->draw_height : state->display_height;
snprintf(
capabilities,
sizeof(capabilities),
"{\"color\":true,\"width\":%u,\"height\":%u,"
"\"formats\":[\"rgb565_base64\",\"bmp_base64\",\"pbm_base64\"],"
"\"platform\":\"tactility\",\"appVersion\":\"0.1.0\","
"\"uiVisible\":%s}",
width,
height,
state->visible ? "true" : "false"
);
return make_tool_result(id, capabilities);
}
if (strcmp(name, "clear_screen") == 0) {
cJSON* color_item = cJSON_GetObjectItemCaseSensitive(arguments, "color");
if (!cJSON_IsNumber(color_item) ||
(color_item->valueint != 0 && color_item->valueint != 1)) {
return make_error(id, -32602, "color must be 0 or 1");
}
if (!mcp_ui_clear(state, color_item->valueint)) {
return make_error(id, -32010, "MCP Screen app is not visible");
}
return make_tool_result(id, "Screen cleared.");
}
if (strcmp(name, "draw_text") == 0) {
cJSON* text_item = cJSON_GetObjectItemCaseSensitive(arguments, "text");
cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x");
cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y");
cJSON* size_item = cJSON_GetObjectItemCaseSensitive(arguments, "size");
if (!cJSON_IsString(text_item) || text_item->valuestring == NULL ||
!cJSON_IsNumber(x_item) || !cJSON_IsNumber(y_item)) {
return make_error(id, -32602, "draw_text requires text, x, and y");
}
if (strlen(text_item->valuestring) > 512) {
return make_error(id, -32602, "text exceeds 512 characters");
}
int size = cJSON_IsNumber(size_item) ? size_item->valueint : 1;
if (size != 1 && size != 2) {
return make_error(id, -32602, "size must be 1 or 2");
}
if (!mcp_ui_draw_text(
state,
text_item->valuestring,
x_item->valueint,
y_item->valueint,
size)) {
return make_error(id, -32010, "MCP Screen app is not visible");
}
char message[160];
snprintf(
message,
sizeof(message),
"Successfully drew text at (%d, %d) with size %d.",
x_item->valueint,
y_item->valueint,
size
);
return make_tool_result(id, message);
}
if (strcmp(name, "draw_raw_rgb565") == 0) {
cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "rgb565_base64");
cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x");
cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y");
cJSON* width_item = cJSON_GetObjectItemCaseSensitive(arguments, "w");
cJSON* height_item = cJSON_GetObjectItemCaseSensitive(arguments, "h");
if (!cJSON_IsString(encoded_item) || !cJSON_IsNumber(x_item) ||
!cJSON_IsNumber(y_item) || !cJSON_IsNumber(width_item) ||
!cJSON_IsNumber(height_item)) {
return make_error(id, -32602, "draw_raw_rgb565 requires rgb565_base64, x, y, w, and h");
}
size_t decoded_size = 0;
uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size);
if (decoded == NULL) {
return make_error(id, -32602, "Invalid RGB565 base64 data");
}
bool drawn = mcp_ui_draw_rgb565_be(
state,
decoded,
decoded_size,
x_item->valueint,
y_item->valueint,
width_item->valueint,
height_item->valueint
);
free(decoded);
return drawn
? make_tool_result(id, "Raw RGB565 data displayed successfully.")
: make_error(id, -32010, "Failed to draw RGB565 data");
}
if (strcmp(name, "draw_color_bmp") == 0) {
cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "bmp_base64");
cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x");
cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y");
if (!cJSON_IsString(encoded_item)) {
return make_error(id, -32602, "Missing bmp_base64");
}
size_t decoded_size = 0;
uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size);
if (decoded == NULL) {
return make_error(id, -32602, "Invalid BMP base64 data");
}
bool drawn = mcp_ui_draw_bmp(
state,
decoded,
decoded_size,
cJSON_IsNumber(x_item) ? x_item->valueint : 0,
cJSON_IsNumber(y_item) ? y_item->valueint : 0
);
free(decoded);
return drawn
? make_tool_result(id, "Color BMP image displayed successfully.")
: make_error(id, -32602, "Unsupported or invalid BMP image");
}
if (strcmp(name, "draw_image") == 0) {
cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "pbm_base64");
cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x");
cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y");
if (!cJSON_IsString(encoded_item)) {
return make_error(
id,
-32602,
"draw_image requires bridge-preprocessed pbm_base64"
);
}
size_t decoded_size = 0;
uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size);
if (decoded == NULL) {
return make_error(id, -32602, "Invalid PBM base64 data");
}
bool drawn = mcp_ui_draw_pbm(
state,
decoded,
decoded_size,
cJSON_IsNumber(x_item) ? x_item->valueint : 0,
cJSON_IsNumber(y_item) ? y_item->valueint : 0
);
free(decoded);
return drawn
? make_tool_result(id, "Image displayed successfully.")
: make_error(id, -32602, "Unsupported or invalid PBM image");
}
if (strcmp(name, "get_screenshot") == 0) {
char* encoded = mcp_ui_get_screenshot_pbm_base64(state);
if (encoded == NULL) {
return make_error(id, -32010, "Screenshot capture failed");
}
size_t result_size = strlen(encoded) + 17;
char* result = malloc(result_size);
if (result == NULL) {
free(encoded);
return make_error(id, -32603, "Out of memory");
}
snprintf(result, result_size, "__PBM_BASE64__:%s", encoded);
cJSON* response = make_tool_result(id, result);
free(result);
free(encoded);
return response;
}
return make_error(id, -32602, "Unknown tool");
}
static cJSON* handle_rpc(McpScreenState* state, const char* body) {
cJSON* request = cJSON_Parse(body);
if (request == NULL) {
return make_error(NULL, -32700, "Parse error");
}
cJSON* id = cJSON_GetObjectItemCaseSensitive(request, "id");
cJSON* jsonrpc = cJSON_GetObjectItemCaseSensitive(request, "jsonrpc");
cJSON* method = cJSON_GetObjectItemCaseSensitive(request, "method");
cJSON* params = cJSON_GetObjectItemCaseSensitive(request, "params");
if (!cJSON_IsObject(request) ||
!cJSON_IsString(jsonrpc) ||
strcmp(jsonrpc->valuestring, "2.0") != 0 ||
!cJSON_IsString(method)) {
cJSON* response = make_error(id, -32600, "Invalid Request");
cJSON_Delete(request);
return response;
}
cJSON* response = NULL;
if (strcmp(method->valuestring, "tools/list") == 0) {
cJSON* tools = cJSON_Parse(TOOLS_JSON);
if (tools == NULL) {
response = make_error(id, -32603, "Failed to construct tool list");
} else {
response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "jsonrpc", "2.0");
cJSON* result = cJSON_AddObjectToObject(response, "result");
cJSON_AddItemToObject(result, "tools", tools);
if (id != NULL) {
cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true));
} else {
cJSON_AddNullToObject(response, "id");
}
}
} else if (strcmp(method->valuestring, "tools/call") == 0) {
response = handle_tool_call(state, id, params);
} else {
response = make_error(id, -32601, "Method not found");
}
cJSON_Delete(request);
return response;
}
static int find_header_end(const char* buffer, int length) {
for (int i = 0; i <= length - 4; ++i) {
if (buffer[i] == '\r' && buffer[i + 1] == '\n' &&
buffer[i + 2] == '\r' && buffer[i + 3] == '\n') {
return i + 4;
}
}
return -1;
}
static int parse_content_length(const char* headers) {
const char* cursor = headers;
while ((cursor = strstr(cursor, "\r\n")) != NULL) {
cursor += 2;
if (strncasecmp(cursor, "Content-Length:", 15) == 0) {
return atoi(cursor + 15);
}
}
return 0;
}
static void send_http_response(int client, int status, const char* content_type, const char* body) {
const char* status_text = status == 200 ? "OK" :
status == 404 ? "Not Found" :
status == 405 ? "Method Not Allowed" :
status == 413 ? "Payload Too Large" :
"Bad Request";
char header[320];
int body_length = body == NULL ? 0 : (int)strlen(body);
int header_length = snprintf(
header,
sizeof(header),
"HTTP/1.1 %d %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n"
"Access-Control-Allow-Origin: *\r\n"
"\r\n",
status,
status_text,
content_type,
body_length
);
send_all(client, header, (size_t)header_length);
if (body_length > 0) {
send_all(client, body, (size_t)body_length);
}
}
static int query_integer(const char* path, const char* name, int default_value) {
const char* query = strchr(path, '?');
if (query == NULL) {
return default_value;
}
query++;
size_t name_size = strlen(name);
while (*query != '\0') {
if (strncmp(query, name, name_size) == 0 && query[name_size] == '=') {
return atoi(query + name_size + 1);
}
query = strchr(query, '&');
if (query == NULL) {
break;
}
query++;
}
return default_value;
}
static void handle_http_client(McpScreenState* state, int client) {
struct timeval timeout = { .tv_sec = 0, .tv_usec = 250000 };
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
char* headers = calloc(1, MCP_HEADER_LIMIT + 1);
if (headers == NULL) {
send_http_response(client, 400, "text/plain", "Out of memory");
return;
}
int received = 0;
int header_end = -1;
while (state->running && received < MCP_HEADER_LIMIT) {
int count = recv(client, headers + received, MCP_HEADER_LIMIT - received, 0);
if (count <= 0) {
free(headers);
return;
}
received += count;
header_end = find_header_end(headers, received);
if (header_end >= 0) {
break;
}
}
if (!state->running) {
free(headers);
return;
}
if (header_end < 0) {
send_http_response(client, 400, "text/plain", "Invalid HTTP headers");
free(headers);
return;
}
headers[header_end - 4] = '\0';
char method[12] = {0};
char path[128] = {0};
if (sscanf(headers, "%11s %127s", method, path) != 2) {
send_http_response(client, 400, "text/plain", "Invalid request line");
free(headers);
return;
}
if (strcmp(method, "POST") != 0) {
send_http_response(client, 405, "text/plain", "POST required");
free(headers);
return;
}
bool is_mcp = strcmp(path, "/api/mcp") == 0;
bool is_raw = strncmp(path, "/api/screen/raw", 15) == 0;
if (!is_mcp && !is_raw) {
send_http_response(client, 404, "text/plain", "Not found");
free(headers);
return;
}
int body_limit = is_raw ? MCP_RAW_BODY_LIMIT : MCP_JSON_BODY_LIMIT;
int content_length = parse_content_length(headers);
if (content_length <= 0 || content_length > body_limit) {
send_http_response(
client,
content_length > body_limit ? 413 : 400,
"text/plain",
"Invalid Content-Length"
);
free(headers);
return;
}
uint8_t* body = malloc((size_t)content_length + (is_mcp ? 1 : 0));
if (body == NULL) {
send_http_response(client, 400, "text/plain", "Out of memory");
free(headers);
return;
}
int body_received = received - header_end;
if (body_received > content_length) {
body_received = content_length;
}
memcpy(body, headers + header_end, body_received);
free(headers);
while (state->running && body_received < content_length) {
int count = recv(client, body + body_received, content_length - body_received, 0);
if (count <= 0) {
send_http_response(client, 400, "text/plain", "Incomplete request body");
free(body);
return;
}
body_received += count;
}
if (!state->running || body_received < content_length) {
free(body);
return;
}
if (is_raw) {
int x = query_integer(path, "x", 0);
int y = query_integer(path, "y", 0);
int width = query_integer(path, "w", state->draw_width);
int height = query_integer(path, "h", state->draw_height);
bool valid_size = width > 0 && height > 0 &&
(size_t)width * height * 2 == (size_t)content_length;
bool drawn = valid_size && mcp_ui_draw_rgb565_be(
state,
body,
content_length,
x,
y,
width,
height
);
send_http_response(
client,
drawn ? 200 : 400,
"text/plain",
drawn ? "OK" : "Invalid RGB565 payload"
);
} else {
body[content_length] = '\0';
cJSON* response = handle_rpc(state, (const char*)body);
char* response_text = cJSON_PrintUnformatted(response);
if (response_text != NULL && strlen(response_text) <= MCP_RESPONSE_LIMIT) {
send_http_response(client, 200, "application/json", response_text);
} else {
send_http_response(client, 400, "text/plain", "Response serialization failed");
}
cJSON_free(response_text);
cJSON_Delete(response);
}
free(body);
}
static void http_task(void* argument) {
McpScreenState* state = argument;
state->http_exited = false;
int server = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
if (server < 0) {
ESP_LOGE(TAG, "Failed to create HTTP socket: errno=%d", errno);
state->http_ready = false;
mcp_ui_set_server_status(state, "MCP socket error");
goto suspend_for_owner;
}
state->http_socket = server;
int reuse = 1;
setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
struct sockaddr_in address = {
.sin_family = AF_INET,
.sin_port = htons(MCP_HTTP_PORT),
.sin_addr.s_addr = htonl(INADDR_ANY)
};
if (bind(server, (struct sockaddr*)&address, sizeof(address)) != 0 ||
listen(server, 3) != 0) {
ESP_LOGE(TAG, "Failed to bind/listen on TCP %d: errno=%d", MCP_HTTP_PORT, errno);
state->http_ready = false;
mcp_ui_set_server_status(state, "MCP bind error");
close_socket(&state->http_socket);
goto suspend_for_owner;
}
int flags = fcntl(server, F_GETFL, 0);
if (flags < 0 || fcntl(server, F_SETFL, flags | O_NONBLOCK) < 0) {
ESP_LOGE(TAG, "Failed to make HTTP listener non-blocking: errno=%d", errno);
state->http_ready = false;
mcp_ui_set_server_status(state, "MCP socket config error");
close_socket(&state->http_socket);
goto suspend_for_owner;
}
ESP_LOGI(TAG, "HTTP MCP server listening on port %d", MCP_HTTP_PORT);
state->http_ready = true;
mcp_ui_set_server_status(state, "MCP :80 ready");
while (state->running) {
struct sockaddr_storage source;
socklen_t source_length = sizeof(source);
int client = accept(server, (struct sockaddr*)&source, &source_length);
if (client < 0) {
if (!state->running) {
break;
}
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
state->client_socket = client;
handle_http_client(state, client);
close_socket(&state->client_socket);
}
close_socket(&state->client_socket);
close_socket(&state->http_socket);
state->http_ready = false;
suspend_for_owner:
/*
* External-app code must not continue after Tactility unloads the ELF.
* Signal completion, then suspend in FreeRTOS code. onDestroy owns the
* final vTaskDelete() and will not return until the worker is gone.
*/
state->http_exited = true;
vTaskSuspend(NULL);
vTaskDelete(NULL);
}
bool mcp_services_start(McpScreenState* state) {
if (state == NULL || state->running) {
ESP_LOGE(TAG, "Service start rejected state=%p running=%d", state, state != NULL && state->running);
return false;
}
/*
* onHide only requests shutdown because it runs in Tactility's GUI
* lifecycle. Reap the worker after it has reached its safe suspended
* state before starting a new foreground server.
*/
if (state->http_task != NULL) {
if (!state->http_exited) {
ESP_LOGE(TAG, "Previous HTTP worker is still stopping");
return false;
}
vTaskDelete(state->http_task);
state->http_task = NULL;
}
state->running = true;
state->http_exited = false;
state->http_socket = -1;
state->client_socket = -1;
BaseType_t http_result = xTaskCreate(
http_task,
"mcp_http",
8192,
state,
4,
&state->http_task
);
/*
* UDP discovery requires lwip_recvfrom/lwip_sendto. Tactility 0.7.0-dev
* currently does not export those symbols to external ELF apps. The
* existing host bridge remains compatible through its HTTP subnet-scan
* fallback. Re-enable discovery when those symbols are exported.
*/
state->discovery_ready = false;
state->discovery_task = NULL;
if (http_result != pdPASS) {
ESP_LOGE(TAG, "Failed to create MCP service tasks");
mcp_services_stop(state);
return false;
}
return true;
}
void mcp_services_hide(McpScreenState* state) {
if (state == NULL) {
return;
}
state->running = false;
state->http_ready = false;
state->discovery_ready = false;
}
void mcp_services_stop(McpScreenState* state) {
if (state == NULL) {
return;
}
mcp_services_hide(state);
for (int attempt = 0; attempt < 80; ++attempt) {
if (state->http_task == NULL || state->http_exited) {
break;
}
vTaskDelay(pdMS_TO_TICKS(10));
}
if (state->http_task != NULL) {
/*
* Normal path: the non-blocking worker has closed its sockets and
* suspended itself. The bounded fallback guarantees that no
* external-ELF instruction survives onDestroy.
*/
vTaskDelete(state->http_task);
state->http_task = NULL;
}
close_socket(&state->http_socket);
close_socket(&state->client_socket);
close_socket(&state->discovery_socket);
state->http_exited = true;
}
+10
View File
@@ -0,0 +1,10 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.mcpscreen
versionName=0.1.0
versionCode=1
name=MCP Screen
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Host smoke test for the Tactility MCP Screen Phase 2 API."""
import argparse
import base64
import json
import socket
import struct
import urllib.request
def discover(timeout: float = 2.0) -> str | None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.settimeout(timeout)
try:
sock.sendto(b"DISCOVER_SCREEN", ("255.255.255.255", 5000))
data, address = sock.recvfrom(128)
if data == b"SCREEN_IP_80":
return address[0]
except TimeoutError:
return None
finally:
sock.close()
return None
def rpc(ip: str, request_id: int, method: str, params: dict | None = None) -> dict:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
}
if params is not None:
payload["params"] = params
request = urllib.request.Request(
f"http://{ip}/api/mcp",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
return json.loads(response.read())
def require_result(response: dict, label: str) -> None:
if "error" in response:
raise RuntimeError(f"{label} failed: {response['error']}")
if "result" not in response:
raise RuntimeError(f"{label} returned no result: {response}")
def make_bmp() -> bytes:
width = 4
height = 4
row_size = width * 3
pixels = bytearray()
colors = [
(0, 0, 255),
(0, 255, 0),
(255, 0, 0),
(255, 255, 255),
]
for y in range(height):
for x in range(width):
blue, green, red = colors[(x + y) % len(colors)]
pixels.extend((blue, green, red))
pixels.extend(b"\0" * ((4 - row_size % 4) % 4))
offset = 54
file_size = offset + len(pixels)
header = (
b"BM"
+ struct.pack("<IHHI", file_size, 0, 0, offset)
+ struct.pack("<IIIHHIIIIII", 40, width, height, 1, 24, 0, len(pixels), 0, 0, 0, 0)
)
return header + pixels
def raw_endpoint(ip: str) -> None:
pixels = bytes(
[
0xF8, 0x00,
0x07, 0xE0,
0x00, 0x1F,
0xFF, 0xFF,
]
)
request = urllib.request.Request(
f"http://{ip}/api/screen/raw?x=2&y=2&w=2&h=2",
data=pixels,
headers={"Content-Type": "application/octet-stream"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
if response.read() != b"OK":
raise RuntimeError("Raw endpoint returned an unexpected response")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--ip", help="Device IP; omit to use UDP discovery")
parser.add_argument(
"--draw",
action="store_true",
help="Also clear the app canvas and draw visible test text",
)
args = parser.parse_args()
ip = args.ip or discover()
if not ip:
raise SystemExit("MCP Screen was not discovered. Pass --ip DEVICE_IP to test directly.")
print(f"Testing MCP Screen at {ip}")
tools = rpc(ip, 1, "tools/list")
require_result(tools, "tools/list")
names = [tool["name"] for tool in tools["result"]["tools"]]
expected = {"get_capabilities", "clear_screen", "draw_text"}
if not expected.issubset(names):
raise RuntimeError(f"Missing tools: {sorted(expected - set(names))}")
print(f"tools/list: {', '.join(names)}")
capabilities = rpc(
ip,
2,
"tools/call",
{"name": "get_capabilities", "arguments": {}},
)
require_result(capabilities, "get_capabilities")
print("get_capabilities:", capabilities["result"]["content"][0]["text"])
if args.draw:
cleared = rpc(
ip,
3,
"tools/call",
{"name": "clear_screen", "arguments": {"color": 1}},
)
require_result(cleared, "clear_screen")
drawn = rpc(
ip,
4,
"tools/call",
{
"name": "draw_text",
"arguments": {
"text": "Tactility MCP is alive",
"x": 20,
"y": 30,
"size": 2,
},
},
)
require_result(drawn, "draw_text")
raw_rpc = rpc(
ip,
5,
"tools/call",
{
"name": "draw_raw_rgb565",
"arguments": {
"rgb565_base64": base64.b64encode(
bytes([0xFF, 0xE0, 0xF8, 0x1F, 0x07, 0xFF, 0x00, 0x00])
).decode(),
"x": 20,
"y": 70,
"w": 4,
"h": 1,
},
},
)
require_result(raw_rpc, "draw_raw_rgb565")
bmp = rpc(
ip,
6,
"tools/call",
{
"name": "draw_color_bmp",
"arguments": {
"bmp_base64": base64.b64encode(make_bmp()).decode(),
"x": 30,
"y": 90,
},
},
)
require_result(bmp, "draw_color_bmp")
pbm_bytes = b"P4\n8 2\n" + bytes([0b10101010, 0b01010101])
pbm = rpc(
ip,
7,
"tools/call",
{
"name": "draw_image",
"arguments": {
"pbm_base64": base64.b64encode(pbm_bytes).decode(),
"x": 50,
"y": 110,
},
},
)
require_result(pbm, "draw_image")
raw_endpoint(ip)
screenshot = rpc(
ip,
8,
"tools/call",
{"name": "get_screenshot", "arguments": {}},
)
require_result(screenshot, "get_screenshot")
screenshot_text = screenshot["result"]["content"][0]["text"]
if not screenshot_text.startswith("__PBM_BASE64__:"):
raise RuntimeError("Screenshot did not return PBM data")
base64.b64decode(screenshot_text.split(":", 1)[1], validate=True)
print("Visible Phase 2 draw and screenshot tests sent.")
print("Phase 2 smoke test passed.")
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
## 4. Workflow: Compiling & Installing Apps
### Step 1: Source Environment & Path
Ensure ESP-IDF environment is sourced and export local SDK path:
```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/.gemini/antigravity/scratch/tactility/release/TactilitySDK
```
### Step 2: Compile the App
Run the Tactility build script, specifying the target platform:
```bash
# Compile and package HelloWorld
python3 tactility.py Apps/HelloWorld build esp32s3 --local-sdk