feat: add Tactility MCP Screen foundation
This commit is contained in:
@@ -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)
|
||||||
@@ -27,6 +27,47 @@ The original reference documents and implementations are:
|
|||||||
- Initial Tactility SDK: `0.7.0-dev`
|
- Initial Tactility SDK: `0.7.0-dev`
|
||||||
- Language: C/C++ with ESP-IDF, FreeRTOS, Tactility SDK, and LVGL
|
- 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, client reads have
|
||||||
|
short timeouts, and `onDestroy` closes sockets, waits for the worker to signal
|
||||||
|
exit, then deletes the suspended worker task before returning.
|
||||||
|
|
||||||
## Design principles
|
## Design principles
|
||||||
|
|
||||||
1. Preserve the existing MCP tool names and JSON-RPC contract wherever the
|
1. Preserve the existing MCP tool names and JSON-RPC contract wherever the
|
||||||
@@ -382,4 +423,3 @@ Implement Phase 1 as a minimal vertical slice:
|
|||||||
6. Add `get_capabilities`, `clear_screen`, and `draw_text`.
|
6. Add `get_capabilities`, `clear_screen`, and `draw_text`.
|
||||||
7. Build.
|
7. Build.
|
||||||
8. Run a host smoke test through the existing bridge.
|
8. Run a host smoke test through the existing bridge.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# MCP Screen
|
||||||
|
|
||||||
|
Native Tactility port of the MicroPython MCP Screen firmware.
|
||||||
|
|
||||||
|
The current Phase 1 slice provides:
|
||||||
|
|
||||||
|
- JSON-RPC 2.0 over `POST /api/mcp` on port 80
|
||||||
|
- `tools/list`
|
||||||
|
- `get_capabilities`
|
||||||
|
- `clear_screen`
|
||||||
|
- `draw_text`
|
||||||
|
|
||||||
|
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 is explicitly stopped and
|
||||||
|
deleted during `onDestroy`. This is required because Tactility unloads the
|
||||||
|
external ELF after the app closes.
|
||||||
|
|
||||||
|
## 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 1 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,241 @@
|
|||||||
|
#include "McpScreen.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <esp_log.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) {
|
||||||
|
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);
|
||||||
|
lv_obj_set_style_bg_color(
|
||||||
|
state->draw_area,
|
||||||
|
color == 1 ? lv_color_black() : lv_color_white(),
|
||||||
|
LV_PART_MAIN
|
||||||
|
);
|
||||||
|
lv_obj_set_style_bg_opa(state->draw_area, LV_OPA_COVER, LV_PART_MAIN);
|
||||||
|
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) {
|
||||||
|
free(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_create(AppHandle app, void* data) {
|
||||||
|
McpScreenState* state = data;
|
||||||
|
state->app = app;
|
||||||
|
if (!mcp_services_start(state)) {
|
||||||
|
ESP_LOGE(TAG, "One or more MCP services failed to start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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, state->http_ready ? "MCP :80 ready" : "MCP starting");
|
||||||
|
lv_obj_set_style_text_color(
|
||||||
|
state->server_label,
|
||||||
|
state->http_ready ? lv_palette_main(LV_PALETTE_GREEN) : 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");
|
||||||
|
|
||||||
|
state->draw_area = lv_obj_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);
|
||||||
|
|
||||||
|
if (!state->override_active) {
|
||||||
|
lv_obj_set_style_bg_color(state->draw_area, lv_color_hex(0x101820), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(state->draw_area, LV_OPA_COVER, LV_PART_MAIN);
|
||||||
|
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;
|
||||||
|
state->visible = false;
|
||||||
|
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,49 @@
|
|||||||
|
#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 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_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
|
||||||
|
);
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
#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_BODY_LIMIT 8192
|
||||||
|
#define MCP_RESPONSE_LIMIT 8192
|
||||||
|
|
||||||
|
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\"]"
|
||||||
|
"}"
|
||||||
|
"}"
|
||||||
|
"]";
|
||||||
|
|
||||||
|
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];
|
||||||
|
snprintf(
|
||||||
|
capabilities,
|
||||||
|
sizeof(capabilities),
|
||||||
|
"{\"color\":true,\"width\":%u,\"height\":%u,"
|
||||||
|
"\"formats\":[\"rgb565_base64\",\"bmp_base64\"],"
|
||||||
|
"\"platform\":\"tactility\",\"appVersion\":\"0.1.0\","
|
||||||
|
"\"uiVisible\":%s}",
|
||||||
|
state->display_width,
|
||||||
|
state->display_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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 void handle_http_client(McpScreenState* state, int client) {
|
||||||
|
struct timeval timeout = { .tv_sec = 0, .tv_usec = 500000 };
|
||||||
|
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||||
|
setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||||
|
|
||||||
|
char* request = calloc(1, MCP_HEADER_LIMIT + MCP_BODY_LIMIT + 1);
|
||||||
|
if (request == NULL) {
|
||||||
|
send_http_response(client, 400, "text/plain", "Out of memory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int received = 0;
|
||||||
|
int header_end = -1;
|
||||||
|
while (received < MCP_HEADER_LIMIT) {
|
||||||
|
int count = recv(client, request + received, MCP_HEADER_LIMIT - received, 0);
|
||||||
|
if (count <= 0) {
|
||||||
|
free(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
received += count;
|
||||||
|
header_end = find_header_end(request, received);
|
||||||
|
if (header_end >= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header_end < 0) {
|
||||||
|
send_http_response(client, 400, "text/plain", "Invalid HTTP headers");
|
||||||
|
free(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request[header_end - 4] = '\0';
|
||||||
|
char method[12] = {0};
|
||||||
|
char path[128] = {0};
|
||||||
|
if (sscanf(request, "%11s %127s", method, path) != 2) {
|
||||||
|
send_http_response(client, 400, "text/plain", "Invalid request line");
|
||||||
|
free(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(method, "POST") != 0) {
|
||||||
|
send_http_response(client, 405, "text/plain", "POST required");
|
||||||
|
free(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (strcmp(path, "/api/mcp") != 0) {
|
||||||
|
send_http_response(client, 404, "text/plain", "Not found");
|
||||||
|
free(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int content_length = parse_content_length(request);
|
||||||
|
if (content_length <= 0 || content_length > MCP_BODY_LIMIT) {
|
||||||
|
send_http_response(
|
||||||
|
client,
|
||||||
|
content_length > MCP_BODY_LIMIT ? 413 : 400,
|
||||||
|
"text/plain",
|
||||||
|
"Invalid Content-Length"
|
||||||
|
);
|
||||||
|
free(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int body_received = received - header_end;
|
||||||
|
memmove(request, request + header_end, body_received);
|
||||||
|
while (body_received < content_length) {
|
||||||
|
int count = recv(client, request + body_received, content_length - body_received, 0);
|
||||||
|
if (count <= 0) {
|
||||||
|
send_http_response(client, 400, "text/plain", "Incomplete request body");
|
||||||
|
free(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body_received += count;
|
||||||
|
}
|
||||||
|
request[content_length] = '\0';
|
||||||
|
|
||||||
|
cJSON* response = handle_rpc(state, request);
|
||||||
|
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(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state->running = true;
|
||||||
|
state->http_exited = false;
|
||||||
|
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_stop(McpScreenState* state) {
|
||||||
|
if (state == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state->running = false;
|
||||||
|
state->http_ready = false;
|
||||||
|
state->discovery_ready = false;
|
||||||
|
close_socket(&state->http_socket);
|
||||||
|
close_socket(&state->client_socket);
|
||||||
|
close_socket(&state->discovery_socket);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < 80; ++attempt) {
|
||||||
|
if (state->http_exited) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state->http_task != NULL) {
|
||||||
|
/*
|
||||||
|
* Normal path: worker is suspended after closing its sockets.
|
||||||
|
* Fallback path: force-delete after 800 ms so loader_dispatch can
|
||||||
|
* never be held until the task watchdog fires.
|
||||||
|
*/
|
||||||
|
vTaskDelete(state->http_task);
|
||||||
|
state->http_task = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host smoke test for the Tactility MCP Screen Phase 1 API."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
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 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")
|
||||||
|
print("Visible draw test sent.")
|
||||||
|
|
||||||
|
print("Phase 1 smoke test passed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user