feat: integrate MCP and ES3C28P audio support with upstream compatibility updates

This commit is contained in:
Adolfo Reyna
2026-07-04 15:41:37 -04:00
parent b8bf59fedf
commit 300ddb3a7f
21 changed files with 1260 additions and 33 deletions
@@ -37,10 +37,10 @@ void McpScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t scr
return;
}
// Fill with a dark slate background
// Fill with a dark slate background (inverted for display path)
size_t pixelCount = (size_t)screenW * screenH;
for (size_t i = 0; i < pixelCount; ++i) {
framebuffer[i] = 0x18E3; // dark blue-grey
framebuffer[i] = ~0x18E3; // dark blue-grey
}
lv_canvas_set_buffer(canvas, framebuffer, screenW, screenH, LV_COLOR_FORMAT_RGB565);
@@ -48,12 +48,13 @@ void McpScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t scr
// Waiting label (removed on first MCP draw via lv_obj_clean)
lv_obj_t* waitLabel = lv_label_create(canvas);
lv_label_set_text(waitLabel, "Waiting for LLM...");
lv_obj_set_style_text_color(waitLabel, lv_color_white(), LV_PART_MAIN);
lv_obj_set_style_text_color(waitLabel, lv_color_black(), LV_PART_MAIN); // white on screen (inverted)
lv_obj_align(waitLabel, LV_ALIGN_CENTER, 0, -20);
lv_obj_t* resLabel = lv_label_create(canvas);
lv_label_set_text_fmt(resLabel, "Display: %dx%d", (int)screenW, (int)screenH);
lv_obj_set_style_text_color(resLabel, lv_palette_lighten(LV_PALETTE_BLUE, 3), LV_PART_MAIN);
lv_color_t resColor = lv_palette_lighten(LV_PALETTE_BLUE, 3);
lv_obj_set_style_text_color(resLabel, lv_color_make(~resColor.red, ~resColor.green, ~resColor.blue), LV_PART_MAIN);
lv_obj_align(resLabel, LV_ALIGN_CENTER, 0, 10);
// Register with McpSystemState
+1 -1
View File
@@ -199,7 +199,7 @@ bool GuiService::onStart(ServiceContext& service) {
thread = new Thread(
GUI_TASK_NAME,
4096, // Last known minimum was 2800 for launching desktop
8192, // Last known minimum was 2800 for launching desktop, increased to 8192 to prevent stack overflow on complex layouts/MCP settings
guiMain
);
thread->setPriority(THREAD_PRIORITY_SERVICE);
@@ -26,6 +26,8 @@
#include <tactility/lvgl_icon_statusbar.h>
#include <cstring>
#include <format>
#include <string>
namespace tt::service::statusbar {
@@ -102,6 +104,13 @@ static const char* getPowerStatusIcon() {
return nullptr;
}
hal::power::PowerDevice::MetricData charging_data;
if (power->supportsMetric(hal::power::PowerDevice::MetricType::IsCharging) &&
power->getMetric(hal::power::PowerDevice::MetricType::IsCharging, charging_data) &&
charging_data.valueAsBool) {
return LVGL_ICON_STATUSBAR_BATTERY_ANDROID_FRAME_BOLT;
}
hal::power::PowerDevice::MetricData charge_level;
if (!power->getMetric(hal::power::PowerDevice::MetricType::ChargeLevel, charge_level)) {
return nullptr;
@@ -210,6 +219,29 @@ class StatusbarService final : public Service {
}
power_last_icon = desired_icon;
}
std::shared_ptr<hal::power::PowerDevice> power;
hal::findDevices<hal::power::PowerDevice>(hal::Device::Type::Power, [&power](const auto& device) {
if (device->supportsMetric(hal::power::PowerDevice::MetricType::ChargeLevel)) {
power = device;
return false;
}
return true;
});
if (power != nullptr) {
hal::power::PowerDevice::MetricData charge_level;
if (power->getMetric(hal::power::PowerDevice::MetricType::ChargeLevel, charge_level)) {
uint8_t charge = charge_level.valueAsUint8;
std::string battery_text = std::format("{}%", charge);
lvgl::statusbar_set_battery_text(battery_text);
lvgl::statusbar_set_battery_visibility(true);
} else {
lvgl::statusbar_set_battery_visibility(false);
}
} else {
lvgl::statusbar_set_battery_visibility(false);
}
}
void updateUsbIcon() {
@@ -157,6 +157,116 @@ static const char* TOOLS_JSON =
"\"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 '/')\"}"
"}"
"}"
"}"
"]";
@@ -173,7 +283,7 @@ static uint8_t* base64_decode(const char* input, size_t* output_size) {
size_t input_size = strlen(payload);
size_t approx_output_len = (input_size * 3) / 4;
uint8_t* output = (uint8_t*)psram_malloc(approx_output_len + 4);
uint8_t* output = (uint8_t*)malloc(approx_output_len + 4);
if (output == NULL) {
return NULL;
}
@@ -564,6 +674,162 @@ static cJSON* handle_tool_call(cJSON* id, cJSON* params) {
? 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");
}
@@ -6,6 +6,7 @@
#include <Tactility/service/ServiceManifest.h>
#include <Tactility/settings/WebServerSettings.h>
#include <Tactility/settings/McpSettings.h>
#include <Tactility/mcp/McpSystem.h>
#include <Tactility/MountPoints.h>
#include <Tactility/file/File.h>
#include <Tactility/Logger.h>
@@ -510,7 +511,7 @@ bool WebServerService::startServer() {
settings.webServerPort,
"0.0.0.0",
handlers,
16384 // Stack size
12288 // Stack size (forces allocation in internal SRAM instead of PSRAM)
);
httpServer->start();
@@ -531,6 +532,11 @@ bool WebServerService::startServer() {
settings.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station");
}
auto mcpSettings = settings::mcp::loadOrGetDefault();
if (mcpSettings.mcpEnabled) {
mcp::startVideoStreamServer();
}
return true;
}
@@ -542,6 +548,8 @@ void WebServerService::stopServer() {
httpServer->stop();
httpServer.reset();
mcp::stopVideoStreamServer();
// Stop AP mode WiFi if we started it
if (apWifiInitialized || apNetif != nullptr) {
stopApMode();
@@ -107,6 +107,23 @@ static bool decrypt(const std::string& ssidInput, std::string& ssidOutput) {
return false;
}
// Strip PKCS#7 padding
if (result_length > 0) {
uint8_t pad_val = result[result_length - 1];
if (pad_val >= 1 && pad_val <= 16 && pad_val <= result_length) {
bool valid_padding = true;
for (size_t i = result_length - pad_val; i < result_length; ++i) {
if (result[i] != pad_val) {
valid_padding = false;
break;
}
}
if (valid_padding) {
result[result_length - pad_val] = 0;
}
}
}
ssidOutput = reinterpret_cast<char*>(result);
free(result);
return true;
+25
View File
@@ -20,6 +20,9 @@
#include <Tactility/service/wifi/WifiSettings.h>
#include <esp_wifi_default.h>
#include <esp_mac.h>
#include <esp_netif.h>
#include <mdns.h>
#include <lwip/esp_netif_net_stack.h>
#include <freertos/FreeRTOS.h>
#include <atomic>
@@ -543,6 +546,28 @@ static void dispatchEnable(std::shared_ptr<Wifi> wifi) {
}
wifi->netif = esp_netif_create_default_wifi_sta();
if (wifi->netif != nullptr) {
uint8_t mac[6];
char hostname[32];
if (esp_read_mac(mac, ESP_MAC_WIFI_STA) == ESP_OK) {
snprintf(hostname, sizeof(hostname), "kidsOS-%02X%02X", mac[4], mac[5]);
} else {
strncpy(hostname, "kidsOS", sizeof(hostname));
}
LOGGER.info("Setting DHCP Hostname to '{}'", hostname);
esp_netif_set_hostname(wifi->netif, hostname);
// Initialize mDNS
esp_err_t mdns_err = mdns_init();
if (mdns_err == ESP_OK) {
LOGGER.info("mDNS initialized, setting hostname to '{}.local'", hostname);
mdns_hostname_set(hostname);
mdns_instance_name_set("kidsOS Tactility Device");
} else {
LOGGER.error("Failed to initialize mDNS: {}", (int)mdns_err);
}
}
// Warning: this is the memory-intensive operation
// It uses over 117kB of RAM with default settings for S3 on IDF v5.1.2
wifi_init_config_t config = WIFI_INIT_CONFIG_DEFAULT();