Files
tactility/Tactility/Source/service/webserver/McpHandler.cpp
T
Adolfo Reyna e3eb3fd415 feat(mcp): restore MCP system with native log.h
- MCP 3.3K LOC: McpSystem 1899 + McpHandler 1029 + McpScreensaver + 2 apps
- Uses native tactility/log.h TAG macros, no old Logger.h
- DisplaySettings McpScreen enum, WebServer coexistence (MCP enabled starts HTTP)
- DisplayIdle: startMcpScreensaver + isDeviceCharging + lock inversion fix 3629ffef
- LVGL 512K PSRAM cache retained from previous perf patch
- Audio kept upstream es8311-module per note we might not need custom
2026-07-18 17:29:37 -04:00

1031 lines
38 KiB
C++

#ifdef ESP_PLATFORM
#include <Tactility/service/webserver/WebServerService.h>
#include <Tactility/mcp/McpSystem.h>
#include <Tactility/settings/McpSettings.h>
#include <Tactility/lvgl/LvglSync.h>
#include <tactility/log.h>
constexpr auto* TAG = "McpHandler";
#include <cJSON.h>
#include <esp_log.h>
#include <esp_http_server.h>
#include <esp_heap_caps.h>
#include <mbedtls/base64.h>
#include <string>
#include <vector>
#include <memory>
#include <sstream>
namespace tt::service::webserver {
static void* psram_malloc(size_t size) {
void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (ptr != nullptr) {
return ptr;
}
return malloc(size);
}
#define MCP_JSON_BODY_LIMIT (256 * 1024)
#define MCP_RAW_BODY_LIMIT (512 * 1024)
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\":{}}"
"},"
"{"
"\"name\":\"play_tone\","
"\"description\":\"Play a pure sine wave tone on the board speaker.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{"
"\"frequency\":{\"type\":\"integer\",\"default\":440},"
"\"duration_ms\":{\"type\":\"integer\",\"default\":1000},"
"\"volume\":{\"type\":\"integer\",\"default\":50}"
"}}"
"},"
"{"
"\"name\":\"play_audio\","
"\"description\":\"Play a 16 kHz 16-bit PCM WAV file from app user data.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{"
"\"filename\":{\"type\":\"string\"},"
"\"volume\":{\"type\":\"integer\",\"default\":50}"
"},\"required\":[\"filename\"]}"
"},"
"{"
"\"name\":\"record_voice\","
"\"description\":\"Record 16 kHz 16-bit mono PCM to app user data.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{"
"\"duration_sec\":{\"type\":\"integer\",\"default\":4},"
"\"filename\":{\"type\":\"string\",\"default\":\"recording.pcm\"}"
"}}"
"},"
"{"
"\"name\":\"play_audio_base64\","
"\"description\":\"Decode and play a base64-encoded 16 kHz 16-bit PCM WAV.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{"
"\"wav_base64\":{\"type\":\"string\"},"
"\"volume\":{\"type\":\"integer\",\"default\":50}"
"},\"required\":[\"wav_base64\"]}"
"},"
"{"
"\"name\":\"play_mp3\","
"\"description\":\"Stream an MP3 file from app user data to the speaker.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{"
"\"filename\":{\"type\":\"string\"},"
"\"volume\":{\"type\":\"integer\",\"default\":50}"
"},\"required\":[\"filename\"]}"
"},"
"{"
"\"name\":\"play_mp3_base64\","
"\"description\":\"Decode and play a base64-encoded MP3.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{"
"\"mp3_base64\":{\"type\":\"string\"},"
"\"volume\":{\"type\":\"integer\",\"default\":50}"
"},\"required\":[\"mp3_base64\"]}"
"},"
"{"
"\"name\":\"set_led\","
"\"description\":\"Control the onboard WS2812 NeoPixel RGB LED.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"r\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":255,\"description\":\"Red channel (0-255)\"},"
"\"g\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":255,\"description\":\"Green channel (0-255)\"},"
"\"b\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":255,\"description\":\"Blue channel (0-255)\"},"
"\"mode\":{\"type\":\"string\",\"enum\":[\"static\",\"breath\",\"rainbow\",\"off\"],\"description\":\"LED mode\"}"
"},"
"\"required\":[\"r\",\"g\",\"b\",\"mode\"]"
"}"
"},"
"{"
"\"name\":\"get_battery\","
"\"description\":\"Read the current battery voltage and estimated capacity percentage.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}"
"},"
"{"
"\"name\":\"get_sensors\","
"\"description\":\"Read onboard sensors (IMU, temperature, humidity).\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}"
"},"
"{"
"\"name\":\"scan_ble\","
"\"description\":\"Scan for nearby Bluetooth Low Energy devices.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"duration_ms\":{\"type\":\"integer\",\"default\":3000,\"description\":\"Scan duration in milliseconds\"}"
"}"
"}"
"},"
"{"
"\"name\":\"write_file\","
"\"description\":\"Write a file to the microSD card.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"path\":{\"type\":\"string\",\"description\":\"Destination path relative to /sdcard/\"},"
"\"content\":{\"type\":\"string\",\"description\":\"Text content of the file\"}"
"},"
"\"required\":[\"path\",\"content\"]"
"}"
"},"
"{"
"\"name\":\"read_file\","
"\"description\":\"Read a text file from the microSD card.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"path\":{\"type\":\"string\",\"description\":\"File path relative to /sdcard/\"}"
"},"
"\"required\":[\"path\"]"
"}"
"},"
"{"
"\"name\":\"download_file\","
"\"description\":\"Download a file from a URL directly to the microSD card.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"url\":{\"type\":\"string\",\"description\":\"The URL of the file to download\"},"
"\"filename\":{\"type\":\"string\",\"description\":\"The destination filename relative to /sdcard/\"}"
"},"
"\"required\":[\"url\",\"filename\"]"
"}"
"},"
"{"
"\"name\":\"get_video_streaming_instructions\","
"\"description\":\"Get details for monochrome TCP streaming and RGB565 color TCP streaming.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"protocol\":{\"type\":\"string\",\"enum\":[\"tcp\",\"color\",\"both\",\"all\"],\"default\":\"all\"}"
"}"
"}"
"},"
"{"
"\"name\":\"get_stream_stats\","
"\"description\":\"Get performance statistics for the active video streams.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}"
"},"
"{"
"\"name\":\"list_apps\","
"\"description\":\"List all applications installed on the system, including metadata and locations.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}"
"},"
"{"
"\"name\":\"run_app\","
"\"description\":\"Launch an application on the board by its App ID.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"app_id\":{\"type\":\"string\",\"description\":\"The unique ID of the application (e.g., 'one.tactility.helloworld')\"}"
"},"
"\"required\":[\"app_id\"]"
"}"
"},"
"{"
"\"name\":\"list_files\","
"\"description\":\"List files and folders in a directory on the SD card, with details like sizes.\","
"\"inputSchema\":{"
"\"type\":\"object\","
"\"properties\":{"
"\"path\":{\"type\":\"string\",\"description\":\"Path relative to /sdcard/ to list (default is '/')\"}"
"}"
"}"
"}"
"]";
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);
size_t approx_output_len = (input_size * 3) / 4;
uint8_t* output = (uint8_t*)malloc(approx_output_len + 4);
if (output == NULL) {
return NULL;
}
size_t actual_len = 0;
int ret = mbedtls_base64_decode(output, approx_output_len + 4, &actual_len,
(const unsigned char*)payload, input_size);
if (ret != 0) {
free(output);
return NULL;
}
*output_size = actual_len;
return output;
}
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_AddArrayToObject(result, "content");
cJSON* item = cJSON_CreateObject();
cJSON_AddStringToObject(item, "type", "text");
cJSON_AddStringToObject(item, "text", text);
cJSON_AddItemToArray(cJSON_GetObjectItem(result, "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(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;
auto& state = mcp::getState();
if (strcmp(name, "get_capabilities") == 0) {
char capabilities[384];
uint16_t width = state.drawWidth > 0 ? state.drawWidth : state.displayWidth;
uint16_t height = state.drawHeight > 0 ? state.drawHeight : state.displayHeight;
snprintf(
capabilities,
sizeof(capabilities),
"{\"color\":true,\"width\":%u,\"height\":%u,"
"\"formats\":[\"rgb565_base64\",\"bmp_base64\",\"pbm_base64\"],"
"\"audio\":{\"sampleRate\":16000,\"bitsPerSample\":16,\"channels\":1},"
"\"platform\":\"tactility\",\"appVersion\":\"0.1.0\","
"\"uiVisible\":%s}",
width,
height,
(state.drawArea != nullptr) ? "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::clearScreen(color_item->valueint)) {
return make_error(id, -32010, "Failed to clear screen");
}
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::drawText(text_item->valuestring, x_item->valueint, y_item->valueint, size)) {
return make_error(id, -32010, "Failed to draw text");
}
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::drawRgb565(
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::drawBmp(
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::drawPbm(
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) {
std::string encoded = mcp::getScreenshotPbmBase64();
if (encoded.empty()) {
return make_error(id, -32010, "Screenshot capture failed");
}
std::string result = "__PBM_BASE64__:" + encoded;
return make_tool_result(id, result.c_str());
}
if (strcmp(name, "play_tone") == 0) {
cJSON* frequency_item = cJSON_GetObjectItemCaseSensitive(arguments, "frequency");
cJSON* duration_item = cJSON_GetObjectItemCaseSensitive(arguments, "duration_ms");
cJSON* volume_item = cJSON_GetObjectItemCaseSensitive(arguments, "volume");
int frequency = cJSON_IsNumber(frequency_item) ? frequency_item->valueint : 440;
int duration = cJSON_IsNumber(duration_item) ? duration_item->valueint : 1000;
int volume = cJSON_IsNumber(volume_item) ? volume_item->valueint : 50;
if (frequency < 50 || frequency > 10000 ||
duration < 50 || duration > 5000 ||
volume < 0 || volume > 100) {
return make_error(id, -32602, "frequency, duration_ms, or volume is out of range");
}
std::string error;
if (!mcp::playTone(frequency, duration, volume, error)) {
return make_error(id, -32020, error.c_str());
}
char message[128];
snprintf(
message,
sizeof(message),
"Played tone of %dHz for %dms at volume %d.",
frequency,
duration,
volume
);
return make_tool_result(id, message);
}
if (strcmp(name, "record_voice") == 0) {
cJSON* duration_item = cJSON_GetObjectItemCaseSensitive(arguments, "duration_sec");
cJSON* filename_item = cJSON_GetObjectItemCaseSensitive(arguments, "filename");
int duration = cJSON_IsNumber(duration_item) ? duration_item->valueint : 4;
const char* filename = cJSON_IsString(filename_item)
? filename_item->valuestring
: "recording.pcm";
if (duration < 1 || duration > 15) {
return make_error(id, -32602, "duration_sec must be between 1 and 15");
}
std::string error;
size_t recorded_bytes = 0;
if (!mcp::recordVoice(duration, filename, recorded_bytes, error)) {
return make_error(id, -32020, error.c_str());
}
char message[180];
snprintf(
message,
sizeof(message),
"Successfully recorded %d seconds of audio to '%s' (%u bytes).",
duration,
filename,
(unsigned)recorded_bytes
);
return make_tool_result(id, message);
}
if (strcmp(name, "play_audio") == 0) {
cJSON* filename_item = cJSON_GetObjectItemCaseSensitive(arguments, "filename");
cJSON* volume_item = cJSON_GetObjectItemCaseSensitive(arguments, "volume");
int volume = cJSON_IsNumber(volume_item) ? volume_item->valueint : 50;
if (!cJSON_IsString(filename_item) || filename_item->valuestring == NULL) {
return make_error(id, -32602, "play_audio requires filename");
}
if (volume < 0 || volume > 100) {
return make_error(id, -32602, "volume must be between 0 and 100");
}
std::string error;
if (!mcp::playAudioFile(filename_item->valuestring, volume, error)) {
return make_error(id, -32020, error.c_str());
}
char message[160];
snprintf(
message,
sizeof(message),
"Successfully played audio file '%s'.",
filename_item->valuestring
);
return make_tool_result(id, message);
}
if (strcmp(name, "play_audio_base64") == 0) {
cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "wav_base64");
cJSON* volume_item = cJSON_GetObjectItemCaseSensitive(arguments, "volume");
int volume = cJSON_IsNumber(volume_item) ? volume_item->valueint : 50;
if (!cJSON_IsString(encoded_item) || encoded_item->valuestring == NULL) {
return make_error(id, -32602, "play_audio_base64 requires wav_base64");
}
if (volume < 0 || volume > 100) {
return make_error(id, -32602, "volume must be between 0 and 100");
}
size_t decoded_size = 0;
uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size);
if (decoded == NULL) {
return make_error(id, -32602, "Invalid WAV base64 data");
}
std::string error;
bool played = mcp::playWavMemory(
decoded,
decoded_size,
volume,
error
);
free(decoded);
return played
? make_tool_result(id, "Successfully played base64 audio.")
: make_error(id, -32020, error.c_str());
}
if (strcmp(name, "play_mp3") == 0) {
cJSON* filename_item = cJSON_GetObjectItemCaseSensitive(arguments, "filename");
cJSON* volume_item = cJSON_GetObjectItemCaseSensitive(arguments, "volume");
int volume = cJSON_IsNumber(volume_item) ? volume_item->valueint : 50;
if (!cJSON_IsString(filename_item) || filename_item->valuestring == NULL) {
return make_error(id, -32602, "play_mp3 requires filename");
}
if (volume < 0 || volume > 100) {
return make_error(id, -32602, "volume must be between 0 and 100");
}
std::string error;
if (!mcp::playMp3File(filename_item->valuestring, volume, error)) {
return make_error(id, -32020, error.c_str());
}
char message[160];
snprintf(
message,
sizeof(message),
"Successfully played MP3 file '%s'.",
filename_item->valuestring
);
return make_tool_result(id, message);
}
if (strcmp(name, "play_mp3_base64") == 0) {
cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "mp3_base64");
cJSON* volume_item = cJSON_GetObjectItemCaseSensitive(arguments, "volume");
int volume = cJSON_IsNumber(volume_item) ? volume_item->valueint : 50;
if (!cJSON_IsString(encoded_item) || encoded_item->valuestring == NULL) {
return make_error(id, -32602, "play_mp3_base64 requires mp3_base64");
}
if (volume < 0 || volume > 100) {
return make_error(id, -32602, "volume must be between 0 and 100");
}
size_t decoded_size = 0;
uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size);
if (decoded == NULL) {
return make_error(id, -32602, "Invalid MP3 base64 data");
}
std::string error;
bool played = mcp::playMp3Memory(
decoded,
decoded_size,
volume,
error
);
free(decoded);
return played
? make_tool_result(id, "Successfully played base64 MP3 audio.")
: make_error(id, -32020, error.c_str());
}
if (strcmp(name, "set_led") == 0) {
cJSON* r_item = cJSON_GetObjectItemCaseSensitive(arguments, "r");
cJSON* g_item = cJSON_GetObjectItemCaseSensitive(arguments, "g");
cJSON* b_item = cJSON_GetObjectItemCaseSensitive(arguments, "b");
cJSON* mode_item = cJSON_GetObjectItemCaseSensitive(arguments, "mode");
if (!cJSON_IsNumber(r_item) || !cJSON_IsNumber(g_item) ||
!cJSON_IsNumber(b_item) || !cJSON_IsString(mode_item)) {
return make_error(id, -32602, "set_led requires r, g, b, and mode");
}
std::string error;
if (!mcp::setLedColor(r_item->valueint, g_item->valueint, b_item->valueint, mode_item->valuestring, error)) {
return make_error(id, -32020, error.c_str());
}
char msg[128];
snprintf(msg, sizeof(msg), "LED set to mode '%s' with base color (%d, %d, %d).",
mode_item->valuestring, r_item->valueint, g_item->valueint, b_item->valueint);
return make_tool_result(id, msg);
}
if (strcmp(name, "get_battery") == 0) {
double voltage_v = 0.0;
int percentage_pct = 0;
std::string error;
if (!mcp::getBatteryStatus(voltage_v, percentage_pct, error)) {
return make_error(id, -32020, error.c_str());
}
char result_buf[128];
snprintf(result_buf, sizeof(result_buf), "{\"voltage_v\":%.3f,\"percentage_pct\":%d}", voltage_v, percentage_pct);
return make_tool_result(id, result_buf);
}
if (strcmp(name, "get_sensors") == 0) {
double temp_c = 0.0;
double hum_pct = 0.0;
std::string imu_json;
std::string error;
if (!mcp::getSensors(temp_c, hum_pct, imu_json, error)) {
return make_error(id, -32020, error.c_str());
}
char result_buf[512];
snprintf(result_buf, sizeof(result_buf), "{\"temperature_c\":%.2f,\"humidity_pct\":%.2f,\"imu\":%s}",
temp_c, hum_pct, imu_json.c_str());
return make_tool_result(id, result_buf);
}
if (strcmp(name, "scan_ble") == 0) {
cJSON* duration_item = cJSON_GetObjectItemCaseSensitive(arguments, "duration_ms");
int duration_ms = cJSON_IsNumber(duration_item) ? duration_item->valueint : 3000;
if (duration_ms < 500 || duration_ms > 15000) {
return make_error(id, -32602, "duration_ms must be between 500 and 15000");
}
std::string devices_json;
std::string error;
if (!mcp::scanBleDevices(duration_ms, devices_json, error)) {
return make_error(id, -32020, error.c_str());
}
return make_tool_result(id, devices_json.c_str());
}
if (strcmp(name, "write_file") == 0) {
cJSON* path_item = cJSON_GetObjectItemCaseSensitive(arguments, "path");
cJSON* content_item = cJSON_GetObjectItemCaseSensitive(arguments, "content");
if (!cJSON_IsString(path_item) || !cJSON_IsString(content_item)) {
return make_error(id, -32602, "write_file requires path and content");
}
std::string error;
if (!mcp::writeSdFile(path_item->valuestring, content_item->valuestring, error)) {
return make_error(id, -32020, error.c_str());
}
return make_tool_result(id, "Successfully wrote file to SD card.");
}
if (strcmp(name, "read_file") == 0) {
cJSON* path_item = cJSON_GetObjectItemCaseSensitive(arguments, "path");
if (!cJSON_IsString(path_item)) {
return make_error(id, -32602, "read_file requires path");
}
std::string content;
std::string error;
if (!mcp::readSdFile(path_item->valuestring, content, error)) {
return make_error(id, -32020, error.c_str());
}
return make_tool_result(id, content.c_str());
}
if (strcmp(name, "download_file") == 0) {
cJSON* url_item = cJSON_GetObjectItemCaseSensitive(arguments, "url");
cJSON* filename_item = cJSON_GetObjectItemCaseSensitive(arguments, "filename");
if (!cJSON_IsString(url_item) || !cJSON_IsString(filename_item)) {
return make_error(id, -32602, "download_file requires url and filename");
}
std::string error;
if (!mcp::downloadSdFile(url_item->valuestring, filename_item->valuestring, error)) {
return make_error(id, -32020, error.c_str());
}
return make_tool_result(id, "Successfully downloaded file to SD card.");
}
if (strcmp(name, "get_video_streaming_instructions") == 0) {
cJSON* protocol_item = cJSON_GetObjectItemCaseSensitive(arguments, "protocol");
const char* protocol = cJSON_IsString(protocol_item) ? protocol_item->valuestring : "all";
char buf[512];
snprintf(buf, sizeof(buf),
"Tactility OS TCP Video Streaming Instructions:\n"
"1. Mono TCP Stream (compatibility mode): Stream raw 15,000-byte PBM/RLCD frames to TCP port 8081.\n"
"2. Color TCP Stream (native): Stream packets to TCP port 8083.\n"
" Packet format:\n"
" - Header (16 bytes): Magic 'RAW\\x01' (4 bytes), X-coord (2 bytes BE), Y-coord (2 bytes BE), Width (2 bytes BE), Height (2 bytes BE), Payload length (4 bytes BE).\n"
" - Payload (variable): Raw big-endian RGB565 pixel data of length 'Payload length'.\n"
"Active protocol selection: %s", protocol);
return make_tool_result(id, buf);
}
if (strcmp(name, "get_stream_stats") == 0) {
std::string stats_json;
if (!mcp::getVideoStreamStats(stats_json)) {
return make_error(id, -32020, "Failed to get stream stats");
}
return make_tool_result(id, stats_json.c_str());
}
if (strcmp(name, "list_apps") == 0) {
std::string apps_json;
std::string error;
if (!mcp::listApps(apps_json, error)) {
return make_error(id, -32020, error.c_str());
}
return make_tool_result(id, apps_json.c_str());
}
if (strcmp(name, "run_app") == 0) {
cJSON* app_id_item = cJSON_GetObjectItemCaseSensitive(arguments, "app_id");
if (!cJSON_IsString(app_id_item)) {
return make_error(id, -32602, "run_app requires app_id");
}
std::string error;
if (!mcp::runApp(app_id_item->valuestring, error)) {
return make_error(id, -32020, error.c_str());
}
char msg[128];
snprintf(msg, sizeof(msg), "Successfully started application '%s'.", app_id_item->valuestring);
return make_tool_result(id, msg);
}
if (strcmp(name, "list_files") == 0) {
cJSON* path_item = cJSON_GetObjectItemCaseSensitive(arguments, "path");
const char* path = cJSON_IsString(path_item) ? path_item->valuestring : "/";
std::string files_json;
std::string error;
if (!mcp::listSdFiles(path, files_json, error)) {
return make_error(id, -32020, error.c_str());
}
return make_tool_result(id, files_json.c_str());
}
return make_error(id, -32602, "Unknown tool");
}
static cJSON* handle_rpc(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(id, params);
} else {
response = make_error(id, -32601, "Method not found");
}
cJSON_Delete(request);
return response;
}
// POST /api/mcp
esp_err_t WebServerService::handleApiMcp(httpd_req_t* request) {
LOG_I(TAG, "POST /api/mcp");
// Load MCP Settings and check if enabled
auto mcpSettings = settings::mcp::loadOrGetDefault();
if (!mcpSettings.mcpEnabled) {
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "MCP server is disabled");
return ESP_FAIL;
}
int content_len = request->content_len;
if (content_len <= 0 || content_len > MCP_JSON_BODY_LIMIT) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Invalid Content-Length");
return ESP_FAIL;
}
char* body = (char*)psram_malloc(content_len + 1);
if (body == NULL) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int received = 0;
while (received < content_len) {
int ret = httpd_req_recv(request, body + received, content_len - received);
if (ret <= 0) {
if (ret == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
free(body);
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Incomplete request body");
return ESP_FAIL;
}
received += ret;
}
body[content_len] = '\0';
cJSON* response = handle_rpc(body);
free(body);
char* response_text = cJSON_PrintUnformatted(response);
cJSON_Delete(response);
if (response_text == NULL) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Serialization failed");
return ESP_FAIL;
}
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(request, response_text);
cJSON_free(response_text);
return ESP_OK;
}
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;
}
// POST /api/screen/raw
esp_err_t WebServerService::handleApiScreenRaw(httpd_req_t* request) {
LOG_I(TAG, "POST /api/screen/raw");
// Load MCP Settings and check if enabled
auto mcpSettings = settings::mcp::loadOrGetDefault();
if (!mcpSettings.mcpEnabled) {
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "MCP server is disabled");
return ESP_FAIL;
}
int content_len = request->content_len;
if (content_len <= 0 || content_len > MCP_RAW_BODY_LIMIT) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Invalid Content-Length");
return ESP_FAIL;
}
uint8_t* body = (uint8_t*)psram_malloc(content_len);
if (body == NULL) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int received = 0;
while (received < content_len) {
int ret = httpd_req_recv(request, (char*)body + received, content_len - received);
if (ret <= 0) {
if (ret == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
free(body);
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Incomplete request body");
return ESP_FAIL;
}
received += ret;
}
auto& state = mcp::getState();
const char* path = request->uri;
int x = query_integer(path, "x", 0);
int y = query_integer(path, "y", 0);
int width = query_integer(path, "w", state.drawWidth);
int height = query_integer(path, "h", state.drawHeight);
bool valid_size = width > 0 && height > 0 &&
(size_t)width * height * 2 == (size_t)content_len;
bool drawn = valid_size && mcp::drawRgb565(
body,
content_len,
x,
y,
width,
height
);
free(body);
if (drawn) {
httpd_resp_set_type(request, "text/plain");
httpd_resp_set_hdr(request, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(request, "OK");
return ESP_OK;
} else {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Invalid RGB565 payload or size");
return ESP_FAIL;
}
}
} // namespace tt::service::webserver
#endif