diff --git a/Apps/FTPServer/CMakeLists.txt b/Apps/FTPServer/CMakeLists.txt new file mode 100644 index 0000000..51d37ba --- /dev/null +++ b/Apps/FTPServer/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.20) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +if (DEFINED ENV{TACTILITY_SDK_PATH}) + set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH}) +else() + set(TACTILITY_SDK_PATH "../../release/TactilitySDK") + message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}") +endif() + +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") +set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +project(FTPServer) +tactility_project(FTPServer) diff --git a/Apps/FTPServer/README.md b/Apps/FTPServer/README.md new file mode 100644 index 0000000..3448e58 --- /dev/null +++ b/Apps/FTPServer/README.md @@ -0,0 +1,34 @@ +# FTP Server + +An FTP server application for [Tactility](https://github.com/TactilityProject/Tactility) devices running on ESP32 platforms. + +## Features + +- Simple FTP server for wireless file transfer to/from your device +- Configurable username, password, and port +- Real-time connection status and activity logging +- Wi-Fi connection management + +## Screenshots + +| Idle | Running | Settings | No Wi-Fi | +|:----:|:-------:|:--------:|:--------:| +| ![Idle](images/idle-off.png) | ![Running](images/running.png) | ![Settings](images/settings.png) | ![No WiFi](images/no-wifi.png) | + +## Usage + +1. Ensure your device is connected to Wi-Fi +2. Configure username, password, and port in settings (default: `esp32`/`esp32` on port `21`) +3. Toggle the switch to start the server +4. Connect using any FTP client with the displayed IP address + +## Supported Platforms + +- ESP32 +- ESP32-S3 +- ESP32-C6 +- ESP32-P4 + +## Requirements + +- Tactility SDK 0.7.0-dev or later diff --git a/Apps/FTPServer/images/idle-off.png b/Apps/FTPServer/images/idle-off.png new file mode 100644 index 0000000..253ec53 Binary files /dev/null and b/Apps/FTPServer/images/idle-off.png differ diff --git a/Apps/FTPServer/images/no-wifi.png b/Apps/FTPServer/images/no-wifi.png new file mode 100644 index 0000000..bd6918b Binary files /dev/null and b/Apps/FTPServer/images/no-wifi.png differ diff --git a/Apps/FTPServer/images/running.png b/Apps/FTPServer/images/running.png new file mode 100644 index 0000000..8022fc3 Binary files /dev/null and b/Apps/FTPServer/images/running.png differ diff --git a/Apps/FTPServer/images/settings.png b/Apps/FTPServer/images/settings.png new file mode 100644 index 0000000..43fa785 Binary files /dev/null and b/Apps/FTPServer/images/settings.png differ diff --git a/Apps/FTPServer/main/CMakeLists.txt b/Apps/FTPServer/main/CMakeLists.txt new file mode 100644 index 0000000..c9014ef --- /dev/null +++ b/Apps/FTPServer/main/CMakeLists.txt @@ -0,0 +1,13 @@ +file(GLOB_RECURSE SOURCE_FILES + Source/*.c* +) + +idf_component_register( + SRCS ${SOURCE_FILES} + # Library headers must be included directly, + # because all regular dependencies get stripped by elf_loader's cmake script + INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include + REQUIRES TactilitySDK + PRIV_REQUIRES esp_wifi esp_netif +) + diff --git a/Apps/FTPServer/main/Source/FTPServer.cpp b/Apps/FTPServer/main/Source/FTPServer.cpp new file mode 100644 index 0000000..a19b418 --- /dev/null +++ b/Apps/FTPServer/main/Source/FTPServer.cpp @@ -0,0 +1,588 @@ +#include "FTPServer.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "FtpServerCore.h" +#include +#include + +#include +#include +#include +#include + +constexpr auto* TAG = "FTPServer"; + +#define WIFI_STATE_CONNECTION_ACTIVE 3 + +#define SETTINGS_FILENAME "ftpserver.properties" + +// Default credentials +#define DEFAULT_FTP_USER "esp32" +#define DEFAULT_FTP_PASS "esp32" +#define DEFAULT_FTP_PORT 21 + +// FTP Server instance (static because FtpServerCore expects it) +static FtpServer::Server* ftpServer = nullptr; + +// Settings (static for persistence across view switches) +static char ftpUsername[33] = DEFAULT_FTP_USER; +static char ftpPassword[33] = DEFAULT_FTP_PASS; +static int ftpPort = DEFAULT_FTP_PORT; + +constexpr auto* KEY_FTPSERVER_USER = "username"; +constexpr auto* KEY_FTPSERVER_PASS = "password"; +constexpr auto* KEY_FTPSERVER_PASS_ENC = "password_enc"; +constexpr auto* KEY_FTPSERVER_PORT = "port"; + +// Simple XOR key for password obfuscation (not cryptographically secure, but prevents casual reading) +static constexpr uint8_t XOR_KEY[] = {0x5A, 0x3C, 0x7E, 0x1D, 0x9B, 0x4F, 0x2A, 0x6E}; +static constexpr size_t XOR_KEY_LEN = sizeof(XOR_KEY); + +// Encode password to hex string with XOR obfuscation +static void encodePassword(const char* plain, char* encoded, size_t encodedSize) { + size_t len = strlen(plain); + size_t outIdx = 0; + + for (size_t i = 0; i < len && outIdx + 2 < encodedSize; i++) { + uint8_t obfuscated = static_cast(plain[i]) ^ XOR_KEY[i % XOR_KEY_LEN]; + snprintf(encoded + outIdx, 3, "%02X", obfuscated); + outIdx += 2; + } + encoded[outIdx] = '\0'; +} + +// Decode hex string with XOR obfuscation back to password +static bool decodePassword(const char* encoded, char* plain, size_t plainSize) { + size_t len = strlen(encoded); + if (len % 2 != 0) { + return false; + } + if ((len / 2) >= plainSize) { + return false; + } + + size_t outIdx = 0; + for (size_t i = 0; i < len && outIdx + 1 < plainSize; i += 2) { + unsigned int byte; + if (sscanf(encoded + i, "%02X", &byte) != 1) { + return false; + } + plain[outIdx] = static_cast(static_cast(byte) ^ XOR_KEY[outIdx % XOR_KEY_LEN]); + outIdx++; + } + plain[outIdx] = '\0'; + return true; +} + +// App handle for settings path +static AppHandle currentAppHandle = nullptr; + +// Pointer to current app instance for log callback +static FTPServer* currentInstance = nullptr; + +//==================================================================================================== +// Settings persistence +//==================================================================================================== +// TODO: Replace these functions with loadPropertiesFiles / savePropertiesFile when available to apps +static bool mkdirRecursive(const char* path) { + char tmp[256]; + char* p = nullptr; + size_t len; + + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + + if (len > 0 && tmp[len - 1] == '/') { + tmp[len - 1] = '\0'; + } + + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + struct stat st; + if (stat(tmp, &st) != 0) { + int ret = mkdir(tmp, 0755); + if (ret != 0 && errno != EEXIST) { + ESP_LOGE(TAG, "Failed to create directory: %s (errno=%d)", tmp, errno); + return false; + } + } + *p = '/'; + } + } + + struct stat st; + if (stat(tmp, &st) != 0) { + int ret = mkdir(tmp, 0755); + if (ret != 0 && errno != EEXIST) { + ESP_LOGE(TAG, "Failed to create final directory: %s (errno=%d)", tmp, errno); + return false; + } + } + + return true; +} + +static bool getSettingsPath(char* buffer, size_t bufferSize) { + if (!currentAppHandle) { + return false; + } + + size_t size = bufferSize; + tt_app_get_user_data_child_path(currentAppHandle, SETTINGS_FILENAME, buffer, &size); + + if (size == 0) { + ESP_LOGE(TAG, "Failed to get user data path"); + return false; + } + + char dirBuffer[256]; + size_t dirSize = sizeof(dirBuffer); + tt_app_get_user_data_path(currentAppHandle, dirBuffer, &dirSize); + + if (dirSize > 0) { + struct stat st; + if (stat(dirBuffer, &st) != 0) { + if (!mkdirRecursive(dirBuffer)) { + ESP_LOGE(TAG, "Failed to create settings directory: %s", dirBuffer); + return false; + } + } + } + + return true; +} + +static bool saveSettings() { + char path[256]; + if (!getSettingsPath(path, sizeof(path))) { + return false; + } + + FILE* file = fopen(path, "w"); + if (file != nullptr) { + fprintf(file, "%s=%s\n", KEY_FTPSERVER_USER, ftpUsername); + // Store password as obfuscated hex instead of plaintext + char encodedPass[128]; + encodePassword(ftpPassword, encodedPass, sizeof(encodedPass)); + fprintf(file, "%s=%s\n", KEY_FTPSERVER_PASS_ENC, encodedPass); + fprintf(file, "%s=%d\n", KEY_FTPSERVER_PORT, ftpPort); + fclose(file); + ESP_LOGI(TAG, "Settings saved to %s", path); + return true; + } else { + ESP_LOGE(TAG, "Failed to open %s for writing", path); + return false; + } +} + +static bool loadSettings() { + char path[256]; + if (!getSettingsPath(path, sizeof(path))) { + return false; + } + + FILE* file = fopen(path, "r"); + if (file == nullptr) { + ESP_LOGI(TAG, "No settings file found, using defaults"); + return false; + } + + bool foundPlaintextPassword = false; + bool foundEncodedPassword = false; + + char line[256]; + while (fgets(line, sizeof(line), file)) { + if (line[0] == '\0' || line[0] == '#' || line[0] == '\n' || line[0] == '\r') { + continue; + } + + char* eq = strchr(line, '='); + if (eq) { + *eq = '\0'; + char* key = line; + char* value = eq + 1; + + char* nl = strchr(value, '\n'); + if (nl) *nl = '\0'; + char* cr = strchr(value, '\r'); + if (cr) *cr = '\0'; + + if (strcmp(key, KEY_FTPSERVER_USER) == 0) { + strncpy(ftpUsername, value, sizeof(ftpUsername) - 1); + ftpUsername[sizeof(ftpUsername) - 1] = '\0'; + } else if (strcmp(key, KEY_FTPSERVER_PASS_ENC) == 0) { + // Decode obfuscated password + if (decodePassword(value, ftpPassword, sizeof(ftpPassword))) { + foundEncodedPassword = true; + } + } else if (strcmp(key, KEY_FTPSERVER_PASS) == 0) { + // Legacy plaintext password - migrate it + if (!foundEncodedPassword) { + strncpy(ftpPassword, value, sizeof(ftpPassword) - 1); + ftpPassword[sizeof(ftpPassword) - 1] = '\0'; + foundPlaintextPassword = true; + } + } else if (strcmp(key, KEY_FTPSERVER_PORT) == 0) { + int port = atoi(value); + if (port > 0 && port <= 65535) { + ftpPort = port; + } + } + } + } + fclose(file); + + // Migrate: if we found a plaintext password, re-save with encoded password + if (foundPlaintextPassword && !foundEncodedPassword) { + ESP_LOGI(TAG, "Migrating plaintext password to encoded format"); + saveSettings(); + } + + ESP_LOGI(TAG, "Settings loaded: user=%s, port=%d", ftpUsername, ftpPort); + return true; +} + +//============================================================================================== +// UI Helpers +//============================================================================================== + +static bool isWifiConnected() { + WifiRadioState state = tt_wifi_get_radio_state(); + return state == WIFI_STATE_CONNECTION_ACTIVE; +} + +//============================================================================================== +// FTPServer View Management +//============================================================================================== + +void FTPServer::stopActiveView() { + if (activeView != nullptr) { + activeView->onStop(); + lv_obj_clean(wrapperWidget); + activeView = nullptr; + } +} + +void FTPServer::showMainView() { + ESP_LOGI(TAG, "showMainView"); + stopActiveView(); + activeView = &mainView; + mainView.onStart(wrapperWidget); + + // Show toolbar items + if (settingsButton) { + lv_obj_remove_flag(settingsButton, LV_OBJ_FLAG_HIDDEN); + } + if (connectSwitch) { + lv_obj_remove_flag(connectSwitch, LV_OBJ_FLAG_HIDDEN); + } + if (clearLogButton) { + lv_obj_remove_flag(clearLogButton, LV_OBJ_FLAG_HIDDEN); + } + + // Update UI based on server state + if (ftpServer && ftpServer->isEnabled()) { + if (spinner) { + lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN); + } + + char ipStr[32] = "IP: --"; + if (isWifiConnected()) { + esp_netif_ip_info_t ipInfo; + if (esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"), &ipInfo) == ESP_OK) { + snprintf(ipStr, sizeof(ipStr), "IP: " IPSTR, IP2STR(&ipInfo.ip)); + } + } + mainView.updateInfoPanel(ipStr, "Running", LV_PALETTE_GREEN); + } else { + if (spinner) { + lv_obj_add_flag(spinner, LV_OBJ_FLAG_HIDDEN); + } + } +} + +void FTPServer::showSettingsView() { + ESP_LOGI(TAG, "showSettingsView"); + stopActiveView(); + activeView = &settingsView; + settingsView.onStart(wrapperWidget, ftpUsername, ftpPassword, ftpPort); + + // Hide toolbar items when in settings + if (settingsButton) { + lv_obj_add_flag(settingsButton, LV_OBJ_FLAG_HIDDEN); + } + if (connectSwitch) { + lv_obj_add_flag(connectSwitch, LV_OBJ_FLAG_HIDDEN); + } + if (spinner) { + lv_obj_add_flag(spinner, LV_OBJ_FLAG_HIDDEN); + } + if (clearLogButton) { + lv_obj_add_flag(clearLogButton, LV_OBJ_FLAG_HIDDEN); + } +} + +void FTPServer::onSettingsSaved(const char* username, const char* password, int port) { + if (username && strlen(username) > 0) { + strncpy(ftpUsername, username, sizeof(ftpUsername) - 1); + ftpUsername[sizeof(ftpUsername) - 1] = '\0'; + } + + if (password && strlen(password) > 0) { + strncpy(ftpPassword, password, sizeof(ftpPassword) - 1); + ftpPassword[sizeof(ftpPassword) - 1] = '\0'; + } + + if (port > 0 && port <= 65535) { + ftpPort = port; + } + + if (ftpServer) { + ftpServer->setCredentials(ftpUsername, ftpPassword); + ftpServer->setPort(static_cast(ftpPort)); + } + + saveSettings(); + ESP_LOGI(TAG, "Settings updated: user=%s, port=%d", ftpUsername, ftpPort); + + showMainView(); +} + +void FTPServer::onSettingsButtonPressed() { + showSettingsView(); +} + +void FTPServer::onSettingsButtonCallback(lv_event_t* event) { + auto* app = static_cast(lv_event_get_user_data(event)); + app->onSettingsButtonPressed(); +} + +void FTPServer::onClearLogButtonPressed() { + mainView.clearLog(); +} + +void FTPServer::onClearLogButtonCallback(lv_event_t* event) { + auto* app = static_cast(lv_event_get_user_data(event)); + app->onClearLogButtonPressed(); +} + +// Timer callback to check FTP server status after start +static void ftpStartCheckTimerCallback(lv_timer_t* timer) { + auto* app = static_cast(lv_timer_get_user_data(timer)); + if (app != nullptr) { + app->checkFtpServerStarted(); + } + lv_timer_delete(timer); +} + +void FTPServer::checkFtpServerStarted() { + // Clear the timer pointer since we're being called (timer will be deleted after this) + ftpStartCheckTimer = nullptr; + + if (activeView != &mainView) { + return; + } + + if (ftpServer && ftpServer->isEnabled()) { + mainView.updateInfoPanel(nullptr, "Running", LV_PALETTE_GREEN); + mainView.logToScreen("FTP Server started!"); + + char userpassStr[100]; + snprintf(userpassStr, sizeof(userpassStr), "User: %s Pass: %s Port: %d", ftpUsername, ftpPassword, ftpPort); + mainView.logToScreen(userpassStr); + mainView.logToScreen("Ready for connections..."); + if (settingsButton != nullptr) { + lv_obj_add_state(settingsButton, LV_STATE_DISABLED); + lv_obj_add_flag(settingsButton, LV_OBJ_FLAG_HIDDEN); + } + } else { + if (spinner != nullptr) { + lv_obj_add_flag(spinner, LV_OBJ_FLAG_HIDDEN); + } + mainView.updateInfoPanel(nullptr, "Error", LV_PALETTE_RED); + mainView.logToScreen("ERROR: Failed to start FTP server!"); + if (connectSwitch != nullptr) { + lv_obj_remove_state(connectSwitch, LV_STATE_CHECKED); + } + } +} + +void FTPServer::onSwitchToggled(bool checked) { + ESP_LOGI(TAG, "Switch toggled: %d", checked); + + if (checked) { + if (!isWifiConnected()) { + ESP_LOGI(TAG, "WiFi not connected"); + mainView.showWifiPrompt(); + lv_obj_remove_state(connectSwitch, LV_STATE_CHECKED); + return; + } + + // Get IP for display + char ipStr[32] = "IP: --"; + esp_netif_ip_info_t ipInfo; + if (esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"), &ipInfo) == ESP_OK) { + snprintf(ipStr, sizeof(ipStr), "IP: " IPSTR, IP2STR(&ipInfo.ip)); + } + + mainView.updateInfoPanel(ipStr, "Starting...", LV_PALETTE_GREEN); + if (spinner) { + lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN); + } + + if (ftpServer) { + ftpServer->setCredentials(ftpUsername, ftpPassword); + ftpServer->start(); + + // Cancel any existing timer before creating a new one + if (ftpStartCheckTimer != nullptr) { + lv_timer_delete(ftpStartCheckTimer); + ftpStartCheckTimer = nullptr; + } + + // Schedule a timer to check server status after 200ms (non-blocking) + ftpStartCheckTimer = lv_timer_create(ftpStartCheckTimerCallback, 200, this); + } + } else { + // Cancel any pending start check timer + if (ftpStartCheckTimer != nullptr) { + lv_timer_delete(ftpStartCheckTimer); + ftpStartCheckTimer = nullptr; + } + + if (ftpServer) { + ESP_LOGI(TAG, "Stopping FTP Server..."); + ftpServer->stop(); + if (spinner) { + lv_obj_add_flag(spinner, LV_OBJ_FLAG_HIDDEN); + } + mainView.updateInfoPanel(nullptr, "Stopped", LV_PALETTE_GREY); + mainView.logToScreen("FTP Server stopped"); + lv_obj_remove_state(settingsButton, LV_STATE_DISABLED); + lv_obj_remove_flag(settingsButton, LV_OBJ_FLAG_HIDDEN); + } + } +} + +void FTPServer::onSwitchToggledCallback(lv_event_t* event) { + lv_event_code_t code = lv_event_get_code(event); + if (code == LV_EVENT_VALUE_CHANGED) { + auto* app = static_cast(lv_event_get_user_data(event)); + lv_obj_t* sw = lv_event_get_target_obj(event); + bool checked = lv_obj_has_state(sw, LV_STATE_CHECKED); + app->onSwitchToggled(checked); + } +} + +//============================================================================================== +// App Lifecycle +//============================================================================================== + +void FTPServer::onShow(AppHandle appHandle, lv_obj_t* parent) { + ESP_LOGI(TAG, "onShow called"); + currentAppHandle = appHandle; + currentInstance = this; + + // Load settings + if (!loadSettings()) { + saveSettings(); + } + + // Create FTP server instance + ftpServer = new FtpServer::Server(); + if (!ftpServer) { + ESP_LOGE(TAG, "Failed to create FTP server!"); + currentAppHandle = nullptr; + currentInstance = nullptr; + return; + } + + ftpServer->setCredentials(ftpUsername, ftpPassword); + ftpServer->setPort(static_cast(ftpPort)); + + ftpServer->register_screen_log_callback([](const char* msg) { + if (currentInstance && currentInstance->mainView.hasValidLogArea()) { + currentInstance->mainView.logToScreen(msg); + } + }); + + // Setup parent layout + 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, 0); + lv_obj_set_style_pad_row(parent, 0, 0); + + // Toolbar + toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); + + // Add spinner to toolbar (hidden initially) + spinner = tt_lvgl_toolbar_add_spinner_action(toolbar); + lv_obj_add_flag(spinner, LV_OBJ_FLAG_HIDDEN); + + // Add settings button to toolbar + settingsButton = tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsButtonCallback, this); + + // Add clear log button to toolbar + clearLogButton = tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onClearLogButtonCallback, this); + + // Add switch to toolbar + connectSwitch = tt_lvgl_toolbar_add_switch_action(toolbar); + lv_obj_add_event_cb(connectSwitch, onSwitchToggledCallback, LV_EVENT_VALUE_CHANGED, this); + + // Create wrapper widget for view swapping + wrapperWidget = lv_obj_create(parent); + lv_obj_set_width(wrapperWidget, LV_PCT(100)); + lv_obj_set_flex_grow(wrapperWidget, 1); + lv_obj_set_style_pad_all(wrapperWidget, 0, 0); + lv_obj_set_style_border_width(wrapperWidget, 0, 0); + lv_obj_set_style_bg_opa(wrapperWidget, LV_OPA_TRANSP, 0); + lv_obj_remove_flag(wrapperWidget, LV_OBJ_FLAG_SCROLLABLE); + + // Show main view + showMainView(); + + ESP_LOGI(TAG, "UI created successfully"); +} + +void FTPServer::onHide(AppHandle context) { + ESP_LOGI(TAG, "onHide called"); + + // Cancel pending timer to prevent callback after teardown + if (ftpStartCheckTimer != nullptr) { + lv_timer_delete(ftpStartCheckTimer); + ftpStartCheckTimer = nullptr; + } + + // Stop active view + stopActiveView(); + + // Save settings + saveSettings(); + + // Stop FTP server + if (ftpServer) { + ftpServer->stop(); + delete ftpServer; + ftpServer = nullptr; + } + + // Clear handles + currentAppHandle = nullptr; + currentInstance = nullptr; + wrapperWidget = nullptr; + toolbar = nullptr; + settingsButton = nullptr; + spinner = nullptr; + connectSwitch = nullptr; + clearLogButton = nullptr; +} diff --git a/Apps/FTPServer/main/Source/FTPServer.h b/Apps/FTPServer/main/Source/FTPServer.h new file mode 100644 index 0000000..3b72670 --- /dev/null +++ b/Apps/FTPServer/main/Source/FTPServer.h @@ -0,0 +1,46 @@ +#pragma once + +#include "MainView.h" +#include "SettingsView.h" +#include "View.h" + +#include + +#include +#include + +class FTPServer final : public App { + + lv_obj_t* wrapperWidget = nullptr; + lv_obj_t* toolbar = nullptr; + lv_obj_t* settingsButton = nullptr; + lv_obj_t* spinner = nullptr; + lv_obj_t* connectSwitch = nullptr; + lv_obj_t* clearLogButton = nullptr; + lv_timer_t* ftpStartCheckTimer = nullptr; + + MainView mainView; + SettingsView settingsView = SettingsView( + [this]() { showMainView(); }, + [this](const char* user, const char* pass, int port) { onSettingsSaved(user, pass, port); } + ); + View* activeView = nullptr; + + void stopActiveView(); + void showMainView(); + void showSettingsView(); + void onSettingsSaved(const char* username, const char* password, int port); + void onSettingsButtonPressed(); + void onSwitchToggled(bool checked); + void onClearLogButtonPressed(); + + static void onSwitchToggledCallback(lv_event_t* event); + static void onSettingsButtonCallback(lv_event_t* event); + static void onClearLogButtonCallback(lv_event_t* event); + +public: + void checkFtpServerStarted(); + + void onShow(AppHandle context, lv_obj_t* parent) override; + void onHide(AppHandle context) override; +}; diff --git a/Apps/FTPServer/main/Source/FtpServerCore.cpp b/Apps/FTPServer/main/Source/FtpServerCore.cpp new file mode 100644 index 0000000..c124efc --- /dev/null +++ b/Apps/FTPServer/main/Source/FtpServerCore.cpp @@ -0,0 +1,1925 @@ +#include "FtpServerCore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dirent.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "lwip/netdb.h" +#include "lwip/sockets.h" + +namespace FtpServer { + +// Internal constants +static constexpr uint32_t FTP_LOG_THROTTLE_MS = 200; +static constexpr uint32_t FTP_LOG_THROTTLE_MAX = 5; +static constexpr uint32_t FTP_SEND_TIMEOUT_MS = 200; +static constexpr uint32_t FTP_PROGRESS_INTERVAL = 100 * 1024; // 100KB +static constexpr uint32_t FTP_DIR_ENTRY_MIN_SPACE = 64; +static constexpr uint32_t FTP_TASK_STACK_SIZE = 1024 * 8; // 8KB - increased for safety margin with path operations + +// Static member initialization +const Server::ftp_cmd_t Server::ftp_cmd_table[] = { + {"FEAT"}, + {"SYST"}, + {"CDUP"}, + {"CWD"}, + {"PWD"}, + {"XPWD"}, + {"SIZE"}, + {"MDTM"}, + {"TYPE"}, + {"USER"}, + {"PASS"}, + {"PASV"}, + {"LIST"}, + {"RETR"}, + {"STOR"}, + {"DELE"}, + {"RMD"}, + {"MKD"}, + {"RNFR"}, + {"RNTO"}, + {"NOOP"}, + {"QUIT"}, + {"APPE"}, + {"NLST"}, + {"AUTH"} +}; + +// Constructor +Server::Server() + : xEventTask(nullptr), + ftp_task_handle(nullptr), + ftp_mutex(nullptr), + ftp_buff_size(FTPSERVER_BUFFER_SIZE), + ftp_timeout(FTP_CMD_TIMEOUT_MS), + TAG("[Server]"), + MOUNT_POINT(""), + ftp_path(nullptr), + ftp_scratch_buffer(nullptr), + ftp_cmd_buffer(nullptr), + ftp_stop(0), + ftp_nlist(0), + ftp_cmd_port(FTP_CMD_PORT) { + ftp_mutex = xSemaphoreCreateMutex(); + if (!ftp_mutex) { + ESP_LOGE(TAG, "Failed to create FTP mutex!"); + } + memset(&ftp_data, 0, sizeof(ftp_data_t)); + memset(ftp_user, 0, sizeof(ftp_user)); + memset(ftp_pass, 0, sizeof(ftp_pass)); +} + +// Destructor +Server::~Server() { + stop(); + deinit(); + if (ftp_mutex) { + vSemaphoreDelete(ftp_mutex); + ftp_mutex = nullptr; + } +} + +static std::atomic last_screen_log_ms{0}; +static std::atomic screen_log_count{0}; + +static std::atomic screen_log_callback{nullptr}; + +void Server::register_screen_log_callback(void (*callback)(const char*)) { + screen_log_callback.store(callback, std::memory_order_release); +} + +void Server::log_to_screen(const char* format, ...) { + auto cb = screen_log_callback.load(std::memory_order_acquire); + if (!cb) return; + + // Throttle progress messages (only every Nth call) + if (strstr(format, "total:") != nullptr) { + static uint32_t progress_counter = 0; + if (++progress_counter % 5 != 0) return; // Show every 5th progress + } + + uint32_t now = mp_hal_ticks_ms(); + uint32_t last_log = last_screen_log_ms.load(std::memory_order_relaxed); + + if (now - last_log < FTP_LOG_THROTTLE_MS) { + uint32_t count = screen_log_count.fetch_add(1, std::memory_order_relaxed); + if (count >= FTP_LOG_THROTTLE_MAX) { + return; + } + } else { + screen_log_count.store(0, std::memory_order_relaxed); + last_screen_log_ms.store(now, std::memory_order_relaxed); + } + + char buffer[128]; + va_list args; + va_start(args, format); + vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + + cb(buffer); +} + +// Helper functions + +// Sanitize path to prevent path traversal attacks and check length constraints +// Returns false if path is invalid/malicious or exceeds max_len +// If max_len is 0, only traversal checks are performed (no length check) +bool Server::sanitize_path(const char* path, size_t max_len) { + if (!path) return true; + + size_t path_len = strlen(path); + if (path_len == 0) return true; + + // Check path length if max_len is specified + if (max_len > 0 && path_len >= max_len) { + ESP_LOGW(TAG, "Path too long (%zu >= %zu): rejected", path_len, max_len); + return false; + } + + // Check for dangerous sequences that could escape the allowed directories + const char* p = path; + while (*p) { + // Look for /../ or /.. at end - these could traverse up + if (p[0] == '/' && p[1] == '.' && p[2] == '.') { + if (p[3] == '/' || p[3] == '\0') { + // Found /../ or /.. - this is handled by close_child() but + // we need to ensure it doesn't escape root storage directories + // For now, reject paths with .. in middle of path after prefix + // The FTP path navigation handles .. correctly via CDUP/CWD + // but direct paths like /sdcard/foo/../../../etc should be blocked + + // Count depth after first component + int depth = 0; + const char* scan = path; + bool after_prefix = false; + while (*scan) { + if (*scan == '/') { + scan++; + if (!after_prefix) { + // Check for known storage prefixes + if (strncmp(scan, FTP_STORAGE_NAME_INTERNAL, strlen(FTP_STORAGE_NAME_INTERNAL)) == 0 || + strncmp(scan, FTP_STORAGE_NAME_SDCARD, strlen(FTP_STORAGE_NAME_SDCARD)) == 0) { + while (*scan && *scan != '/') scan++; + after_prefix = true; + continue; + } + } + if (scan[0] == '.' && scan[1] == '.' && (scan[2] == '/' || scan[2] == '\0')) { + depth--; + scan += 2; + } else if (scan[0] != '\0' && scan[0] != '/') { + depth++; + while (*scan && *scan != '/') scan++; + } + } else { + scan++; + } + } + if (depth < 0) { + ESP_LOGW(TAG, "Path traversal attempt blocked: %s", path); + return false; + } + } + } + p++; + } + return true; +} + +void Server::translate_path(char* actual, size_t actual_size, const char* display) { + if (actual_size == 0) return; + + // Check for /data prefix + const char* internal_prefix = "/" FTP_STORAGE_NAME_INTERNAL; + size_t internal_len = strlen(internal_prefix); + + if (strncmp(display, internal_prefix, internal_len) == 0 && + (display[internal_len] == '/' || display[internal_len] == '\0')) { + const char* suffix = display + internal_len; + snprintf(actual, actual_size, "%s%s", VFS_NATIVE_INTERNAL_MP, suffix); + return; + } + + // Check for /sdcard prefix + const char* sd_prefix = "/" FTP_STORAGE_NAME_SDCARD; + size_t sd_len = strlen(sd_prefix); + + if (strncmp(display, sd_prefix, sd_len) == 0 && + (display[sd_len] == '/' || display[sd_len] == '\0')) { + const char* suffix = display + sd_len; + snprintf(actual, actual_size, "%s%s", VFS_NATIVE_EXTERNAL_MP, suffix); + return; + } + + // No match - use display path as-is + snprintf(actual, actual_size, "%s", display); +} + +void Server::get_full_path(char* fullname, size_t size, const char* display_path) { + char actual[FTP_MAX_PATH_SIZE]; + translate_path(actual, sizeof(actual), display_path); + snprintf(fullname, size, "%s%s", MOUNT_POINT, actual); +} + +bool Server::secure_compare(const char* a, const char* b, size_t len) { + volatile uint8_t result = 0; + for (size_t i = 0; i < len; i++) { + result |= (uint8_t)a[i] ^ (uint8_t)b[i]; + } + return result == 0; +} + +uint64_t Server::mp_hal_ticks_ms() { + uint64_t time_ms = xTaskGetTickCount() * portTICK_PERIOD_MS; + return time_ms; +} + +bool Server::add_virtual_dir_if_mounted(const char* mount_point, const char* name, char* list, uint32_t maxlistsize, uint32_t* next) { + DIR* test_dir = opendir(mount_point); + if (test_dir == nullptr) return false; + closedir(test_dir); + + if (*next >= maxlistsize) return false; + + struct dirent de = {}; + de.d_type = DT_DIR; + strncpy(de.d_name, name, sizeof(de.d_name) - 1); + + char* list_ptr = list + *next; + uint32_t remaining = maxlistsize - *next; + if (remaining < 128) return false; // Ensure adequate space for entry + *next += get_eplf_item(list_ptr, remaining, &de); + return true; +} + +void Server::stoupper(char* str) { + while (str && *str != '\0') { + *str = (char)toupper((int)(*str)); + str++; + } +} + +// File operations +bool Server::open_file(const char* path, const char* mode) { + ESP_LOGD(TAG, "open_file: path=[%s]", path); + + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "open_file: invalid path rejected"); + return false; + } + + char fullname[FTP_MAX_PATH_SIZE]; + get_full_path(fullname, sizeof(fullname), path); + + // Check if path is on SD Card + if (strncmp(fullname, VFS_NATIVE_EXTERNAL_MP, strlen(VFS_NATIVE_EXTERNAL_MP)) == 0) { + // Verify SD Card is still accessible + struct stat st; + // Delay before SD card operation to prevent SPI bus conflicts + // on devices like T-Deck Plus + vTaskDelay(pdMS_TO_TICKS(5)); + if (stat(VFS_NATIVE_EXTERNAL_MP, &st) != 0) { + ESP_LOGE(TAG, "SD Card not accessible!"); + log_to_screen("[!!] SD Card unavailable"); + return false; + } + } + + ESP_LOGD(TAG, "open_file: fullname=[%s]", fullname); + // Small delay before file open to allow SPI bus to settle + vTaskDelay(pdMS_TO_TICKS(2)); + ftp_data.fp = fopen(fullname, mode); + if (ftp_data.fp == nullptr) { + ESP_LOGE(TAG, "open_file: open fail [%s]", fullname); + return false; + } + ftp_data.e_open = E_FTP_FILE_OPEN; + return true; +} + +void Server::close_files_dir() { + if (ftp_data.e_open == E_FTP_FILE_OPEN) { + fclose(ftp_data.fp); + ftp_data.fp = nullptr; + } else if (ftp_data.e_open == E_FTP_DIR_OPEN) { + if (!ftp_data.listroot) { + closedir(ftp_data.dp); + } + ftp_data.dp = nullptr; + } + ftp_data.e_open = E_FTP_NOTHING_OPEN; +} + +void Server::close_filesystem_on_error() { + close_files_dir(); + if (ftp_data.fp) { + fclose(ftp_data.fp); + ftp_data.fp = nullptr; + } + if (ftp_data.dp && !ftp_data.listroot) { + closedir(ftp_data.dp); + ftp_data.dp = nullptr; + } +} + +Server::ftp_result_t Server::read_file(char* filebuf, uint32_t desiredsize, uint32_t* actualsize) { + ftp_result_t result = E_FTP_RESULT_CONTINUE; + *actualsize = fread(filebuf, 1, desiredsize, ftp_data.fp); + if (*actualsize == 0) { + if (feof(ftp_data.fp)) + result = E_FTP_RESULT_OK; + else + result = E_FTP_RESULT_FAILED; + close_files_dir(); + } else if (*actualsize < desiredsize) { + close_files_dir(); + result = E_FTP_RESULT_OK; + } + return result; +} + +Server::ftp_result_t Server::write_file(char* filebuf, uint32_t size) { + ftp_result_t result = E_FTP_RESULT_FAILED; + uint32_t actualsize = fwrite(filebuf, 1, size, ftp_data.fp); + if (actualsize == size) { + result = E_FTP_RESULT_OK; + } else { + close_files_dir(); + } + return result; +} + +Server::ftp_result_t Server::open_dir_for_listing(const char* path) { + if (ftp_data.dp) { + closedir(ftp_data.dp); + ftp_data.dp = nullptr; + } + + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "open_dir_for_listing: invalid path rejected"); + return E_FTP_RESULT_FAILED; + } + + if (strcmp(path, "/") == 0) { + ftp_data.listroot = true; + ftp_data.e_open = E_FTP_DIR_OPEN; + return E_FTP_RESULT_CONTINUE; + } else { + ftp_data.listroot = false; + char actual_path[FTP_MAX_PATH_SIZE]; + translate_path(actual_path, sizeof(actual_path), path); + char fullname[FTP_MAX_PATH_SIZE]; + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_path); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "open_dir_for_listing: path too long, truncation rejected"); + return E_FTP_RESULT_FAILED; + } + // Delay before SD card operation to prevent SPI bus conflicts + vTaskDelay(pdMS_TO_TICKS(2)); + ftp_data.dp = opendir(fullname); + if (ftp_data.dp == nullptr) { + return E_FTP_RESULT_FAILED; + } + ftp_data.e_open = E_FTP_DIR_OPEN; + return E_FTP_RESULT_CONTINUE; + } +} + +int Server::get_eplf_item(char* dest, uint32_t destsize, struct dirent* de) { + const char* type = (de->d_type & DT_DIR) ? "d" : "-"; + + char fullname[FTP_MAX_PATH_SIZE]; + int written = snprintf(fullname, sizeof(fullname), "%s%s%s%s", MOUNT_POINT, ftp_path, (ftp_path[strlen(ftp_path) - 1] != '/') ? "/" : "", de->d_name); + if (written >= (int)sizeof(fullname)) { + ESP_LOGW(TAG, "Path too long in get_eplf_item, truncated"); + } + + struct stat buf; + int res = stat(fullname, &buf); + if (res < 0) { + buf.st_size = 0; + buf.st_mtime = 946684800; + } + + char str_time[64]; + struct tm* tm_info; + time_t now; + if (time(&now) < 0) now = 946684800; + tm_info = localtime(&buf.st_mtime); + + if (tm_info != nullptr) { + if ((buf.st_mtime + FTP_UNIX_SECONDS_180_DAYS) < now) + strftime(str_time, sizeof(str_time), "%b %d %Y", tm_info); + else + strftime(str_time, sizeof(str_time), "%b %d %H:%M", tm_info); + } else { + snprintf(str_time, sizeof(str_time), "Jan 1 1970"); + } + + int addsize; + if (ftp_nlist) + addsize = snprintf(dest, destsize, "%s\r\n", de->d_name); + else + addsize = snprintf(dest, destsize, "%srw-rw-rw- 1 root root %9" PRIu32 " %s %s\r\n", type, (uint32_t)buf.st_size, str_time, de->d_name); + + // If entry doesn't fit in remaining buffer, skip it + if (addsize < 0 || (uint32_t)addsize >= destsize) { + ESP_LOGW(TAG, "Entry '%s' too long for buffer (%d >= %" PRIu32 "), skipping", de->d_name, addsize, destsize); + return 0; + } + + return addsize; +} + +Server::ftp_result_t Server::list_dir(char* list, uint32_t maxlistsize, uint32_t* listsize) { + uint32_t next = 0; + uint32_t listcount = 0; + ftp_result_t result = E_FTP_RESULT_CONTINUE; + if (ftp_data.listroot) { + // Add virtual directories for mounted storage devices + add_virtual_dir_if_mounted(VFS_NATIVE_INTERNAL_MP, FTP_STORAGE_NAME_INTERNAL, list, maxlistsize, &next); + add_virtual_dir_if_mounted(VFS_NATIVE_EXTERNAL_MP, FTP_STORAGE_NAME_SDCARD, list, maxlistsize, &next); + result = E_FTP_RESULT_OK; + } else { + struct dirent* de; + while (((maxlistsize - next) > 64) && (listcount < 8)) { + de = readdir(ftp_data.dp); + if (de == nullptr) { + result = E_FTP_RESULT_OK; + break; + } + if (de->d_name[0] == '.' && de->d_name[1] == 0) continue; + if (de->d_name[0] == '.' && de->d_name[1] == '.' && de->d_name[2] == 0) continue; + char* list_ptr = list + next; + uint32_t remaining = maxlistsize - next; + next += get_eplf_item(list_ptr, remaining, de); + listcount++; + } + } + if (result == E_FTP_RESULT_OK) { + close_files_dir(); + } + *listsize = next; + return result; +} + +// Socket operations +void Server::close_cmd_data() { + closesocket(ftp_data.c_sd); + closesocket(ftp_data.d_sd); + ftp_data.c_sd = -1; + ftp_data.d_sd = -1; + close_filesystem_on_error(); +} + +void Server::reset() { + ESP_LOGW(TAG, "FTP RESET"); + closesocket(ftp_data.lc_sd); + closesocket(ftp_data.ld_sd); + ftp_data.lc_sd = -1; + ftp_data.ld_sd = -1; + close_cmd_data(); + ftp_data.e_open = E_FTP_NOTHING_OPEN; + ftp_data.state = E_FTP_STE_START; + ftp_data.substate = E_FTP_STE_SUB_DISCONNECTED; +} + +bool Server::create_listening_socket(int32_t* sd, uint32_t port, uint8_t backlog) { + struct sockaddr_in sServerAddress; + int32_t _sd; + int32_t result; + + *sd = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); + _sd = *sd; + + if (_sd > 0) { + uint32_t option = fcntl(_sd, F_GETFL, 0); + option |= O_NONBLOCK; + fcntl(_sd, F_SETFL, option); + + option = 1; + result = + setsockopt(_sd, SOL_SOCKET, SO_REUSEADDR, &option, sizeof(option)); + + sServerAddress.sin_family = AF_INET; + sServerAddress.sin_addr.s_addr = INADDR_ANY; + sServerAddress.sin_len = sizeof(sServerAddress); + sServerAddress.sin_port = htons(port); + + result |= bind(_sd, (const struct sockaddr*)&sServerAddress, sizeof(sServerAddress)); + result |= listen(_sd, backlog); + + if (!result) { + return true; + } + closesocket(*sd); + } + return false; +} + +Server::ftp_result_t Server::wait_for_connection(int32_t l_sd, int32_t* n_sd, uint32_t* ip_addr) { + struct sockaddr_in sClientAddress; + socklen_t in_addrSize = sizeof(sClientAddress); + + *n_sd = accept(l_sd, (struct sockaddr*)&sClientAddress, (socklen_t*)&in_addrSize); + int32_t _sd = *n_sd; + if (_sd < 0) { + if (errno == EAGAIN) { + return E_FTP_RESULT_CONTINUE; + } + reset(); + return E_FTP_RESULT_FAILED; + } + + if (ip_addr) { + struct sockaddr_in clientAddr = {}; + struct sockaddr_in serverAddr = {}; + in_addrSize = sizeof(clientAddr); + if (getpeername(_sd, (struct sockaddr*)&clientAddr, (socklen_t*)&in_addrSize) == 0) { + ESP_LOGI(TAG, "Client IP: 0x%08" PRIx32, clientAddr.sin_addr.s_addr); + } else { + ESP_LOGW(TAG, "getpeername failed (errno=%d)", errno); + } + in_addrSize = sizeof(serverAddr); + if (getsockname(_sd, (struct sockaddr*)&serverAddr, (socklen_t*)&in_addrSize) == 0) { + ESP_LOGI(TAG, "Server IP: 0x%08" PRIx32, serverAddr.sin_addr.s_addr); + *ip_addr = serverAddr.sin_addr.s_addr; + } else { + ESP_LOGW(TAG, "getsockname failed (errno=%d)", errno); + } + } + + uint32_t option = fcntl(_sd, F_GETFL, 0); + if (l_sd != ftp_data.ld_sd) option |= O_NONBLOCK; + fcntl(_sd, F_SETFL, option); + + return E_FTP_RESULT_OK; +} + +// Communication +void Server::send_reply(uint32_t status, const char* message) { + const char* msg = message ? message : ""; + // Use snprintf to safely format the entire reply, avoiding strcpy/strcat overflow + int written = snprintf((char*)ftp_cmd_buffer, FTP_MAX_PARAM_SIZE + FTP_CMD_SIZE_MAX - 1, + "%" PRIu32 " %s\r\n", status, msg); + if (written < 0 || written >= FTP_MAX_PARAM_SIZE + FTP_CMD_SIZE_MAX - 1) { + ESP_LOGW(TAG, "Reply truncated (status=%lu)", (unsigned long)status); + // Ensure null termination + ftp_cmd_buffer[FTP_MAX_PARAM_SIZE + FTP_CMD_SIZE_MAX - 1] = '\0'; + } + + int32_t timeout = 200; + ssize_t send_result; + size_t size = strlen((char*)ftp_cmd_buffer); + + vTaskDelay(1); + + while (timeout > 0) { + send_result = send(ftp_data.c_sd, ftp_cmd_buffer, size, 0); + if (send_result == (ssize_t)size) { + if (status == 221) { + if (ftp_data.d_sd >= 0) { + closesocket(ftp_data.d_sd); + ftp_data.d_sd = -1; + } + if (ftp_data.ld_sd >= 0) { + closesocket(ftp_data.ld_sd); + ftp_data.ld_sd = -1; + } + if (ftp_data.c_sd >= 0) { + closesocket(ftp_data.c_sd); + ftp_data.c_sd = -1; + } + ftp_data.substate = E_FTP_STE_SUB_DISCONNECTED; + close_filesystem_on_error(); + } else if (status == 426 || status == 451 || status == 550) { + if (ftp_data.d_sd >= 0) { + closesocket(ftp_data.d_sd); + ftp_data.d_sd = -1; + } + close_filesystem_on_error(); + } + vTaskDelay(1); + break; + } else { + if (errno != EAGAIN) { + reset(); + ESP_LOGW(TAG, "Error sending command reply."); + break; + } + vTaskDelay(1); + timeout -= portTICK_PERIOD_MS; + } + } + if (timeout <= 0) { + ESP_LOGW(TAG, "Timeout sending command reply."); + reset(); + } +} + +void Server::send_list(uint32_t datasize) { + int32_t timeout = 200; + uint32_t bytes_sent = 0; + + vTaskDelay(1); + + while (timeout > 0 && bytes_sent < datasize) { + ssize_t send_result = send(ftp_data.d_sd, ftp_data.dBuffer + bytes_sent, datasize - bytes_sent, 0); + if (send_result > 0) { + bytes_sent += send_result; + if (bytes_sent >= datasize) { + vTaskDelay(1); + ESP_LOGI(TAG, "Send OK"); + return; + } + // Partial send - continue without resetting timeout + } else if (send_result < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + // Would block - wait and retry + vTaskDelay(1); + timeout -= portTICK_PERIOD_MS; + } else { + // Actual error + ESP_LOGW(TAG, "Error sending list data (errno=%d).", errno); + reset(); + return; + } + } else { + // send_result == 0: connection closed + ESP_LOGW(TAG, "Connection closed while sending list data."); + reset(); + return; + } + } + if (bytes_sent < datasize) { + ESP_LOGW(TAG, "Timeout sending list data (sent %" PRIu32 "/%" PRIu32 ").", bytes_sent, datasize); + reset(); + } +} + +void Server::send_file_data(uint32_t datasize) { + uint32_t timeout = 200; + uint32_t bytes_sent = 0; + + vTaskDelay(1); + + while (timeout > 0 && bytes_sent < datasize) { + ssize_t send_result = send(ftp_data.d_sd, ftp_data.dBuffer + bytes_sent, datasize - bytes_sent, 0); + if (send_result > 0) { + bytes_sent += send_result; + if (bytes_sent >= datasize) { + vTaskDelay(1); + ESP_LOGI(TAG, "Send OK"); + return; + } + // Partial send - continue without resetting timeout + } else if (send_result < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + // Would block - wait and retry + vTaskDelay(1); + timeout -= portTICK_PERIOD_MS; + } else { + // Actual error + ESP_LOGW(TAG, "Error sending file data (errno=%d).", errno); + reset(); + return; + } + } else { + // send_result == 0: connection closed + ESP_LOGW(TAG, "Connection closed while sending file data."); + reset(); + return; + } + } + if (bytes_sent < datasize) { + ESP_LOGW(TAG, "Timeout sending file data (sent %" PRIu32 "/%" PRIu32 ").", bytes_sent, datasize); + reset(); + } +} + +Server::ftp_result_t Server::recv_non_blocking(int32_t sd, void* buff, int32_t Maxlen, int32_t* rxLen) { + if (sd < 0) return E_FTP_RESULT_FAILED; + + *rxLen = recv(sd, buff, Maxlen, 0); + if (*rxLen > 0) + return E_FTP_RESULT_OK; + else if (errno != EAGAIN) + return E_FTP_RESULT_FAILED; + + return E_FTP_RESULT_CONTINUE; +} + +// Path operations +void Server::open_child(char* pwd, char* dir) { + ESP_LOGD(TAG, "open_child: [%s] + [%s]", pwd, dir); + if (strlen(dir) > 0) { + if (dir[0] == '/') { + // ** absolute path + strncpy(pwd, dir, FTP_MAX_PARAM_SIZE - 1); + pwd[FTP_MAX_PARAM_SIZE - 1] = '\0'; + } else { + // ** relative path + size_t pwd_len = strlen(pwd); + // add trailing '/' if needed + if ((pwd_len > 1) && (pwd[pwd_len - 1] != '/') && (dir[0] != '/')) { + if (pwd_len < FTP_MAX_PARAM_SIZE - 1) { + pwd[pwd_len++] = '/'; + pwd[pwd_len] = '\0'; + } + } + // append directory/file name + strncat(pwd, dir, FTP_MAX_PARAM_SIZE - pwd_len - 1); + } + } + ESP_LOGD(TAG, "open_child, New pwd: %s", pwd); +} + +void Server::close_child(char* pwd) { + ESP_LOGD(TAG, "close_child: [%s] (len=%d)", pwd, strlen(pwd)); + + // Remove last path component + uint len = strlen(pwd); + + // Handle trailing slash first + if (len > 1 && pwd[len - 1] == '/') { + pwd[len - 1] = '\0'; + len--; + } + + // Now find and remove the last path component + // Walk backwards to find the previous '/' + while (len > 1) { + len--; + if (pwd[len] == '/') { + pwd[len] = '\0'; + break; + } + } + + // If we're at a top-level directory like "/sdcard" or "/data", go to root + if (len <= 1 || pwd[0] != '/' || strchr(pwd + 1, '/') == nullptr) { + pwd[0] = '/'; + pwd[1] = '\0'; + } + + ESP_LOGD(TAG, "close_child, New pwd: %s", pwd); +} + +void Server::remove_fname_from_path(char* pwd, char* fname) { + ESP_LOGD(TAG, "remove_fname_from_path: %s - %s", pwd, fname); + size_t fname_len = strlen(fname); + if (fname_len == 0) return; + + size_t pwd_len = strlen(pwd); + if (pwd_len < fname_len) return; + + // Check if fname is the suffix of pwd (the last path component) + // It should match at the end, possibly preceded by '/' + char* suffix_start = pwd + pwd_len - fname_len; + if (strcmp(suffix_start, fname) == 0) { + // Verify it's a complete path component (preceded by '/' or is the entire path after root) + if (suffix_start == pwd || *(suffix_start - 1) == '/') { + // Remove the trailing slash before the filename too, if present + if (suffix_start > pwd && *(suffix_start - 1) == '/') { + suffix_start--; + } + *suffix_start = '\0'; + // Ensure we don't leave an empty path + if (pwd[0] == '\0') { + pwd[0] = '/'; + pwd[1] = '\0'; + } + } + } + ESP_LOGD(TAG, "remove_fname_from_path: New pwd: %s", pwd); +} + +// Command parsing +void Server::pop_param(char** str, char* param, size_t maxlen, bool stop_on_space, bool stop_on_newline) { + char lastc = '\0'; + size_t copied = 0; + bool in_quotes = false; + + // Skip leading spaces + while (**str == ' ') (*str)++; + + // Check if parameter is quoted + if (**str == '"') { + in_quotes = true; + (*str)++; // Skip opening quote + } + + while (**str != '\0') { + // Handle closing quote + if (in_quotes && **str == '"') { + (*str)++; // Skip closing quote + break; + } + + if (!in_quotes && stop_on_space && (**str == ' ')) break; + if ((**str == '\r') || (**str == '\n')) { + if (!stop_on_newline) { + (*str)++; + continue; + } else + break; + } + if ((**str == '/') && (lastc == '/')) { + (*str)++; + continue; + } + lastc = **str; + if (copied + 1 < maxlen) { + *param++ = **str; + copied++; + } + (*str)++; + } + *param = '\0'; + + // Trim trailing whitespace + while (copied > 0 && (param[-1] == ' ' || param[-1] == '\r' || param[-1] == '\n')) { + param--; + *param = '\0'; + copied--; + } +} + +Server::ftp_cmd_index_t Server::pop_command(char** str) { + char _cmd[FTP_CMD_SIZE_MAX]; + pop_param(str, _cmd, FTP_CMD_SIZE_MAX, true, true); + stoupper(_cmd); + for (ftp_cmd_index_t i = (ftp_cmd_index_t)0; i < E_FTP_NUM_FTP_CMDS; + i = (ftp_cmd_index_t)(i + 1)) { + if (!strcmp(_cmd, ftp_cmd_table[i].cmd)) { + (*str)++; + return i; + } + } + return E_FTP_CMD_NOT_SUPPORTED; +} + +void Server::get_param_and_open_child(char** bufptr) { + pop_param(bufptr, ftp_scratch_buffer, FTP_MAX_PARAM_SIZE, false, false); + open_child(ftp_path, ftp_scratch_buffer); + ftp_data.closechild = true; +} + +// Main command processing +void Server::process_cmd() { + int32_t len; + char* bufptr = (char*)ftp_cmd_buffer; + ftp_result_t result; + struct stat buf; + int res; + + memset(bufptr, 0, FTP_MAX_PARAM_SIZE + FTP_CMD_SIZE_MAX); + ftp_data.closechild = false; + + const int32_t cmd_buf_cap = FTP_MAX_PARAM_SIZE + FTP_CMD_SIZE_MAX; + result = recv_non_blocking(ftp_data.c_sd, ftp_cmd_buffer, cmd_buf_cap - 1, &len); + if (result == E_FTP_RESULT_FAILED) { + ESP_LOGI(TAG, "Client disconnected"); + close_cmd_data(); + ftp_data.substate = E_FTP_STE_SUB_DISCONNECTED; + return; + } + if (result == E_FTP_RESULT_OK) { + if (len >= cmd_buf_cap) { + len = cmd_buf_cap - 1; + } + ftp_cmd_buffer[len] = '\0'; + ftp_cmd_index_t cmd = pop_command(&bufptr); + if (!ftp_data.loggin.passvalid && + ((cmd != E_FTP_CMD_USER) && (cmd != E_FTP_CMD_PASS) && + (cmd != E_FTP_CMD_QUIT) && (cmd != E_FTP_CMD_FEAT) && + (cmd != E_FTP_CMD_AUTH))) { + send_reply(332, nullptr); + return; + } + if ((cmd >= 0) && (cmd < E_FTP_NUM_FTP_CMDS)) { + ESP_LOGI(TAG, "CMD: %s", ftp_cmd_table[cmd].cmd); + } else { + ESP_LOGI(TAG, "CMD: %d", cmd); + } + // Use safe path buffers with proper size and safe string operations + char fullname[FTP_MAX_PATH_SIZE]; + char fullname2[FTP_MAX_PATH_SIZE]; + snprintf(fullname, sizeof(fullname), "%s", MOUNT_POINT); + snprintf(fullname2, sizeof(fullname2), "%s", MOUNT_POINT); + + switch (cmd) { + case E_FTP_CMD_FEAT: + send_reply(502, (char*)"no-features"); + break; + case E_FTP_CMD_AUTH: + send_reply(504, (char*)"not-supported"); + break; + case E_FTP_CMD_SYST: + send_reply(215, (char*)"UNIX Type: L8"); + break; + case E_FTP_CMD_CDUP: + ESP_LOGI(TAG, "CDUP from %s", ftp_path); + close_child(ftp_path); + ESP_LOGI(TAG, "CDUP to %s", ftp_path); + send_reply(250, nullptr); + break; + case E_FTP_CMD_CWD: + pop_param(&bufptr, ftp_scratch_buffer, FTP_MAX_PARAM_SIZE, false, true); // Don't stop on space, DO stop on newline + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(ftp_scratch_buffer, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "CWD: invalid path rejected"); + send_reply(550, nullptr); + break; + } + if (strlen(ftp_scratch_buffer) > 0) { + if ((ftp_scratch_buffer[0] == '.') && + (ftp_scratch_buffer[1] == '\0')) { + ftp_data.dp = nullptr; + send_reply(250, nullptr); + break; + } + if ((ftp_scratch_buffer[0] == '.') && + (ftp_scratch_buffer[1] == '.') && + (ftp_scratch_buffer[2] == '\0')) { + close_child(ftp_path); + send_reply(250, nullptr); + break; + } else { + open_child(ftp_path, ftp_scratch_buffer); + } + } + if ((ftp_path[0] == '/') && (ftp_path[1] == '\0')) { + ftp_data.dp = nullptr; + send_reply(250, nullptr); + } else { + char actual_path[FTP_MAX_PATH_SIZE]; + translate_path(actual_path, sizeof(actual_path), ftp_path); + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_path); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "CWD: path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_CWD fullname=[%s]", fullname); + // Delay before SD card operation to prevent SPI bus conflicts + vTaskDelay(pdMS_TO_TICKS(2)); + ftp_data.dp = opendir(fullname); + if (ftp_data.dp != nullptr) { + closedir(ftp_data.dp); + ftp_data.dp = nullptr; + ESP_LOGI(TAG, "Changed directory to: %s", ftp_path); + send_reply(250, nullptr); + } else { + close_child(ftp_path); + send_reply(550, nullptr); + } + } + break; + case E_FTP_CMD_PWD: + case E_FTP_CMD_XPWD: { + // Buffer needs to hold ftp_path (up to FTP_MAX_PARAM_SIZE) plus quotes + char lpath[FTP_MAX_PARAM_SIZE + 4]; + // RFC 959 requires quoted path: 257 "pathname" is current directory + snprintf(lpath, sizeof(lpath), "\"%s\"", ftp_path); + send_reply(257, lpath); + } break; + case E_FTP_CMD_SIZE: { + get_param_and_open_child(&bufptr); + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(ftp_path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "SIZE: invalid path rejected"); + send_reply(550, nullptr); + break; + } + char actual_path_size[FTP_MAX_PATH_SIZE]; + translate_path(actual_path_size, sizeof(actual_path_size), ftp_path); + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_path_size); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "SIZE: path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_SIZE fullname=[%s]", fullname); + int res = stat(fullname, &buf); + if (res == 0) { + snprintf((char*)ftp_data.dBuffer, ftp_buff_size, "%" PRIu32, (uint32_t)buf.st_size); + send_reply(213, (char*)ftp_data.dBuffer); + } else { + send_reply(550, nullptr); + } + } break; + case E_FTP_CMD_MDTM: { + get_param_and_open_child(&bufptr); + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(ftp_path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "MDTM: invalid path rejected"); + send_reply(550, nullptr); + break; + } + char actual_path_mdtm[FTP_MAX_PATH_SIZE]; + translate_path(actual_path_mdtm, sizeof(actual_path_mdtm), ftp_path); + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_path_mdtm); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "MDTM: path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_MDTM fullname=[%s]", fullname); + res = stat(fullname, &buf); + if (res == 0) { + time_t time = buf.st_mtime; + struct tm* ptm = localtime(&time); + strftime((char*)ftp_data.dBuffer, ftp_buff_size, "%Y%m%d%H%M%S", ptm); + ESP_LOGI(TAG, "E_FTP_CMD_MDTM ftp_data.dBuffer=[%s]", ftp_data.dBuffer); + send_reply(213, (char*)ftp_data.dBuffer); + } else { + send_reply(550, nullptr); + } + } break; + case E_FTP_CMD_TYPE: + send_reply(200, nullptr); + break; + case E_FTP_CMD_USER: + pop_param(&bufptr, ftp_scratch_buffer, FTP_MAX_PARAM_SIZE, true, true); + { + ftp_data.loggin.uservalid = false; + size_t user_len = strlen(ftp_user); + size_t input_len = strlen(ftp_scratch_buffer); + if (user_len == input_len && user_len > 0 && + secure_compare(ftp_scratch_buffer, ftp_user, user_len)) { + ftp_data.loggin.uservalid = true; + } + // Clear credentials from memory after validation + memset(ftp_scratch_buffer, 0, FTP_MAX_PARAM_SIZE); + } + send_reply(331, nullptr); + break; + case E_FTP_CMD_PASS: + pop_param(&bufptr, ftp_scratch_buffer, FTP_MAX_PARAM_SIZE, true, true); + { + // Rate limiting: max 3 login attempts, then delay increases + static const uint8_t MAX_LOGIN_RETRIES = 3; + if (ftp_data.logginRetries >= MAX_LOGIN_RETRIES) { + // Add exponential backoff delay for repeated failures + uint32_t delay_ms = 1000 * (ftp_data.logginRetries - MAX_LOGIN_RETRIES + 1); + if (delay_ms > 5000) delay_ms = 5000; // Cap at 5 seconds + ESP_LOGW(TAG, "Login rate limited, delaying %lu ms", (unsigned long)delay_ms); + vTaskDelay(pdMS_TO_TICKS(delay_ms)); + } + + size_t pass_len = strlen(ftp_pass); + size_t input_len = strlen(ftp_scratch_buffer); + bool valid = ftp_data.loggin.uservalid && pass_len == input_len && + secure_compare(ftp_scratch_buffer, ftp_pass, pass_len); + + // Clear password from memory immediately after validation + memset(ftp_scratch_buffer, 0, FTP_MAX_PARAM_SIZE); + + if (valid) { + ftp_data.loggin.passvalid = true; + ftp_data.logginRetries = 0; // Reset on success + send_reply(230, nullptr); + ESP_LOGW(TAG, "Connected."); + break; + } + ftp_data.logginRetries++; + } + send_reply(530, nullptr); + break; + case E_FTP_CMD_PASV: { + closesocket(ftp_data.d_sd); + ftp_data.d_sd = -1; + ftp_data.substate = E_FTP_STE_SUB_DISCONNECTED; + bool socketcreated = true; + if (ftp_data.ld_sd < 0) { + socketcreated = create_listening_socket( + &ftp_data.ld_sd, FTP_PASSIVE_DATA_PORT, + FTP_DATA_CLIENTS_MAX - 1 + ); + } + if (socketcreated) { + uint8_t* pip = (uint8_t*)&ftp_data.ip_addr; + ftp_data.dtimeout = 0; + snprintf((char*)ftp_data.dBuffer, ftp_buff_size, "(%u,%u,%u,%u,%u,%u)", pip[0], pip[1], pip[2], pip[3], (FTP_PASSIVE_DATA_PORT >> 8), (FTP_PASSIVE_DATA_PORT & 0xFF)); + ftp_data.substate = E_FTP_STE_SUB_LISTEN_FOR_DATA; + ESP_LOGI(TAG, "Data socket created"); + send_reply(227, (char*)ftp_data.dBuffer); + } else { + ESP_LOGW(TAG, "Error creating data socket"); + send_reply(425, nullptr); + } + } break; + case E_FTP_CMD_LIST: + case E_FTP_CMD_NLST: + get_param_and_open_child(&bufptr); + if (cmd == E_FTP_CMD_LIST) + ftp_nlist = 0; + else + ftp_nlist = 1; + if (open_dir_for_listing(ftp_path) == E_FTP_RESULT_CONTINUE) { + ftp_data.state = E_FTP_STE_CONTINUE_LISTING; + send_reply(150, nullptr); + } else { + send_reply(550, nullptr); + } + break; + case E_FTP_CMD_RETR: + ftp_data.total = 0; + ftp_data.time = 0; + get_param_and_open_child(&bufptr); + if ((strlen(ftp_path) > 0) && + (ftp_path[strlen(ftp_path) - 1] != '/')) { + if (open_file(ftp_path, "rb")) { + log_to_screen("[<<] Download: %s", ftp_path); + ftp_data.state = E_FTP_STE_CONTINUE_FILE_TX; + vTaskDelay(pdMS_TO_TICKS(20)); + send_reply(150, nullptr); + } else { + ftp_data.state = E_FTP_STE_END_TRANSFER; + send_reply(550, nullptr); + } + } else { + ftp_data.state = E_FTP_STE_END_TRANSFER; + send_reply(550, nullptr); + } + break; + case E_FTP_CMD_APPE: + ftp_data.total = 0; + ftp_data.time = 0; + get_param_and_open_child(&bufptr); + if ((strlen(ftp_path) > 0) && + (ftp_path[strlen(ftp_path) - 1] != '/')) { + if (open_file(ftp_path, "ab")) { + log_to_screen("[OK] Append: %s", ftp_path); + ftp_data.state = E_FTP_STE_CONTINUE_FILE_RX; + vTaskDelay(pdMS_TO_TICKS(20)); + send_reply(150, nullptr); + } else { + ftp_data.state = E_FTP_STE_END_TRANSFER; + send_reply(550, nullptr); + } + } else { + ftp_data.state = E_FTP_STE_END_TRANSFER; + send_reply(550, nullptr); + } + break; + case E_FTP_CMD_STOR: + ftp_data.total = 0; + ftp_data.time = 0; + get_param_and_open_child(&bufptr); + if ((strlen(ftp_path) > 0) && + (ftp_path[strlen(ftp_path) - 1] != '/')) { + ESP_LOGI(TAG, "E_FTP_CMD_STOR ftp_path=[%s]", ftp_path); + if (open_file(ftp_path, "wb")) { + log_to_screen("[>>] Upload: %s", ftp_path); + ftp_data.state = E_FTP_STE_CONTINUE_FILE_RX; + vTaskDelay(pdMS_TO_TICKS(20)); + send_reply(150, nullptr); + } else { + ftp_data.state = E_FTP_STE_END_TRANSFER; + send_reply(550, nullptr); + } + } else { + ftp_data.state = E_FTP_STE_END_TRANSFER; + send_reply(550, nullptr); + } + break; + case E_FTP_CMD_DELE: + get_param_and_open_child(&bufptr); + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(ftp_path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "DELE: invalid path rejected"); + send_reply(550, nullptr); + break; + } + if ((strlen(ftp_path) > 0) && + (ftp_path[strlen(ftp_path) - 1] != '/')) { + ESP_LOGI(TAG, "E_FTP_CMD_DELE ftp_path=[%s]", ftp_path); + char actual_path_dele[FTP_MAX_PATH_SIZE]; + translate_path(actual_path_dele, sizeof(actual_path_dele), ftp_path); + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_path_dele); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "DELE: path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_DELE fullname=[%s]", fullname); + // Delay before SD card operation to prevent SPI bus conflicts + vTaskDelay(pdMS_TO_TICKS(5)); + if (unlink(fullname) == 0) { + vTaskDelay(pdMS_TO_TICKS(20)); + ESP_LOGI(TAG, "File deleted: %s", ftp_path); + send_reply(250, nullptr); + log_to_screen("[OK] Deleted: %s", ftp_path); + } else + send_reply(550, nullptr); + } else + send_reply(250, nullptr); + break; + case E_FTP_CMD_RMD: + get_param_and_open_child(&bufptr); + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(ftp_path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "RMD: invalid path rejected"); + send_reply(550, nullptr); + break; + } + if ((strlen(ftp_path) > 0) && + (ftp_path[strlen(ftp_path) - 1] != '/')) { + ESP_LOGI(TAG, "E_FTP_CMD_RMD ftp_path=[%s]", ftp_path); + char actual_path_rmd[FTP_MAX_PATH_SIZE]; + translate_path(actual_path_rmd, sizeof(actual_path_rmd), ftp_path); + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_path_rmd); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "RMD: path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_RMD fullname=[%s]", fullname); + // Delay before SD card operation to prevent SPI bus conflicts + vTaskDelay(pdMS_TO_TICKS(5)); + if (rmdir(fullname) == 0) { + vTaskDelay(pdMS_TO_TICKS(20)); + ESP_LOGI(TAG, "Directory removed: %s", ftp_path); + send_reply(250, nullptr); + log_to_screen("[OK] Removed dir: %s", ftp_path); + } else + send_reply(550, nullptr); + } else + send_reply(250, nullptr); + break; + case E_FTP_CMD_MKD: + get_param_and_open_child(&bufptr); + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(ftp_path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "MKD: invalid path rejected"); + send_reply(550, nullptr); + break; + } + if ((strlen(ftp_path) > 0) && + (ftp_path[strlen(ftp_path) - 1] != '/')) { + ESP_LOGI(TAG, "E_FTP_CMD_MKD ftp_path=[%s]", ftp_path); + char actual_path_mkd[FTP_MAX_PATH_SIZE]; + translate_path(actual_path_mkd, sizeof(actual_path_mkd), ftp_path); + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_path_mkd); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "MKD: path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_MKD fullname=[%s]", fullname); + // Add delay before SD card operation to allow SPI bus to settle + // This helps prevent SPI conflicts on devices like T-Deck Plus + vTaskDelay(pdMS_TO_TICKS(5)); + if (mkdir(fullname, 0755) == 0) { + vTaskDelay(pdMS_TO_TICKS(50)); + ESP_LOGI(TAG, "Directory created: %s", ftp_path); + send_reply(250, nullptr); + log_to_screen("[OK] Created dir: %s", ftp_path); + } else + send_reply(550, nullptr); + } else + send_reply(250, nullptr); + break; + case E_FTP_CMD_RNFR: + get_param_and_open_child(&bufptr); + // Validate path to prevent traversal attacks and check length + if (!sanitize_path(ftp_path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "RNFR: invalid path rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_RNFR ftp_path=[%s]", ftp_path); + { + char actual_path_rnfr[FTP_MAX_PATH_SIZE]; + translate_path(actual_path_rnfr, sizeof(actual_path_rnfr), ftp_path); + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_path_rnfr); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "RNFR: path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_RNFR fullname=[%s]", fullname); + res = stat(fullname, &buf); + if (res == 0) { + send_reply(350, nullptr); + strncpy((char*)ftp_data.dBuffer, ftp_path, ftp_buff_size - 1); + ((char*)ftp_data.dBuffer)[ftp_buff_size - 1] = '\0'; + } else { + send_reply(550, nullptr); + } + log_to_screen("[**] Renaming: %s", ftp_path); + } + break; + case E_FTP_CMD_RNTO: + get_param_and_open_child(&bufptr); + // Validate both paths to prevent traversal attacks and check length + if (!sanitize_path(ftp_path, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "RNTO: invalid destination path rejected"); + send_reply(550, nullptr); + break; + } + if (!sanitize_path((char*)ftp_data.dBuffer, FTP_MAX_PATH_SIZE)) { + ESP_LOGW(TAG, "RNTO: invalid source path rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_RNTO ftp_path=[%s], ftp_data.dBuffer=[%s]", ftp_path, (char*)ftp_data.dBuffer); + { + char actual_old[FTP_MAX_PATH_SIZE]; + translate_path(actual_old, sizeof(actual_old), (char*)ftp_data.dBuffer); + int written = snprintf(fullname, sizeof(fullname), "%s%s", MOUNT_POINT, actual_old); + if (written < 0 || (size_t)written >= sizeof(fullname)) { + ESP_LOGW(TAG, "RNTO: source path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_RNTO fullname=[%s]", fullname); + char actual_new[FTP_MAX_PATH_SIZE]; + translate_path(actual_new, sizeof(actual_new), ftp_path); + written = snprintf(fullname2, sizeof(fullname2), "%s%s", MOUNT_POINT, actual_new); + if (written < 0 || (size_t)written >= sizeof(fullname2)) { + ESP_LOGW(TAG, "RNTO: destination path too long, truncation rejected"); + send_reply(550, nullptr); + break; + } + ESP_LOGI(TAG, "E_FTP_CMD_RNTO fullname2=[%s]", fullname2); + // Delay before SD card operation to prevent SPI bus conflicts + vTaskDelay(pdMS_TO_TICKS(5)); + if (rename(fullname, fullname2) == 0) { + ESP_LOGI(TAG, "File renamed from %s to %s", (char*)ftp_data.dBuffer, ftp_path); + send_reply(250, nullptr); + } else { + send_reply(550, nullptr); + } + } + log_to_screen("[OK] Renamed to: %s", ftp_path); + break; + case E_FTP_CMD_NOOP: + send_reply(200, nullptr); + break; + case E_FTP_CMD_QUIT: + ESP_LOGI(TAG, "Client disconnected (QUIT)"); + send_reply(221, nullptr); + close_cmd_data(); + ftp_data.state = E_FTP_STE_START; + break; + default: + send_reply(502, nullptr); + break; + } + + if (ftp_data.closechild) { + remove_fname_from_path(ftp_path, ftp_scratch_buffer); + } + } else if (result == E_FTP_RESULT_CONTINUE) { + if (ftp_data.ctimeout > ftp_timeout) { + send_reply(221, nullptr); + ESP_LOGW(TAG, "Connection timeout"); + } + } else { + close_cmd_data(); + } +} + +void Server::wait_for_enabled() { + if (ftp_data.enabled) { + ftp_data.state = E_FTP_STE_START; + } +} + +void Server::deinit() { + if (ftp_path) free(ftp_path); + if (ftp_cmd_buffer) free(ftp_cmd_buffer); + if (ftp_data.dBuffer) free(ftp_data.dBuffer); + if (ftp_scratch_buffer) free(ftp_scratch_buffer); + ftp_path = nullptr; + ftp_cmd_buffer = nullptr; + ftp_data.dBuffer = nullptr; + ftp_scratch_buffer = nullptr; +} + +bool Server::init() { + ftp_stop.store(0, std::memory_order_release); + deinit(); + // Reset buffer size to default at init to prevent memory accumulation from previous sessions + ftp_buff_size = FTPSERVER_BUFFER_SIZE; + memset(&ftp_data, 0, sizeof(ftp_data_t)); + ftp_data.dBuffer = (uint8_t*)malloc(ftp_buff_size + 1); + if (ftp_data.dBuffer == nullptr) { + goto error_dbuffer; + } + ftp_path = (char*)malloc(FTP_MAX_PARAM_SIZE); + if (ftp_path == nullptr) { + goto error_path; + } + strcpy(ftp_path, "/"); + ftp_scratch_buffer = (char*)malloc(FTP_MAX_PARAM_SIZE); + if (ftp_scratch_buffer == nullptr) { + goto error_scratch; + } + ftp_cmd_buffer = (char*)malloc(FTP_MAX_PARAM_SIZE + FTP_CMD_SIZE_MAX); + if (ftp_cmd_buffer == nullptr) { + goto error_cmd; + } + + ftp_data.c_sd = -1; + ftp_data.d_sd = -1; + ftp_data.lc_sd = -1; + ftp_data.ld_sd = -1; + ftp_data.e_open = E_FTP_NOTHING_OPEN; + ftp_data.state = E_FTP_STE_DISABLED; + ftp_data.substate = E_FTP_STE_SUB_DISCONNECTED; + + return true; + +error_cmd: + free(ftp_scratch_buffer); +error_scratch: + free(ftp_path); +error_path: + free(ftp_data.dBuffer); +error_dbuffer: + ftp_data.dBuffer = nullptr; + ftp_path = nullptr; + ftp_scratch_buffer = nullptr; + ftp_cmd_buffer = nullptr; + return false; +} + +int Server::run(uint32_t elapsed) { + if (!ftp_mutex) { + ESP_LOGE(TAG, "FTP mutex not initialized"); + return -1; + } + xSemaphoreTake(ftp_mutex, portMAX_DELAY); + + if (ftp_stop.load(std::memory_order_acquire)) { + ESP_LOGI(TAG, "Stop flag detected in run()"); + xSemaphoreGive(ftp_mutex); + return -2; + } + + ftp_data.dtimeout += elapsed; + ftp_data.ctimeout += elapsed; + ftp_data.time += elapsed; + + switch (ftp_data.state) { + case E_FTP_STE_DISABLED: + wait_for_enabled(); + break; + case E_FTP_STE_START: + if (create_listening_socket(&ftp_data.lc_sd, ftp_cmd_port, FTP_CMD_CLIENTS_MAX - 1)) { + ftp_data.state = E_FTP_STE_READY; + } + break; + case E_FTP_STE_READY: + if (ftp_data.c_sd < 0 && + ftp_data.substate == E_FTP_STE_SUB_DISCONNECTED) { + if (E_FTP_RESULT_OK == wait_for_connection(ftp_data.lc_sd, &ftp_data.c_sd, &ftp_data.ip_addr)) { + ftp_data.txRetries = 0; + ftp_data.logginRetries = 0; + ftp_data.ctimeout = 0; + ftp_data.loggin.uservalid = false; + ftp_data.loggin.passvalid = false; + strcpy(ftp_path, "/"); + ESP_LOGI(TAG, "Connected."); + send_reply(220, (char*)FTP_SERVER_NAME); + break; + } + } + if (ftp_data.c_sd > 0 && + ftp_data.substate != E_FTP_STE_SUB_LISTEN_FOR_DATA) { + process_cmd(); + if (ftp_data.state != E_FTP_STE_READY) { + break; + } + } + break; + case E_FTP_STE_END_TRANSFER: + if (ftp_data.d_sd >= 0) { + closesocket(ftp_data.d_sd); + ftp_data.d_sd = -1; + } + break; + case E_FTP_STE_CONTINUE_LISTING: { + uint32_t listsize = 0; + ftp_result_t list_res = + list_dir((char*)ftp_data.dBuffer, ftp_buff_size, &listsize); + if (listsize > 0) send_list(listsize); + if (list_res == E_FTP_RESULT_OK) { + send_reply(226, nullptr); + ftp_data.state = E_FTP_STE_END_TRANSFER; + } + ftp_data.ctimeout = 0; + } break; + case E_FTP_STE_CONTINUE_FILE_TX: { + uint32_t readsize; + ftp_result_t result; + ftp_data.ctimeout = 0; + result = + read_file((char*)ftp_data.dBuffer, ftp_buff_size, &readsize); + if (result == E_FTP_RESULT_FAILED) { + send_reply(451, nullptr); + ftp_data.state = E_FTP_STE_END_TRANSFER; + } else { + if (readsize > 0) { + send_file_data(readsize); + ftp_data.total += readsize; + ESP_LOGI(TAG, "Sent %" PRIu32 ", total: %" PRIu32, readsize, ftp_data.total); + if (ftp_data.total % 102400 == 0 && ftp_data.total > 0) { + log_to_screen("[^^] Progress: %" PRIu32 " KB", ftp_data.total / 1024); + } + } + if (result == E_FTP_RESULT_OK) { + send_reply(226, nullptr); + ftp_data.state = E_FTP_STE_END_TRANSFER; + ESP_LOGI(TAG, "File sent (%" PRIu32 " bytes in %" PRIu32 " msec).", ftp_data.total, ftp_data.time); + } + } + } break; + case E_FTP_STE_CONTINUE_FILE_RX: { + int32_t len; + ftp_result_t result = E_FTP_RESULT_OK; + ESP_LOGI(TAG, "ftp_buff_size=%d", ftp_buff_size); + result = recv_non_blocking(ftp_data.d_sd, ftp_data.dBuffer, ftp_buff_size, &len); + if (result == E_FTP_RESULT_OK) { + ftp_data.dtimeout = 0; + ftp_data.ctimeout = 0; + if (E_FTP_RESULT_OK != + write_file((char*)ftp_data.dBuffer, len)) { + send_reply(451, nullptr); + ftp_data.state = E_FTP_STE_END_TRANSFER; + ESP_LOGW(TAG, "Error writing to file"); + } else { + ftp_data.total += len; + ESP_LOGI(TAG, "Received %" PRIu32 ", total: %" PRIu32, len, ftp_data.total); + if (ftp_data.total % 102400 == 0 && ftp_data.total > 0) { + log_to_screen("[^^] Progress: %" PRIu32 " KB", ftp_data.total / 1024); + } + } + } else if (result == E_FTP_RESULT_CONTINUE) { + if (ftp_data.dtimeout > FTP_DATA_TIMEOUT_MS) { + close_files_dir(); + send_reply(426, nullptr); + ftp_data.state = E_FTP_STE_END_TRANSFER; + ESP_LOGW(TAG, "Receiving to file timeout"); + } + } else { + close_files_dir(); + send_reply(226, nullptr); + ftp_data.state = E_FTP_STE_END_TRANSFER; + ESP_LOGI(TAG, "File received (%" PRIu32 " bytes in %" PRIu32 " msec).", ftp_data.total, ftp_data.time); + break; + } + } break; + default: + break; + } + + switch (ftp_data.substate) { + case E_FTP_STE_SUB_DISCONNECTED: + break; + case E_FTP_STE_SUB_LISTEN_FOR_DATA: + if (E_FTP_RESULT_OK == + wait_for_connection(ftp_data.ld_sd, &ftp_data.d_sd, nullptr)) { + ftp_data.dtimeout = 0; + ftp_data.substate = E_FTP_STE_SUB_DATA_CONNECTED; + } else if (ftp_data.dtimeout > FTP_DATA_TIMEOUT_MS) { + ESP_LOGW(TAG, "Waiting for data connection timeout (%" PRIi32 ")", ftp_data.dtimeout); + ftp_data.dtimeout = 0; + closesocket(ftp_data.ld_sd); + ftp_data.ld_sd = -1; + ftp_data.substate = E_FTP_STE_SUB_DISCONNECTED; + } + break; + case E_FTP_STE_SUB_DATA_CONNECTED: + if (ftp_data.state == E_FTP_STE_READY && + (ftp_data.dtimeout > FTP_DATA_TIMEOUT_MS)) { + closesocket(ftp_data.ld_sd); + closesocket(ftp_data.d_sd); + ftp_data.ld_sd = -1; + ftp_data.d_sd = -1; + close_filesystem_on_error(); + ftp_data.substate = E_FTP_STE_SUB_DISCONNECTED; + ESP_LOGW(TAG, "Data connection timeout"); + } + break; + default: + break; + } + + if (ftp_data.d_sd < 0 && (ftp_data.state > E_FTP_STE_READY)) { + ftp_data.substate = E_FTP_STE_SUB_DISCONNECTED; + ftp_data.state = E_FTP_STE_READY; + } + + xSemaphoreGive(ftp_mutex); + return 0; +} + +bool Server::enable() { + bool res = false; + if (ftp_data.state == E_FTP_STE_DISABLED) { + ftp_data.enabled = true; + res = true; + } + return res; +} + +bool Server::disable() { + bool res = false; + if (ftp_data.state == E_FTP_STE_READY) { + reset(); + ftp_data.enabled = false; + ftp_data.state = E_FTP_STE_DISABLED; + res = true; + } + return res; +} + +bool Server::terminate() { + bool res = false; + if (ftp_data.state == E_FTP_STE_READY) { + ftp_stop.store(1, std::memory_order_release); + reset(); + res = true; + } + return res; +} + +bool Server::stop_requested() { return ftp_stop.load(std::memory_order_acquire) == 1; } + +// Task loop - the main FTP server loop running in FreeRTOS task +void Server::task_loop() { + ESP_LOGI(TAG, "ftp_task start"); + // Use default credentials if not set + if (ftp_user[0] == '\0') { + strncpy(ftp_user, "ftp", FTP_USER_PASS_LEN_MAX); + ftp_user[FTP_USER_PASS_LEN_MAX] = '\0'; + } + if (ftp_pass[0] == '\0') { + strncpy(ftp_pass, "ftp123", FTP_USER_PASS_LEN_MAX); + ftp_pass[FTP_USER_PASS_LEN_MAX] = '\0'; + } + + uint64_t elapsed, time_ms = mp_hal_ticks_ms(); + + if (!init()) { + ESP_LOGE(TAG, "Init Error"); + xEventGroupSetBits(xEventTask, FTP_TASK_FINISH_BIT); + vTaskDelete(nullptr); + return; + } + + enable(); + + time_ms = mp_hal_ticks_ms(); + + while (1) { + // CHECK STOP FLAG FIRST + if (ftp_stop.load(std::memory_order_acquire) || stop_requested()) { + ESP_LOGI(TAG, "Stop requested, exiting task loop"); + break; + } + + elapsed = mp_hal_ticks_ms() - time_ms; + time_ms = mp_hal_ticks_ms(); + + int res = run(elapsed); + if (res < 0) { + if (res == -1) { + ESP_LOGE(TAG, "Run Error"); + } + if (res == -2) { + ESP_LOGI(TAG, "Stop requested via run()"); + } + break; + } + + vTaskDelay(1); + } + + ESP_LOGW(TAG, "Task terminating, cleaning up..."); + // Cleanup before exit + reset(); // Close all sockets + deinit(); // Free memory + + ESP_LOGW(TAG, "Task terminated!"); + xEventGroupSetBits(xEventTask, FTP_TASK_FINISH_BIT); + vTaskDelete(nullptr); +} + +// Static task wrapper for FreeRTOS +void Server::task_wrapper(void* pvParameters) { + Server* server = static_cast(pvParameters); + server->task_loop(); +} + +// Public interface implementation +void Server::start() { + if (ftp_mutex) { + xSemaphoreTake(ftp_mutex, portMAX_DELAY); + } + + if (ftp_task_handle) { + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } + ESP_LOGW("FTP", "FTP server already running"); + return; + } + + if (xEventTask) { + ESP_LOGW("FTP", "Event group already exists, cleaning up"); + vEventGroupDelete(xEventTask); + } + + xEventTask = xEventGroupCreate(); + if (!xEventTask) { + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } + ESP_LOGE("FTP", "Failed to create event group"); + return; + } + BaseType_t result = + xTaskCreate(task_wrapper, "FTP", FTP_TASK_STACK_SIZE, this, 1, &ftp_task_handle); + if (result != pdPASS) { + ESP_LOGE("FTP", "Failed to create FTP task"); + ftp_task_handle = nullptr; + vEventGroupDelete(xEventTask); + xEventTask = nullptr; + } else { + ESP_LOGI("FTP", "FTP server started"); + } + + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } +} + +void Server::stop() { + ESP_LOGI(TAG, "stop() called"); + + // Thread-safe check + if (ftp_mutex) { + ESP_LOGI(TAG, "Acquiring mutex for stop"); + xSemaphoreTake(ftp_mutex, portMAX_DELAY); + } + + if (!ftp_task_handle) { + ESP_LOGI(TAG, "No task running, nothing to stop"); + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } + return; + } + + ESP_LOGI(TAG, "Setting stop bit"); + + // Set the stop flag so task_loop and run() see it + ftp_stop.store(1, std::memory_order_release); + + // Release mutex before waiting (task needs it to check stop flag) + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } + + // Set stop bit + xEventGroupSetBits(xEventTask, FTP_STOP_BIT); + + ESP_LOGI(TAG, "Waiting for task to finish..."); + + // Wait for task to finish with timeout + EventBits_t bits = xEventGroupWaitBits( + xEventTask, + FTP_TASK_FINISH_BIT, + pdFALSE, + pdFALSE, + pdMS_TO_TICKS(5000) // 5 second timeout + ); + + if (bits & FTP_TASK_FINISH_BIT) { + ESP_LOGI(TAG, "Task finished cleanly"); + } else { + ESP_LOGE(TAG, "Task did not finish in time! Forcing cleanup"); + // Force kill task (not ideal but prevents lockup) + if (ftp_task_handle) { + vTaskDelete(ftp_task_handle); + // Allow FreeRTOS to fully remove the task before accessing shared state + // This reduces (but doesn't eliminate) the risk of racing with task cleanup + vTaskDelay(pdMS_TO_TICKS(100)); + ftp_task_handle = nullptr; + } + // Task cleanup didn't run, do it here + // Note: These may access partially corrupted state if task was mid-operation + // but it's better than leaking resources + reset(); + deinit(); + } + + // Re-acquire mutex for cleanup + if (ftp_mutex) { + ESP_LOGI(TAG, "Re-acquiring mutex for cleanup"); + xSemaphoreTake(ftp_mutex, portMAX_DELAY); + } + + // Cleanup + if (xEventTask) { + vEventGroupDelete(xEventTask); + xEventTask = nullptr; + } + ftp_task_handle = nullptr; + ftp_data.enabled = false; // Clear enabled flag so isEnabled() returns false + ftp_stop.store(0, std::memory_order_release); // Reset stop flag for next start + + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } + + ESP_LOGI(TAG, "stop() completed"); +} + +bool Server::isEnabled() const { + bool enabled = false; + if (ftp_mutex) { + xSemaphoreTake(ftp_mutex, portMAX_DELAY); + } + enabled = ftp_data.enabled; + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } + return enabled; +} + +int Server::getState() const { + int fstate = 0; + if (ftp_mutex) { + xSemaphoreTake(ftp_mutex, portMAX_DELAY); + } + + fstate = ftp_data.state | (ftp_data.substate << 8); + if ((ftp_data.state == E_FTP_STE_READY) && (ftp_data.c_sd > 0)) + fstate = E_FTP_STE_CONNECTED; + + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } + return fstate; +} + +void Server::setCredentials(const char* username, const char* password) { + if (ftp_mutex) { + xSemaphoreTake(ftp_mutex, portMAX_DELAY); + } + + if (username) { + strncpy(ftp_user, username, FTP_USER_PASS_LEN_MAX); + ftp_user[FTP_USER_PASS_LEN_MAX] = '\0'; + } + + if (password) { + strncpy(ftp_pass, password, FTP_USER_PASS_LEN_MAX); + ftp_pass[FTP_USER_PASS_LEN_MAX] = '\0'; + } + + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } +} + +void Server::setPort(uint16_t port) { + if (ftp_mutex) { + xSemaphoreTake(ftp_mutex, portMAX_DELAY); + } + + ftp_cmd_port = port; + + if (ftp_mutex) { + xSemaphoreGive(ftp_mutex); + } + + ESP_LOGI(TAG, "Port updated to %u", port); +} + +} // namespace FtpServer \ No newline at end of file diff --git a/Apps/FTPServer/main/Source/FtpServerCore.h b/Apps/FTPServer/main/Source/FtpServerCore.h new file mode 100644 index 0000000..06c31e8 --- /dev/null +++ b/Apps/FTPServer/main/Source/FtpServerCore.h @@ -0,0 +1,243 @@ +#ifndef FTP_SERVER_CORE_H +#define FTP_SERVER_CORE_H + +#include +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "sdkconfig.h" + +// String constants used for compile-time concatenation (must be macros) +#define FTP_STORAGE_NAME_INTERNAL "data" +#define FTP_STORAGE_NAME_SDCARD "sdcard" + +namespace FtpServer { + +// Namespace-scoped constants (preferred over macros for type safety) +inline constexpr uint16_t FTP_CMD_PORT = 21; +inline constexpr uint16_t FTP_PASSIVE_DATA_PORT = 2024; +inline constexpr size_t FTP_CMD_SIZE_MAX = 6; +inline constexpr uint8_t FTP_CMD_CLIENTS_MAX = 1; +inline constexpr uint8_t FTP_DATA_CLIENTS_MAX = 1; +inline constexpr size_t FTP_MAX_PARAM_SIZE = 512 + 1; +// 180 days = 15552000 seconds +// FTP LIST shows "MMM DD YYYY" for old files, "MMM DD HH:MM" for recent +inline constexpr time_t FTP_UNIX_SECONDS_180_DAYS = 180 * 24 * 60 * 60; +inline constexpr uint32_t FTP_DATA_TIMEOUT_MS = 10000; +inline constexpr uint8_t FTP_SOCKETFIFO_ELEMENTS_MAX = 4; +inline constexpr size_t FTP_USER_PASS_LEN_MAX = 32; +inline constexpr uint32_t FTP_CMD_TIMEOUT_MS = 300 * 1000; +inline constexpr size_t FTPSERVER_BUFFER_SIZE = 1024; +inline constexpr size_t FTPSERVER_MAX_BUFFER_SIZE = 16 * 1024; // Maximum buffer growth cap (16KB) +inline constexpr size_t FTP_MAX_PATH_SIZE = 256; // Safe maximum path size for all operations + +inline constexpr const char* VFS_NATIVE_INTERNAL_MP = "/data"; +inline constexpr const char* VFS_NATIVE_EXTERNAL_MP = "/sdcard"; +inline constexpr const char* FTP_SERVER_NAME = "Tactility FTP Server"; + +class Server { +public: + + // Public enums + typedef enum { + E_FTP_STE_DISABLED = 0, + E_FTP_STE_START, + E_FTP_STE_READY, + E_FTP_STE_END_TRANSFER, + E_FTP_STE_CONTINUE_LISTING, + E_FTP_STE_CONTINUE_FILE_TX, + E_FTP_STE_CONTINUE_FILE_RX, + E_FTP_STE_CONNECTED + } ftp_state_t; + + typedef enum { + E_FTP_STE_SUB_DISCONNECTED = 0, + E_FTP_STE_SUB_LISTEN_FOR_DATA, + E_FTP_STE_SUB_DATA_CONNECTED + } ftp_substate_t; + + typedef enum { + E_FTP_RESULT_OK = 0, + E_FTP_RESULT_CONTINUE, + E_FTP_RESULT_FAILED + } ftp_result_t; + + typedef enum { + E_FTP_NOTHING_OPEN = 0, + E_FTP_FILE_OPEN, + E_FTP_DIR_OPEN + } ftp_e_open_t; + + // Constructor/Destructor + Server(); + ~Server(); + + // Public interface + void start(); + void stop(); + bool isEnabled() const; + int getState() const; + void register_screen_log_callback(void (*callback)(const char*)); + void setCredentials(const char* username, const char* password); + void setPort(uint16_t port); + +private: + + // Private structs + typedef struct { + const char* cmd; + } ftp_cmd_t; + + typedef struct { + bool uservalid : 1; + bool passvalid : 1; + } ftp_loggin_t; + + typedef struct { + uint8_t* dBuffer; + uint32_t ctimeout; + union { + DIR* dp; + FILE* fp; + }; + int32_t lc_sd; + int32_t ld_sd; + int32_t c_sd; + int32_t d_sd; + int32_t dtimeout; + uint32_t ip_addr; + uint8_t state; + uint8_t substate; + uint8_t txRetries; + uint8_t logginRetries; + ftp_loggin_t loggin; + uint8_t e_open; + bool closechild; + bool enabled; + bool listroot; + uint32_t total; + uint32_t time; + } ftp_data_t; + + typedef enum { + E_FTP_CMD_NOT_SUPPORTED = -1, + E_FTP_CMD_FEAT = 0, + E_FTP_CMD_SYST, + E_FTP_CMD_CDUP, + E_FTP_CMD_CWD, + E_FTP_CMD_PWD, + E_FTP_CMD_XPWD, + E_FTP_CMD_SIZE, + E_FTP_CMD_MDTM, + E_FTP_CMD_TYPE, + E_FTP_CMD_USER, + E_FTP_CMD_PASS, + E_FTP_CMD_PASV, + E_FTP_CMD_LIST, + E_FTP_CMD_RETR, + E_FTP_CMD_STOR, + E_FTP_CMD_DELE, + E_FTP_CMD_RMD, + E_FTP_CMD_MKD, + E_FTP_CMD_RNFR, + E_FTP_CMD_RNTO, + E_FTP_CMD_NOOP, + E_FTP_CMD_QUIT, + E_FTP_CMD_APPE, + E_FTP_CMD_NLST, + E_FTP_CMD_AUTH, + E_FTP_NUM_FTP_CMDS + } ftp_cmd_index_t; + + // Member variables (formerly global) + static constexpr int FTP_STOP_BIT = (1 << 0); + static constexpr int FTP_TASK_FINISH_BIT = (1 << 2); + + EventGroupHandle_t xEventTask; + TaskHandle_t ftp_task_handle; + mutable SemaphoreHandle_t ftp_mutex; // mutable for const-correct mutex usage in const methods + + int ftp_buff_size; + int ftp_timeout; + const char* TAG; + const char* MOUNT_POINT; + + ftp_data_t ftp_data; + char* ftp_path; + char* ftp_scratch_buffer; + char* ftp_cmd_buffer; + std::atomic ftp_stop; + char ftp_user[FTP_USER_PASS_LEN_MAX + 1]; + char ftp_pass[FTP_USER_PASS_LEN_MAX + 1]; + uint8_t ftp_nlist; + uint16_t ftp_cmd_port; + + static const ftp_cmd_t ftp_cmd_table[]; + + // Private helper methods + bool sanitize_path(const char* path, size_t max_len = 0); + void translate_path(char* actual, size_t actual_size, const char* display); + void get_full_path(char* fullname, size_t size, const char* display_path); + bool secure_compare(const char* a, const char* b, size_t len); + bool add_virtual_dir_if_mounted(const char* mount_point, const char* name, char* list, uint32_t maxlistsize, uint32_t* next); + uint64_t mp_hal_ticks_ms(); + void stoupper(char* str); + void log_to_screen(const char* format, ...); + + // File operations + bool open_file(const char* path, const char* mode); + void close_files_dir(); + void close_filesystem_on_error(); + ftp_result_t read_file(char* filebuf, uint32_t desiredsize, uint32_t* actualsize); + ftp_result_t write_file(char* filebuf, uint32_t size); + ftp_result_t open_dir_for_listing(const char* path); + int get_eplf_item(char* dest, uint32_t destsize, struct dirent* de); + ftp_result_t list_dir(char* list, uint32_t maxlistsize, uint32_t* listsize); + + // Socket operations + void close_cmd_data(); + void reset(); + bool create_listening_socket(int32_t* sd, uint32_t port, uint8_t backlog); + ftp_result_t wait_for_connection(int32_t l_sd, int32_t* n_sd, uint32_t* ip_addr); + + // Communication + void send_reply(uint32_t status, const char* message); + void send_list(uint32_t datasize); + void send_file_data(uint32_t datasize); + ftp_result_t recv_non_blocking(int32_t sd, void* buff, int32_t Maxlen, int32_t* rxLen); + + // Path operations + void open_child(char* pwd, char* dir); + void close_child(char* pwd); + void remove_fname_from_path(char* pwd, char* fname); + + // Command parsing + void pop_param(char** str, char* param, size_t maxlen, bool stop_on_space, bool stop_on_newline); + ftp_cmd_index_t pop_command(char** str); + void get_param_and_open_child(char** bufptr); + + // Main processing + void process_cmd(); + void wait_for_enabled(); + + // Initialization + bool init(); + void deinit(); + int run(uint32_t elapsed); + bool enable(); + bool disable(); + bool terminate(); + bool stop_requested(); + + // Task wrapper (must be static for FreeRTOS) + static void task_wrapper(void* pvParameters); + void task_loop(); +}; + +} // namespace FtpServer + +#endif /* FTP_SERVER_CORE_H */ diff --git a/Apps/FTPServer/main/Source/MainView.cpp b/Apps/FTPServer/main/Source/MainView.cpp new file mode 100644 index 0000000..941e3b0 --- /dev/null +++ b/Apps/FTPServer/main/Source/MainView.cpp @@ -0,0 +1,208 @@ +#include "MainView.h" +#include +#include +#include +#include +#include + +void MainView::wifiConnectCallback(lv_event_t* e) { + tt_app_start("WifiManage"); +} + +void MainView::onStart(lv_obj_t* parentWidget) { + parent = parentWidget; + + lv_coord_t screenWidth = lv_obj_get_width(parent); + bool isSmallScreen = (screenWidth < 280); + + // Main content wrapper + mainWrapper = lv_obj_create(parent); + lv_obj_set_size(mainWrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(mainWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(mainWrapper, isSmallScreen ? 4 : 8, 0); + lv_obj_set_style_pad_row(mainWrapper, isSmallScreen ? 4 : 6, 0); + lv_obj_set_style_bg_opa(mainWrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(mainWrapper, 0, 0); + lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + + // Info panel (IP, status) + infoPanel = lv_obj_create(mainWrapper); + lv_obj_set_width(infoPanel, LV_PCT(100)); + lv_obj_set_height(infoPanel, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(infoPanel, isSmallScreen ? 4 : 8, 0); + lv_obj_set_style_bg_color(infoPanel, lv_color_hex(0x1a1a1a), 0); + lv_obj_set_style_bg_opa(infoPanel, LV_OPA_COVER, 0); + lv_obj_set_style_radius(infoPanel, 6, 0); + lv_obj_set_style_border_width(infoPanel, 0, 0); + lv_obj_remove_flag(infoPanel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(infoPanel, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(infoPanel, 15, 0); + lv_obj_set_flex_align(infoPanel, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + ipLabel = lv_label_create(infoPanel); + lv_label_set_text(ipLabel, "IP: --"); + + statusLabel = lv_label_create(infoPanel); + lv_label_set_text(statusLabel, "Ready"); + + // Log textarea + logTextarea = lv_textarea_create(mainWrapper); + lv_textarea_set_placeholder_text(logTextarea, "FTP activity will appear here..."); + lv_obj_set_width(logTextarea, LV_PCT(100)); + lv_obj_set_flex_grow(logTextarea, 1); + lv_obj_add_state(logTextarea, LV_STATE_DISABLED); + lv_obj_set_style_bg_color(logTextarea, lv_color_hex(0x0a0a0a), 0); + lv_obj_set_style_border_color(logTextarea, lv_color_hex(0x333333), 0); + lv_obj_set_style_radius(logTextarea, 6, 0); + lv_obj_remove_flag(logTextarea, LV_OBJ_FLAG_CLICK_FOCUSABLE); + lv_textarea_set_cursor_click_pos(logTextarea, false); + lv_obj_set_scrollbar_mode(logTextarea, LV_SCROLLBAR_MODE_AUTO); + lv_obj_remove_state(logTextarea, LV_STATE_FOCUSED); + tt_lvgl_software_keyboard_hide(); +} + +void MainView::onStop() { + statusLabel = nullptr; + ipLabel = nullptr; + wifiButton = nullptr; + wifiCard = nullptr; + logTextarea = nullptr; + mainWrapper = nullptr; + infoPanel = nullptr; + parent = nullptr; +} + +void MainView::updateInfoPanel(const char* ip, const char* status, lv_palette_t color) { + lv_color_t labelColor = (color != LV_PALETTE_NONE) ? lv_palette_main(color) : lv_color_hex(0xffffff); + + if (ip && ipLabel) { + lv_label_set_text(ipLabel, ip); + lv_obj_set_style_text_color(ipLabel, labelColor, 0); + } + if (status && statusLabel) { + lv_label_set_text(statusLabel, status); + lv_obj_set_style_text_color(statusLabel, labelColor, 0); + } +} + +void MainView::logToScreen(const char* message) { + if (logTextarea == nullptr || !message || message[0] == '\0') return; + + const int MAX_LINES = 50; + + tt_lvgl_lock(tt::kernel::MAX_TICKS); + const char* current = lv_textarea_get_text(logTextarea); + + // Treat empty string same as nullptr (ignores placeholder) + if (current && current[0] == '\0') { + current = nullptr; + } + + // Count existing lines + int lineCount = 0; + if (current && current[0] != '\0') { + for (const char* p = current; *p; p++) { + if (*p == '\n') lineCount++; + } + lineCount++; // Count the last line (no trailing newline) + } + + // Only trim if we EXCEED the limit (not equal to it) + const char* start = current; + if (lineCount > MAX_LINES && current) { + start = strchr(current, '\n'); + if (start) start++; // Skip past the newline + else start = current; + } + + // Calculate required buffer size to avoid truncation + size_t start_len = (start && start[0] != '\0') ? strlen(start) : 0; + size_t msg_len = strlen(message); + size_t required_size = start_len + 1 + msg_len + 1; // existing + newline + message + null + + // Use stack buffer for small content, heap for larger + static constexpr size_t STACK_BUFFER_SIZE = 512; + char stack_buffer[STACK_BUFFER_SIZE]; + char* buffer = stack_buffer; + bool heap_allocated = false; + + if (required_size > STACK_BUFFER_SIZE) { + buffer = static_cast(malloc(required_size)); + if (buffer == nullptr) { + // Fallback: use stack buffer with truncation + buffer = stack_buffer; + required_size = STACK_BUFFER_SIZE; + } else { + heap_allocated = true; + } + } + + // Build new text + if (start && start[0] != '\0') { + snprintf(buffer, required_size, "%s\n%s", start, message); + } else { + snprintf(buffer, required_size, "%s", message); + } + + lv_textarea_set_text(logTextarea, buffer); + lv_obj_scroll_to_y(logTextarea, LV_COORD_MAX, LV_ANIM_ON); + + if (heap_allocated) { + free(buffer); + } + + tt_lvgl_unlock(); +} + +void MainView::clearLog() { + if (!logTextarea) return; + + tt_lvgl_lock(tt::kernel::MAX_TICKS); + lv_textarea_set_text(logTextarea, ""); + tt_lvgl_unlock(); +} + +void MainView::showWifiPrompt() { + if (!logTextarea) return; + if (wifiCard) { + lv_obj_delete(wifiCard); + wifiCard = nullptr; + } + + lv_coord_t width = lv_obj_get_width(mainWrapper); + bool isSmall = (width < 240); + + wifiCard = lv_obj_create(logTextarea); + lv_obj_set_size(wifiCard, LV_PCT(90), LV_SIZE_CONTENT); + lv_obj_center(wifiCard); + lv_obj_set_style_radius(wifiCard, isSmall ? 8 : 12, 0); + lv_obj_set_style_bg_color(wifiCard, lv_color_hex(0x2a2a2a), 0); + lv_obj_set_style_bg_opa(wifiCard, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(wifiCard, 1, 0); + lv_obj_set_style_border_color(wifiCard, lv_color_hex(0x444444), 0); + lv_obj_set_style_pad_all(wifiCard, isSmall ? 10 : 16, 0); + lv_obj_set_flex_flow(wifiCard, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(wifiCard, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(wifiCard, isSmall ? 6 : 10, 0); + + lv_obj_t* wifiIcon = lv_label_create(wifiCard); + lv_label_set_text(wifiIcon, LV_SYMBOL_WIFI); + lv_obj_set_style_text_color(wifiIcon, lv_color_hex(0xFF9500), 0); + + lv_obj_t* wifiLabel = lv_label_create(wifiCard); + lv_label_set_text(wifiLabel, "No Wi-Fi Connection"); + lv_obj_set_style_text_align(wifiLabel, LV_TEXT_ALIGN_CENTER, 0); + + wifiButton = lv_btn_create(wifiCard); + lv_obj_set_size(wifiButton, isSmall ? 120 : 150, isSmall ? 28 : 34); + lv_obj_set_style_radius(wifiButton, 6, 0); + lv_obj_set_style_bg_color(wifiButton, lv_palette_main(LV_PALETTE_BLUE), 0); + + lv_obj_t* btnLabel = lv_label_create(wifiButton); + lv_label_set_text(btnLabel, "Connect"); + lv_obj_center(btnLabel); + + lv_obj_add_event_cb(wifiButton, wifiConnectCallback, LV_EVENT_CLICKED, nullptr); + + updateInfoPanel(nullptr, "No WiFi", LV_PALETTE_RED); +} diff --git a/Apps/FTPServer/main/Source/MainView.h b/Apps/FTPServer/main/Source/MainView.h new file mode 100644 index 0000000..14bc4f4 --- /dev/null +++ b/Apps/FTPServer/main/Source/MainView.h @@ -0,0 +1,35 @@ +#pragma once + +#include "View.h" + +#include +#include +#include + +class MainView final : public View { + +private: + + lv_obj_t* parent = nullptr; + lv_obj_t* statusLabel = nullptr; + lv_obj_t* ipLabel = nullptr; + lv_obj_t* wifiButton = nullptr; + lv_obj_t* wifiCard = nullptr; + lv_obj_t* logTextarea = nullptr; + lv_obj_t* mainWrapper = nullptr; + lv_obj_t* infoPanel = nullptr; + + static void wifiConnectCallback(lv_event_t* e); + +public: + + void onStart(lv_obj_t* parent); + void onStop() override; + + void updateInfoPanel(const char* ip, const char* status, lv_palette_t color); + void logToScreen(const char* message); + void showWifiPrompt(); + void clearLog(); + + bool hasValidLogArea() const { return logTextarea != nullptr; } +}; diff --git a/Apps/FTPServer/main/Source/SettingsView.cpp b/Apps/FTPServer/main/Source/SettingsView.cpp new file mode 100644 index 0000000..cea0abd --- /dev/null +++ b/Apps/FTPServer/main/Source/SettingsView.cpp @@ -0,0 +1,185 @@ +#include "SettingsView.h" +#include +#include +#include + +lv_obj_t* SettingsView::createSettingsRow(lv_obj_t* parent) { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + return row; +} + +void SettingsView::handleCancel() { + if (onCancel) { + onCancel(); + } +} + +void SettingsView::handleSave() { + if (!usernameInput || !passwordInput || !portInput) { + if (onCancel) { + onCancel(); + } + return; + } + + const char* newUser = lv_textarea_get_text(usernameInput); + const char* newPass = lv_textarea_get_text(passwordInput); + const char* newPortStr = lv_textarea_get_text(portInput); + + int port = currentPort; + if (newPortStr && strlen(newPortStr) > 0) { + int p = atoi(newPortStr); + if (p > 0 && p <= 65535) { + port = p; + } + } + + if (onSave) { + onSave(newUser, newPass, port); + } +} + +void SettingsView::onStart(lv_obj_t* parent, const char* username, const char* password, int port) { + currentUsername = username; + currentPassword = password; + currentPort = port; + + lv_coord_t screenWidth = lv_obj_get_width(parent); + bool isSmall = (screenWidth < 280); + + // Main container + lv_obj_t* container = lv_obj_create(parent); + lv_obj_set_size(container, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_opa(container, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(container, 0, 0); + lv_obj_set_style_pad_all(container, isSmall ? 8 : 16, 0); + lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(container, isSmall ? 12 : 16, 0); + + // Username row + lv_obj_t* userRow = createSettingsRow(container); + lv_obj_t* userLabel = lv_label_create(userRow); + lv_label_set_text(userLabel, "Username:"); + + usernameInput = lv_textarea_create(userRow); + lv_textarea_set_one_line(usernameInput, true); + lv_textarea_set_text(usernameInput, username); + lv_textarea_set_placeholder_text(usernameInput, "user"); + lv_obj_set_width(usernameInput, isSmall ? LV_PCT(55) : LV_PCT(60)); + lv_obj_set_style_bg_color(usernameInput, lv_color_hex(0x1a1a1a), 0); + lv_obj_set_style_border_color(usernameInput, lv_color_hex(0x555555), 0); + + // Password row + lv_obj_t* passRow = createSettingsRow(container); + lv_obj_t* passLabel = lv_label_create(passRow); + lv_label_set_text(passLabel, "Password:"); + + // Container for password input + show/hide button + lv_obj_t* passInputContainer = lv_obj_create(passRow); + lv_obj_set_width(passInputContainer, isSmall ? LV_PCT(55) : LV_PCT(60)); + lv_obj_set_height(passInputContainer, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(passInputContainer, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(passInputContainer, 0, 0); + lv_obj_set_style_pad_all(passInputContainer, 0, 0); + lv_obj_set_flex_flow(passInputContainer, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(passInputContainer, 4, 0); + + passwordInput = lv_textarea_create(passInputContainer); + lv_textarea_set_one_line(passwordInput, true); + lv_textarea_set_text(passwordInput, password); + lv_textarea_set_placeholder_text(passwordInput, "pass"); + lv_textarea_set_password_mode(passwordInput, true); + lv_obj_set_flex_grow(passwordInput, 1); + lv_obj_set_style_bg_color(passwordInput, lv_color_hex(0x1a1a1a), 0); + lv_obj_set_style_border_color(passwordInput, lv_color_hex(0x555555), 0); + + // Show/hide password button + showPasswordBtn = lv_btn_create(passInputContainer); + lv_obj_set_size(showPasswordBtn, isSmall ? 28 : 32, isSmall ? 28 : 32); + lv_obj_set_style_bg_color(showPasswordBtn, lv_color_hex(0x333333), 0); + lv_obj_set_style_radius(showPasswordBtn, 4, 0); + lv_obj_set_style_pad_all(showPasswordBtn, 0, 0); + lv_obj_add_event_cb(showPasswordBtn, onShowPasswordClickedCallback, LV_EVENT_CLICKED, this); + + lv_obj_t* eyeIcon = lv_label_create(showPasswordBtn); + lv_label_set_text(eyeIcon, LV_SYMBOL_EYE_CLOSE); + lv_obj_center(eyeIcon); + + // Port row + lv_obj_t* portRow = createSettingsRow(container); + lv_obj_t* portLabel = lv_label_create(portRow); + lv_label_set_text(portLabel, "Port:"); + + portInput = lv_textarea_create(portRow); + lv_textarea_set_one_line(portInput, true); + char portStr[8]; + snprintf(portStr, sizeof(portStr), "%d", port); + lv_textarea_set_text(portInput, portStr); + lv_textarea_set_accepted_chars(portInput, "0123456789"); + lv_textarea_set_max_length(portInput, 5); + lv_obj_set_width(portInput, isSmall ? LV_PCT(55) : LV_PCT(60)); + lv_obj_set_style_bg_color(portInput, lv_color_hex(0x1a1a1a), 0); + lv_obj_set_style_border_color(portInput, lv_color_hex(0x555555), 0); + + // Spacer + lv_obj_t* spacer = lv_obj_create(container); + lv_obj_set_flex_grow(spacer, 1); + lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(spacer, 0, 0); + + // Button row + lv_obj_t* btnRow = lv_obj_create(container); + lv_obj_set_size(btnRow, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(btnRow, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(btnRow, 0, 0); + lv_obj_set_style_pad_all(btnRow, 0, 0); + lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(btnRow, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + // Cancel button + lv_obj_t* cancelBtn = lv_btn_create(btnRow); + lv_obj_set_size(cancelBtn, isSmall ? 90 : 110, isSmall ? 36 : 42); + lv_obj_set_style_bg_color(cancelBtn, lv_color_hex(0x555555), 0); + lv_obj_set_style_radius(cancelBtn, 6, 0); + lv_obj_add_event_cb(cancelBtn, onCancelClickedCallback, LV_EVENT_CLICKED, this); + + lv_obj_t* cancelLabel = lv_label_create(cancelBtn); + lv_label_set_text(cancelLabel, "Cancel"); + lv_obj_center(cancelLabel); + + // Save button + lv_obj_t* saveBtn = lv_btn_create(btnRow); + lv_obj_set_size(saveBtn, isSmall ? 90 : 110, isSmall ? 36 : 42); + lv_obj_set_style_bg_color(saveBtn, lv_palette_main(LV_PALETTE_BLUE), 0); + lv_obj_set_style_radius(saveBtn, 6, 0); + lv_obj_add_event_cb(saveBtn, onSaveClickedCallback, LV_EVENT_CLICKED, this); + + lv_obj_t* saveLabel = lv_label_create(saveBtn); + lv_label_set_text(saveLabel, "Save"); + lv_obj_center(saveLabel); +} + +void SettingsView::togglePasswordVisibility() { + passwordVisible = !passwordVisible; + lv_textarea_set_password_mode(passwordInput, !passwordVisible); + + // Update button icon + lv_obj_t* btnLabel = lv_obj_get_child(showPasswordBtn, 0); + if (btnLabel) { + lv_label_set_text(btnLabel, passwordVisible ? LV_SYMBOL_EYE_OPEN : LV_SYMBOL_EYE_CLOSE); + } +} + +void SettingsView::onStop() { + usernameInput = nullptr; + passwordInput = nullptr; + portInput = nullptr; + showPasswordBtn = nullptr; + passwordVisible = false; +} diff --git a/Apps/FTPServer/main/Source/SettingsView.h b/Apps/FTPServer/main/Source/SettingsView.h new file mode 100644 index 0000000..81e06f0 --- /dev/null +++ b/Apps/FTPServer/main/Source/SettingsView.h @@ -0,0 +1,58 @@ +#pragma once + +#include "View.h" +#include +#include + +class SettingsView final : public View { + +public: + + typedef std::function OnCancelFunction; + typedef std::function OnSaveFunction; + +private: + + OnCancelFunction onCancel; + OnSaveFunction onSave; + + lv_obj_t* usernameInput = nullptr; + lv_obj_t* passwordInput = nullptr; + lv_obj_t* portInput = nullptr; + lv_obj_t* showPasswordBtn = nullptr; + bool passwordVisible = false; + + // Current settings values + const char* currentUsername; + const char* currentPassword; + int currentPort; + + static lv_obj_t* createSettingsRow(lv_obj_t* parent); + + static void onCancelClickedCallback(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + view->handleCancel(); + } + + static void onSaveClickedCallback(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + view->handleSave(); + } + + static void onShowPasswordClickedCallback(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + view->togglePasswordVisibility(); + } + + void handleCancel(); + void handleSave(); + void togglePasswordVisibility(); + +public: + + SettingsView(OnCancelFunction onCancel, OnSaveFunction onSave) + : onCancel(std::move(onCancel)), onSave(std::move(onSave)), currentUsername(""), currentPassword(""), currentPort(21) {} + + void onStart(lv_obj_t* parent, const char* username, const char* password, int port); + void onStop() override; +}; diff --git a/Apps/FTPServer/main/Source/View.h b/Apps/FTPServer/main/Source/View.h new file mode 100644 index 0000000..01d3a85 --- /dev/null +++ b/Apps/FTPServer/main/Source/View.h @@ -0,0 +1,8 @@ +#pragma once + +class View { +public: + + virtual ~View() = default; + virtual void onStop() = 0; +}; diff --git a/Apps/FTPServer/main/Source/main.cpp b/Apps/FTPServer/main/Source/main.cpp new file mode 100644 index 0000000..4614224 --- /dev/null +++ b/Apps/FTPServer/main/Source/main.cpp @@ -0,0 +1,10 @@ +#include "FTPServer.h" + +extern "C" { + +int main(int argc, char* argv[]) { + registerApp(); + return 0; +} + +} diff --git a/Apps/FTPServer/manifest.properties b/Apps/FTPServer/manifest.properties new file mode 100644 index 0000000..120ad7a --- /dev/null +++ b/Apps/FTPServer/manifest.properties @@ -0,0 +1,10 @@ +[manifest] +version=0.1 +[target] +sdk=0.7.0-dev +platforms=esp32,esp32s3,esp32c6,esp32p4 +[app] +id=one.tactility.ftpserver +versionName=0.3.0 +versionCode=3 +name=FTP Server diff --git a/Apps/FTPServer/tactility.py b/Apps/FTPServer/tactility.py new file mode 100644 index 0000000..81f271c --- /dev/null +++ b/Apps/FTPServer/tactility.py @@ -0,0 +1,693 @@ +import configparser +import json +import os +import re +import shutil +import sys +import subprocess +import time +import urllib.request +import zipfile +import requests +import tarfile + +ttbuild_path = ".tactility" +ttbuild_version = "3.1.0" +ttbuild_cdn = "https://cdn.tactility.one" +ttbuild_sdk_json_validity = 3600 # seconds +ttport = 6666 +verbose = False +use_local_sdk = False +local_base_path = None + +shell_color_red = "\033[91m" +shell_color_orange = "\033[93m" +shell_color_green = "\033[32m" +shell_color_purple = "\033[35m" +shell_color_cyan = "\033[36m" +shell_color_reset = "\033[m" + +def print_help(): + print("Usage: python tactility.py [action] [options]") + print("") + print("Actions:") + print(" build [esp32,esp32s3] Build the app. Optionally specify a platform.") + print(" esp32: ESP32") + print(" esp32s3: ESP32 S3") + print(" clean Clean the build folders") + print(" clearcache Clear the SDK cache") + print(" updateself Update this tool") + print(" run [ip] Run the application") + print(" install [ip] Install the application") + print(" uninstall [ip] Uninstall the application") + print(" bir [ip] [esp32,esp32s3] Build, install then run. Optionally specify a platform.") + print(" brrr [ip] [esp32,esp32s3] Functionally the same as \"bir\", but \"app goes brrr\" meme variant.") + print("") + print("Options:") + print(" --help Show this commandline info") + print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.") + print(" --skip-build Run everything except the idf.py/CMake commands") + print(" --verbose Show extra console output") + +# region Core + +def download_file(url, filepath): + global verbose + if verbose: + print(f"Downloading from {url} to {filepath}") + request = urllib.request.Request( + url, + data=None, + headers={ + "User-Agent": f"Tactility Build Tool {ttbuild_version}" + } + ) + try: + response = urllib.request.urlopen(request) + file = open(filepath, mode="wb") + file.write(response.read()) + file.close() + return True + except OSError as error: + if verbose: + print_error(f"Failed to fetch URL {url}\n{error}") + return False + +def print_warning(message): + print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}") + +def print_error(message): + print(f"{shell_color_red}ERROR: {message}{shell_color_reset}") + +def print_status_busy(status): + sys.stdout.write(f"⌛ {status}\r") + +def print_status_success(status): + # Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text + print(f"✅ {shell_color_green}{status}{shell_color_reset} ") + +def print_status_error(status): + # Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text + print(f"❌ {shell_color_red}{status}{shell_color_reset} ") + +def exit_with_error(message): + print_error(message) + sys.exit(1) + +def get_url(ip, path): + return f"http://{ip}:{ttport}{path}" + +def read_properties_file(path): + config = configparser.RawConfigParser() + config.read(path) + return config + +#endregion Core + +#region SDK helpers + +def read_sdk_json(): + json_file_path = os.path.join(ttbuild_path, "tool.json") + with open(json_file_path) as json_file: + return json.load(json_file) + +def get_sdk_dir(version, platform): + global use_local_sdk, local_base_path + if use_local_sdk: + base_path = local_base_path + if base_path is None: + exit_with_error("TACTILITY_SDK_PATH environment variable is not set") + sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") + sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") + if not os.path.isdir(sdk_dir): + exit_with_error(f"Local SDK folder not found for platform {platform}: {sdk_dir}") + return sdk_dir + else: + return os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK") + +def validate_local_sdks(platforms, version): + if not use_local_sdk: + return + global local_base_path + base_path = local_base_path + for platform in platforms: + sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") + sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") + if not os.path.isdir(sdk_dir): + exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}") + +def get_sdk_root_dir(version, platform): + global ttbuild_cdn + return os.path.join(ttbuild_path, f"{version}-{platform}") + +def get_sdk_url(version, file): + global ttbuild_cdn + return f"{ttbuild_cdn}/sdk/{version}/{file}" + +def sdk_exists(version, platform): + sdk_dir = get_sdk_dir(version, platform) + return os.path.isdir(sdk_dir) + +def should_update_tool_json(): + global ttbuild_cdn + json_filepath = os.path.join(ttbuild_path, "tool.json") + if os.path.exists(json_filepath): + json_modification_time = os.path.getmtime(json_filepath) + now = time.time() + global ttbuild_sdk_json_validity + minimum_seconds_difference = ttbuild_sdk_json_validity + return (now - json_modification_time) > minimum_seconds_difference + else: + return True + +def update_tool_json(): + global ttbuild_cdn, ttbuild_path + json_url = f"{ttbuild_cdn}/sdk/tool.json" + json_filepath = os.path.join(ttbuild_path, "tool.json") + return download_file(json_url, json_filepath) + +def should_fetch_sdkconfig_files(platform_targets): + for platform in platform_targets: + sdkconfig_filename = f"sdkconfig.app.{platform}" + if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)): + return True + return False + +def fetch_sdkconfig_files(platform_targets): + for platform in platform_targets: + sdkconfig_filename = f"sdkconfig.app.{platform}" + target_path = os.path.join(ttbuild_path, sdkconfig_filename) + if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path): + exit_with_error(f"Failed to download sdkconfig file for {platform}") + +#endregion SDK helpers + +#region Validation + +def validate_environment(): + if os.environ.get("IDF_PATH") is None: + if sys.platform == "win32": + exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1") + else: + exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh") + if not os.path.exists("manifest.properties"): + exit_with_error("manifest.properties not found") + if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None: + print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.") + print_warning("If you want to use it, use the '--local-sdk' parameter") + elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None: + exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.") + +def validate_self(sdk_json): + if not "toolVersion" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolVersion not found)") + if not "toolCompatibility" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolCompatibility not found)") + if not "toolDownloadUrl" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolDownloadUrl not found)") + tool_version = sdk_json["toolVersion"] + tool_compatibility = sdk_json["toolCompatibility"] + if tool_version != ttbuild_version: + print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})") + print_warning(f"Run 'tactility.py updateself' to update.") + if re.search(tool_compatibility, ttbuild_version) is None: + print_error("The tool is not compatible anymore.") + print_error("Run 'tactility.py updateself' to update.") + sys.exit(1) + +#endregion Validation + +#region Manifest + +def read_manifest(): + return read_properties_file("manifest.properties") + +def validate_manifest(manifest): + # [manifest] + if not "manifest" in manifest: + exit_with_error("Invalid manifest format: [manifest] not found") + if not "version" in manifest["manifest"]: + exit_with_error("Invalid manifest format: [manifest] version not found") + # [target] + if not "target" in manifest: + exit_with_error("Invalid manifest format: [target] not found") + if not "sdk" in manifest["target"]: + exit_with_error("Invalid manifest format: [target] sdk not found") + if not "platforms" in manifest["target"]: + exit_with_error("Invalid manifest format: [target] platforms not found") + # [app] + if not "app" in manifest: + exit_with_error("Invalid manifest format: [app] not found") + if not "id" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] id not found") + if not "versionName" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] versionName not found") + if not "versionCode" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] versionCode not found") + if not "name" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] name not found") + +def is_valid_manifest_platform(manifest, platform): + manifest_platforms = manifest["target"]["platforms"].split(",") + return platform in manifest_platforms + +def validate_manifest_platform(manifest, platform): + if not is_valid_manifest_platform(manifest, platform): + exit_with_error(f"Platform {platform} is not available in the manifest.") + +def get_manifest_target_platforms(manifest, requested_platform): + if requested_platform == "" or requested_platform is None: + return manifest["target"]["platforms"].split(",") + else: + validate_manifest_platform(manifest, requested_platform) + return [requested_platform] + +#endregion Manifest + +#region SDK download + +def sdk_download(version, platform): + sdk_root_dir = get_sdk_root_dir(version, platform) + os.makedirs(sdk_root_dir, exist_ok=True) + sdk_index_url = get_sdk_url(version, "index.json") + print(f"Downloading SDK version {version} for {platform}") + sdk_index_filepath = os.path.join(sdk_root_dir, "index.json") + if verbose: + print(f"Downloading {sdk_index_url} to {sdk_index_filepath}") + if not download_file(sdk_index_url, sdk_index_filepath): + # TODO: 404 check, print a more accurate error + print_error(f"Failed to download SDK version {version}. Check your internet connection and make sure this release exists.") + return False + with open(sdk_index_filepath) as sdk_index_json_file: + sdk_index_json = json.load(sdk_index_json_file) + sdk_platforms = sdk_index_json["platforms"] + if platform not in sdk_platforms: + print_error(f"Platform {platform} not found in {sdk_platforms} for version {version}") + return False + sdk_platform_file = sdk_platforms[platform] + sdk_zip_source_url = get_sdk_url(version, sdk_platform_file) + sdk_zip_target_filepath = os.path.join(sdk_root_dir, f"{version}-{platform}.zip") + if verbose: + print(f"Downloading {sdk_zip_source_url} to {sdk_zip_target_filepath}") + if not download_file(sdk_zip_source_url, sdk_zip_target_filepath): + print_error(f"Failed to download {sdk_zip_source_url} to {sdk_zip_target_filepath}") + return False + with zipfile.ZipFile(sdk_zip_target_filepath, "r") as zip_ref: + zip_ref.extractall(os.path.join(sdk_root_dir, "TactilitySDK")) + return True + +def sdk_download_all(version, platforms): + for platform in platforms: + if not sdk_exists(version, platform): + if not sdk_download(version, platform): + return False + else: + if verbose: + print(f"Using cached download for SDK version {version} and platform {platform}") + return True + +#endregion SDK download + +#region Building + +def get_cmake_path(platform): + return os.path.join("build", f"cmake-build-{platform}") + +def find_elf_file(platform): + cmake_dir = get_cmake_path(platform) + if os.path.exists(cmake_dir): + for file in os.listdir(cmake_dir): + if file.endswith(".app.elf"): + return os.path.join(cmake_dir, file) + return None + +def build_all(version, platforms, skip_build): + for platform in platforms: + # First build command must be "idf.py build", otherwise it fails to execute "idf.py elf" + # We check if the ELF file exists and run the correct command + # This can lead to code caching issues, so sometimes a clean build is required + if find_elf_file(platform) is None: + if not build_first(version, platform, skip_build): + return False + else: + if not build_consecutively(version, platform, skip_build): + return False + return True + +def wait_for_process(process): + buffer = [] + if sys.platform != "win32": + os.set_blocking(process.stdout.fileno(), False) + while process.poll() is None: + while True: + line = process.stdout.readline() + if line: + decoded_line = line.decode("UTF-8") + if decoded_line != "": + buffer.append(decoded_line) + else: + break + else: + break + # Read any remaining output + for line in process.stdout: + decoded_line = line.decode("UTF-8") + if decoded_line: + buffer.append(decoded_line) + return buffer + +# The first build must call "idf.py build" and consecutive builds must call "idf.py elf" as it finishes faster. +# The problem is that the "idf.py build" always results in an error, even though the elf file is created. +# The solution is to suppress the error if we find that the elf file was created. +def build_first(version, platform, skip_build): + sdk_dir = get_sdk_dir(version, platform) + if verbose: + print(f"Using SDK at {sdk_dir}") + os.environ["TACTILITY_SDK_PATH"] = sdk_dir + sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") + shutil.copy(sdkconfig_path, "sdkconfig") + elf_path = find_elf_file(platform) + # Remove previous elf file: re-creation of the file is used to measure if the build succeeded, + # as the actual build job will always fail due to technical issues with the elf cmake script + if elf_path is not None: + os.remove(elf_path) + if skip_build: + return True + print(f"Building first {platform} build") + cmake_path = get_cmake_path(platform) + print_status_busy(f"Building {platform} ELF") + shell_needed = sys.platform == "win32" + build_command = ["idf.py", "-B", cmake_path, "build"] + if verbose: + print(f"Running command: {" ".join(build_command)}") + with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: + build_output = wait_for_process(process) + # The return code is never expected to be 0 due to a bug in the elf cmake script, but we keep it just in case + if process.returncode == 0: + print(f"{shell_color_green}Building for {platform} ✅{shell_color_reset}") + return True + else: + if find_elf_file(platform) is None: + for line in build_output: + print(line, end="") + print_status_error(f"Building {platform} ELF") + return False + else: + print_status_success(f"Building {platform} ELF") + return True + +def build_consecutively(version, platform, skip_build): + sdk_dir = get_sdk_dir(version, platform) + if verbose: + print(f"Using SDK at {sdk_dir}") + os.environ["TACTILITY_SDK_PATH"] = sdk_dir + sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") + shutil.copy(sdkconfig_path, "sdkconfig") + if skip_build: + return True + cmake_path = get_cmake_path(platform) + print_status_busy(f"Building {platform} ELF") + shell_needed = sys.platform == "win32" + build_command = ["idf.py", "-B", cmake_path, "elf"] + if verbose: + print(f"Running command: {" ".join(build_command)}") + with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: + build_output = wait_for_process(process) + if process.returncode == 0: + print_status_success(f"Building {platform} ELF") + return True + else: + for line in build_output: + print(line, end="") + print_status_error(f"Building {platform} ELF") + return False + +#endregion Building + +#region Packaging + +def package_intermediate_manifest(target_path): + if not os.path.isfile("manifest.properties"): + print_error("manifest.properties not found") + return + shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties")) + +def package_intermediate_binaries(target_path, platforms): + elf_dir = os.path.join(target_path, "elf") + os.makedirs(elf_dir, exist_ok=True) + for platform in platforms: + elf_path = find_elf_file(platform) + if elf_path is None: + print_error(f"ELF file not found at {elf_path}") + return + shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}.elf")) + +def package_intermediate_assets(target_path): + if os.path.isdir("assets"): + shutil.copytree("assets", os.path.join(target_path, "assets"), dirs_exist_ok=True) + +def package_intermediate(platforms): + target_path = os.path.join("build", "package-intermediate") + if os.path.isdir(target_path): + shutil.rmtree(target_path) + os.makedirs(target_path, exist_ok=True) + package_intermediate_manifest(target_path) + package_intermediate_binaries(target_path, platforms) + package_intermediate_assets(target_path) + +def package_name(platforms): + elf_path = find_elf_file(platforms[0]) + elf_base_name = os.path.basename(elf_path).removesuffix(".app.elf") + return os.path.join("build", f"{elf_base_name}.app") + + +def package_all(platforms): + status = f"Building package with {platforms}" + print_status_busy(status) + package_intermediate(platforms) + # Create build/something.app + try: + tar_path = package_name(platforms) + tar = tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT) + tar.add(os.path.join("build", "package-intermediate"), arcname="") + tar.close() + print_status_success(status) + return True + except Exception as e: + print_status_error(f"Building package failed: {e}") + return False + +#endregion Packaging + +def setup_environment(): + global ttbuild_path + os.makedirs(ttbuild_path, exist_ok=True) + +def build_action(manifest, platform_arg): + # Environment validation + validate_environment() + platforms_to_build = get_manifest_target_platforms(manifest, platform_arg) + + if use_local_sdk: + global local_base_path + local_base_path = os.environ.get("TACTILITY_SDK_PATH") + validate_local_sdks(platforms_to_build, manifest["target"]["sdk"]) + + if should_fetch_sdkconfig_files(platforms_to_build): + fetch_sdkconfig_files(platforms_to_build) + + if not use_local_sdk: + sdk_json = read_sdk_json() + validate_self(sdk_json) + # Build + sdk_version = manifest["target"]["sdk"] + if not use_local_sdk: + if not sdk_download_all(sdk_version, platforms_to_build): + exit_with_error("Failed to download one or more SDKs") + if not build_all(sdk_version, platforms_to_build, skip_build): # Environment validation + return False + if not skip_build: + package_all(platforms_to_build) + return True + +def clean_action(): + if os.path.exists("build"): + print_status_busy("Removing build/") + shutil.rmtree("build") + print_status_success("Removed build/") + else: + print("Nothing to clean") + +def clear_cache_action(): + if os.path.exists(ttbuild_path): + print_status_busy(f"Removing {ttbuild_path}/") + shutil.rmtree(ttbuild_path) + print_status_success(f"Removed {ttbuild_path}/") + else: + print("Nothing to clear") + +def update_self_action(): + sdk_json = read_sdk_json() + tool_download_url = sdk_json["toolDownloadUrl"] + if download_file(tool_download_url, "tactility.py"): + print("Updated") + else: + exit_with_error("Update failed") + +def get_device_info(ip): + print_status_busy(f"Requesting device info") + url = get_url(ip, "/info") + try: + response = requests.get(url) + if response.status_code != 200: + print_error("Run failed") + else: + print_status_success(f"Received device info:") + print(response.json()) + except requests.RequestException as e: + print_status_error(f"Device info request failed: {e}") + +def run_action(manifest, ip): + app_id = manifest["app"]["id"] + print_status_busy("Running") + url = get_url(ip, "/app/run") + params = {'id': app_id} + try: + response = requests.post(url, params=params) + if response.status_code != 200: + print_error("Run failed") + else: + print_status_success("Running") + except requests.RequestException as e: + print_status_error(f"Running request failed: {e}") + +def install_action(ip, platforms): + print_status_busy("Installing") + for platform in platforms: + elf_path = find_elf_file(platform) + if elf_path is None: + print_status_error(f"ELF file not built for {platform}") + return False + package_path = package_name(platforms) + # print(f"Installing {package_path} to {ip}") + url = get_url(ip, "/app/install") + try: + # Prepare multipart form data + with open(package_path, 'rb') as file: + files = { + 'elf': file + } + response = requests.put(url, files=files) + if response.status_code != 200: + print_status_error("Install failed") + return False + else: + print_status_success("Installing") + return True + except requests.RequestException as e: + print_status_error(f"Install request failed: {e}") + return False + except IOError as e: + print_status_error(f"Install file error: {e}") + return False + +def uninstall_action(manifest, ip): + app_id = manifest["app"]["id"] + print_status_busy("Uninstalling") + url = get_url(ip, "/app/uninstall") + params = {'id': app_id} + try: + response = requests.put(url, params=params) + if response.status_code != 200: + print_status_error("Server responded that uninstall failed") + else: + print_status_success("Uninstalled") + except requests.RequestException as e: + print_status_error(f"Uninstall request failed: {e}") + +#region Main + +if __name__ == "__main__": + print(f"Tactility Build System v{ttbuild_version}") + if "--help" in sys.argv: + print_help() + sys.exit() + # Argument validation + if len(sys.argv) == 1: + print_help() + sys.exit(1) + if "--verbose" in sys.argv: + verbose = True + sys.argv.remove("--verbose") + skip_build = False + if "--skip-build" in sys.argv: + skip_build = True + sys.argv.remove("--skip-build") + if "--local-sdk" in sys.argv: + use_local_sdk = True + sys.argv.remove("--local-sdk") + action_arg = sys.argv[1] + + # Environment setup + setup_environment() + if not os.path.isfile("manifest.properties"): + exit_with_error("manifest.properties not found") + manifest = read_manifest() + validate_manifest(manifest) + all_platform_targets = manifest["target"]["platforms"].split(",") + # Update SDK cache (tool.json) + if should_update_tool_json() and not update_tool_json(): + exit_with_error("Failed to retrieve SDK info") + # Actions + if action_arg == "build": + if len(sys.argv) < 2: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + if len(sys.argv) > 2: + platform = sys.argv[2] + if not build_action(manifest, platform): + sys.exit(1) + elif action_arg == "clean": + clean_action() + elif action_arg == "clearcache": + clear_cache_action() + elif action_arg == "updateself": + update_self_action() + elif action_arg == "run": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + run_action(manifest, sys.argv[2]) + elif action_arg == "install": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + platforms_to_install = all_platform_targets + if len(sys.argv) >= 4: + platform = sys.argv[3] + platforms_to_install = [platform] + install_action(sys.argv[2], platforms_to_install) + elif action_arg == "uninstall": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + uninstall_action(manifest, sys.argv[2]) + elif action_arg == "bir" or action_arg == "brrr": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + platforms_to_install = all_platform_targets + if len(sys.argv) >= 4: + platform = sys.argv[3] + platforms_to_install = [platform] + if build_action(manifest, platform): + if install_action(sys.argv[2], platforms_to_install): + run_action(manifest, sys.argv[2]) + else: + print_help() + exit_with_error("Unknown commandline parameter") + +#endregion Main diff --git a/Apps/MystifyDemo/CMakeLists.txt b/Apps/MystifyDemo/CMakeLists.txt new file mode 100644 index 0000000..65aaab4 --- /dev/null +++ b/Apps/MystifyDemo/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.20) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +if (DEFINED ENV{TACTILITY_SDK_PATH}) + set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH}) +else() + set(TACTILITY_SDK_PATH "../../release/TactilitySDK") + message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}") +endif() + +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") +set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +project(Mystify) +tactility_project(Mystify) diff --git a/Apps/MystifyDemo/main/CMakeLists.txt b/Apps/MystifyDemo/main/CMakeLists.txt new file mode 100644 index 0000000..e8bdcd6 --- /dev/null +++ b/Apps/MystifyDemo/main/CMakeLists.txt @@ -0,0 +1,9 @@ +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) + +idf_component_register( + SRC_DIRS "Source" + # Library headers must be included directly, + # because all regular dependencies get stripped by elf_loader's cmake script + INCLUDE_DIRS "Include" "../../../Libraries/TactilityCpp/Include" + REQUIRES TactilitySDK +) diff --git a/Apps/MystifyDemo/main/Include/Application.h b/Apps/MystifyDemo/main/Include/Application.h new file mode 100644 index 0000000..90bcf71 --- /dev/null +++ b/Apps/MystifyDemo/main/Include/Application.h @@ -0,0 +1,6 @@ +#pragma once + +#include "drivers/DisplayDriver.h" +#include "drivers/TouchDriver.h" + +void runApplication(DisplayDriver* display, TouchDriver* touch); diff --git a/Apps/MystifyDemo/main/Include/MystifyDemo.h b/Apps/MystifyDemo/main/Include/MystifyDemo.h new file mode 100644 index 0000000..9be2c48 --- /dev/null +++ b/Apps/MystifyDemo/main/Include/MystifyDemo.h @@ -0,0 +1,317 @@ +#pragma once + +#include "PixelBuffer.h" +#include "drivers/DisplayDriver.h" +#include +#include +#include + +/** + * Mystify Screensaver Demo + * + * Classic Windows-style mystify screensaver with bouncing polygons and trailing edges. + * Adapted to work with DisplayDriver and PixelBuffer abstractions. + * + * Usage: + * MystifyDemo mystify; + * mystify.init(display); + * + * while (!shouldExit) { + * mystify.update(); + * } + */ +class MystifyDemo { +public: + static constexpr int NUM_POLYGONS = 2; + static constexpr int NUM_VERTICES = 4; + static constexpr int TRAIL_LENGTH = 8; + static constexpr int COLOR_CHANGE_INTERVAL = 200; // Frames between color changes + static constexpr int STRIP_HEIGHT = 16; // Draw in strips to avoid SPI buffer overflow + + MystifyDemo() = default; + ~MystifyDemo() { deinit(); } + + // Non-copyable, non-movable (owns PixelBuffer) + MystifyDemo(const MystifyDemo&) = delete; + MystifyDemo& operator=(const MystifyDemo&) = delete; + MystifyDemo(MystifyDemo&&) = delete; + MystifyDemo& operator=(MystifyDemo&&) = delete; + + bool init(DisplayDriver* display) { + if (!display) { + return false; + } + + display_ = display; + width_ = display->getWidth(); + height_ = display->getHeight(); + + if (width_ <= 0 || height_ <= 0) { + return false; + } + + // Seed random generator with hardware entropy + srand(static_cast(esp_random())); + + // Allocate full-screen framebuffer + void* mem = malloc(sizeof(PixelBuffer)); + if (!mem) { + return false; + } + framebuffer_ = new(mem) PixelBuffer(width_, height_, display->getColorFormat()); + + initPolygons(); + return true; + } + + void deinit() { + if (framebuffer_) { + framebuffer_->~PixelBuffer(); + free(framebuffer_); + framebuffer_ = nullptr; + } + display_ = nullptr; + } + + void update() { + if (!framebuffer_ || !display_) return; + + // Clear framebuffer to black + framebuffer_->clear(); + + // Update and draw each polygon + for (int p = 0; p < NUM_POLYGONS; p++) { + updatePolygon(polygons_[p]); + drawPolygon(polygons_[p]); + } + + // Send framebuffer to display in strips (full screen is too large for single SPI transaction) + display_->lock(); + for (int y = 0; y < height_; y += STRIP_HEIGHT) { + int stripEnd = (y + STRIP_HEIGHT > height_) ? height_ : y + STRIP_HEIGHT; + display_->drawBitmap(0, y, width_, stripEnd, framebuffer_->getDataAtRow(y)); + } + display_->unlock(); + } + +private: + // Smooth sub-pixel movement with floats + struct Vertex { + float x = 0; + float y = 0; + float dx = 0; + float dy = 0; + }; + + struct Polygon { + Vertex vertices[NUM_VERTICES]; + // History: [trail_index][vertex_index] = {x, y} + int16_t historyX[TRAIL_LENGTH][NUM_VERTICES]; + int16_t historyY[TRAIL_LENGTH][NUM_VERTICES]; + uint8_t colorIndex; + int historyHead = 0; + bool historyFull = false; + int colorChangeCounter = 0; + }; + + // Vibrant colors as RGB888 for format-agnostic rendering + struct Color { + uint8_t r, g, b; + }; + + static constexpr Color COLOR_POOL[] = { + {255, 0, 255}, // Magenta + {0, 255, 255}, // Cyan + {255, 255, 0}, // Yellow + {255, 128, 0}, // Orange + {0, 255, 128}, // Spring green + {128, 0, 255}, // Purple + {255, 64, 128}, // Hot pink + {128, 255, 0}, // Lime + }; + static constexpr int COLOR_POOL_SIZE = sizeof(COLOR_POOL) / sizeof(COLOR_POOL[0]); + + DisplayDriver* display_ = nullptr; + PixelBuffer* framebuffer_ = nullptr; + uint16_t width_ = 0; + uint16_t height_ = 0; + Polygon polygons_[NUM_POLYGONS]; + + static float randomFloat(float min, float max) { + return min + (max - min) * (static_cast(rand()) / static_cast(RAND_MAX)); + } + + void initPolygons() { + for (int p = 0; p < NUM_POLYGONS; p++) { + Polygon& polygon = polygons_[p]; + + // Pick random color from pool + polygon.colorIndex = rand() % COLOR_POOL_SIZE; + polygon.historyHead = 0; + polygon.historyFull = false; + // Stagger color changes so polygons don't change simultaneously + polygon.colorChangeCounter = rand() % COLOR_CHANGE_INTERVAL; + + // Initialize vertices with random positions and velocities + for (int v = 0; v < NUM_VERTICES; v++) { + Vertex& vertex = polygon.vertices[v]; + vertex.x = static_cast(rand() % width_); + vertex.y = static_cast(rand() % height_); + + // Speed range for smooth movement + vertex.dx = randomFloat(0.8f, 2.0f); + vertex.dy = randomFloat(0.8f, 2.0f); + if (rand() % 2) vertex.dx = -vertex.dx; + if (rand() % 2) vertex.dy = -vertex.dy; + + // Ensure dx != dy for more interesting movement patterns + if (std::fabs(vertex.dx - vertex.dy) < 0.3f) { + vertex.dy += (vertex.dy > 0 ? 0.5f : -0.5f); + } + } + + // Initialize history with current positions + for (int t = 0; t < TRAIL_LENGTH; t++) { + for (int v = 0; v < NUM_VERTICES; v++) { + polygon.historyX[t][v] = static_cast(polygon.vertices[v].x); + polygon.historyY[t][v] = static_cast(polygon.vertices[v].y); + } + } + } + } + + void updatePolygon(Polygon& polygon) { + constexpr float minSpeed = 0.5f; + constexpr float maxSpeed = 2.5f; + + // Periodic color change + polygon.colorChangeCounter++; + if (polygon.colorChangeCounter >= COLOR_CHANGE_INTERVAL) { + polygon.colorChangeCounter = 0; + // Pick a different color + uint8_t newColor; + do { + newColor = rand() % COLOR_POOL_SIZE; + } while (newColor == polygon.colorIndex && COLOR_POOL_SIZE > 1); + polygon.colorIndex = newColor; + } + + // Move vertices + for (int v = 0; v < NUM_VERTICES; v++) { + Vertex& vertex = polygon.vertices[v]; + vertex.x += vertex.dx; + vertex.y += vertex.dy; + + // Bounce off edges with slight angle variation for organic movement + if (vertex.x <= 0) { + vertex.x = 0; + vertex.dx = std::fabs(vertex.dx); + vertex.dy *= (1.0f + randomFloat(-0.1f, 0.1f)); + } else if (vertex.x >= width_ - 1) { + vertex.x = static_cast(width_ - 1); + vertex.dx = -std::fabs(vertex.dx); + vertex.dy *= (1.0f + randomFloat(-0.1f, 0.1f)); + } + + if (vertex.y <= 0) { + vertex.y = 0; + vertex.dy = std::fabs(vertex.dy); + vertex.dx *= (1.0f + randomFloat(-0.1f, 0.1f)); + } else if (vertex.y >= height_ - 1) { + vertex.y = static_cast(height_ - 1); + vertex.dy = -std::fabs(vertex.dy); + vertex.dx *= (1.0f + randomFloat(-0.1f, 0.1f)); + } + + // Clamp speeds to prevent runaway acceleration or stalling + auto clampSpeed = [minSpeed, maxSpeed](float& speed) { + float sign = (speed >= 0) ? 1.0f : -1.0f; + float absSpeed = std::fabs(speed); + if (absSpeed < minSpeed) absSpeed = minSpeed; + if (absSpeed > maxSpeed) absSpeed = maxSpeed; + speed = sign * absSpeed; + }; + clampSpeed(vertex.dx); + clampSpeed(vertex.dy); + } + + // Advance history ring buffer + polygon.historyHead = (polygon.historyHead + 1) % TRAIL_LENGTH; + if (polygon.historyHead == 0) { + polygon.historyFull = true; + } + + // Store current positions + for (int v = 0; v < NUM_VERTICES; v++) { + polygon.historyX[polygon.historyHead][v] = static_cast(polygon.vertices[v].x); + polygon.historyY[polygon.historyHead][v] = static_cast(polygon.vertices[v].y); + } + } + + void drawPolygon(const Polygon& polygon) { + const Color& baseColor = COLOR_POOL[polygon.colorIndex]; + + // Draw trail from oldest to newest (so newest is on top) + for (int t = TRAIL_LENGTH - 1; t >= 0; t--) { + int histIndex = polygon.historyHead - t; + if (histIndex < 0) histIndex += TRAIL_LENGTH; + + // Skip if we don't have enough history yet + if (!polygon.historyFull && histIndex > polygon.historyHead) { + continue; + } + + // Calculate brightness for this trail frame (older = dimmer) + int brightness = 255 - (t * 230 / TRAIL_LENGTH); + if (brightness < 25) brightness = 25; + + // Scale color by brightness + uint8_t r = (baseColor.r * brightness) / 255; + uint8_t g = (baseColor.g * brightness) / 255; + uint8_t b = (baseColor.b * brightness) / 255; + + // Draw edges connecting vertices + for (int e = 0; e < NUM_VERTICES; e++) { + int nextVertex = (e + 1) % NUM_VERTICES; + + int x0 = polygon.historyX[histIndex][e]; + int y0 = polygon.historyY[histIndex][e]; + int x1 = polygon.historyX[histIndex][nextVertex]; + int y1 = polygon.historyY[histIndex][nextVertex]; + + drawLine(x0, y0, x1, y1, r, g, b); + } + } + } + + // Bresenham's line algorithm + void drawLine(int x0, int y0, int x1, int y1, uint8_t r, uint8_t g, uint8_t b) { + int dx = std::abs(x1 - x0); + int dy = std::abs(y1 - y0); + int sx = (x0 < x1) ? 1 : -1; + int sy = (y0 < y1) ? 1 : -1; + int err = dx - dy; + + while (true) { + setPixel(x0, y0, r, g, b); + + if (x0 == x1 && y0 == y1) break; + + int e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x0 += sx; + } + if (e2 < dx) { + err += dx; + y0 += sy; + } + } + } + + void setPixel(int x, int y, uint8_t r, uint8_t g, uint8_t b) { + if (x >= 0 && x < width_ && y >= 0 && y < height_) { + framebuffer_->setPixel(x, y, r, g, b); + } + } +}; diff --git a/Apps/MystifyDemo/main/Include/PixelBuffer.h b/Apps/MystifyDemo/main/Include/PixelBuffer.h new file mode 100644 index 0000000..c13b47f --- /dev/null +++ b/Apps/MystifyDemo/main/Include/PixelBuffer.h @@ -0,0 +1,125 @@ +#pragma once + +#include +#include "drivers/Colors.h" + +#include +#include + +class PixelBuffer { + uint16_t pixelWidth; + uint16_t pixelHeight; + ColorFormat colorFormat; + uint8_t* data; + +public: + + PixelBuffer(uint16_t pixelWidth, uint16_t pixelHeight, ColorFormat colorFormat) : + pixelWidth(pixelWidth), + pixelHeight(pixelHeight), + colorFormat(colorFormat) + { + data = static_cast(malloc(pixelWidth * pixelHeight * getPixelSize())); + assert(data != nullptr); + } + + ~PixelBuffer() { + free(data); + } + + uint16_t getPixelWidth() const { + return pixelWidth; + } + + uint16_t getPixelHeight() const { + return pixelHeight; + } + + ColorFormat getColorFormat() const { + return colorFormat; + } + + void* getData() const { + return data; + } + + uint32_t getDataSize() const { + return pixelWidth * pixelHeight * getPixelSize(); + } + + void* getDataAtRow(uint16_t row) const { + auto address = reinterpret_cast(data) + (row * getRowDataSize()); + return reinterpret_cast(address); + } + + uint16_t getRowDataSize() const { + return pixelWidth * getPixelSize(); + } + + uint8_t getPixelSize() const { + switch (colorFormat) { + case COLOR_FORMAT_MONOCHROME: + return 1; + case COLOR_FORMAT_BGR565: + case COLOR_FORMAT_BGR565_SWAPPED: + case COLOR_FORMAT_RGB565: + case COLOR_FORMAT_RGB565_SWAPPED: + return 2; + case COLOR_FORMAT_RGB888: + return 3; + default: + // TODO: Crash with error + return 0; + } + } + + uint8_t* getPixelAddress(uint16_t x, uint16_t y) const { + uint32_t offset = ((y * getPixelWidth()) + x) * getPixelSize(); + uint32_t address = reinterpret_cast(data) + offset; + return reinterpret_cast(address); + } + + void setPixel(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b) const { + auto address = getPixelAddress(x, y); + switch (colorFormat) { + case COLOR_FORMAT_MONOCHROME: + *address = (uint8_t)((uint16_t)r + (uint16_t)g + (uint16_t)b / 3); + break; + case COLOR_FORMAT_BGR565: + Colors::rgb888ToBgr565(r, g, b, reinterpret_cast(address)); + break; + case COLOR_FORMAT_BGR565_SWAPPED: { + // TODO: Make proper conversion function + Colors::rgb888ToBgr565(r, g, b, reinterpret_cast(address)); + uint8_t temp = *address; + *address = *(address + 1); + *(address + 1) = temp; + break; + } + case COLOR_FORMAT_RGB565: { + Colors::rgb888ToRgb565(r, g, b, reinterpret_cast(address)); + break; + } + case COLOR_FORMAT_RGB565_SWAPPED: { + // TODO: Make proper conversion function + Colors::rgb888ToRgb565(r, g, b, reinterpret_cast(address)); + uint8_t temp = *address; + *address = *(address + 1); + *(address + 1) = temp; + break; + } + case COLOR_FORMAT_RGB888: { + uint8_t pixel[3] = { r, g, b }; + memcpy(address, pixel, 3); + break; + } + default: + // NO-OP + break; + } + } + + void clear(int value = 0) const { + memset(data, value, getDataSize()); + } +}; \ No newline at end of file diff --git a/Apps/MystifyDemo/main/Include/drivers/Colors.h b/Apps/MystifyDemo/main/Include/drivers/Colors.h new file mode 100644 index 0000000..d068574 --- /dev/null +++ b/Apps/MystifyDemo/main/Include/drivers/Colors.h @@ -0,0 +1,35 @@ +#pragma once + +class Colors { + +public: + + static void rgb888ToRgb565(uint8_t red, uint8_t green, uint8_t blue, uint16_t* rgb565) { + uint16_t _rgb565 = (red >> 3); + _rgb565 = (_rgb565 << 6) | (green >> 2); + _rgb565 = (_rgb565 << 5) | (blue >> 3); + *rgb565 = _rgb565; + } + + static void rgb888ToBgr565(uint8_t red, uint8_t green, uint8_t blue, uint16_t* bgr565) { + uint16_t _bgr565 = (blue >> 3); + _bgr565 = (_bgr565 << 6) | (green >> 2); + _bgr565 = (_bgr565 << 5) | (red >> 3); + *bgr565 = _bgr565; + } + + static void rgb565ToRgb888(uint16_t rgb565, uint32_t* rgb888) { + uint32_t _rgb565 = rgb565; + uint8_t b = (_rgb565 >> 8) & 0xF8; + uint8_t g = (_rgb565 >> 3) & 0xFC; + uint8_t r = (_rgb565 << 3) & 0xF8; + + uint8_t* r8p = reinterpret_cast(rgb888); + uint8_t* g8p = r8p + 1; + uint8_t* b8p = r8p + 2; + + *r8p = r | ((r >> 3) & 0x7); + *g8p = g | ((g >> 2) & 0x3); + *b8p = b | ((b >> 3) & 0x7); + } +}; \ No newline at end of file diff --git a/Apps/MystifyDemo/main/Include/drivers/DisplayDriver.h b/Apps/MystifyDemo/main/Include/drivers/DisplayDriver.h new file mode 100644 index 0000000..702fc59 --- /dev/null +++ b/Apps/MystifyDemo/main/Include/drivers/DisplayDriver.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +/** + * Wrapper for tt_hal_display_driver_* + */ +class DisplayDriver { + + DisplayDriverHandle handle = nullptr; + +public: + + explicit DisplayDriver(DeviceId id) { + assert(tt_hal_display_driver_supported(id)); + handle = tt_hal_display_driver_alloc(id); + assert(handle != nullptr); + } + + ~DisplayDriver() { + tt_hal_display_driver_free(handle); + } + + bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const { + return tt_hal_display_driver_lock(handle, timeout); + } + + void unlock() const { + tt_hal_display_driver_unlock(handle); + } + + uint16_t getWidth() const { + return tt_hal_display_driver_get_pixel_width(handle); + } + + uint16_t getHeight() const { + return tt_hal_display_driver_get_pixel_height(handle); + } + + ColorFormat getColorFormat() const { + return tt_hal_display_driver_get_colorformat(handle); + } + + void drawBitmap(int xStart, int yStart, int xEnd, int yEnd, const void* pixelData) const { + tt_hal_display_driver_draw_bitmap(handle, xStart, yStart, xEnd, yEnd, pixelData); + } +}; diff --git a/Apps/MystifyDemo/main/Include/drivers/TouchDriver.h b/Apps/MystifyDemo/main/Include/drivers/TouchDriver.h new file mode 100644 index 0000000..622f126 --- /dev/null +++ b/Apps/MystifyDemo/main/Include/drivers/TouchDriver.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +/** + * Wrapper for tt_hal_touch_driver_* + */ +class TouchDriver { + + TouchDriverHandle handle = nullptr; + +public: + + explicit TouchDriver(DeviceId id) { + assert(tt_hal_touch_driver_supported(id)); + handle = tt_hal_touch_driver_alloc(id); + assert(handle != nullptr); + } + + ~TouchDriver() { + tt_hal_touch_driver_free(handle); + } + + bool getTouchedPoints(uint16_t* x, uint16_t* y, uint16_t* strength, uint8_t* count, uint8_t maxCount) const { + return tt_hal_touch_driver_get_touched_points(handle, x, y, strength, count, maxCount); + } +}; diff --git a/Apps/MystifyDemo/main/Source/Application.cpp b/Apps/MystifyDemo/main/Source/Application.cpp new file mode 100644 index 0000000..8c2d650 --- /dev/null +++ b/Apps/MystifyDemo/main/Source/Application.cpp @@ -0,0 +1,36 @@ +#include "Application.h" +#include "MystifyDemo.h" +#include "PixelBuffer.h" +#include "esp_log.h" + +#include + +constexpr auto TAG = "Application"; +constexpr int MYSTIFY_FRAME_DELAY_MS = 50; // ~20 FPS for smooth animation + +static bool isTouched(TouchDriver* touch) { + uint16_t x, y, strength; + uint8_t pointCount = 0; + return touch->getTouchedPoints(&x, &y, &strength, &pointCount, 1); +} + +void runApplication(DisplayDriver* display, TouchDriver* touch) { + // Run the Mystify screensaver demo + MystifyDemo mystify; + if (!mystify.init(display)) { + ESP_LOGE(TAG, "Failed to initialize MystifyDemo"); + return; + } + + ESP_LOGI(TAG, "Starting Mystify demo - touch to exit"); + + do { + mystify.update(); + + // Frame rate limiter - ~20 FPS for smooth animation + tt::kernel::delayTicks(tt::kernel::millisToTicks(MYSTIFY_FRAME_DELAY_MS)); + } while (!isTouched(touch)); + + ESP_LOGI(TAG, "Mystify demo ended"); +} + diff --git a/Apps/MystifyDemo/main/Source/Main.cpp b/Apps/MystifyDemo/main/Source/Main.cpp new file mode 100644 index 0000000..c8949d9 --- /dev/null +++ b/Apps/MystifyDemo/main/Source/Main.cpp @@ -0,0 +1,101 @@ +#include "Application.h" +#include "drivers/DisplayDriver.h" +#include "drivers/TouchDriver.h" + +#include + +#include +#include +#include + +constexpr auto TAG = "Main"; + +/** Find a DisplayDevice that supports the DisplayDriver interface */ +static bool findUsableDisplay(DeviceId& deviceId) { + uint16_t display_count = 0; + if (!tt_hal_device_find(DEVICE_TYPE_DISPLAY, &deviceId, &display_count, 1)) { + ESP_LOGE(TAG, "No display device found"); + return false; + } + + if (!tt_hal_display_driver_supported(deviceId)) { + ESP_LOGE(TAG, "Display doesn't support driver mode"); + return false; + } + + return true; +} + +/** Find a TouchDevice that supports the TouchDriver interface */ +static bool findUsableTouch(DeviceId& deviceId) { + uint16_t touch_count = 0; + if (!tt_hal_device_find(DEVICE_TYPE_TOUCH, &deviceId, &touch_count, 1)) { + ESP_LOGE(TAG, "No touch device found"); + return false; + } + + if (!tt_hal_touch_driver_supported(deviceId)) { + ESP_LOGE(TAG, "Touch doesn't support driver mode"); + return false; + } + + return true; +} + +static void onCreate(AppHandle appHandle, void* data) { + DeviceId display_id; + if (!findUsableDisplay(display_id)) { + tt_app_stop(); + tt_app_alertdialog_start("Error", "The display doesn't support the required features.", nullptr, 0); + return; + } + + DeviceId touch_id; + if (!findUsableTouch(touch_id)) { + tt_app_stop(); + tt_app_alertdialog_start("Error", "The touch driver doesn't support the required features.", nullptr, 0); + return; + } + + // Stop LVGL first (because it's currently using the drivers we want to use) + tt_lvgl_stop(); + + ESP_LOGI(TAG, "Creating display driver"); + auto display = new DisplayDriver(display_id); + + ESP_LOGI(TAG, "Creating touch driver"); + auto touch = new TouchDriver(touch_id); + + // Run the main logic + ESP_LOGI(TAG, "Running application"); + runApplication(display, touch); + + ESP_LOGI(TAG, "Cleanup display driver"); + delete display; + + ESP_LOGI(TAG, "Cleanup touch driver"); + delete touch; + + ESP_LOGI(TAG, "Stopping application"); + tt_app_stop(); +} + +static void onDestroy(AppHandle appHandle, void* data) { + // Restart LVGL to resume rendering of regular apps + if (!tt_lvgl_is_started()) { + ESP_LOGI(TAG, "Restarting LVGL"); + tt_lvgl_start(); + } +} + +extern "C" { + +int main(int argc, char* argv[]) { + tt_app_register((AppRegistration) { + .onCreate = onCreate, + .onDestroy = onDestroy + }); + return 0; +} + +} diff --git a/Apps/MystifyDemo/manifest.properties b/Apps/MystifyDemo/manifest.properties new file mode 100644 index 0000000..70cc330 --- /dev/null +++ b/Apps/MystifyDemo/manifest.properties @@ -0,0 +1,10 @@ +[manifest] +version=0.1 +[target] +sdk=0.7.0-dev +platforms=esp32,esp32s3,esp32c6,esp32p4 +[app] +id=one.tactility.mystifydemo +versionName=0.3.0 +versionCode=3 +name=Mystify Demo diff --git a/Apps/MystifyDemo/tactility.py b/Apps/MystifyDemo/tactility.py new file mode 100644 index 0000000..81f271c --- /dev/null +++ b/Apps/MystifyDemo/tactility.py @@ -0,0 +1,693 @@ +import configparser +import json +import os +import re +import shutil +import sys +import subprocess +import time +import urllib.request +import zipfile +import requests +import tarfile + +ttbuild_path = ".tactility" +ttbuild_version = "3.1.0" +ttbuild_cdn = "https://cdn.tactility.one" +ttbuild_sdk_json_validity = 3600 # seconds +ttport = 6666 +verbose = False +use_local_sdk = False +local_base_path = None + +shell_color_red = "\033[91m" +shell_color_orange = "\033[93m" +shell_color_green = "\033[32m" +shell_color_purple = "\033[35m" +shell_color_cyan = "\033[36m" +shell_color_reset = "\033[m" + +def print_help(): + print("Usage: python tactility.py [action] [options]") + print("") + print("Actions:") + print(" build [esp32,esp32s3] Build the app. Optionally specify a platform.") + print(" esp32: ESP32") + print(" esp32s3: ESP32 S3") + print(" clean Clean the build folders") + print(" clearcache Clear the SDK cache") + print(" updateself Update this tool") + print(" run [ip] Run the application") + print(" install [ip] Install the application") + print(" uninstall [ip] Uninstall the application") + print(" bir [ip] [esp32,esp32s3] Build, install then run. Optionally specify a platform.") + print(" brrr [ip] [esp32,esp32s3] Functionally the same as \"bir\", but \"app goes brrr\" meme variant.") + print("") + print("Options:") + print(" --help Show this commandline info") + print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.") + print(" --skip-build Run everything except the idf.py/CMake commands") + print(" --verbose Show extra console output") + +# region Core + +def download_file(url, filepath): + global verbose + if verbose: + print(f"Downloading from {url} to {filepath}") + request = urllib.request.Request( + url, + data=None, + headers={ + "User-Agent": f"Tactility Build Tool {ttbuild_version}" + } + ) + try: + response = urllib.request.urlopen(request) + file = open(filepath, mode="wb") + file.write(response.read()) + file.close() + return True + except OSError as error: + if verbose: + print_error(f"Failed to fetch URL {url}\n{error}") + return False + +def print_warning(message): + print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}") + +def print_error(message): + print(f"{shell_color_red}ERROR: {message}{shell_color_reset}") + +def print_status_busy(status): + sys.stdout.write(f"⌛ {status}\r") + +def print_status_success(status): + # Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text + print(f"✅ {shell_color_green}{status}{shell_color_reset} ") + +def print_status_error(status): + # Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text + print(f"❌ {shell_color_red}{status}{shell_color_reset} ") + +def exit_with_error(message): + print_error(message) + sys.exit(1) + +def get_url(ip, path): + return f"http://{ip}:{ttport}{path}" + +def read_properties_file(path): + config = configparser.RawConfigParser() + config.read(path) + return config + +#endregion Core + +#region SDK helpers + +def read_sdk_json(): + json_file_path = os.path.join(ttbuild_path, "tool.json") + with open(json_file_path) as json_file: + return json.load(json_file) + +def get_sdk_dir(version, platform): + global use_local_sdk, local_base_path + if use_local_sdk: + base_path = local_base_path + if base_path is None: + exit_with_error("TACTILITY_SDK_PATH environment variable is not set") + sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") + sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") + if not os.path.isdir(sdk_dir): + exit_with_error(f"Local SDK folder not found for platform {platform}: {sdk_dir}") + return sdk_dir + else: + return os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK") + +def validate_local_sdks(platforms, version): + if not use_local_sdk: + return + global local_base_path + base_path = local_base_path + for platform in platforms: + sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") + sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") + if not os.path.isdir(sdk_dir): + exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}") + +def get_sdk_root_dir(version, platform): + global ttbuild_cdn + return os.path.join(ttbuild_path, f"{version}-{platform}") + +def get_sdk_url(version, file): + global ttbuild_cdn + return f"{ttbuild_cdn}/sdk/{version}/{file}" + +def sdk_exists(version, platform): + sdk_dir = get_sdk_dir(version, platform) + return os.path.isdir(sdk_dir) + +def should_update_tool_json(): + global ttbuild_cdn + json_filepath = os.path.join(ttbuild_path, "tool.json") + if os.path.exists(json_filepath): + json_modification_time = os.path.getmtime(json_filepath) + now = time.time() + global ttbuild_sdk_json_validity + minimum_seconds_difference = ttbuild_sdk_json_validity + return (now - json_modification_time) > minimum_seconds_difference + else: + return True + +def update_tool_json(): + global ttbuild_cdn, ttbuild_path + json_url = f"{ttbuild_cdn}/sdk/tool.json" + json_filepath = os.path.join(ttbuild_path, "tool.json") + return download_file(json_url, json_filepath) + +def should_fetch_sdkconfig_files(platform_targets): + for platform in platform_targets: + sdkconfig_filename = f"sdkconfig.app.{platform}" + if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)): + return True + return False + +def fetch_sdkconfig_files(platform_targets): + for platform in platform_targets: + sdkconfig_filename = f"sdkconfig.app.{platform}" + target_path = os.path.join(ttbuild_path, sdkconfig_filename) + if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path): + exit_with_error(f"Failed to download sdkconfig file for {platform}") + +#endregion SDK helpers + +#region Validation + +def validate_environment(): + if os.environ.get("IDF_PATH") is None: + if sys.platform == "win32": + exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1") + else: + exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh") + if not os.path.exists("manifest.properties"): + exit_with_error("manifest.properties not found") + if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None: + print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.") + print_warning("If you want to use it, use the '--local-sdk' parameter") + elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None: + exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.") + +def validate_self(sdk_json): + if not "toolVersion" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolVersion not found)") + if not "toolCompatibility" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolCompatibility not found)") + if not "toolDownloadUrl" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolDownloadUrl not found)") + tool_version = sdk_json["toolVersion"] + tool_compatibility = sdk_json["toolCompatibility"] + if tool_version != ttbuild_version: + print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})") + print_warning(f"Run 'tactility.py updateself' to update.") + if re.search(tool_compatibility, ttbuild_version) is None: + print_error("The tool is not compatible anymore.") + print_error("Run 'tactility.py updateself' to update.") + sys.exit(1) + +#endregion Validation + +#region Manifest + +def read_manifest(): + return read_properties_file("manifest.properties") + +def validate_manifest(manifest): + # [manifest] + if not "manifest" in manifest: + exit_with_error("Invalid manifest format: [manifest] not found") + if not "version" in manifest["manifest"]: + exit_with_error("Invalid manifest format: [manifest] version not found") + # [target] + if not "target" in manifest: + exit_with_error("Invalid manifest format: [target] not found") + if not "sdk" in manifest["target"]: + exit_with_error("Invalid manifest format: [target] sdk not found") + if not "platforms" in manifest["target"]: + exit_with_error("Invalid manifest format: [target] platforms not found") + # [app] + if not "app" in manifest: + exit_with_error("Invalid manifest format: [app] not found") + if not "id" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] id not found") + if not "versionName" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] versionName not found") + if not "versionCode" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] versionCode not found") + if not "name" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] name not found") + +def is_valid_manifest_platform(manifest, platform): + manifest_platforms = manifest["target"]["platforms"].split(",") + return platform in manifest_platforms + +def validate_manifest_platform(manifest, platform): + if not is_valid_manifest_platform(manifest, platform): + exit_with_error(f"Platform {platform} is not available in the manifest.") + +def get_manifest_target_platforms(manifest, requested_platform): + if requested_platform == "" or requested_platform is None: + return manifest["target"]["platforms"].split(",") + else: + validate_manifest_platform(manifest, requested_platform) + return [requested_platform] + +#endregion Manifest + +#region SDK download + +def sdk_download(version, platform): + sdk_root_dir = get_sdk_root_dir(version, platform) + os.makedirs(sdk_root_dir, exist_ok=True) + sdk_index_url = get_sdk_url(version, "index.json") + print(f"Downloading SDK version {version} for {platform}") + sdk_index_filepath = os.path.join(sdk_root_dir, "index.json") + if verbose: + print(f"Downloading {sdk_index_url} to {sdk_index_filepath}") + if not download_file(sdk_index_url, sdk_index_filepath): + # TODO: 404 check, print a more accurate error + print_error(f"Failed to download SDK version {version}. Check your internet connection and make sure this release exists.") + return False + with open(sdk_index_filepath) as sdk_index_json_file: + sdk_index_json = json.load(sdk_index_json_file) + sdk_platforms = sdk_index_json["platforms"] + if platform not in sdk_platforms: + print_error(f"Platform {platform} not found in {sdk_platforms} for version {version}") + return False + sdk_platform_file = sdk_platforms[platform] + sdk_zip_source_url = get_sdk_url(version, sdk_platform_file) + sdk_zip_target_filepath = os.path.join(sdk_root_dir, f"{version}-{platform}.zip") + if verbose: + print(f"Downloading {sdk_zip_source_url} to {sdk_zip_target_filepath}") + if not download_file(sdk_zip_source_url, sdk_zip_target_filepath): + print_error(f"Failed to download {sdk_zip_source_url} to {sdk_zip_target_filepath}") + return False + with zipfile.ZipFile(sdk_zip_target_filepath, "r") as zip_ref: + zip_ref.extractall(os.path.join(sdk_root_dir, "TactilitySDK")) + return True + +def sdk_download_all(version, platforms): + for platform in platforms: + if not sdk_exists(version, platform): + if not sdk_download(version, platform): + return False + else: + if verbose: + print(f"Using cached download for SDK version {version} and platform {platform}") + return True + +#endregion SDK download + +#region Building + +def get_cmake_path(platform): + return os.path.join("build", f"cmake-build-{platform}") + +def find_elf_file(platform): + cmake_dir = get_cmake_path(platform) + if os.path.exists(cmake_dir): + for file in os.listdir(cmake_dir): + if file.endswith(".app.elf"): + return os.path.join(cmake_dir, file) + return None + +def build_all(version, platforms, skip_build): + for platform in platforms: + # First build command must be "idf.py build", otherwise it fails to execute "idf.py elf" + # We check if the ELF file exists and run the correct command + # This can lead to code caching issues, so sometimes a clean build is required + if find_elf_file(platform) is None: + if not build_first(version, platform, skip_build): + return False + else: + if not build_consecutively(version, platform, skip_build): + return False + return True + +def wait_for_process(process): + buffer = [] + if sys.platform != "win32": + os.set_blocking(process.stdout.fileno(), False) + while process.poll() is None: + while True: + line = process.stdout.readline() + if line: + decoded_line = line.decode("UTF-8") + if decoded_line != "": + buffer.append(decoded_line) + else: + break + else: + break + # Read any remaining output + for line in process.stdout: + decoded_line = line.decode("UTF-8") + if decoded_line: + buffer.append(decoded_line) + return buffer + +# The first build must call "idf.py build" and consecutive builds must call "idf.py elf" as it finishes faster. +# The problem is that the "idf.py build" always results in an error, even though the elf file is created. +# The solution is to suppress the error if we find that the elf file was created. +def build_first(version, platform, skip_build): + sdk_dir = get_sdk_dir(version, platform) + if verbose: + print(f"Using SDK at {sdk_dir}") + os.environ["TACTILITY_SDK_PATH"] = sdk_dir + sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") + shutil.copy(sdkconfig_path, "sdkconfig") + elf_path = find_elf_file(platform) + # Remove previous elf file: re-creation of the file is used to measure if the build succeeded, + # as the actual build job will always fail due to technical issues with the elf cmake script + if elf_path is not None: + os.remove(elf_path) + if skip_build: + return True + print(f"Building first {platform} build") + cmake_path = get_cmake_path(platform) + print_status_busy(f"Building {platform} ELF") + shell_needed = sys.platform == "win32" + build_command = ["idf.py", "-B", cmake_path, "build"] + if verbose: + print(f"Running command: {" ".join(build_command)}") + with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: + build_output = wait_for_process(process) + # The return code is never expected to be 0 due to a bug in the elf cmake script, but we keep it just in case + if process.returncode == 0: + print(f"{shell_color_green}Building for {platform} ✅{shell_color_reset}") + return True + else: + if find_elf_file(platform) is None: + for line in build_output: + print(line, end="") + print_status_error(f"Building {platform} ELF") + return False + else: + print_status_success(f"Building {platform} ELF") + return True + +def build_consecutively(version, platform, skip_build): + sdk_dir = get_sdk_dir(version, platform) + if verbose: + print(f"Using SDK at {sdk_dir}") + os.environ["TACTILITY_SDK_PATH"] = sdk_dir + sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") + shutil.copy(sdkconfig_path, "sdkconfig") + if skip_build: + return True + cmake_path = get_cmake_path(platform) + print_status_busy(f"Building {platform} ELF") + shell_needed = sys.platform == "win32" + build_command = ["idf.py", "-B", cmake_path, "elf"] + if verbose: + print(f"Running command: {" ".join(build_command)}") + with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: + build_output = wait_for_process(process) + if process.returncode == 0: + print_status_success(f"Building {platform} ELF") + return True + else: + for line in build_output: + print(line, end="") + print_status_error(f"Building {platform} ELF") + return False + +#endregion Building + +#region Packaging + +def package_intermediate_manifest(target_path): + if not os.path.isfile("manifest.properties"): + print_error("manifest.properties not found") + return + shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties")) + +def package_intermediate_binaries(target_path, platforms): + elf_dir = os.path.join(target_path, "elf") + os.makedirs(elf_dir, exist_ok=True) + for platform in platforms: + elf_path = find_elf_file(platform) + if elf_path is None: + print_error(f"ELF file not found at {elf_path}") + return + shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}.elf")) + +def package_intermediate_assets(target_path): + if os.path.isdir("assets"): + shutil.copytree("assets", os.path.join(target_path, "assets"), dirs_exist_ok=True) + +def package_intermediate(platforms): + target_path = os.path.join("build", "package-intermediate") + if os.path.isdir(target_path): + shutil.rmtree(target_path) + os.makedirs(target_path, exist_ok=True) + package_intermediate_manifest(target_path) + package_intermediate_binaries(target_path, platforms) + package_intermediate_assets(target_path) + +def package_name(platforms): + elf_path = find_elf_file(platforms[0]) + elf_base_name = os.path.basename(elf_path).removesuffix(".app.elf") + return os.path.join("build", f"{elf_base_name}.app") + + +def package_all(platforms): + status = f"Building package with {platforms}" + print_status_busy(status) + package_intermediate(platforms) + # Create build/something.app + try: + tar_path = package_name(platforms) + tar = tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT) + tar.add(os.path.join("build", "package-intermediate"), arcname="") + tar.close() + print_status_success(status) + return True + except Exception as e: + print_status_error(f"Building package failed: {e}") + return False + +#endregion Packaging + +def setup_environment(): + global ttbuild_path + os.makedirs(ttbuild_path, exist_ok=True) + +def build_action(manifest, platform_arg): + # Environment validation + validate_environment() + platforms_to_build = get_manifest_target_platforms(manifest, platform_arg) + + if use_local_sdk: + global local_base_path + local_base_path = os.environ.get("TACTILITY_SDK_PATH") + validate_local_sdks(platforms_to_build, manifest["target"]["sdk"]) + + if should_fetch_sdkconfig_files(platforms_to_build): + fetch_sdkconfig_files(platforms_to_build) + + if not use_local_sdk: + sdk_json = read_sdk_json() + validate_self(sdk_json) + # Build + sdk_version = manifest["target"]["sdk"] + if not use_local_sdk: + if not sdk_download_all(sdk_version, platforms_to_build): + exit_with_error("Failed to download one or more SDKs") + if not build_all(sdk_version, platforms_to_build, skip_build): # Environment validation + return False + if not skip_build: + package_all(platforms_to_build) + return True + +def clean_action(): + if os.path.exists("build"): + print_status_busy("Removing build/") + shutil.rmtree("build") + print_status_success("Removed build/") + else: + print("Nothing to clean") + +def clear_cache_action(): + if os.path.exists(ttbuild_path): + print_status_busy(f"Removing {ttbuild_path}/") + shutil.rmtree(ttbuild_path) + print_status_success(f"Removed {ttbuild_path}/") + else: + print("Nothing to clear") + +def update_self_action(): + sdk_json = read_sdk_json() + tool_download_url = sdk_json["toolDownloadUrl"] + if download_file(tool_download_url, "tactility.py"): + print("Updated") + else: + exit_with_error("Update failed") + +def get_device_info(ip): + print_status_busy(f"Requesting device info") + url = get_url(ip, "/info") + try: + response = requests.get(url) + if response.status_code != 200: + print_error("Run failed") + else: + print_status_success(f"Received device info:") + print(response.json()) + except requests.RequestException as e: + print_status_error(f"Device info request failed: {e}") + +def run_action(manifest, ip): + app_id = manifest["app"]["id"] + print_status_busy("Running") + url = get_url(ip, "/app/run") + params = {'id': app_id} + try: + response = requests.post(url, params=params) + if response.status_code != 200: + print_error("Run failed") + else: + print_status_success("Running") + except requests.RequestException as e: + print_status_error(f"Running request failed: {e}") + +def install_action(ip, platforms): + print_status_busy("Installing") + for platform in platforms: + elf_path = find_elf_file(platform) + if elf_path is None: + print_status_error(f"ELF file not built for {platform}") + return False + package_path = package_name(platforms) + # print(f"Installing {package_path} to {ip}") + url = get_url(ip, "/app/install") + try: + # Prepare multipart form data + with open(package_path, 'rb') as file: + files = { + 'elf': file + } + response = requests.put(url, files=files) + if response.status_code != 200: + print_status_error("Install failed") + return False + else: + print_status_success("Installing") + return True + except requests.RequestException as e: + print_status_error(f"Install request failed: {e}") + return False + except IOError as e: + print_status_error(f"Install file error: {e}") + return False + +def uninstall_action(manifest, ip): + app_id = manifest["app"]["id"] + print_status_busy("Uninstalling") + url = get_url(ip, "/app/uninstall") + params = {'id': app_id} + try: + response = requests.put(url, params=params) + if response.status_code != 200: + print_status_error("Server responded that uninstall failed") + else: + print_status_success("Uninstalled") + except requests.RequestException as e: + print_status_error(f"Uninstall request failed: {e}") + +#region Main + +if __name__ == "__main__": + print(f"Tactility Build System v{ttbuild_version}") + if "--help" in sys.argv: + print_help() + sys.exit() + # Argument validation + if len(sys.argv) == 1: + print_help() + sys.exit(1) + if "--verbose" in sys.argv: + verbose = True + sys.argv.remove("--verbose") + skip_build = False + if "--skip-build" in sys.argv: + skip_build = True + sys.argv.remove("--skip-build") + if "--local-sdk" in sys.argv: + use_local_sdk = True + sys.argv.remove("--local-sdk") + action_arg = sys.argv[1] + + # Environment setup + setup_environment() + if not os.path.isfile("manifest.properties"): + exit_with_error("manifest.properties not found") + manifest = read_manifest() + validate_manifest(manifest) + all_platform_targets = manifest["target"]["platforms"].split(",") + # Update SDK cache (tool.json) + if should_update_tool_json() and not update_tool_json(): + exit_with_error("Failed to retrieve SDK info") + # Actions + if action_arg == "build": + if len(sys.argv) < 2: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + if len(sys.argv) > 2: + platform = sys.argv[2] + if not build_action(manifest, platform): + sys.exit(1) + elif action_arg == "clean": + clean_action() + elif action_arg == "clearcache": + clear_cache_action() + elif action_arg == "updateself": + update_self_action() + elif action_arg == "run": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + run_action(manifest, sys.argv[2]) + elif action_arg == "install": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + platforms_to_install = all_platform_targets + if len(sys.argv) >= 4: + platform = sys.argv[3] + platforms_to_install = [platform] + install_action(sys.argv[2], platforms_to_install) + elif action_arg == "uninstall": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + uninstall_action(manifest, sys.argv[2]) + elif action_arg == "bir" or action_arg == "brrr": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + platforms_to_install = all_platform_targets + if len(sys.argv) >= 4: + platform = sys.argv[3] + platforms_to_install = [platform] + if build_action(manifest, platform): + if install_action(sys.argv[2], platforms_to_install): + run_action(manifest, sys.argv[2]) + else: + print_help() + exit_with_error("Unknown commandline parameter") + +#endregion Main diff --git a/Apps/Snake/CMakeLists.txt b/Apps/Snake/CMakeLists.txt new file mode 100644 index 0000000..a9a5772 --- /dev/null +++ b/Apps/Snake/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.20) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +if (DEFINED ENV{TACTILITY_SDK_PATH}) + set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH}) +else() + set(TACTILITY_SDK_PATH "../../release/TactilitySDK") + message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}") +endif() + +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") +set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +project(Snake) +tactility_project(Snake) diff --git a/Apps/Snake/README.md b/Apps/Snake/README.md new file mode 100644 index 0000000..b587264 --- /dev/null +++ b/Apps/Snake/README.md @@ -0,0 +1,65 @@ +# Snake + +The classic Snake game for Tactility. + +## Overview + +Snake is a faithful implementation of the classic arcade game where you control a snake that grows longer as it eats food. Navigate carefully to avoid hitting walls or your own tail! + +## Features + +- **Four Difficulty Levels**: Easy, Medium, Hard, and Hell - with wall collision toggle for the ultimate challenge. +- **Multiple Input Methods**: Touch gestures and keyboard support. +- **Visual Feedback**: Color-coded snake head and body with smooth movement. +- **Score Tracking**: Real-time score display with game over detection. +- **High Score Persistence**: Saves best scores for each difficulty level. +- **Progressive Speed**: Game speeds up as your snake grows longer. +- **Responsive UI**: Automatically adapts grid size to available screen space. +- **Non-Square Grids**: Takes advantage of the full display area. + +## Screenshots + +Screenshots taken directly from my Lilygo T-Deck Plus. +Tested on Lilygo T-Deck Plus and M5Stack Cardputer. + +![alt text](images/easy.png) ![alt text](images/medium.png) ![alt text](images/hardhell.png) +![alt text](images/selection.png) + +## Requirements + +- Tactility +- Touchscreen or keyboard + +## Usage + +1. Launch the Snake app. +2. Optionally select "How to Play" to learn the controls. +3. Select your preferred difficulty (Easy, Medium, Hard, or Hell). +4. Control the snake to eat food and grow longer. +5. Avoid hitting yourself (and walls in Hell mode)! +6. Game ends when you collide - try to get the highest score! + +## Controls + +- **Touchscreen**: Swipe up, down, left, or right to change direction. +- **Keyboard (Arrow Keys)**: Use arrow keys (Up, Down, Left, Right) for movement. +- **Keyboard (WASD)**: Use W, A, S, D keys for movement. +- **Keyboard (Cardputer)**: Use semicolon (;), comma (,), period (.), slash (/) for up, left, down, right. + +## Game Rules + +- Snake starts in the center moving right. +- Eat food (red dot) to grow longer and increase score. +- Each food eaten adds one segment to your snake. +- Cannot reverse direction (no 180-degree turns). +- In Easy/Medium/Hard: Snake wraps around screen edges. +- In Hell mode: Hitting walls = instant death! +- Fill the entire grid to win (if you're that good)! + +## Difficulty Levels + +- **Easy**: Large cells (16px) - fewer cells, slower pace, wrap-around walls. +- **Medium**: Medium cells (12px) - balanced challenge, wrap-around walls. +- **Hard**: Small cells (8px) - many cells, requires quick reflexes, wrap-around walls. +- **Hell**: Small cells (8px) + wall collision - hitting walls means game over! + diff --git a/Apps/Snake/images/easy.png b/Apps/Snake/images/easy.png new file mode 100644 index 0000000..602e212 Binary files /dev/null and b/Apps/Snake/images/easy.png differ diff --git a/Apps/Snake/images/hardhell.png b/Apps/Snake/images/hardhell.png new file mode 100644 index 0000000..5cdfa08 Binary files /dev/null and b/Apps/Snake/images/hardhell.png differ diff --git a/Apps/Snake/images/medium.png b/Apps/Snake/images/medium.png new file mode 100644 index 0000000..a50db77 Binary files /dev/null and b/Apps/Snake/images/medium.png differ diff --git a/Apps/Snake/images/selection.png b/Apps/Snake/images/selection.png new file mode 100644 index 0000000..9c850f7 Binary files /dev/null and b/Apps/Snake/images/selection.png differ diff --git a/Apps/Snake/main/CMakeLists.txt b/Apps/Snake/main/CMakeLists.txt new file mode 100644 index 0000000..0309010 --- /dev/null +++ b/Apps/Snake/main/CMakeLists.txt @@ -0,0 +1,11 @@ +file(GLOB_RECURSE SOURCE_FILES + Source/*.c* +) + +idf_component_register( + SRCS ${SOURCE_FILES} + # Library headers must be included directly, + # because all regular dependencies get stripped by elf_loader's cmake script + INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include + REQUIRES TactilitySDK +) diff --git a/Apps/Snake/main/Source/Snake.cpp b/Apps/Snake/main/Source/Snake.cpp new file mode 100644 index 0000000..fb654e7 --- /dev/null +++ b/Apps/Snake/main/Source/Snake.cpp @@ -0,0 +1,353 @@ +/** + * @file Snake.cpp + * @brief Snake game app implementation for Tactility + */ +#include "Snake.h" + +#include +#include +#include +#include +#include +#include +#include + +constexpr auto* TAG = "Snake"; + +// Preferences keys for high scores (one per difficulty) +static constexpr const char* PREF_NAMESPACE = "Snake"; +static constexpr const char* PREF_HIGH_EASY = "high_easy"; +static constexpr const char* PREF_HIGH_MED = "high_med"; +static constexpr const char* PREF_HIGH_HARD = "high_hard"; +static constexpr const char* PREF_HIGH_HELL = "high_hell"; + +// High scores for each difficulty (loaded from preferences) +static int32_t highScoreEasy = 0; +static int32_t highScoreMedium = 0; +static int32_t highScoreHard = 0; +static int32_t highScoreHell = 0; +static int32_t currentDifficulty = -1; // Track which difficulty is being played + +// Static UI element pointers (invalidated on hide, recreated on show) +static lv_obj_t* scoreLabel = nullptr; +static lv_obj_t* scoreWrapper = nullptr; +static lv_obj_t* toolbar = nullptr; +static lv_obj_t* mainWrapper = nullptr; +static lv_obj_t* newGameWrapper = nullptr; +static lv_obj_t* gameObject = nullptr; + +// Dialog launch IDs for tracking which dialog returned +static AppLaunchId selectionDialogId = 0; +static AppLaunchId gameOverDialogId = 0; +static AppLaunchId helpDialogId = 0; + +// State tracking (persists across hide/show cycles) +static int32_t pendingSelection = -1; // -1 = show selection, 1-3 = start game with difficulty +static bool shouldExit = false; +static bool showHelpOnShow = false; // Show help dialog when onShow is called + +static constexpr size_t DIFFICULTY_COUNT = 4; + +// Selection dialog indices (0 = How to Play, 1-4 = difficulties) +static constexpr int32_t SELECTION_HOW_TO_PLAY = 0; +static constexpr int32_t SELECTION_EASY = 1; +static constexpr int32_t SELECTION_MEDIUM = 2; +static constexpr int32_t SELECTION_HARD = 3; +static constexpr int32_t SELECTION_HELL = 4; + +// Difficulty options (cell sizes - larger = easier) +// Hell uses same size as Hard but with wall collision enabled +static const uint16_t difficultySizes[DIFFICULTY_COUNT] = { SNAKE_CELL_LARGE, SNAKE_CELL_MEDIUM, SNAKE_CELL_SMALL, SNAKE_CELL_SMALL }; + +static int getToolbarHeight(UiScale uiScale) { + if (uiScale == UiScale::UiScaleSmallest) { + return 22; + } else { + return 40; + } +} + +static void loadHighScores() { + PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); + if (prefs) { + tt_preferences_opt_int32(prefs, PREF_HIGH_EASY, &highScoreEasy); + tt_preferences_opt_int32(prefs, PREF_HIGH_MED, &highScoreMedium); + tt_preferences_opt_int32(prefs, PREF_HIGH_HARD, &highScoreHard); + tt_preferences_opt_int32(prefs, PREF_HIGH_HELL, &highScoreHell); + tt_preferences_free(prefs); + } +} + +static void saveHighScore(int32_t difficulty, int32_t score) { + PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); + if (prefs) { + switch (difficulty) { + case SELECTION_EASY: + highScoreEasy = score; + tt_preferences_put_int32(prefs, PREF_HIGH_EASY, score); + break; + case SELECTION_MEDIUM: + highScoreMedium = score; + tt_preferences_put_int32(prefs, PREF_HIGH_MED, score); + break; + case SELECTION_HARD: + highScoreHard = score; + tt_preferences_put_int32(prefs, PREF_HIGH_HARD, score); + break; + case SELECTION_HELL: + highScoreHell = score; + tt_preferences_put_int32(prefs, PREF_HIGH_HELL, score); + break; + } + tt_preferences_free(prefs); + } +} + +static int32_t getHighScore(int32_t difficulty) { + switch (difficulty) { + case SELECTION_EASY: return highScoreEasy; + case SELECTION_MEDIUM: return highScoreMedium; + case SELECTION_HARD: return highScoreHard; + case SELECTION_HELL: return highScoreHell; + default: return 0; + } +} + +static void showSelectionDialog() { + const char* items[] = { "How to Play", "Easy", "Medium", "Hard", "Hell" }; + selectionDialogId = tt_app_selectiondialog_start("Snake", 5, items); +} + +static void showHelpDialog() { + const char* buttons[] = { "OK" }; + helpDialogId = tt_app_alertdialog_start( + "How to Play", + "Swipe or use arrow keys to change direction.\n" + "Eat food to grow longer.\n" + "Don't hit yourself!", + buttons, 1); +} + +void Snake::snakeEventCb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* label = (lv_obj_t*)lv_event_get_user_data(e); + + if (code == LV_EVENT_VALUE_CHANGED) { + if (snake_get_game_over(gameObject)) { + int32_t score = snake_get_score(gameObject); + int32_t length = snake_get_length(gameObject); + int32_t prevHighScore = getHighScore(currentDifficulty); + bool isNewHighScore = score > prevHighScore; + + // Save high score if it's a new record + if (isNewHighScore) { + saveHighScore(currentDifficulty, score); + } + + const char* alertDialogLabels[] = { "OK" }; + char message[120]; + if (isNewHighScore && score > 0) { + snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32 "\nLENGTH: %" PRId32, + score, length); + } else { + snprintf(message, sizeof(message), "GAME OVER!\n\nSCORE: %" PRId32 "\nLENGTH: %" PRId32 "\nBEST: %" PRId32, + score, length, getHighScore(currentDifficulty)); + } + gameOverDialogId = tt_app_alertdialog_start( + isNewHighScore && score > 0 ? "NEW HIGH SCORE!" : "GAME OVER!", + message, alertDialogLabels, 1); + } else { + // Update score display + lv_label_set_text_fmt(label, "SCORE: %u", snake_get_score(gameObject)); + } + } +} + +void Snake::newGameBtnEvent(lv_event_t* e) { + lv_obj_t* obj = (lv_obj_t*)lv_event_get_user_data(e); + if (obj == nullptr) { + return; + } + snake_set_new_game(obj); + // Update score label + if (scoreLabel) { + lv_label_set_text_fmt(scoreLabel, "SCORE: %u", snake_get_score(obj)); + } +} + +void Snake::createGame(lv_obj_t* parent, uint16_t cell_size, bool wallCollision, lv_obj_t* tb) { + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + + // Create game widget + gameObject = snake_create(parent, cell_size, wallCollision); + lv_obj_set_size(gameObject, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_grow(gameObject, 1); + + // Create score wrapper in toolbar + scoreWrapper = lv_obj_create(tb); + lv_obj_set_size(scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100)); + lv_obj_set_style_pad_top(scoreWrapper, 4, LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(scoreWrapper, 10, LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_remove_flag(scoreWrapper, LV_OBJ_FLAG_SCROLLABLE); + + // Create score label + scoreLabel = lv_label_create(scoreWrapper); + lv_label_set_text_fmt(scoreLabel, "SCORE: %u", snake_get_score(gameObject)); + lv_obj_set_style_text_align(scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT); + lv_obj_align(scoreLabel, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_text_font(scoreLabel, lv_font_get_default(), 0); + lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_GREEN), LV_PART_MAIN); + + // Register event callback for game state changes + lv_obj_add_event_cb(gameObject, snakeEventCb, LV_EVENT_VALUE_CHANGED, scoreLabel); + + // Create new game button wrapper + newGameWrapper = lv_obj_create(tb); + lv_obj_set_width(newGameWrapper, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(newGameWrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_all(newGameWrapper, 2, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(newGameWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(newGameWrapper, 0, LV_STATE_DEFAULT); + + // Create new game button + auto ui_scale = tt_hal_configuration_get_ui_scale(); + auto toolbar_height = getToolbarHeight(ui_scale); + lv_obj_t* newGameBtn = lv_btn_create(newGameWrapper); + if (ui_scale == UiScale::UiScaleSmallest) { + lv_obj_set_size(newGameBtn, toolbar_height - 8, toolbar_height - 8); + } else { + lv_obj_set_size(newGameBtn, toolbar_height - 6, toolbar_height - 6); + } + lv_obj_set_style_pad_all(newGameBtn, 0, LV_STATE_DEFAULT); + lv_obj_align(newGameBtn, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, gameObject); + + lv_obj_t* btnIcon = lv_image_create(newGameBtn); + lv_image_set_src(btnIcon, LV_SYMBOL_REFRESH); + lv_obj_align(btnIcon, LV_ALIGN_CENTER, 0, 0); +} + +void Snake::onDestroy(AppHandle appHandle) { + // Reset all static state + scoreLabel = nullptr; + scoreWrapper = nullptr; + toolbar = nullptr; + mainWrapper = nullptr; + newGameWrapper = nullptr; + gameObject = nullptr; + selectionDialogId = 0; + gameOverDialogId = 0; + helpDialogId = 0; + pendingSelection = -1; + shouldExit = false; + showHelpOnShow = false; + currentDifficulty = -1; +} + +void Snake::onHide(AppHandle appHandle) { + // LVGL objects are destroyed when app is hidden, mark pointers as invalid + scoreLabel = nullptr; + scoreWrapper = nullptr; + toolbar = nullptr; + mainWrapper = nullptr; + newGameWrapper = nullptr; + gameObject = nullptr; +} + +void Snake::onShow(AppHandle appHandle, lv_obj_t* parent) { + // Check if we should exit (user closed selection dialog) + if (shouldExit) { + shouldExit = false; + tt_app_stop(); + return; + } + + // Always recreate UI when shown (previous objects destroyed on hide) + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + + // Create toolbar + toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + // Create main wrapper + mainWrapper = lv_obj_create(parent); + lv_obj_set_width(mainWrapper, LV_PCT(100)); + lv_obj_set_flex_grow(mainWrapper, 1); + lv_obj_set_style_pad_all(mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_pad_row(mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_pad_column(mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_border_width(mainWrapper, 0, LV_PART_MAIN); + lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + + // Load high scores on first show + static bool highScoresLoaded = false; + if (!highScoresLoaded) { + loadHighScores(); + highScoresLoaded = true; + } + + // Check if we need to show the help dialog + if (showHelpOnShow) { + showHelpOnShow = false; + showHelpDialog(); + // Check if we have a pending difficulty selection from onResult + } else if (pendingSelection >= SELECTION_EASY && pendingSelection <= SELECTION_HELL) { + // Force layout update before creating game so dimensions are computed + lv_obj_update_layout(parent); + // Track which difficulty we're playing for high score saving + currentDifficulty = pendingSelection; + // Start game with selected difficulty (convert selection index to difficulty index) + int32_t difficultyIndex = pendingSelection - SELECTION_EASY; + // Hell mode enables wall collision (hitting walls = game over) + bool wallCollision = (pendingSelection == SELECTION_HELL); + createGame(mainWrapper, difficultySizes[difficultyIndex], wallCollision, toolbar); + pendingSelection = -1; + } else { + // Show selection dialog + showSelectionDialog(); + } +} + +void Snake::onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) { + // Don't manipulate LVGL objects here - they may be invalid + // Just store state for onShow to handle + + if (launchId == selectionDialogId && selectionDialogId != 0) { + selectionDialogId = 0; + + int32_t selection = -1; + if (resultData != nullptr) { + selection = tt_app_selectiondialog_get_result_index(resultData); + } + + if (selection == SELECTION_HOW_TO_PLAY) { + // Mark to show help dialog in onShow + showHelpOnShow = true; + } else if (selection >= SELECTION_EASY && selection <= SELECTION_HELL) { + // Store selection for onShow to handle + pendingSelection = selection; + } else { + // User closed dialog without selecting - mark for exit + shouldExit = true; + } + + } else if (launchId == helpDialogId && helpDialogId != 0) { + helpDialogId = 0; + // Return to selection dialog + pendingSelection = -1; + + } else if (launchId == gameOverDialogId && gameOverDialogId != 0) { + gameOverDialogId = 0; + // Mark to show selection dialog in onShow + pendingSelection = -1; + } +} diff --git a/Apps/Snake/main/Source/Snake.h b/Apps/Snake/main/Source/Snake.h new file mode 100644 index 0000000..8b6bbf1 --- /dev/null +++ b/Apps/Snake/main/Source/Snake.h @@ -0,0 +1,27 @@ +/** + * @file Snake.h + * @brief Snake game app class for Tactility + */ +#pragma once + +#include +#include +#include + +#include "SnakeUi.h" +#include "SnakeLogic.h" +#include "SnakeHelpers.h" + +class Snake final : public App { + + static void snakeEventCb(lv_event_t* e); + static void newGameBtnEvent(lv_event_t* e); + static void createGame(lv_obj_t* parent, uint16_t cell_size, bool wallCollision, lv_obj_t* toolbar); + +public: + + void onShow(AppHandle context, lv_obj_t* parent) override; + void onHide(AppHandle context) override; + void onDestroy(AppHandle context) override; + void onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override; +}; diff --git a/Apps/Snake/main/Source/SnakeHelpers.h b/Apps/Snake/main/Source/SnakeHelpers.h new file mode 100644 index 0000000..fbee73e --- /dev/null +++ b/Apps/Snake/main/Source/SnakeHelpers.h @@ -0,0 +1,109 @@ +/** + * @file SnakeHelpers.h + * @brief Data structures and constants for the Snake game + */ +#ifndef SNAKE_HELPERS_H +#define SNAKE_HELPERS_H + +#ifdef __cplusplus +extern "C" { +#endif + +/********************* + * INCLUDES + *********************/ +#include "lvgl.h" +#include +#include + +/********************* + * DEFINES + *********************/ + +// Cell sizes in pixels (larger = easier, smaller = harder) +#define SNAKE_CELL_LARGE 16 // Easy - bigger cells, fewer cells fit +#define SNAKE_CELL_MEDIUM 12 // Medium +#define SNAKE_CELL_SMALL 8 // Hard - smaller cells, more cells fit + +// Game timing +#define SNAKE_GAME_SPEED_MS 150 // Initial timer interval in milliseconds +#define SNAKE_MIN_SPEED_MS 60 // Minimum (fastest) timer interval +#define SNAKE_SPEED_DECREASE_MS 5 // Speed increase per food eaten (ms reduction) + +// Visual settings +#define SNAKE_INITIAL_LENGTH 3 +#define SNAKE_CELL_RADIUS 2 + +// Colors +#define SNAKE_HEAD_COLOR lv_color_hex(0x4CAF50) // Green +#define SNAKE_BODY_COLOR lv_color_hex(0x81C784) // Light green +#define SNAKE_FOOD_COLOR lv_color_hex(0xF44336) // Red +#define SNAKE_BG_COLOR lv_color_hex(0x212121) // Dark background +#define SNAKE_GRID_COLOR lv_color_hex(0x424242) // Grid lines + +/********************** + * TYPEDEFS + **********************/ + +/** + * @brief Snake movement direction + */ +typedef enum { + SNAKE_DIR_UP = 0, + SNAKE_DIR_DOWN, + SNAKE_DIR_LEFT, + SNAKE_DIR_RIGHT +} snake_direction_t; + +/** + * @brief Snake body segment (doubly-linked list node) + */ +typedef struct snake_segment { + int16_t x; // Grid x position + int16_t y; // Grid y position + lv_obj_t* obj; // LVGL object for this segment + struct snake_segment* prior; // Previous segment (towards tail) + struct snake_segment* next; // Next segment (towards head) +} snake_segment_t; + +/** + * @brief Complete game state + */ +typedef struct { + // Snake data + snake_segment_t* head; // Snake head (linked list) + + // UI elements + lv_obj_t* widget; // Parent widget (for events) + lv_obj_t* container; // Main game container + lv_obj_t* food; // Food object + lv_timer_t* timer; // Game timer + + // Game state + snake_direction_t direction; // Current movement direction + snake_direction_t next_direction;// Buffered direction (prevents 180° reversal) + + // Grid settings (supports non-square) + uint16_t grid_width; // Grid width in cells + uint16_t grid_height; // Grid height in cells + uint16_t cell_size; // Pixel size per cell + + // Score tracking + uint16_t score; + uint16_t length; + + // Food position + int16_t food_x; + int16_t food_y; + + // State flags + bool game_over; + bool paused; + bool wall_collision_enabled; // If true, hitting walls = game over +} snake_game_t; + +#ifdef __cplusplus +} /*extern "C"*/ +#endif + +#endif /*SNAKE_HELPERS_H*/ diff --git a/Apps/Snake/main/Source/SnakeLogic.c b/Apps/Snake/main/Source/SnakeLogic.c new file mode 100644 index 0000000..ada09ae --- /dev/null +++ b/Apps/Snake/main/Source/SnakeLogic.c @@ -0,0 +1,321 @@ +/** + * @file SnakeLogic.c + * @brief Pure game logic for the Snake game + */ +#include "SnakeLogic.h" +#include +#include + +/** + * @brief Initialize the snake body as a linked list + */ +snake_segment_t* snake_init_body(uint16_t length, int16_t start_x, int16_t start_y) { + if (length == 0) { + return NULL; + } + + // Create head segment + snake_segment_t* head = (snake_segment_t*)lv_malloc(sizeof(snake_segment_t)); + if (!head) { + return NULL; + } + + head->x = start_x; + head->y = start_y; + head->obj = NULL; + head->prior = NULL; + head->next = NULL; + + // Create remaining segments (body extends to the left of head) + snake_segment_t* current = head; + for (uint16_t i = 1; i < length; i++) { + snake_segment_t* segment = (snake_segment_t*)lv_malloc(sizeof(snake_segment_t)); + if (!segment) { + // Clean up already allocated segments + snake_free_body(head); + return NULL; + } + + segment->x = start_x - i; // Body extends left from head + segment->y = start_y; + segment->obj = NULL; + segment->prior = current; + segment->next = NULL; + + current->next = segment; + current = segment; + } + + return head; +} + +/** + * @brief Free all memory used by the snake body + */ +void snake_free_body(snake_segment_t* head) { + snake_segment_t* current = head; + while (current != NULL) { + snake_segment_t* next = current->next; + // Note: LVGL objects must be deleted separately by the UI layer + lv_free(current); + current = next; + } +} + +/** + * @brief Add a new segment to the tail of the snake + */ +bool snake_grow(snake_segment_t* head) { + if (!head) { + return false; + } + + // Find the tail + snake_segment_t* tail = head; + while (tail->next != NULL) { + tail = tail->next; + } + + // Create new segment at tail position (will be updated on next move) + snake_segment_t* new_segment = (snake_segment_t*)lv_malloc(sizeof(snake_segment_t)); + if (!new_segment) { + return false; + } + + new_segment->x = tail->x; + new_segment->y = tail->y; + new_segment->obj = NULL; + new_segment->prior = tail; + new_segment->next = NULL; + + tail->next = new_segment; + + return true; +} + +/** + * @brief Move the snake one step in the current direction + */ +bool snake_move(snake_game_t* game) { + if (!game || !game->head || game->game_over) { + return false; + } + + // Apply buffered direction + game->direction = game->next_direction; + + // Calculate new head position + int16_t new_x = game->head->x; + int16_t new_y = game->head->y; + + switch (game->direction) { + case SNAKE_DIR_UP: + new_y--; + break; + case SNAKE_DIR_DOWN: + new_y++; + break; + case SNAKE_DIR_LEFT: + new_x--; + break; + case SNAKE_DIR_RIGHT: + new_x++; + break; + } + + // Handle wall collision or wrap-around + if (game->wall_collision_enabled) { + // Wall collision mode - hitting walls = game over + if (new_x < 0 || new_x >= game->grid_width || + new_y < 0 || new_y >= game->grid_height) { + game->game_over = true; + return false; + } + } else { + // Wrap around walls + if (new_x < 0) new_x = game->grid_width - 1; + else if (new_x >= game->grid_width) new_x = 0; + if (new_y < 0) new_y = game->grid_height - 1; + else if (new_y >= game->grid_height) new_y = 0; + } + + // Check for self collision BEFORE moving (so tail hasn't vacated yet) + // Skip the tail segment since it will move out of the way + snake_segment_t* segment = game->head->next; + while (segment != NULL && segment->next != NULL) { // Stop before tail + if (segment->x == new_x && segment->y == new_y) { + game->game_over = true; + return false; + } + segment = segment->next; + } + // Also check the tail - it will move, so new head CAN go there + // (This is intentional - allows snake to "chase its tail") + + // Move body segments (from tail to head, each takes position of previous) + snake_segment_t* tail = game->head; + while (tail->next != NULL) { + tail = tail->next; + } + + // Move from tail towards head + while (tail->prior != NULL) { + tail->x = tail->prior->x; + tail->y = tail->prior->y; + tail = tail->prior; + } + + // Move head to new position + game->head->x = new_x; + game->head->y = new_y; + + return true; +} + +/** + * @brief Set the snake's next direction (with 180° reversal prevention) + */ +bool snake_set_direction(snake_game_t* game, snake_direction_t dir) { + if (!game) { + return false; + } + + // Prevent 180° reversal - check against BUFFERED direction to handle rapid inputs + snake_direction_t current = game->next_direction; + + if ((current == SNAKE_DIR_UP && dir == SNAKE_DIR_DOWN) || + (current == SNAKE_DIR_DOWN && dir == SNAKE_DIR_UP) || + (current == SNAKE_DIR_LEFT && dir == SNAKE_DIR_RIGHT) || + (current == SNAKE_DIR_RIGHT && dir == SNAKE_DIR_LEFT)) { + return false; + } + + game->next_direction = dir; + return true; +} + +/** + * @brief Check if a position is occupied by the snake body + */ +static bool is_position_on_snake(snake_segment_t* head, int16_t x, int16_t y) { + snake_segment_t* current = head; + while (current != NULL) { + if (current->x == x && current->y == y) { + return true; + } + current = current->next; + } + return false; +} + +/** + * @brief Spawn food at a random location not occupied by snake + * @return true if food was placed, false if grid is full (win condition) + */ +bool snake_spawn_food(snake_game_t* game) { + if (!game || game->grid_width == 0 || game->grid_height == 0) { + return false; + } + + int16_t x, y; + const int32_t grid_size = (int32_t)game->grid_width * game->grid_height; + uint16_t snake_len = snake_count_segments(game->head); + + // Use random sampling for sparse grids, deterministic search for dense grids + if (snake_len < (grid_size * 3 / 4)) { + // Random sampling - efficient for sparse grids + int attempts = 0; + do { + x = rand() % game->grid_width; + y = rand() % game->grid_height; + attempts++; + } while (is_position_on_snake(game->head, x, y) && attempts < grid_size); + + if (!is_position_on_snake(game->head, x, y)) { + game->food_x = x; + game->food_y = y; + return true; + } + } + + // Deterministic search - guaranteed to find free cell if one exists + int16_t start_y = rand() % game->grid_height; + int16_t start_x = rand() % game->grid_width; + for (int16_t i = 0; i < game->grid_height; i++) { + int16_t gy = (start_y + i) % game->grid_height; + for (int16_t j = 0; j < game->grid_width; j++) { + int16_t gx = (start_x + j) % game->grid_width; + if (!is_position_on_snake(game->head, gx, gy)) { + game->food_x = gx; + game->food_y = gy; + return true; + } + } + } + + // Grid is truly full - win condition + return false; +} + +/** + * @brief Check if snake head collides with walls (unused - wrap-around enabled) + */ +bool snake_check_wall_collision(snake_game_t* game) { + if (!game || !game->head) { + return false; + } + + int16_t x = game->head->x; + int16_t y = game->head->y; + + return (x < 0 || x >= game->grid_width || y < 0 || y >= game->grid_height); +} + +/** + * @brief Check if snake head collides with its own body + */ +bool snake_check_self_collision(snake_game_t* game) { + if (!game || !game->head) { + return false; + } + + int16_t head_x = game->head->x; + int16_t head_y = game->head->y; + + // Check collision with body segments (skip head itself) + snake_segment_t* current = game->head->next; + while (current != NULL) { + if (current->x == head_x && current->y == head_y) { + return true; + } + current = current->next; + } + + return false; +} + +/** + * @brief Check if snake head collides with food + */ +bool snake_check_food_collision(snake_game_t* game) { + if (!game || !game->head) { + return false; + } + + return (game->head->x == game->food_x && game->head->y == game->food_y); +} + +/** + * @brief Count the number of segments in the snake + */ +uint16_t snake_count_segments(snake_segment_t* head) { + uint16_t length = 0; + snake_segment_t* current = head; + + while (current != NULL) { + length++; + current = current->next; + } + + return length; +} diff --git a/Apps/Snake/main/Source/SnakeLogic.h b/Apps/Snake/main/Source/SnakeLogic.h new file mode 100644 index 0000000..8e058b1 --- /dev/null +++ b/Apps/Snake/main/Source/SnakeLogic.h @@ -0,0 +1,97 @@ +/** + * @file SnakeLogic.h + * @brief Pure game logic for the Snake game (no UI dependencies) + */ +#ifndef SNAKE_LOGIC_H +#define SNAKE_LOGIC_H + +#ifdef __cplusplus +extern "C" { +#endif + +/********************* + * INCLUDES + *********************/ +#include "SnakeHelpers.h" + +/*********************** + * FUNCTION PROTOTYPES + **********************/ + +/** + * @brief Initialize the snake body as a linked list + * @param length Initial length of the snake + * @param start_x Starting x position (grid coordinate) + * @param start_y Starting y position (grid coordinate) + * @return Pointer to the head segment, or NULL on failure + */ +snake_segment_t* snake_init_body(uint16_t length, int16_t start_x, int16_t start_y); + +/** + * @brief Free all memory used by the snake body + * @param head Pointer to the head segment + */ +void snake_free_body(snake_segment_t* head); + +/** + * @brief Add a new segment to the tail of the snake + * @param head Pointer to the head segment + * @return true on success, false on allocation failure + */ +bool snake_grow(snake_segment_t* head); + +/** + * @brief Move the snake one step in the current direction + * @param game Game state containing snake and direction + * @return true if move was successful, false if collision occurred + */ +bool snake_move(snake_game_t* game); + +/** + * @brief Set the snake's next direction (with 180° reversal prevention) + * @param game Game state + * @param dir New direction + * @return true if direction was set, false if it would cause 180° reversal + */ +bool snake_set_direction(snake_game_t* game, snake_direction_t dir); + +/** + * @brief Spawn food at a random location not occupied by snake + * @param game Game state + * @return true if food was placed, false if grid is full (win condition) + */ +bool snake_spawn_food(snake_game_t* game); + +/** + * @brief Check if snake head collides with walls + * @param game Game state + * @return true if collision detected + */ +bool snake_check_wall_collision(snake_game_t* game); + +/** + * @brief Check if snake head collides with its own body + * @param game Game state + * @return true if collision detected + */ +bool snake_check_self_collision(snake_game_t* game); + +/** + * @brief Check if snake head collides with food + * @param game Game state + * @return true if collision detected + */ +bool snake_check_food_collision(snake_game_t* game); + +/** + * @brief Count the number of segments in the snake + * @param head Pointer to the head segment + * @return Number of segments + */ +uint16_t snake_count_segments(snake_segment_t* head); + +#ifdef __cplusplus +} /*extern "C"*/ +#endif + +#endif /*SNAKE_LOGIC_H*/ diff --git a/Apps/Snake/main/Source/SnakeUi.c b/Apps/Snake/main/Source/SnakeUi.c new file mode 100644 index 0000000..51af582 --- /dev/null +++ b/Apps/Snake/main/Source/SnakeUi.c @@ -0,0 +1,494 @@ +/** + * @file SnakeUi.c + * @brief LVGL widget implementation for the Snake game + */ +#include "SnakeUi.h" +#include "SnakeLogic.h" +#include +#include +#include +#include + +// Forward declarations +static void game_play_event(lv_event_t* e); +static void snake_timer_cb(lv_timer_t* timer); +static void delete_event(lv_event_t* e); +static void focus_event(lv_event_t* e); +static void snake_draw(snake_game_t* game); +static void snake_create_segment_objects(snake_game_t* game); +static void snake_delete_segment_objects(snake_game_t* game); + +// Static flag to ensure srand is only called once +static bool srand_initialized = false; + +/** + * @brief Free all resources for the snake game object + */ +static void delete_event(lv_event_t* e) { + lv_obj_t* obj = lv_event_get_target_obj(e); + snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj); + + if (game) { + // Stop timer first + if (game->timer) { + lv_timer_delete(game->timer); + game->timer = NULL; + } + + // Restore edit mode to false before cleanup + if (tt_lvgl_hardware_keyboard_is_available()) { + lv_group_t* group = lv_group_get_default(); + if (group) { + lv_group_set_editing(group, false); + } + } + + // Delete LVGL objects for snake segments + snake_delete_segment_objects(game); + + // Free snake body linked list + snake_free_body(game->head); + game->head = NULL; + + // Food object is a child of container, will be deleted automatically + // Container is a child of obj, will be deleted automatically + + lv_free(game); + lv_obj_set_user_data(obj, NULL); + } +} + +/** + * @brief Handle focus/defocus to manage edit mode for keyboard input + */ +static void focus_event(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + lv_group_t* group = lv_group_get_default(); + + if (!group) return; + + if (code == LV_EVENT_FOCUSED) { + // Enable edit mode so arrow keys control the game + lv_group_set_editing(group, true); + } else if (code == LV_EVENT_DEFOCUSED) { + // Restore normal focus navigation + lv_group_set_editing(group, false); + } +} + +/** + * @brief Delete LVGL objects for all snake segments + */ +static void snake_delete_segment_objects(snake_game_t* game) { + if (!game || !game->head) return; + + snake_segment_t* segment = game->head; + while (segment != NULL) { + if (segment->obj) { + lv_obj_delete(segment->obj); + segment->obj = NULL; + } + segment = segment->next; + } +} + +/** + * @brief Create LVGL objects for all snake segments + */ +static void snake_create_segment_objects(snake_game_t* game) { + if (!game || !game->head || !game->container) return; + + snake_segment_t* segment = game->head; + bool is_head = true; + + while (segment != NULL) { + segment->obj = lv_obj_create(game->container); + lv_obj_set_size(segment->obj, game->cell_size - 2, game->cell_size - 2); + lv_obj_set_style_radius(segment->obj, SNAKE_CELL_RADIUS, LV_PART_MAIN); + lv_obj_set_style_border_width(segment->obj, 0, LV_PART_MAIN); + + if (is_head) { + lv_obj_set_style_bg_color(segment->obj, SNAKE_HEAD_COLOR, LV_PART_MAIN); + is_head = false; + } else { + lv_obj_set_style_bg_color(segment->obj, SNAKE_BODY_COLOR, LV_PART_MAIN); + } + + // Position will be set by snake_draw() + segment = segment->next; + } +} + +/** + * @brief Update LVGL object positions based on game state + */ +static void snake_draw(snake_game_t* game) { + if (!game || !game->container) return; + + // Update snake segment positions + snake_segment_t* segment = game->head; + while (segment != NULL) { + if (segment->obj) { + lv_coord_t px = segment->x * game->cell_size + 1; + lv_coord_t py = segment->y * game->cell_size + 1; + lv_obj_set_pos(segment->obj, px, py); + } + segment = segment->next; + } + + // Update food position + if (game->food) { + lv_coord_t fx = game->food_x * game->cell_size + 1; + lv_coord_t fy = game->food_y * game->cell_size + 1; + lv_obj_set_pos(game->food, fx, fy); + } +} + +/** + * @brief Timer callback - moves snake and updates display + */ +static void snake_timer_cb(lv_timer_t* timer) { + snake_game_t* game = (snake_game_t*)lv_timer_get_user_data(timer); + if (!game || game->game_over || game->paused) return; + + // Move snake + bool move_ok = snake_move(game); + + if (!move_ok) { + // Collision occurred, game over + game->game_over = true; + lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL); + return; + } + + // Check food collision + if (snake_check_food_collision(game)) { + // Grow snake and update score + if (!snake_grow(game->head)) { + // Memory allocation failed - treat as game over + game->game_over = true; + lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL); + return; + } + game->score++; + game->length++; + + // Create LVGL object for new segment + snake_segment_t* tail = game->head; + while (tail->next != NULL) { + tail = tail->next; + } + if (tail && !tail->obj && game->container) { + tail->obj = lv_obj_create(game->container); + lv_obj_set_size(tail->obj, game->cell_size - 2, game->cell_size - 2); + lv_obj_set_style_radius(tail->obj, SNAKE_CELL_RADIUS, LV_PART_MAIN); + lv_obj_set_style_border_width(tail->obj, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(tail->obj, SNAKE_BODY_COLOR, LV_PART_MAIN); + } + + // Spawn new food - if fails, grid is full (win!) + if (!snake_spawn_food(game)) { + // Hide food since there's no valid position + if (game->food) { + lv_obj_add_flag(game->food, LV_OBJ_FLAG_HIDDEN); + } + // Player won - end the game + game->game_over = true; + lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL); + return; + } + + // Increase speed (decrease timer period) as snake grows + uint32_t foods_eaten = game->length - SNAKE_INITIAL_LENGTH; + uint32_t speed_reduction = foods_eaten * SNAKE_SPEED_DECREASE_MS; + uint32_t new_period = SNAKE_GAME_SPEED_MS; + if (speed_reduction < (SNAKE_GAME_SPEED_MS - SNAKE_MIN_SPEED_MS)) { + new_period = SNAKE_GAME_SPEED_MS - speed_reduction; + } else { + new_period = SNAKE_MIN_SPEED_MS; + } + lv_timer_set_period(game->timer, new_period); + + // Notify score change + lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL); + } + + // Update display + snake_draw(game); +} + +/** + * @brief Event callback for game play (gesture/key) + */ +static void game_play_event(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* obj = (lv_obj_t*)lv_event_get_user_data(e); + snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj); + + if (!game || game->game_over) return; + + if (code == LV_EVENT_GESTURE) { + lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_active()); + switch (dir) { + case LV_DIR_TOP: + snake_set_direction(game, SNAKE_DIR_UP); + break; + case LV_DIR_BOTTOM: + snake_set_direction(game, SNAKE_DIR_DOWN); + break; + case LV_DIR_LEFT: + snake_set_direction(game, SNAKE_DIR_LEFT); + break; + case LV_DIR_RIGHT: + snake_set_direction(game, SNAKE_DIR_RIGHT); + break; + default: + break; + } + } else if (code == LV_EVENT_KEY) { + uint32_t key = lv_event_get_key(e); + // Arrow keys, WASD, and punctuation keys for cardputer + switch (key) { + case LV_KEY_UP: + case 'w': + case 'W': + case ';': + snake_set_direction(game, SNAKE_DIR_UP); + break; + case LV_KEY_DOWN: + case 's': + case 'S': + case '.': + snake_set_direction(game, SNAKE_DIR_DOWN); + break; + case LV_KEY_LEFT: + case 'a': + case 'A': + case ',': + snake_set_direction(game, SNAKE_DIR_LEFT); + break; + case LV_KEY_RIGHT: + case 'd': + case 'D': + case '/': + snake_set_direction(game, SNAKE_DIR_RIGHT); + break; + default: + break; + } + } +} + +/** + * @brief Create a new Snake game widget + */ +lv_obj_t* snake_create(lv_obj_t* parent, uint16_t cell_size, bool wall_collision) { + // Create main object + lv_obj_t* obj = lv_obj_create(parent); + if (!obj) return NULL; + + // Allocate game state + snake_game_t* game = (snake_game_t*)lv_malloc(sizeof(snake_game_t)); + if (!game) { + lv_obj_delete(obj); + return NULL; + } + memset(game, 0, sizeof(snake_game_t)); + lv_obj_set_user_data(obj, game); + + // Store widget reference for events + game->widget = obj; + + // Initialize game state + game->cell_size = cell_size; + game->score = 0; + game->length = SNAKE_INITIAL_LENGTH; + game->direction = SNAKE_DIR_RIGHT; + game->next_direction = SNAKE_DIR_RIGHT; + game->game_over = false; + game->paused = false; + game->wall_collision_enabled = wall_collision; + + // Set up main object + lv_obj_set_size(obj, LV_PCT(100), LV_PCT(100)); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, LV_PART_MAIN); + + // Create game container (the actual playing field) + game->container = lv_obj_create(obj); + lv_obj_remove_flag(game->container, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(game->container, SNAKE_BG_COLOR, LV_PART_MAIN); + lv_obj_set_style_border_width(game->container, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(game->container, SNAKE_GRID_COLOR, LV_PART_MAIN); + lv_obj_set_style_pad_all(game->container, 0, LV_PART_MAIN); + lv_group_remove_obj(game->container); + lv_obj_remove_flag(game->container, LV_OBJ_FLAG_GESTURE_BUBBLE); + + // Calculate grid dimensions based on available space (non-square) + lv_coord_t available_w = lv_obj_get_content_width(parent) - 6; + lv_coord_t available_h = lv_obj_get_content_height(parent) - 6; + + if (available_w < 50) available_w = 50; + if (available_h < 50) available_h = 50; + + // Calculate how many cells fit in each dimension + game->grid_width = available_w / cell_size; + game->grid_height = available_h / cell_size; + + // Ensure minimum grid size (3x3 minimum for playable game) + if (game->grid_width < 3) game->grid_width = 3; + if (game->grid_height < 3) game->grid_height = 3; + + // Calculate actual field size + lv_coord_t field_w = game->cell_size * game->grid_width; + lv_coord_t field_h = game->cell_size * game->grid_height; + + lv_obj_set_size(game->container, field_w, field_h); + lv_obj_center(game->container); + + // Initialize snake body at center + int16_t start_x = game->grid_width / 2; + int16_t start_y = game->grid_height / 2; + game->head = snake_init_body(SNAKE_INITIAL_LENGTH, start_x, start_y); + if (!game->head) { + lv_obj_set_user_data(obj, NULL); + lv_free(game); + lv_obj_delete(obj); + return NULL; + } + + // Create LVGL objects for snake segments + snake_create_segment_objects(game); + + // Create food object + game->food = lv_obj_create(game->container); + lv_obj_set_size(game->food, game->cell_size - 2, game->cell_size - 2); + lv_obj_set_style_radius(game->food, game->cell_size / 2, LV_PART_MAIN); + lv_obj_set_style_border_width(game->food, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(game->food, SNAKE_FOOD_COLOR, LV_PART_MAIN); + + // Initialize random seed only once + if (!srand_initialized) { + srand((unsigned int)time(NULL)); + srand_initialized = true; + } + + // Spawn initial food + snake_spawn_food(game); + + // Initial draw + snake_draw(game); + + // Add event callbacks for touch gestures and keyboard + lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_GESTURE, obj); + lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_KEY, obj); + lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL); + + // Set up keyboard focus if available + if (tt_lvgl_hardware_keyboard_is_available()) { + lv_group_t* group = lv_group_get_default(); + if (group) { + lv_group_add_obj(group, game->container); + // Register focus handlers to manage edit mode lifecycle + lv_obj_add_event_cb(game->container, focus_event, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(game->container, focus_event, LV_EVENT_DEFOCUSED, NULL); + // Focus the container (will trigger FOCUSED event and enable edit mode) + lv_group_focus_obj(game->container); + } + } + + // Start game timer + game->timer = lv_timer_create(snake_timer_cb, SNAKE_GAME_SPEED_MS, game); + + return obj; +} + +/** + * @brief Reset the game to start a new game + */ +void snake_set_new_game(lv_obj_t* obj) { + snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj); + if (!game) return; + + // Stop timer during reset + if (game->timer) { + lv_timer_pause(game->timer); + } + + // Delete old snake segment objects + snake_delete_segment_objects(game); + + // Free old snake body + snake_free_body(game->head); + game->head = NULL; + + // Reset game state + game->score = 0; + game->length = SNAKE_INITIAL_LENGTH; + game->direction = SNAKE_DIR_RIGHT; + game->next_direction = SNAKE_DIR_RIGHT; + game->game_over = false; + game->paused = false; + + // Create new snake body at center + int16_t start_x = game->grid_width / 2; + int16_t start_y = game->grid_height / 2; + game->head = snake_init_body(SNAKE_INITIAL_LENGTH, start_x, start_y); + + if (!game->head) { + // Memory allocation failed - keep game in over state + game->game_over = true; + lv_obj_send_event(obj, LV_EVENT_VALUE_CHANGED, NULL); + return; + } + + // Create new segment objects + snake_create_segment_objects(game); + + // Spawn new food and unhide it + if (snake_spawn_food(game)) { + if (game->food) { + lv_obj_remove_flag(game->food, LV_OBJ_FLAG_HIDDEN); + } + } + + // Draw + snake_draw(game); + + // Resume timer + if (game->timer) { + lv_timer_resume(game->timer); + } + + // Notify change + lv_obj_send_event(obj, LV_EVENT_VALUE_CHANGED, NULL); +} + +/** + * @brief Get the current score + */ +uint16_t snake_get_score(lv_obj_t* obj) { + snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj); + if (!game) return 0; + return game->score; +} + +/** + * @brief Get the current snake length + */ +uint16_t snake_get_length(lv_obj_t* obj) { + snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj); + if (!game) return 0; + return game->length; +} + +/** + * @brief Check if game is over + */ +bool snake_get_game_over(lv_obj_t* obj) { + snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj); + if (!game) return true; + return game->game_over; +} diff --git a/Apps/Snake/main/Source/SnakeUi.h b/Apps/Snake/main/Source/SnakeUi.h new file mode 100644 index 0000000..c0501af --- /dev/null +++ b/Apps/Snake/main/Source/SnakeUi.h @@ -0,0 +1,64 @@ +/** + * @file SnakeUi.h + * @brief LVGL widget interface for the Snake game + */ +#ifndef SNAKE_UI_H +#define SNAKE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/********************* + * INCLUDES + *********************/ +#include +#include +#include "SnakeHelpers.h" +#include "lvgl.h" + +/*********************** + * FUNCTION PROTOTYPES + **********************/ + +/** + * @brief Create a new Snake game widget + * @param parent Parent LVGL object + * @param cell_size Size of each cell in pixels (SNAKE_CELL_SMALL, MEDIUM, or LARGE) + * @param wall_collision If true, hitting walls = game over; if false, snake wraps around + * @return Pointer to the created LVGL object, or NULL on failure + */ +lv_obj_t* snake_create(lv_obj_t* parent, uint16_t cell_size, bool wall_collision); + +/** + * @brief Reset the game to start a new game + * @param obj Snake game LVGL object + */ +void snake_set_new_game(lv_obj_t* obj); + +/** + * @brief Get the current score + * @param obj Snake game LVGL object + * @return Current score + */ +uint16_t snake_get_score(lv_obj_t* obj); + +/** + * @brief Get the current snake length + * @param obj Snake game LVGL object + * @return Current length + */ +uint16_t snake_get_length(lv_obj_t* obj); + +/** + * @brief Check if game is over + * @param obj Snake game LVGL object + * @return true if game is over + */ +bool snake_get_game_over(lv_obj_t* obj); + +#ifdef __cplusplus +} /*extern "C"*/ +#endif + +#endif /*SNAKE_UI_H*/ diff --git a/Apps/Snake/main/Source/main.cpp b/Apps/Snake/main/Source/main.cpp new file mode 100644 index 0000000..c6ac3c3 --- /dev/null +++ b/Apps/Snake/main/Source/main.cpp @@ -0,0 +1,11 @@ +#include "Snake.h" +#include + +extern "C" { + +int main(int argc, char* argv[]) { + registerApp(); + return 0; +} + +} diff --git a/Apps/Snake/manifest.properties b/Apps/Snake/manifest.properties new file mode 100644 index 0000000..157bde1 --- /dev/null +++ b/Apps/Snake/manifest.properties @@ -0,0 +1,11 @@ +[manifest] +version=0.1 +[target] +sdk=0.7.0-dev +platforms=esp32,esp32s3,esp32c6,esp32p4 +[app] +id=one.tactility.snake +versionName=0.3.0 +versionCode=3 +name=Snake +description=Classic Snake game diff --git a/Apps/Snake/tactility.py b/Apps/Snake/tactility.py new file mode 100644 index 0000000..81f271c --- /dev/null +++ b/Apps/Snake/tactility.py @@ -0,0 +1,693 @@ +import configparser +import json +import os +import re +import shutil +import sys +import subprocess +import time +import urllib.request +import zipfile +import requests +import tarfile + +ttbuild_path = ".tactility" +ttbuild_version = "3.1.0" +ttbuild_cdn = "https://cdn.tactility.one" +ttbuild_sdk_json_validity = 3600 # seconds +ttport = 6666 +verbose = False +use_local_sdk = False +local_base_path = None + +shell_color_red = "\033[91m" +shell_color_orange = "\033[93m" +shell_color_green = "\033[32m" +shell_color_purple = "\033[35m" +shell_color_cyan = "\033[36m" +shell_color_reset = "\033[m" + +def print_help(): + print("Usage: python tactility.py [action] [options]") + print("") + print("Actions:") + print(" build [esp32,esp32s3] Build the app. Optionally specify a platform.") + print(" esp32: ESP32") + print(" esp32s3: ESP32 S3") + print(" clean Clean the build folders") + print(" clearcache Clear the SDK cache") + print(" updateself Update this tool") + print(" run [ip] Run the application") + print(" install [ip] Install the application") + print(" uninstall [ip] Uninstall the application") + print(" bir [ip] [esp32,esp32s3] Build, install then run. Optionally specify a platform.") + print(" brrr [ip] [esp32,esp32s3] Functionally the same as \"bir\", but \"app goes brrr\" meme variant.") + print("") + print("Options:") + print(" --help Show this commandline info") + print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.") + print(" --skip-build Run everything except the idf.py/CMake commands") + print(" --verbose Show extra console output") + +# region Core + +def download_file(url, filepath): + global verbose + if verbose: + print(f"Downloading from {url} to {filepath}") + request = urllib.request.Request( + url, + data=None, + headers={ + "User-Agent": f"Tactility Build Tool {ttbuild_version}" + } + ) + try: + response = urllib.request.urlopen(request) + file = open(filepath, mode="wb") + file.write(response.read()) + file.close() + return True + except OSError as error: + if verbose: + print_error(f"Failed to fetch URL {url}\n{error}") + return False + +def print_warning(message): + print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}") + +def print_error(message): + print(f"{shell_color_red}ERROR: {message}{shell_color_reset}") + +def print_status_busy(status): + sys.stdout.write(f"⌛ {status}\r") + +def print_status_success(status): + # Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text + print(f"✅ {shell_color_green}{status}{shell_color_reset} ") + +def print_status_error(status): + # Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text + print(f"❌ {shell_color_red}{status}{shell_color_reset} ") + +def exit_with_error(message): + print_error(message) + sys.exit(1) + +def get_url(ip, path): + return f"http://{ip}:{ttport}{path}" + +def read_properties_file(path): + config = configparser.RawConfigParser() + config.read(path) + return config + +#endregion Core + +#region SDK helpers + +def read_sdk_json(): + json_file_path = os.path.join(ttbuild_path, "tool.json") + with open(json_file_path) as json_file: + return json.load(json_file) + +def get_sdk_dir(version, platform): + global use_local_sdk, local_base_path + if use_local_sdk: + base_path = local_base_path + if base_path is None: + exit_with_error("TACTILITY_SDK_PATH environment variable is not set") + sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") + sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") + if not os.path.isdir(sdk_dir): + exit_with_error(f"Local SDK folder not found for platform {platform}: {sdk_dir}") + return sdk_dir + else: + return os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK") + +def validate_local_sdks(platforms, version): + if not use_local_sdk: + return + global local_base_path + base_path = local_base_path + for platform in platforms: + sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") + sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") + if not os.path.isdir(sdk_dir): + exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}") + +def get_sdk_root_dir(version, platform): + global ttbuild_cdn + return os.path.join(ttbuild_path, f"{version}-{platform}") + +def get_sdk_url(version, file): + global ttbuild_cdn + return f"{ttbuild_cdn}/sdk/{version}/{file}" + +def sdk_exists(version, platform): + sdk_dir = get_sdk_dir(version, platform) + return os.path.isdir(sdk_dir) + +def should_update_tool_json(): + global ttbuild_cdn + json_filepath = os.path.join(ttbuild_path, "tool.json") + if os.path.exists(json_filepath): + json_modification_time = os.path.getmtime(json_filepath) + now = time.time() + global ttbuild_sdk_json_validity + minimum_seconds_difference = ttbuild_sdk_json_validity + return (now - json_modification_time) > minimum_seconds_difference + else: + return True + +def update_tool_json(): + global ttbuild_cdn, ttbuild_path + json_url = f"{ttbuild_cdn}/sdk/tool.json" + json_filepath = os.path.join(ttbuild_path, "tool.json") + return download_file(json_url, json_filepath) + +def should_fetch_sdkconfig_files(platform_targets): + for platform in platform_targets: + sdkconfig_filename = f"sdkconfig.app.{platform}" + if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)): + return True + return False + +def fetch_sdkconfig_files(platform_targets): + for platform in platform_targets: + sdkconfig_filename = f"sdkconfig.app.{platform}" + target_path = os.path.join(ttbuild_path, sdkconfig_filename) + if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path): + exit_with_error(f"Failed to download sdkconfig file for {platform}") + +#endregion SDK helpers + +#region Validation + +def validate_environment(): + if os.environ.get("IDF_PATH") is None: + if sys.platform == "win32": + exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1") + else: + exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh") + if not os.path.exists("manifest.properties"): + exit_with_error("manifest.properties not found") + if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None: + print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.") + print_warning("If you want to use it, use the '--local-sdk' parameter") + elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None: + exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.") + +def validate_self(sdk_json): + if not "toolVersion" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolVersion not found)") + if not "toolCompatibility" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolCompatibility not found)") + if not "toolDownloadUrl" in sdk_json: + exit_with_error("Server returned invalid SDK data format (toolDownloadUrl not found)") + tool_version = sdk_json["toolVersion"] + tool_compatibility = sdk_json["toolCompatibility"] + if tool_version != ttbuild_version: + print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})") + print_warning(f"Run 'tactility.py updateself' to update.") + if re.search(tool_compatibility, ttbuild_version) is None: + print_error("The tool is not compatible anymore.") + print_error("Run 'tactility.py updateself' to update.") + sys.exit(1) + +#endregion Validation + +#region Manifest + +def read_manifest(): + return read_properties_file("manifest.properties") + +def validate_manifest(manifest): + # [manifest] + if not "manifest" in manifest: + exit_with_error("Invalid manifest format: [manifest] not found") + if not "version" in manifest["manifest"]: + exit_with_error("Invalid manifest format: [manifest] version not found") + # [target] + if not "target" in manifest: + exit_with_error("Invalid manifest format: [target] not found") + if not "sdk" in manifest["target"]: + exit_with_error("Invalid manifest format: [target] sdk not found") + if not "platforms" in manifest["target"]: + exit_with_error("Invalid manifest format: [target] platforms not found") + # [app] + if not "app" in manifest: + exit_with_error("Invalid manifest format: [app] not found") + if not "id" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] id not found") + if not "versionName" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] versionName not found") + if not "versionCode" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] versionCode not found") + if not "name" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] name not found") + +def is_valid_manifest_platform(manifest, platform): + manifest_platforms = manifest["target"]["platforms"].split(",") + return platform in manifest_platforms + +def validate_manifest_platform(manifest, platform): + if not is_valid_manifest_platform(manifest, platform): + exit_with_error(f"Platform {platform} is not available in the manifest.") + +def get_manifest_target_platforms(manifest, requested_platform): + if requested_platform == "" or requested_platform is None: + return manifest["target"]["platforms"].split(",") + else: + validate_manifest_platform(manifest, requested_platform) + return [requested_platform] + +#endregion Manifest + +#region SDK download + +def sdk_download(version, platform): + sdk_root_dir = get_sdk_root_dir(version, platform) + os.makedirs(sdk_root_dir, exist_ok=True) + sdk_index_url = get_sdk_url(version, "index.json") + print(f"Downloading SDK version {version} for {platform}") + sdk_index_filepath = os.path.join(sdk_root_dir, "index.json") + if verbose: + print(f"Downloading {sdk_index_url} to {sdk_index_filepath}") + if not download_file(sdk_index_url, sdk_index_filepath): + # TODO: 404 check, print a more accurate error + print_error(f"Failed to download SDK version {version}. Check your internet connection and make sure this release exists.") + return False + with open(sdk_index_filepath) as sdk_index_json_file: + sdk_index_json = json.load(sdk_index_json_file) + sdk_platforms = sdk_index_json["platforms"] + if platform not in sdk_platforms: + print_error(f"Platform {platform} not found in {sdk_platforms} for version {version}") + return False + sdk_platform_file = sdk_platforms[platform] + sdk_zip_source_url = get_sdk_url(version, sdk_platform_file) + sdk_zip_target_filepath = os.path.join(sdk_root_dir, f"{version}-{platform}.zip") + if verbose: + print(f"Downloading {sdk_zip_source_url} to {sdk_zip_target_filepath}") + if not download_file(sdk_zip_source_url, sdk_zip_target_filepath): + print_error(f"Failed to download {sdk_zip_source_url} to {sdk_zip_target_filepath}") + return False + with zipfile.ZipFile(sdk_zip_target_filepath, "r") as zip_ref: + zip_ref.extractall(os.path.join(sdk_root_dir, "TactilitySDK")) + return True + +def sdk_download_all(version, platforms): + for platform in platforms: + if not sdk_exists(version, platform): + if not sdk_download(version, platform): + return False + else: + if verbose: + print(f"Using cached download for SDK version {version} and platform {platform}") + return True + +#endregion SDK download + +#region Building + +def get_cmake_path(platform): + return os.path.join("build", f"cmake-build-{platform}") + +def find_elf_file(platform): + cmake_dir = get_cmake_path(platform) + if os.path.exists(cmake_dir): + for file in os.listdir(cmake_dir): + if file.endswith(".app.elf"): + return os.path.join(cmake_dir, file) + return None + +def build_all(version, platforms, skip_build): + for platform in platforms: + # First build command must be "idf.py build", otherwise it fails to execute "idf.py elf" + # We check if the ELF file exists and run the correct command + # This can lead to code caching issues, so sometimes a clean build is required + if find_elf_file(platform) is None: + if not build_first(version, platform, skip_build): + return False + else: + if not build_consecutively(version, platform, skip_build): + return False + return True + +def wait_for_process(process): + buffer = [] + if sys.platform != "win32": + os.set_blocking(process.stdout.fileno(), False) + while process.poll() is None: + while True: + line = process.stdout.readline() + if line: + decoded_line = line.decode("UTF-8") + if decoded_line != "": + buffer.append(decoded_line) + else: + break + else: + break + # Read any remaining output + for line in process.stdout: + decoded_line = line.decode("UTF-8") + if decoded_line: + buffer.append(decoded_line) + return buffer + +# The first build must call "idf.py build" and consecutive builds must call "idf.py elf" as it finishes faster. +# The problem is that the "idf.py build" always results in an error, even though the elf file is created. +# The solution is to suppress the error if we find that the elf file was created. +def build_first(version, platform, skip_build): + sdk_dir = get_sdk_dir(version, platform) + if verbose: + print(f"Using SDK at {sdk_dir}") + os.environ["TACTILITY_SDK_PATH"] = sdk_dir + sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") + shutil.copy(sdkconfig_path, "sdkconfig") + elf_path = find_elf_file(platform) + # Remove previous elf file: re-creation of the file is used to measure if the build succeeded, + # as the actual build job will always fail due to technical issues with the elf cmake script + if elf_path is not None: + os.remove(elf_path) + if skip_build: + return True + print(f"Building first {platform} build") + cmake_path = get_cmake_path(platform) + print_status_busy(f"Building {platform} ELF") + shell_needed = sys.platform == "win32" + build_command = ["idf.py", "-B", cmake_path, "build"] + if verbose: + print(f"Running command: {" ".join(build_command)}") + with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: + build_output = wait_for_process(process) + # The return code is never expected to be 0 due to a bug in the elf cmake script, but we keep it just in case + if process.returncode == 0: + print(f"{shell_color_green}Building for {platform} ✅{shell_color_reset}") + return True + else: + if find_elf_file(platform) is None: + for line in build_output: + print(line, end="") + print_status_error(f"Building {platform} ELF") + return False + else: + print_status_success(f"Building {platform} ELF") + return True + +def build_consecutively(version, platform, skip_build): + sdk_dir = get_sdk_dir(version, platform) + if verbose: + print(f"Using SDK at {sdk_dir}") + os.environ["TACTILITY_SDK_PATH"] = sdk_dir + sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") + shutil.copy(sdkconfig_path, "sdkconfig") + if skip_build: + return True + cmake_path = get_cmake_path(platform) + print_status_busy(f"Building {platform} ELF") + shell_needed = sys.platform == "win32" + build_command = ["idf.py", "-B", cmake_path, "elf"] + if verbose: + print(f"Running command: {" ".join(build_command)}") + with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: + build_output = wait_for_process(process) + if process.returncode == 0: + print_status_success(f"Building {platform} ELF") + return True + else: + for line in build_output: + print(line, end="") + print_status_error(f"Building {platform} ELF") + return False + +#endregion Building + +#region Packaging + +def package_intermediate_manifest(target_path): + if not os.path.isfile("manifest.properties"): + print_error("manifest.properties not found") + return + shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties")) + +def package_intermediate_binaries(target_path, platforms): + elf_dir = os.path.join(target_path, "elf") + os.makedirs(elf_dir, exist_ok=True) + for platform in platforms: + elf_path = find_elf_file(platform) + if elf_path is None: + print_error(f"ELF file not found at {elf_path}") + return + shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}.elf")) + +def package_intermediate_assets(target_path): + if os.path.isdir("assets"): + shutil.copytree("assets", os.path.join(target_path, "assets"), dirs_exist_ok=True) + +def package_intermediate(platforms): + target_path = os.path.join("build", "package-intermediate") + if os.path.isdir(target_path): + shutil.rmtree(target_path) + os.makedirs(target_path, exist_ok=True) + package_intermediate_manifest(target_path) + package_intermediate_binaries(target_path, platforms) + package_intermediate_assets(target_path) + +def package_name(platforms): + elf_path = find_elf_file(platforms[0]) + elf_base_name = os.path.basename(elf_path).removesuffix(".app.elf") + return os.path.join("build", f"{elf_base_name}.app") + + +def package_all(platforms): + status = f"Building package with {platforms}" + print_status_busy(status) + package_intermediate(platforms) + # Create build/something.app + try: + tar_path = package_name(platforms) + tar = tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT) + tar.add(os.path.join("build", "package-intermediate"), arcname="") + tar.close() + print_status_success(status) + return True + except Exception as e: + print_status_error(f"Building package failed: {e}") + return False + +#endregion Packaging + +def setup_environment(): + global ttbuild_path + os.makedirs(ttbuild_path, exist_ok=True) + +def build_action(manifest, platform_arg): + # Environment validation + validate_environment() + platforms_to_build = get_manifest_target_platforms(manifest, platform_arg) + + if use_local_sdk: + global local_base_path + local_base_path = os.environ.get("TACTILITY_SDK_PATH") + validate_local_sdks(platforms_to_build, manifest["target"]["sdk"]) + + if should_fetch_sdkconfig_files(platforms_to_build): + fetch_sdkconfig_files(platforms_to_build) + + if not use_local_sdk: + sdk_json = read_sdk_json() + validate_self(sdk_json) + # Build + sdk_version = manifest["target"]["sdk"] + if not use_local_sdk: + if not sdk_download_all(sdk_version, platforms_to_build): + exit_with_error("Failed to download one or more SDKs") + if not build_all(sdk_version, platforms_to_build, skip_build): # Environment validation + return False + if not skip_build: + package_all(platforms_to_build) + return True + +def clean_action(): + if os.path.exists("build"): + print_status_busy("Removing build/") + shutil.rmtree("build") + print_status_success("Removed build/") + else: + print("Nothing to clean") + +def clear_cache_action(): + if os.path.exists(ttbuild_path): + print_status_busy(f"Removing {ttbuild_path}/") + shutil.rmtree(ttbuild_path) + print_status_success(f"Removed {ttbuild_path}/") + else: + print("Nothing to clear") + +def update_self_action(): + sdk_json = read_sdk_json() + tool_download_url = sdk_json["toolDownloadUrl"] + if download_file(tool_download_url, "tactility.py"): + print("Updated") + else: + exit_with_error("Update failed") + +def get_device_info(ip): + print_status_busy(f"Requesting device info") + url = get_url(ip, "/info") + try: + response = requests.get(url) + if response.status_code != 200: + print_error("Run failed") + else: + print_status_success(f"Received device info:") + print(response.json()) + except requests.RequestException as e: + print_status_error(f"Device info request failed: {e}") + +def run_action(manifest, ip): + app_id = manifest["app"]["id"] + print_status_busy("Running") + url = get_url(ip, "/app/run") + params = {'id': app_id} + try: + response = requests.post(url, params=params) + if response.status_code != 200: + print_error("Run failed") + else: + print_status_success("Running") + except requests.RequestException as e: + print_status_error(f"Running request failed: {e}") + +def install_action(ip, platforms): + print_status_busy("Installing") + for platform in platforms: + elf_path = find_elf_file(platform) + if elf_path is None: + print_status_error(f"ELF file not built for {platform}") + return False + package_path = package_name(platforms) + # print(f"Installing {package_path} to {ip}") + url = get_url(ip, "/app/install") + try: + # Prepare multipart form data + with open(package_path, 'rb') as file: + files = { + 'elf': file + } + response = requests.put(url, files=files) + if response.status_code != 200: + print_status_error("Install failed") + return False + else: + print_status_success("Installing") + return True + except requests.RequestException as e: + print_status_error(f"Install request failed: {e}") + return False + except IOError as e: + print_status_error(f"Install file error: {e}") + return False + +def uninstall_action(manifest, ip): + app_id = manifest["app"]["id"] + print_status_busy("Uninstalling") + url = get_url(ip, "/app/uninstall") + params = {'id': app_id} + try: + response = requests.put(url, params=params) + if response.status_code != 200: + print_status_error("Server responded that uninstall failed") + else: + print_status_success("Uninstalled") + except requests.RequestException as e: + print_status_error(f"Uninstall request failed: {e}") + +#region Main + +if __name__ == "__main__": + print(f"Tactility Build System v{ttbuild_version}") + if "--help" in sys.argv: + print_help() + sys.exit() + # Argument validation + if len(sys.argv) == 1: + print_help() + sys.exit(1) + if "--verbose" in sys.argv: + verbose = True + sys.argv.remove("--verbose") + skip_build = False + if "--skip-build" in sys.argv: + skip_build = True + sys.argv.remove("--skip-build") + if "--local-sdk" in sys.argv: + use_local_sdk = True + sys.argv.remove("--local-sdk") + action_arg = sys.argv[1] + + # Environment setup + setup_environment() + if not os.path.isfile("manifest.properties"): + exit_with_error("manifest.properties not found") + manifest = read_manifest() + validate_manifest(manifest) + all_platform_targets = manifest["target"]["platforms"].split(",") + # Update SDK cache (tool.json) + if should_update_tool_json() and not update_tool_json(): + exit_with_error("Failed to retrieve SDK info") + # Actions + if action_arg == "build": + if len(sys.argv) < 2: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + if len(sys.argv) > 2: + platform = sys.argv[2] + if not build_action(manifest, platform): + sys.exit(1) + elif action_arg == "clean": + clean_action() + elif action_arg == "clearcache": + clear_cache_action() + elif action_arg == "updateself": + update_self_action() + elif action_arg == "run": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + run_action(manifest, sys.argv[2]) + elif action_arg == "install": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + platforms_to_install = all_platform_targets + if len(sys.argv) >= 4: + platform = sys.argv[3] + platforms_to_install = [platform] + install_action(sys.argv[2], platforms_to_install) + elif action_arg == "uninstall": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + uninstall_action(manifest, sys.argv[2]) + elif action_arg == "bir" or action_arg == "brrr": + if len(sys.argv) < 3: + print_help() + exit_with_error("Commandline parameter missing") + platform = None + platforms_to_install = all_platform_targets + if len(sys.argv) >= 4: + platform = sys.argv[3] + platforms_to_install = [platform] + if build_action(manifest, platform): + if install_action(sys.argv[2], platforms_to_install): + run_action(manifest, sys.argv[2]) + else: + print_help() + exit_with_error("Unknown commandline parameter") + +#endregion Main diff --git a/Apps/TwoEleven/README.md b/Apps/TwoEleven/README.md index ebc2980..3b9d41d 100644 --- a/Apps/TwoEleven/README.md +++ b/Apps/TwoEleven/README.md @@ -13,13 +13,14 @@ TwoEleven is a faithful implementation of the popular 2048 game, where you slide - **Intuitive Controls**: Swipe gestures on touchscreens or use arrow keys for keyboard input. - **Visual Feedback**: Color-coded tiles with smooth animations. - **Score Tracking**: Real-time score display with win/lose detection. +- **High Score Persistence**: Saves best scores for each grid size. - **Responsive UI**: Optimized for small screens with clean, modern design. - **Thread-Safe**: Proper handling of UI updates to prevent crashes. ## Screenshots Screenshots taken directly from my Lilygo T-Deck Plus. -Which is also the only device it has been tested on so far. +Tested on Lilygo T-Deck Plus and M5Stack Cardputer. ![alt text](images/3x3.png) ![alt text](images/4x4.png) ![alt text](images/5x5.png) ![alt text](images/6x6.png) ![alt text](images/selection.png) @@ -31,7 +32,7 @@ Which is also the only device it has been tested on so far. ## Usage -1. Launch the TwoEleven app. +1. Launch the 2048 app. 2. Select your preferred grid size (3x3 to 6x6). 3. Swipe tiles in any direction to move and combine them. 4. Reach the 2048 tile to win, or get stuck to lose. @@ -40,8 +41,10 @@ Which is also the only device it has been tested on so far. ## Controls - **Touchscreen**: Swipe up, down, left, or right to move tiles. -- **Keyboard**: Use arrow keys (↑, ↓, ←, →) for movement. -- **New Game**: Press the "New" button to reset the board. +- **Keyboard (Arrow Keys)**: Use arrow keys (Up, Down, Left, Right) for movement. +- **Keyboard (WASD)**: Use W, A, S, D keys for movement. +- **Keyboard (Cardputer)**: Use semicolon (;), comma (,), period (.), slash (/) for up, left, down, right. +- **New Game**: Press the refresh button in the toolbar to reset the board. ## Game Rules @@ -51,7 +54,3 @@ Which is also the only device it has been tested on so far. - New tiles appear after each move. - Game ends when you reach 2048 (win) or no moves are possible (lose). -## TODO - -- Maybe trackball one day? -- Maybe other keys rather being limited to arrow directions. (why so limited lvgl?) diff --git a/Apps/TwoEleven/images/3x3.png b/Apps/TwoEleven/images/3x3.png index eeb5de7..cb94e0a 100644 Binary files a/Apps/TwoEleven/images/3x3.png and b/Apps/TwoEleven/images/3x3.png differ diff --git a/Apps/TwoEleven/images/4x4.png b/Apps/TwoEleven/images/4x4.png index 5d592c2..f33d0f8 100644 Binary files a/Apps/TwoEleven/images/4x4.png and b/Apps/TwoEleven/images/4x4.png differ diff --git a/Apps/TwoEleven/images/5x5.png b/Apps/TwoEleven/images/5x5.png index 9e506f4..8bbdb1f 100644 Binary files a/Apps/TwoEleven/images/5x5.png and b/Apps/TwoEleven/images/5x5.png differ diff --git a/Apps/TwoEleven/images/6x6.png b/Apps/TwoEleven/images/6x6.png index 6c5aab4..6d9169c 100644 Binary files a/Apps/TwoEleven/images/6x6.png and b/Apps/TwoEleven/images/6x6.png differ diff --git a/Apps/TwoEleven/images/selection.png b/Apps/TwoEleven/images/selection.png index 26881bb..738cf97 100644 Binary files a/Apps/TwoEleven/images/selection.png and b/Apps/TwoEleven/images/selection.png differ diff --git a/Apps/TwoEleven/main/Source/TwoEleven.cpp b/Apps/TwoEleven/main/Source/TwoEleven.cpp index 4472a9e..69110ec 100644 --- a/Apps/TwoEleven/main/Source/TwoEleven.cpp +++ b/Apps/TwoEleven/main/Source/TwoEleven.cpp @@ -1,11 +1,34 @@ +/** + * @file TwoEleven.cpp + * @brief 2048 game app implementation for Tactility + */ #include "TwoEleven.h" +#include +#include #include #include +#include +#include #include constexpr auto* TAG = "TwoEleven"; +// Preferences keys for high scores (one per grid size) +static constexpr const char* PREF_NAMESPACE = "TwoEleven"; +static constexpr const char* PREF_HIGH_3X3 = "high_3x3"; +static constexpr const char* PREF_HIGH_4X4 = "high_4x4"; +static constexpr const char* PREF_HIGH_5X5 = "high_5x5"; +static constexpr const char* PREF_HIGH_6X6 = "high_6x6"; + +// High scores for each grid size (loaded from preferences) +static int32_t highScore3x3 = 0; +static int32_t highScore4x4 = 0; +static int32_t highScore5x5 = 0; +static int32_t highScore6x6 = 0; +static int32_t currentGridSize = -1; // Track which grid size is being played + +// Static UI element pointers (invalidated on hide, recreated on show) static lv_obj_t* scoreLabel = nullptr; static lv_obj_t* scoreWrapper = nullptr; static lv_obj_t* toolbar = nullptr; @@ -13,46 +36,172 @@ static lv_obj_t* mainWrapper = nullptr; static lv_obj_t* newGameWrapper = nullptr; static lv_obj_t* gameObject = nullptr; -static uint16_t selectedSize = 4; +// Dialog launch IDs for tracking which dialog returned +static AppLaunchId selectionDialogId = 0; +static AppLaunchId gameOverDialogId = 0; +static AppLaunchId winDialogId = 0; +static AppLaunchId helpDialogId = 0; + +// State tracking (persists across hide/show cycles) +static int32_t pendingSelection = -1; // -1 = show selection, 1-4 = start game with size +static bool shouldExit = false; +static bool showHelpOnShow = false; // Show help dialog when onShow is called + +static constexpr size_t SIZE_COUNT = 4; + +// Selection dialog indices (0 = How to Play, 1-4 = grid sizes) +static constexpr int32_t SELECTION_HOW_TO_PLAY = 0; +static constexpr int32_t SELECTION_3X3 = 1; +static constexpr int32_t SELECTION_4X4 = 2; +static constexpr int32_t SELECTION_5X5 = 3; +static constexpr int32_t SELECTION_6X6 = 4; + +// Grid size options (index matches selection - 1) +static const uint16_t gridSizes[SIZE_COUNT] = { 3, 4, 5, 6 }; + +static int getToolbarHeight(UiScale uiScale) { + if (uiScale == UiScale::UiScaleSmallest) { + return 22; + } else { + return 40; + } +} + +static void loadHighScores() { + PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); + if (prefs) { + tt_preferences_opt_int32(prefs, PREF_HIGH_3X3, &highScore3x3); + tt_preferences_opt_int32(prefs, PREF_HIGH_4X4, &highScore4x4); + tt_preferences_opt_int32(prefs, PREF_HIGH_5X5, &highScore5x5); + tt_preferences_opt_int32(prefs, PREF_HIGH_6X6, &highScore6x6); + tt_preferences_free(prefs); + } +} + +static void saveHighScore(int32_t gridSize, int32_t score) { + PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); + if (prefs) { + switch (gridSize) { + case SELECTION_3X3: + highScore3x3 = score; + tt_preferences_put_int32(prefs, PREF_HIGH_3X3, score); + break; + case SELECTION_4X4: + highScore4x4 = score; + tt_preferences_put_int32(prefs, PREF_HIGH_4X4, score); + break; + case SELECTION_5X5: + highScore5x5 = score; + tt_preferences_put_int32(prefs, PREF_HIGH_5X5, score); + break; + case SELECTION_6X6: + highScore6x6 = score; + tt_preferences_put_int32(prefs, PREF_HIGH_6X6, score); + break; + } + tt_preferences_free(prefs); + } +} + +static int32_t getHighScore(int32_t gridSize) { + switch (gridSize) { + case SELECTION_3X3: return highScore3x3; + case SELECTION_4X4: return highScore4x4; + case SELECTION_5X5: return highScore5x5; + case SELECTION_6X6: return highScore6x6; + default: return 0; + } +} + +static void showSelectionDialog() { + const char* items[] = { "How to Play", "3x3", "4x4", "5x5", "6x6" }; + selectionDialogId = tt_app_selectiondialog_start("2048", 5, items); +} + +static void showHelpDialog() { + const char* buttons[] = { "OK" }; + helpDialogId = tt_app_alertdialog_start( + "How to Play", + "Swipe or use arrow keys to move tiles.\n" + "Tiles with the same number merge.\n" + "Reach 2048 to win!", + buttons, 1); +} void TwoEleven::twoElevenEventCb(lv_event_t* e) { lv_event_code_t code = lv_event_get_code(e); - lv_obj_t* obj_2048 = lv_event_get_target_obj(e); - lv_obj_t* scoreLabel = (lv_obj_t *)lv_event_get_user_data(e); - - const char* alertDialogLabels[] = { "OK" }; + lv_obj_t* label = (lv_obj_t*)lv_event_get_user_data(e); if (code == LV_EVENT_VALUE_CHANGED) { - if (twoeleven_get_best_tile(obj_2048) >= 2048) { - char message[64]; - sprintf(message, "YOU WIN!\n\nSCORE: %d", twoeleven_get_score(obj_2048)); - tt_app_alertdialog_start("YOU WIN!", message, alertDialogLabels, 1); - } else if (twoeleven_get_status(obj_2048)) { - char message[64]; - sprintf(message, "GAME OVER!\n\nSCORE: %d", twoeleven_get_score(obj_2048)); - tt_app_alertdialog_start("GAME OVER!", message, alertDialogLabels, 1); + int32_t score = twoeleven_get_score(gameObject); + + if (twoeleven_get_best_tile(gameObject) >= 2048) { + int32_t prevHighScore = getHighScore(currentGridSize); + bool isNewHighScore = score > prevHighScore; + + // Save high score if it's a new record + if (isNewHighScore) { + saveHighScore(currentGridSize, score); + } + + const char* alertDialogLabels[] = { "OK" }; + char message[100]; + if (isNewHighScore) { + snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32, score); + winDialogId = tt_app_alertdialog_start("YOU WIN!", message, alertDialogLabels, 1); + } else { + snprintf(message, sizeof(message), "YOU WIN!\n\nSCORE: %" PRId32 "\nBEST: %" PRId32, score, getHighScore(currentGridSize)); + winDialogId = tt_app_alertdialog_start("YOU WIN!", message, alertDialogLabels, 1); + } + } else if (twoeleven_get_status(gameObject)) { + int32_t prevHighScore = getHighScore(currentGridSize); + bool isNewHighScore = score > prevHighScore; + + // Save high score if it's a new record + if (isNewHighScore) { + saveHighScore(currentGridSize, score); + } + + const char* alertDialogLabels[] = { "OK" }; + char message[100]; + if (isNewHighScore && score > 0) { + snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32, score); + gameOverDialogId = tt_app_alertdialog_start("NEW HIGH SCORE!", message, alertDialogLabels, 1); + } else { + snprintf(message, sizeof(message), "GAME OVER!\n\nSCORE: %" PRId32 "\nBEST: %" PRId32, score, getHighScore(currentGridSize)); + gameOverDialogId = tt_app_alertdialog_start("GAME OVER!", message, alertDialogLabels, 1); + } } else { - lv_label_set_text_fmt(scoreLabel, "SCORE: %d", twoeleven_get_score(obj_2048)); + // Update score display + lv_label_set_text_fmt(label, "SCORE: %" PRId32, score); } } } void TwoEleven::newGameBtnEvent(lv_event_t* e) { - lv_obj_t* obj_2048 = (lv_obj_t *)lv_event_get_user_data(e); - twoeleven_set_new_game(obj_2048); + lv_obj_t* obj = (lv_obj_t*)lv_event_get_user_data(e); + if (obj == nullptr) { + return; + } + twoeleven_set_new_game(obj); + // Update score label + if (scoreLabel) { + lv_label_set_text_fmt(scoreLabel, "SCORE: %" PRId32, twoeleven_get_score(obj)); + } } -void TwoEleven::create_game(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar) { +void TwoEleven::createGame(lv_obj_t* parent, uint16_t size, lv_obj_t* tb) { lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - //game... + // Create game widget gameObject = twoeleven_create(parent, size); lv_obj_set_style_text_font(gameObject, lv_font_get_default(), 0); lv_obj_set_size(gameObject, LV_PCT(100), LV_PCT(100)); lv_obj_set_flex_grow(gameObject, 1); - scoreWrapper = lv_obj_create(toolbar); + // Create score wrapper in toolbar + scoreWrapper = lv_obj_create(tb); lv_obj_set_size(scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100)); lv_obj_set_style_pad_top(scoreWrapper, 4, LV_STATE_DEFAULT); lv_obj_set_style_pad_bottom(scoreWrapper, 0, LV_STATE_DEFAULT); @@ -64,119 +213,91 @@ void TwoEleven::create_game(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar) lv_obj_set_style_bg_opa(scoreWrapper, 0, LV_STATE_DEFAULT); lv_obj_remove_flag(scoreWrapper, LV_OBJ_FLAG_SCROLLABLE); - //toolbar new score + // Create score label scoreLabel = lv_label_create(scoreWrapper); - lv_label_set_text_fmt(scoreLabel, "SCORE: %d", twoeleven_get_score(gameObject)); + lv_label_set_text_fmt(scoreLabel, "SCORE: %" PRId32, twoeleven_get_score(gameObject)); lv_obj_set_style_text_align(scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT); lv_obj_align(scoreLabel, LV_ALIGN_CENTER, 0, 0); lv_obj_set_size(scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_text_font(scoreLabel, lv_font_get_default(), 0); lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_AMBER), LV_PART_MAIN); - lv_obj_add_event_cb(gameObject, twoElevenEventCb, LV_EVENT_ALL, scoreLabel); - newGameWrapper = lv_obj_create(toolbar); + // Register event callback for game state changes + lv_obj_add_event_cb(gameObject, twoElevenEventCb, LV_EVENT_VALUE_CHANGED, scoreLabel); + + // Create new game button wrapper + newGameWrapper = lv_obj_create(tb); lv_obj_set_width(newGameWrapper, LV_SIZE_CONTENT); lv_obj_set_flex_flow(newGameWrapper, LV_FLEX_FLOW_ROW); lv_obj_set_style_pad_all(newGameWrapper, 2, LV_STATE_DEFAULT); lv_obj_set_style_border_width(newGameWrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(newGameWrapper, 0, LV_STATE_DEFAULT); - //toolbar reset + // Create new game button + auto ui_scale = tt_hal_configuration_get_ui_scale(); + auto toolbar_height = getToolbarHeight(ui_scale); lv_obj_t* newGameBtn = lv_btn_create(newGameWrapper); - lv_obj_set_size(newGameBtn, 34, 34); + if (ui_scale == UiScale::UiScaleSmallest) { + lv_obj_set_size(newGameBtn, toolbar_height - 8, toolbar_height - 8); + } else { + lv_obj_set_size(newGameBtn, toolbar_height - 6, toolbar_height - 6); + } lv_obj_set_style_pad_all(newGameBtn, 0, LV_STATE_DEFAULT); lv_obj_align(newGameBtn, LV_ALIGN_CENTER, 0, 0); lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, gameObject); - lv_obj_t* btnLabel = lv_image_create(newGameBtn); - lv_image_set_src(btnLabel, LV_SYMBOL_REFRESH); - lv_obj_align(btnLabel, LV_ALIGN_CENTER, 0, 0); + lv_obj_t* btnIcon = lv_image_create(newGameBtn); + lv_image_set_src(btnIcon, LV_SYMBOL_REFRESH); + lv_obj_align(btnIcon, LV_ALIGN_CENTER, 0, 0); } -void TwoEleven::create_selection(lv_obj_t* parent, lv_obj_t* toolbar) { - lv_obj_t* selection = lv_obj_create(parent); - lv_obj_set_size(selection, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(selection, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_grow(selection, 1); - lv_obj_remove_flag(selection, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_pad_all(selection, 0, LV_PART_MAIN); - lv_obj_set_style_border_width(selection, 0, LV_STATE_DEFAULT); - - lv_obj_t* titleWrapper = lv_obj_create(selection); - lv_obj_set_size(titleWrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(titleWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(titleWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(titleWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_flow(titleWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(titleWrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_remove_flag(titleWrapper, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t* titleLabel = lv_label_create(titleWrapper); - lv_label_set_text(titleLabel, "Select Matrix Size"); - lv_obj_align(titleLabel, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_size(titleLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - - lv_obj_t* controlsWrapper = lv_obj_create(titleWrapper); - lv_obj_set_size(controlsWrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(controlsWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(controlsWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(controlsWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_flow(controlsWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(controlsWrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_remove_flag(controlsWrapper, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t* touchControlsLabel = lv_label_create(controlsWrapper); - lv_label_set_text(touchControlsLabel, "Touchscreen:\nSwipe up, down, left, right to move tiles."); - lv_obj_set_style_text_font(touchControlsLabel, lv_font_get_default(), 0); - lv_obj_set_style_text_align(touchControlsLabel, LV_TEXT_ALIGN_CENTER, 0); - - lv_obj_t* keyControlsLabel = lv_label_create(controlsWrapper); - lv_label_set_text_fmt(keyControlsLabel, "Keyboard:\nUse arrow keys (%s, %s, %s, %s) to move tiles.", LV_SYMBOL_UP, LV_SYMBOL_DOWN, LV_SYMBOL_LEFT, LV_SYMBOL_RIGHT); - lv_obj_set_style_text_font(keyControlsLabel, lv_font_get_default(), 0); - lv_obj_set_style_text_align(keyControlsLabel, LV_TEXT_ALIGN_CENTER, 0); - - lv_obj_t* buttonContainer = lv_obj_create(selection); - lv_obj_set_flex_flow(buttonContainer, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(buttonContainer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_remove_flag(buttonContainer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(buttonContainer, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_bg_opa(buttonContainer, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(buttonContainer, 0, LV_STATE_DEFAULT); - - for(int s = 3; s <= 6; s++) { - lv_obj_t* btn = lv_btn_create(buttonContainer); - lv_obj_set_size(btn, 60, 40); - lv_obj_t* lbl = lv_label_create(btn); - char txt[10]; - sprintf(txt, "%dx%d", s, s); - lv_label_set_text(lbl, txt); - lv_obj_center(lbl); - lv_obj_add_event_cb(btn, size_select_cb, LV_EVENT_CLICKED, (void*)s); - } +void TwoEleven::onDestroy(AppHandle appHandle) { + // Reset all static state + scoreLabel = nullptr; + scoreWrapper = nullptr; + toolbar = nullptr; + mainWrapper = nullptr; + newGameWrapper = nullptr; + gameObject = nullptr; + selectionDialogId = 0; + gameOverDialogId = 0; + winDialogId = 0; + helpDialogId = 0; + pendingSelection = -1; + shouldExit = false; + showHelpOnShow = false; + currentGridSize = -1; } -void TwoEleven::size_select_cb(lv_event_t* e) { - selectedSize = (uint16_t)(uintptr_t)lv_event_get_user_data(e); - lv_obj_t* selection = lv_obj_get_parent(lv_event_get_target_obj(e)); - lv_obj_t* selectionWrapper = lv_obj_get_parent(selection); - lv_obj_clean(selectionWrapper); +void TwoEleven::onHide(AppHandle appHandle) { + // LVGL objects are destroyed when app is hidden, mark pointers as invalid scoreLabel = nullptr; scoreWrapper = nullptr; + toolbar = nullptr; + mainWrapper = nullptr; newGameWrapper = nullptr; gameObject = nullptr; - create_game(selectionWrapper, selectedSize, toolbar); } void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) { + // Check if we should exit (user closed selection dialog) + if (shouldExit) { + shouldExit = false; + tt_app_stop(); + return; + } + + // Always recreate UI when shown (previous objects destroyed on hide) lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + // Create toolbar toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + // Create main wrapper mainWrapper = lv_obj_create(parent); lv_obj_set_width(mainWrapper, LV_PCT(100)); - lv_obj_set_height(mainWrapper, LV_PCT(100)); lv_obj_set_flex_grow(mainWrapper, 1); lv_obj_set_style_pad_all(mainWrapper, 2, LV_PART_MAIN); lv_obj_set_style_pad_row(mainWrapper, 2, LV_PART_MAIN); @@ -184,19 +305,69 @@ void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_style_border_width(mainWrapper, 0, LV_PART_MAIN); lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); - create_selection(mainWrapper, toolbar); + // Load high scores on first show + static bool highScoresLoaded = false; + if (!highScoresLoaded) { + loadHighScores(); + highScoresLoaded = true; + } + + // Check if we need to show the help dialog + if (showHelpOnShow) { + showHelpOnShow = false; + showHelpDialog(); + // Check if we have a pending size selection from onResult + } else if (pendingSelection >= SELECTION_3X3 && pendingSelection <= SELECTION_6X6) { + // Force layout update before creating game so dimensions are computed + lv_obj_update_layout(parent); + // Track which grid size we're playing for high score saving + currentGridSize = pendingSelection; + // Start game with selected size (convert selection index to size index) + int32_t sizeIndex = pendingSelection - SELECTION_3X3; + createGame(mainWrapper, gridSizes[sizeIndex], toolbar); + pendingSelection = -1; + } else { + // Show selection dialog + showSelectionDialog(); + } } void TwoEleven::onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) { - if (result == APP_RESULT_OK && resultData != nullptr) { - // Dialog closed with OK, go back to selection - tt_lvgl_lock(TT_LVGL_DEFAULT_LOCK_TIME); - lv_obj_clean(mainWrapper); - scoreLabel = nullptr; - scoreWrapper = nullptr; - newGameWrapper = nullptr; - gameObject = nullptr; - create_selection(mainWrapper, toolbar); - tt_lvgl_unlock(); + // Don't manipulate LVGL objects here - they may be invalid + // Just store state for onShow to handle + + if (launchId == selectionDialogId && selectionDialogId != 0) { + selectionDialogId = 0; + + int32_t selection = -1; + if (resultData != nullptr) { + selection = tt_app_selectiondialog_get_result_index(resultData); + } + + if (selection == SELECTION_HOW_TO_PLAY) { + // Mark to show help dialog in onShow + showHelpOnShow = true; + } else if (selection >= SELECTION_3X3 && selection <= SELECTION_6X6) { + // Store selection for onShow to handle + pendingSelection = selection; + } else { + // User closed dialog without selecting - mark for exit + shouldExit = true; + } + + } else if (launchId == helpDialogId && helpDialogId != 0) { + helpDialogId = 0; + // Return to selection dialog + pendingSelection = -1; + + } else if (launchId == gameOverDialogId && gameOverDialogId != 0) { + gameOverDialogId = 0; + // Mark to show selection dialog in onShow + pendingSelection = -1; + + } else if (launchId == winDialogId && winDialogId != 0) { + winDialogId = 0; + // Mark to show selection dialog in onShow + pendingSelection = -1; } } diff --git a/Apps/TwoEleven/main/Source/TwoEleven.h b/Apps/TwoEleven/main/Source/TwoEleven.h index c9a0494..8e2cdcd 100644 --- a/Apps/TwoEleven/main/Source/TwoEleven.h +++ b/Apps/TwoEleven/main/Source/TwoEleven.h @@ -13,12 +13,12 @@ class TwoEleven final : public App { static void twoElevenEventCb(lv_event_t* e); static void newGameBtnEvent(lv_event_t* e); - static void create_game(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar); - static void create_selection(lv_obj_t* parent, lv_obj_t* toolbar); - static void size_select_cb(lv_event_t* e); + static void createGame(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar); public: void onShow(AppHandle context, lv_obj_t* parent) override; + void onHide(AppHandle context) override; + void onDestroy(AppHandle context) override; void onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override; }; \ No newline at end of file diff --git a/Apps/TwoEleven/main/Source/TwoElevenUi.c b/Apps/TwoEleven/main/Source/TwoElevenUi.c index 6c447b4..356185f 100644 --- a/Apps/TwoEleven/main/Source/TwoElevenUi.c +++ b/Apps/TwoEleven/main/Source/TwoElevenUi.c @@ -3,9 +3,11 @@ #include "TwoElevenHelpers.h" #include #include +#include static void game_play_event(lv_event_t * e); static void btnm_event_cb(lv_event_t * e); +static void focus_event(lv_event_t* e); /** * @brief Free all resources for the 2048 game object @@ -15,6 +17,13 @@ static void delete_event(lv_event_t * e) lv_obj_t * obj = lv_event_get_target_obj(e); twoeleven_t * game_2048 = (twoeleven_t *)lv_obj_get_user_data(obj); if (game_2048) { + // Reset group editing mode if we enabled it + if (tt_lvgl_hardware_keyboard_is_available()) { + lv_group_t* group = lv_group_get_default(); + if (group) { + lv_group_set_editing(group, false); + } + } for (uint16_t index = 0; index < game_2048->map_count; index++) { if (game_2048->btnm_map[index]) { lv_free(game_2048->btnm_map[index]); @@ -32,6 +41,24 @@ static void delete_event(lv_event_t * e) } } +/** + * @brief Handle focus/defocus to manage edit mode for keyboard input + */ +static void focus_event(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + lv_group_t* group = lv_group_get_default(); + + if (!group) return; + + if (code == LV_EVENT_FOCUSED) { + // Enable edit mode so arrow keys control the game + lv_group_set_editing(group, true); + } else if (code == LV_EVENT_DEFOCUSED) { + // Restore normal focus navigation + lv_group_set_editing(group, false); + } +} + /** * @brief Create a new 2048 game object */ @@ -113,10 +140,23 @@ lv_obj_t * twoeleven_create(lv_obj_t * parent, uint16_t matrix_size) lv_btnmatrix_set_map(game_2048->btnm, (const char **)game_2048->btnm_map); lv_btnmatrix_set_btn_ctrl_all(game_2048->btnm, LV_BTNMATRIX_CTRL_DISABLED); - lv_obj_add_event_cb(game_2048->btnm, game_play_event, LV_EVENT_ALL, obj); + lv_obj_add_event_cb(game_2048->btnm, game_play_event, LV_EVENT_GESTURE, obj); + lv_obj_add_event_cb(game_2048->btnm, game_play_event, LV_EVENT_KEY, obj); lv_obj_add_event_cb(game_2048->btnm, btnm_event_cb, LV_EVENT_DRAW_TASK_ADDED, NULL); lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL); + if (tt_lvgl_hardware_keyboard_is_available()) { + lv_group_t* group = lv_group_get_default(); + if (group) { + lv_group_add_obj(group, game_2048->btnm); + // Register focus handlers to manage edit mode lifecycle + lv_obj_add_event_cb(game_2048->btnm, focus_event, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(game_2048->btnm, focus_event, LV_EVENT_DEFOCUSED, NULL); + // Focus the container (will trigger FOCUSED event and enable edit mode) + lv_group_focus_obj(game_2048->btnm); + } + } + return obj; } @@ -175,17 +215,31 @@ static void game_play_event(lv_event_t * e) } else if (code == LV_EVENT_KEY) { game_2048->game_over = game_over(game_2048->matrix_size, (const uint16_t **)game_2048->matrix); if (!game_2048->game_over) { - switch (*((const uint8_t *) lv_event_get_param(e))) { + uint32_t key = lv_event_get_key(e); + switch (key) { + // Arrow keys, WASD, and punctuation keys for cardputer case LV_KEY_UP: + case 'w': + case 'W': + case ';': success = move_right(&(game_2048->score), game_2048->matrix_size, game_2048->matrix); break; case LV_KEY_DOWN: + case 's': + case 'S': + case '.': success = move_left(&(game_2048->score), game_2048->matrix_size, game_2048->matrix); break; case LV_KEY_LEFT: + case 'a': + case 'A': + case ',': success = move_up(&(game_2048->score), game_2048->matrix_size, game_2048->matrix); break; case LV_KEY_RIGHT: + case 'd': + case 'D': + case '/': success = move_down(&(game_2048->score), game_2048->matrix_size, game_2048->matrix); break; default: