#ifdef ESP_PLATFORM #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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); } static const auto LOGGER = Logger("McpHandler"); #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) { LOGGER.info("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) { LOGGER.info("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