From 01ffe420eb8243ca8a31c57244e1836a6726bd26 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Thu, 22 Jan 2026 06:47:59 +1000 Subject: [PATCH] Webserver addition and TactilityC symbols (#451) --- .../service/webserver/settings.properties | 27 + Data/data/webserver/dashboard.html | 1147 ++++++++++ Data/data/webserver/version.json | 3 + .../Statusbar/assets/webserver_ap_white.png | Bin 0 -> 663 bytes .../assets/webserver_station_white.png | Bin 0 -> 755 bytes Data/webserver/default.html | 63 + Data/webserver/version.json | 3 + Documentation/webserver.md | 515 +++++ Tactility/CMakeLists.txt | 1 + .../Include/Tactility/network/HttpServer.h | 4 +- .../service/webserver/AssetVersion.h | 72 + .../Tactility/settings/WebServerSettings.h | 66 + .../service/webserver/WebServerService.h | 105 + Tactility/Source/Tactility.cpp | 17 +- .../webserversettings/WebServerSettings.cpp | 408 ++++ Tactility/Source/network/HttpServer.cpp | 34 +- Tactility/Source/network/HttpdReq.cpp | 27 +- .../Source/service/webserver/AssetVersion.cpp | 395 ++++ .../service/webserver/WebServerService.cpp | 1862 +++++++++++++++++ .../Source/settings/WebServerSettings.cpp | 260 +++ TactilityC/Source/symbols/freertos.cpp | 3 + TactilityC/Source/tt_init.cpp | 15 +- 22 files changed, 5006 insertions(+), 21 deletions(-) create mode 100644 Data/data/service/webserver/settings.properties create mode 100644 Data/data/webserver/dashboard.html create mode 100644 Data/data/webserver/version.json create mode 100644 Data/system/service/Statusbar/assets/webserver_ap_white.png create mode 100644 Data/system/service/Statusbar/assets/webserver_station_white.png create mode 100644 Data/webserver/default.html create mode 100644 Data/webserver/version.json create mode 100644 Documentation/webserver.md create mode 100644 Tactility/Include/Tactility/service/webserver/AssetVersion.h create mode 100644 Tactility/Include/Tactility/settings/WebServerSettings.h create mode 100644 Tactility/Private/Tactility/service/webserver/WebServerService.h create mode 100644 Tactility/Source/app/webserversettings/WebServerSettings.cpp create mode 100644 Tactility/Source/service/webserver/AssetVersion.cpp create mode 100644 Tactility/Source/service/webserver/WebServerService.cpp create mode 100644 Tactility/Source/settings/WebServerSettings.cpp diff --git a/Data/data/service/webserver/settings.properties b/Data/data/service/webserver/settings.properties new file mode 100644 index 00000000..fceba429 --- /dev/null +++ b/Data/data/service/webserver/settings.properties @@ -0,0 +1,27 @@ +# Web Server Settings +# WiFi and HTTP server configuration + +# WiFi Enable (0=disabled, 1=enabled) +wifiEnabled=0 + +# WiFi Mode (0=Station/Client, 1=Access Point) +wifiMode=0 + +# Access Point Mode Settings (create own WiFi network) +# apSsid will be auto-generated as Tactility-XXXX if empty +# apPassword will be auto-generated if empty or insecure (WPA2 requires 8-63 chars) +# apOpenNetwork: if 1, create open network without password (ignores apPassword) +apSsid= +apPassword= +apOpenNetwork=0 +apChannel=1 + +# Web Server Settings +webServerEnabled=0 +webServerPort=80 + +# HTTP Basic Authentication (optional) +# When auth is enabled with empty/insecure credentials, strong random credentials are auto-generated +webServerAuthEnabled=0 +webServerUsername= +webServerPassword= diff --git a/Data/data/webserver/dashboard.html b/Data/data/webserver/dashboard.html new file mode 100644 index 00000000..54ec3b02 --- /dev/null +++ b/Data/data/webserver/dashboard.html @@ -0,0 +1,1147 @@ + + + + + + Tactility Dashboard + + + +
+
+

Tactility Dashboard

+

Loading...

+
+ +
+ + + +
+ + +
+
+
Loading system information...
+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+
+ + + +
NameTypeSizeAction
Select the Files tab to browse
+
+ + +
+
+
+

Drag & drop an .app file here, or click to select

+ + +
+
+

Installed Apps

+
+
Loading apps...
+
+
+
+ +
+ Tactility WebServer - Auto-refreshes every 30 seconds +
+
+ + + + diff --git a/Data/data/webserver/version.json b/Data/data/webserver/version.json new file mode 100644 index 00000000..5dfe44db --- /dev/null +++ b/Data/data/webserver/version.json @@ -0,0 +1,3 @@ +{ + "version": 0 +} diff --git a/Data/system/service/Statusbar/assets/webserver_ap_white.png b/Data/system/service/Statusbar/assets/webserver_ap_white.png new file mode 100644 index 0000000000000000000000000000000000000000..2d39d9652e39673f623b40434aded51390a2a160 GIT binary patch literal 663 zcmV;I0%-k-P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0xU^HK~y+Tm6Xkk zPhk|t&vR$UyNo8XP*cQWcHT;pg_I&$u*59l4OOl*|&3HSz_vfbY`Sm3(e(lpJ%rfK%u-fTNX8(;-+SuIa(FPdy< zyOMMX*wl)D4guGs{H;y==aKXlI1eoE8t(^APc|!od_vL>P$P5$IB9!N(=_{lGm^%- zvAhFzmi}qj?vwOD(wObd<#`WaucY_Dh4Sn#fW^Ie0EZ;q6M8G@jil=}_10)FUsZZ% zMbgD?W*>!a3f+_RyCzX6U(dfmM` zpvS=WQa3Ni`P}60p$|e$+c^KU>yc7FE5JHQ6H^xYh|ra8|D1DvUV3IF=X`w1moe7< zG*~0)YgaQcRQe(!s-={g|0@wiU%7D7L$&zl{UQ+6N xbOHX2?IpI4+S(RT)xh2U{{A~DC70SBzX6d;N;5i?S|Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0*FaOK~y+Tg_J>v zl~okS|L49Ldm&{OXqP%x+GR*+8xX9|KR`&3PKwZF>*!$fkH7cyDZ+ znG3eJ$#yR@zsbz2zzb@-eH?f{$%~o!-=;A~kU!i02$<=N2f%kp{cu3R=G0d~gp1 z*bUqSF1Oc&8DQ6FG&%@e0M=|DsCAMQbzltetfZlo2c_Pe?MEYGF(S@yY;4T-`~B;+ z=79jSwl7T>`B&iOx_->W4sB0CE=H7PNk2*&*q-Uqw$H`j zq@E-xyfY%6lXO_pX~}aG4J0XIvUXw+xZHll9Pp;36-o29vZ%b#<(BRdbxbrYrJT2Y z!}dx + + + + + Tactility Dashboard + + + +

Tactility Default Dashboard

+ +
+

Version 0 - Default Placeholder

+

This is the default dashboard bundled with firmware.

+

To customize this interface:

+
    +
  1. Create your custom dashboard HTML/CSS/JS files
  2. +
  3. Add them to /sdcard/tactility/webserver/
  4. +
  5. Create version.json with {"version": 1} or higher
  6. +
  7. Reboot or click "Sync Assets" on the Core Interface
  8. +
+

Your custom assets will automatically replace this page!

+
+ + + + diff --git a/Data/webserver/version.json b/Data/webserver/version.json new file mode 100644 index 00000000..5dfe44db --- /dev/null +++ b/Data/webserver/version.json @@ -0,0 +1,3 @@ +{ + "version": 0 +} diff --git a/Documentation/webserver.md b/Documentation/webserver.md new file mode 100644 index 00000000..a4839b3a --- /dev/null +++ b/Documentation/webserver.md @@ -0,0 +1,515 @@ +# WebServer Service + +The WebServer service provides a built-in HTTP server for remote device management, file operations, and system monitoring through a web browser. + +## Features + +- **Dashboard**: Real-time system information, memory stats, and storage overview +- **File Browser**: Navigate, upload, download, rename, and delete files on internal storage and SD card +- **App Management**: List installed apps, run apps remotely, install/uninstall external apps +- **WiFi Status**: View current WiFi connection details +- **Screenshot Capture**: Capture the current display as a PNG +- **System Controls**: Sync assets, reboot device + +## Enabling the WebServer + +The WebServer is disabled by default to conserve memory. Enable it through: + +1. **Settings App**: Navigate to Settings > WebServer Settings +2. **Programmatically**: Call `tt::service::webserver::setWebServerEnabled(true)` + +When enabled, a statusbar icon appears indicating the server mode (AP or Station). + +## Accessing the Dashboard + +Once enabled, access the dashboard by navigating to the device's IP address in a web browser: + +```text +http:/// +``` + +**Access Point Mode:** When using AP mode, connect to the device's WiFi network (SSID shown in settings, default `Tactility-XXXX`) and navigate to `http://192.168.4.1/` + +The root URL redirects to `/dashboard.html` which provides a tabbed interface for all features. + +## API Endpoints + +All API endpoints return JSON responses unless otherwise noted. + +### System Information + +#### GET /api/sysinfo + +Returns comprehensive system information. + +**Response:** +```json +{ + "firmware": { + "version": "1.0.0", + "idf_version": "5.3.0" + }, + "chip": { + "model": "ESP32-S3", + "cores": 2, + "revision": 0, + "features": ["Embedded Flash", "WiFi 2.4GHz", "BLE"], + "flash_size": 16777216 + }, + "heap": { + "free": 123456, + "total": 327680, + "min_free": 100000, + "largest_block": 65536 + }, + "psram": { + "free": 4000000, + "total": 8388608, + "min_free": 3500000, + "largest_block": 2000000 + }, + "storage": { + "data": { + "free": 1000000, + "total": 3145728, + "mounted": true + }, + "sdcard": { + "free": 15000000000, + "total": 32000000000, + "mounted": true + } + }, + "uptime": 3600, + "task_count": 25 +} +``` + +### WiFi Status + +#### GET /api/wifi + +Returns current WiFi connection status. + +**Response:** +```json +{ + "state": "connected", + "ip": "192.168.1.100", + "ssid": "MyNetwork", + "rssi": -45, + "secure": true +} +``` + +**State values:** +- `off` - WiFi radio is off +- `turning_on` - WiFi is starting +- `turning_off` - WiFi is stopping +- `on` - WiFi is on but not connected +- `connecting` - Connection in progress +- `connected` - Connected to access point + +### Screenshot + +#### GET /api/screenshot + +Captures the current display and returns a PNG. The screenshot is also saved to storage with an incrementing filename. + +**Response:** PNG data (`image/png`) + +**Save Location:** +- SD card root (if mounted): `/sdcard/webscreenshot1.png`, `/sdcard/webscreenshot2.png`, etc. +- Internal storage (fallback): `/data/webscreenshot1.png`, `/data/webscreenshot2.png`, etc. + +**Requirements:** `TT_FEATURE_SCREENSHOT_ENABLED` must be defined in the build. + +**Note:** Returns 501 Not Implemented if screenshot feature is disabled. + +### App Management + +#### GET /api/apps + +Lists all installed applications. + +**Response:** +```json +{ + "apps": [ + { + "id": "com.example.myapp", + "name": "My App", + "version": "1.0.0", + "category": "user", + "isExternal": true, + "hidden": false, + "icon": "/data/app/com.example.myapp/icon.png" + } + ] +} +``` + +**Category values:** `user`, `system`, `settings` + +#### POST /api/apps/run?id=xxx + +Runs an application by its ID. If the app is already running, it will be stopped first. + +**Parameters:** +- `id` (required): Application ID + +**Response:** `ok` on success + +#### POST /api/apps/uninstall?id=xxx + +Uninstalls an external application. System apps cannot be uninstalled. + +**Parameters:** +- `id` (required): Application ID + +**Response:** `ok` on success + +**Errors:** +- 403 Forbidden: Cannot uninstall system apps +- 500 Internal Server Error: Uninstall failed + +#### PUT /api/apps/install + +Installs an application from an uploaded `.app` file (tar archive). + +**Content-Type:** `multipart/form-data` + +**Form field:** `file` - The `.app` file to install + +**Response:** `ok` on success + +### File System Operations + +#### GET /fs/list?path=/path + +Lists directory contents. + +**Parameters:** +- `path` (optional): Directory path. Defaults to `/` which shows mount points. + +**Response:** +```json +{ + "path": "/data", + "entries": [ + {"name": "app", "type": "dir", "size": 0}, + {"name": "settings.json", "type": "file", "size": 1234} + ] +} +``` + +**Special paths:** +- `/` - Shows available mount points (data, sdcard if mounted) +- `/data` - Internal flash storage +- `/sdcard` - SD card (if mounted) + +#### GET /fs/download?path=/path/to/file + +Downloads a file. + +**Parameters:** +- `path` (required): Full path to the file + +**Response:** File contents with appropriate Content-Type header and Content-Disposition for download. + +#### POST /fs/upload?path=/path/to/file + +Uploads a file. The request body contains the raw file data. + +**Parameters:** +- `path` (required): Full destination path including filename + +**Content-Type:** Any (raw file data in body) + +**Response:** `Uploaded X bytes` + +**Limits:** Maximum file size is 10MB. + +#### POST /fs/mkdir?path=/path/to/newdir + +Creates a new directory. + +**Parameters:** +- `path` (required): Full path of directory to create + +**Response:** `ok` on success + +#### POST /fs/delete?path=/path/to/item + +Deletes a file or directory (recursive for directories). + +**Parameters:** +- `path` (required): Full path to delete + +**Response:** `ok` on success + +**Restrictions:** Cannot delete mount points (`/data`, `/sdcard`). + +#### POST /fs/rename?path=/path/to/old&newName=newname + +Renames a file or directory. + +**Parameters:** +- `path` (required): Full path to the item to rename +- `newName` (required): New name (filename only, not a path) + +**Response:** `ok` on success + +**Restrictions:** +- `newName` cannot contain path separators or `..` +- Cannot overwrite existing items + +#### GET /fs/tree + +Returns a tree structure of all mount points and their immediate contents. + +**Response:** +```json +{ + "mounts": [ + { + "name": "data", + "path": "/data", + "entries": [ + {"name": "app", "type": "dir"}, + {"name": "tmp", "type": "dir"} + ] + } + ] +} +``` + +### Admin Operations + +#### POST /admin/sync + +Synchronizes web assets from the Data partition. + +**Response:** `Assets synchronized successfully` + +#### POST /admin/reboot + +Reboots the device after a 1-second delay. + +**Response:** `Rebooting...` + +## Static Assets + +The WebServer serves static files from: + +1. **Primary**: `/data/webserver/` (internal flash) +2. **Fallback**: `/sdcard/tactility/webserver/` (SD card) + +The dashboard HTML file is served from these locations. If `dashboard.html` doesn't exist, `default.html` is served as a fallback. + +## Asset Synchronization + +The WebServer includes an asset synchronization system that keeps web assets in sync between the Data partition and SD card. This enables recovery after firmware updates and backup of user customizations. + +### Storage Locations + +| Location | Path | Purpose | +|----------|------|---------| +| Data Partition | `/data/webserver/` | Primary storage, served by WebServer | +| SD Card | `/sdcard/tactility/webserver/` | Backup storage for recovery | + +### Version Tracking + +Each storage location maintains a `version.json` file: + +```json +{ + "version": 1 +} +``` + +The version is an integer that increments when assets are updated. This allows the sync system to determine which location has newer assets. + +### Sync Scenarios + +The `syncAssets()` function handles several scenarios: + +#### First Boot (No SD Card Backup) +- **Condition**: Data partition has assets, SD card backup doesn't exist +- **Action**: Skip backup during boot to avoid watchdog timeout +- **Note**: SD backup is deferred to first settings save + +#### No SD Card Available +- **Condition**: SD card not mounted or unavailable +- **Action**: Create default Data structure with version 0 if needed +- **Note**: System operates normally without SD backup + +#### Post-Flash Recovery +- **Condition**: Data partition empty, SD card has backup +- **Action**: Copy entire SD backup to Data partition +- **Use Case**: Restoring assets after flashing new firmware that erased Data + +#### Firmware Update (SD Newer) +- **Condition**: SD version > Data version +- **Action**: Copy SD assets to Data partition +- **Use Case**: SD card contains newer assets from a firmware update package + +#### User Customization (Data Newer) +- **Condition**: Data version > SD version +- **Action**: Defer backup to avoid boot watchdog timeout +- **Note**: Backup occurs on next settings save or manual sync + +#### Versions Match +- **Condition**: Data version == SD version +- **Action**: No synchronization needed + +### Boot Watchdog Considerations + +Some sync operations are intentionally deferred during boot to avoid triggering the ESP32 watchdog timer: + +- **Deferred**: Copying from Data to SD (user customization backup) +- **Deferred**: Creating SD version.json +- **Immediate**: Copying from SD to Data (recovery and firmware update) + +This ensures the device boots reliably even with slow or corrupted SD cards. + +### Manual Synchronization + +#### Settings App +Navigate to **Settings > Web Server** and tap **"Sync Assets Now"** to manually trigger synchronization. + +#### API Endpoint +Send a POST request to `/admin/sync`: + +```bash +curl -X POST http:///admin/sync +``` + +**Response:** `Assets synchronized successfully` + +### Programmatic Access + +```cpp +#include + +// Check asset status +bool hasData = tt::service::webserver::hasDataAssets(); +bool hasSd = tt::service::webserver::hasSdAssets(); + +// Load versions +tt::service::webserver::AssetVersion dataVer, sdVer; +tt::service::webserver::loadDataVersion(dataVer); +tt::service::webserver::loadSdVersion(sdVer); + +// Trigger sync +bool success = tt::service::webserver::syncAssets(); +``` + +### Directory Structure + +```text +/data/webserver/ +├── version.json # Version tracking +├── dashboard.html # Main dashboard UI +└── ... # Other web assets + +/sdcard/tactility/webserver/ +├── version.json # Version tracking (backup) +├── dashboard.html # Dashboard backup +└── ... # Other web assets (backup) +``` + +### Updating Assets + +To update web assets with a new version: + +1. Place new assets in `/sdcard/tactility/webserver/` +2. Update `/sdcard/tactility/webserver/version.json` with a higher version number +3. Reboot the device or trigger manual sync +4. The sync system will detect the newer SD version and copy to Data + +## Security Considerations + +> **⚠️ Security Warning**: The WebServer is unauthenticated by default, allowing anyone on the network to: +> - Upload, download, and delete files +> - Install and uninstall applications +> - Reboot the device +> - Capture screenshots +> +> **Strongly recommended**: +> - Enable HTTP Basic Authentication in Settings > Web Server before exposing the device to untrusted networks +> - Keep "AP Open Network" disabled (use WPA2 password protection) to prevent unauthorized network access + +- **⚠️ Open Network Option**: The "AP Open Network" setting allows creating an unprotected access point without a password. **This is convenient for quick access but exposes the device to anyone within WiFi range**, potentially allowing unauthorized access to all WebServer functionality if HTTP authentication is also disabled. +- **Automatic credential generation**: Credentials are automatically generated when empty: + - **AP Password**: Generated when empty (unless "AP Open Network" is enabled) + - **HTTP Auth**: Generated when auth is enabled but username or password are empty + - Generated credentials are 12 alphanumeric characters (~71 bits of entropy) and persisted immediately + - User-set credentials are preserved (the system only replaces empty credentials, not weak user-chosen passwords) + - Check Settings > Web Server to view the generated credentials +- File operations are restricted to `/data` and `/sdcard` paths +- Path traversal attacks are blocked (e.g., `../` is rejected) +- Mount points cannot be deleted +- System apps cannot be uninstalled via the API + +## Configuration + +Settings are stored in the WebServer settings file and can be configured via **Settings > Web Server**: + +| Setting | Description | Default | +|---------|-------------|---------| +| WiFi Mode | Station (connect to existing network) or Access Point (create own network) | Station | +| AP Open Network | Create an open AP without password protection | Disabled | +| AP Password | Password for Access Point mode (WPA2, 8-63 chars). Disabled when Open Network is enabled. | Auto-generated | +| Web Server Enabled | Whether the HTTP server is running | Disabled | +| Require Authentication | Enable HTTP Basic Authentication | Disabled | +| Username | Authentication username (when auth enabled) | Auto-generated | +| Password | Authentication password (when auth enabled) | Auto-generated | + +**Note:** The system automatically generates secure credentials when they are empty. Generated credentials are 12-character alphanumeric strings with ~71 bits of entropy. See **Security Considerations** for details. + +**Note:** WiFi Station credentials are managed separately via the WiFi settings menu. + +## Statusbar Icons + +When the WebServer is running, a statusbar icon indicates the WiFi mode: +- `webserver_ap_white.png` - Access Point mode +- `webserver_station_white.png` - Station mode + +## Events + +The WebServer publishes events: +- `WebServerStarted` - Fired when the HTTP server starts +- `WebServerStopped` - Fired when the HTTP server stops +- `WebServerSettingsChanged` - Fired when settings are modified + +## Troubleshooting + +### "No slots left for registering handler" + +The ESP-IDF HTTP server has a limit on URI handlers. The WebServer configures this dynamically based on the number of handlers needed, but if you see this error, check `CONFIG_HTTPD_MAX_URI_HANDLERS` in sdkconfig. + +### 404 for dashboard.html + +Ensure the `dashboard.html` file exists in `/data/webserver/`. Run the asset sync operation or copy files manually. + +### Screenshot fails + +- Verify `TT_FEATURE_SCREENSHOT_ENABLED` is defined +- Check available heap memory (screenshot requires ~width*height*3 bytes) +- Ensure the save location (SD card or `/data`) is writable +- Screenshots are saved as `webscreenshot1.png`, `webscreenshot2.png`, etc. up to 9999 + +### File upload fails + +- Check file size is under 10MB limit +- Verify the destination path is writable +- Ensure the parent directory exists + +### Asset sync fails + +- Check SD card is properly mounted and writable +- Verify sufficient space on destination (Data or SD card) +- Check logs for specific file copy errors +- Maximum directory depth is 16 levels +- If sync hangs during boot, the SD card may be slow or corrupted diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index b4895b72..0f78a544 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -27,6 +27,7 @@ if (DEFINED ENV{ESP_IDF_VERSION}) vfs fatfs lwip + spi_flash ) if ("${IDF_TARGET}" STREQUAL "esp32s3") list(APPEND REQUIRES_LIST esp_tinyusb) diff --git a/Tactility/Include/Tactility/network/HttpServer.h b/Tactility/Include/Tactility/network/HttpServer.h index c15fea93..27dd8ee4 100644 --- a/Tactility/Include/Tactility/network/HttpServer.h +++ b/Tactility/Include/Tactility/network/HttpServer.h @@ -52,10 +52,10 @@ public: address(address), stackSize(stackSize), matchUri(matchUri), - handlers(handlers) + handlers(std::move(handlers)) {} - void start(); + bool start(); void stop(); diff --git a/Tactility/Include/Tactility/service/webserver/AssetVersion.h b/Tactility/Include/Tactility/service/webserver/AssetVersion.h new file mode 100644 index 00000000..e96d9881 --- /dev/null +++ b/Tactility/Include/Tactility/service/webserver/AssetVersion.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +namespace tt::service::webserver { + +/** + * @brief Asset version tracking for web server content synchronization + * + * Manages version.json files in both Data partition and SD card to ensure + * proper synchronization and recovery of web assets. + */ +struct AssetVersion { + uint32_t version; // Integer version number + + AssetVersion() : version(0) {} + explicit AssetVersion(uint32_t v) : version(v) {} +}; + +/** + * @brief Load asset version from Data partition + * @param[out] version The version structure to populate + * @return true if version was loaded successfully + */ +bool loadDataVersion(AssetVersion& version); + +/** + * @brief Load asset version from SD card + * @param[out] version The version structure to populate + * @return true if version was loaded successfully + */ +bool loadSdVersion(AssetVersion& version); + +/** + * @brief Save asset version to Data partition + * @param[in] version The version to save + * @return true if version was saved successfully + */ +bool saveDataVersion(const AssetVersion& version); + +/** + * @brief Save asset version to SD card + * @param[in] version The version to save + * @return true if version was saved successfully + */ +bool saveSdVersion(const AssetVersion& version); + +/** + * @brief Check if Data partition has any web assets + * @return true if Data partition contains web assets + */ +bool hasDataAssets(); + +/** + * @brief Check if SD card has any web assets + * @return true if SD card contains web assets backup + */ +bool hasSdAssets(); + +/** + * @brief Synchronize assets between Data partition and SD card based on version + * + * Logic: + * - If Data is empty: Copy from SD card (recovery mode) + * - If SD version > Data version: Copy SD -> Data (firmware update) + * - If Data version > SD version: Copy Data -> SD (backup customizations) + * + * @return true if sync completed successfully + */ +bool syncAssets(); + +} // namespace tt::service::webserver diff --git a/Tactility/Include/Tactility/settings/WebServerSettings.h b/Tactility/Include/Tactility/settings/WebServerSettings.h new file mode 100644 index 00000000..96abdcfc --- /dev/null +++ b/Tactility/Include/Tactility/settings/WebServerSettings.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include + +namespace tt::settings::webserver { + +enum class WiFiMode : uint8_t { + Station = 0, // Connect to existing WiFi network + AccessPoint = 1 // Create own WiFi network +}; + +struct WebServerSettings { + // WiFi Configuration + bool wifiEnabled = false; // Enable/disable WiFi entirely + WiFiMode wifiMode = WiFiMode::Station; // Station or Access Point + + // Access Point Mode Settings + std::string apSsid{}; // Default: "Tactility-XXXX" (last 4 of MAC) + std::string apPassword{}; // Password for WPA2 (8-63 chars) + bool apOpenNetwork = false; // If true, create open network (no password) + uint8_t apChannel = 1; // 1-13 + + // Web Server Settings + bool webServerEnabled = false; + uint16_t webServerPort = 80; // Default: 80 + + // Optional HTTP Basic Auth + bool webServerAuthEnabled = false; + std::string webServerUsername{}; + std::string webServerPassword{}; +}; + +/** + * @brief Load web server settings from persistent storage + * @param[out] settings The settings structure to populate + * @return true if settings were loaded successfully, false otherwise + */ +bool load(WebServerSettings& settings); + +/** + * @brief Get default web server settings + * @return Default settings structure + */ +WebServerSettings getDefault(); + +/** + * @brief Load settings or return defaults if loading fails + * @return Settings structure (either loaded or default) + */ +WebServerSettings loadOrGetDefault(); + +/** + * @brief Save web server settings to persistent storage + * @param[in] settings The settings to save + * @return true if settings were saved successfully, false otherwise + */ +bool save(const WebServerSettings& settings); + +/** + * @brief Generate default AP SSID based on device MAC address + * @return SSID string in format "Tactility-XXXX" + */ +std::string generateDefaultApSsid(); + +} diff --git a/Tactility/Private/Tactility/service/webserver/WebServerService.h b/Tactility/Private/Tactility/service/webserver/WebServerService.h new file mode 100644 index 00000000..a27dc525 --- /dev/null +++ b/Tactility/Private/Tactility/service/webserver/WebServerService.h @@ -0,0 +1,105 @@ +#pragma once +#ifdef ESP_PLATFORM + +#include +#include +#include +#include + +#include +#include +#include + +namespace tt::service::webserver { + +enum class WebServerEvent { + /** WebServer settings have been modified (WiFi/HTTP credentials, enable/disable states) */ + WebServerSettingsChanged, + /** HTTP server has started and is accepting connections */ + WebServerStarted, + /** HTTP server has stopped and is no longer accepting connections */ + WebServerStopped +}; + +/** + * @brief Web server service with resilient asset architecture + * + * Provides: + * - Core HTML endpoints (hardcoded in firmware) + * - Dynamic asset serving from Data partition + * - SD card fallback + * - Asset synchronization + */ +class WebServerService final : public Service { +private: + mutable RecursiveMutex mutex; + std::unique_ptr httpServer; + PubSub::SubscriptionHandle settingsEventSubscription = nullptr; + std::shared_ptr> pubsub = std::make_shared>(); + int8_t statusbarIconId = -1; // Statusbar icon for WebServer state + + // AP mode WiFi management + esp_netif_t* apNetif = nullptr; + bool apWifiInitialized = false; + + bool startApMode(); + void stopApMode(); + + // Core HTML endpoints (hardcoded in firmware) + static esp_err_t handleRoot(httpd_req_t* request); + static esp_err_t handleSync(httpd_req_t* request); + static esp_err_t handleReboot(httpd_req_t* request); + + // File browser endpoints + static esp_err_t handleFileBrowser(httpd_req_t* request); + static esp_err_t handleFsList(httpd_req_t* request); + static esp_err_t handleFsTree(httpd_req_t* request); + static esp_err_t handleFsDownload(httpd_req_t* request); + static esp_err_t handleFsMkdir(httpd_req_t* request); + static esp_err_t handleFsDelete(httpd_req_t* request); + static esp_err_t handleFsRename(httpd_req_t* request); + static esp_err_t handleFsUpload(httpd_req_t* request); + // Consolidated dispatch handlers to reduce URI handler table usage + static esp_err_t handleFsGenericGet(httpd_req_t* request); + static esp_err_t handleFsGenericPost(httpd_req_t* request); + // Admin dispatcher to consolidate small POST endpoints (sync/reboot) + static esp_err_t handleAdminPost(httpd_req_t* request); + + // API endpoints + static esp_err_t handleApiGet(httpd_req_t* request); + static esp_err_t handleApiPost(httpd_req_t* request); + static esp_err_t handleApiPut(httpd_req_t* request); + static esp_err_t handleApiSysinfo(httpd_req_t* request); + static esp_err_t handleApiApps(httpd_req_t* request); + static esp_err_t handleApiAppsRun(httpd_req_t* request); + static esp_err_t handleApiAppsUninstall(httpd_req_t* request); + static esp_err_t handleApiAppsInstall(httpd_req_t* request); + static esp_err_t handleApiWifi(httpd_req_t* request); + static esp_err_t handleApiScreenshot(httpd_req_t* request); + + // Dynamic asset serving + static esp_err_t handleAssets(httpd_req_t* request); + + bool startServer(); + void stopServer(); + +public: + + bool onStart(ServiceContext& service) override; + void onStop(ServiceContext& service) override; + + void setEnabled(bool enabled); + bool isEnabled() const; + + std::shared_ptr> getPubsub() const { return pubsub; } +}; + +// Global accessor for controlling the WebServer service +void setWebServerEnabled(bool enabled); + +// Get the pubsub for subscribing to WebServer events +std::shared_ptr> getPubsub(); + +} // namespace + +#endif diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 4f4cd4ed..e6b0ed4a 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -60,6 +60,9 @@ namespace service { #if TT_FEATURE_SCREENSHOT_ENABLED namespace screenshot { extern const ServiceManifest manifest; } #endif +#ifdef ESP_PLATFORM + namespace webserver { extern const ServiceManifest manifest; } +#endif } @@ -104,17 +107,17 @@ namespace app { namespace wifiapsettings { extern const AppManifest manifest; } namespace wificonnect { extern const AppManifest manifest; } namespace wifimanage { extern const AppManifest manifest; } +#ifdef ESP_PLATFORM + namespace webserversettings { extern const AppManifest manifest; } +#endif #if TT_FEATURE_SCREENSHOT_ENABLED - namespace screenshot { extern const AppManifest manifest; } + namespace screenshot { extern const AppManifest manifest; } #endif #ifdef ESP_PLATFORM namespace crashdiagnostics { extern const AppManifest manifest; } #endif } -#ifndef ESP_PLATFORM -#endif - // endregion // List of all apps excluding Boot app (as Boot app calls this function indirectly) @@ -146,6 +149,9 @@ static void registerInternalApps() { addAppManifest(app::wifiapsettings::manifest); addAppManifest(app::wificonnect::manifest); addAppManifest(app::wifimanage::manifest); +#ifdef ESP_PLATFORM + addAppManifest(app::webserversettings::manifest); +#endif #if defined(CONFIG_TINYUSB_MSC_ENABLED) && CONFIG_TINYUSB_MSC_ENABLED addAppManifest(app::usbsettings::manifest); @@ -263,6 +269,9 @@ static void registerAndStartPrimaryServices() { #if defined(CONFIG_TT_WIFI_ENABLED) && !defined(CONFIG_ESP_WIFI_REMOTE_ENABLED) addService(service::espnow::manifest); #endif +#ifdef ESP_PLATFORM + addService(service::webserver::manifest); +#endif } void createTempDirectory(const std::string& rootPath) { diff --git a/Tactility/Source/app/webserversettings/WebServerSettings.cpp b/Tactility/Source/app/webserversettings/WebServerSettings.cpp new file mode 100644 index 00000000..68f8ca07 --- /dev/null +++ b/Tactility/Source/app/webserversettings/WebServerSettings.cpp @@ -0,0 +1,408 @@ +#ifdef ESP_PLATFORM + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace tt::app::webserversettings { + +static const auto LOGGER = tt::Logger("WebServerSettingsApp"); + +class WebServerSettingsApp final : public App { + + settings::webserver::WebServerSettings wsSettings; + settings::webserver::WebServerSettings originalSettings; + bool updated = false; + bool wifiSettingsChanged = false; + bool webServerEnabledChanged = false; + lv_obj_t* dropdownWifiMode = nullptr; + lv_obj_t* textAreaApPassword = nullptr; + lv_obj_t* switchApOpenNetwork = nullptr; + lv_obj_t* switchWebServerEnabled = nullptr; + lv_obj_t* switchWebServerAuthEnabled = nullptr; + lv_obj_t* textAreaWebServerUsername = nullptr; + lv_obj_t* textAreaWebServerPassword = nullptr; + lv_obj_t* labelUrl = nullptr; + lv_obj_t* labelUrlValue = nullptr; + + static void onWifiModeChanged(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + auto* dropdown = static_cast(lv_event_get_target(e)); + auto index = lv_dropdown_get_selected(dropdown); + getMainDispatcher().dispatch([app, index] { + app->wsSettings.wifiMode = static_cast(index); + app->updated = true; + app->wifiSettingsChanged = true; + app->updateUrlDisplay(); + }); + } + + static void onWebServerEnabledSwitch(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + bool enabled = lv_obj_has_state(app->switchWebServerEnabled, LV_STATE_CHECKED); + getMainDispatcher().dispatch([app, enabled] { + app->wsSettings.webServerEnabled = enabled; + app->updated = true; + app->webServerEnabledChanged = true; + app->updateUrlDisplay(); + }); + } + + static void onWebServerAuthEnabledSwitch(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + bool enabled = lv_obj_has_state(app->switchWebServerAuthEnabled, LV_STATE_CHECKED); + + if (app->textAreaWebServerUsername && app->textAreaWebServerPassword) { + if (enabled) { + lv_obj_remove_state(app->textAreaWebServerUsername, LV_STATE_DISABLED); + lv_obj_add_flag(app->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); + + lv_obj_remove_state(app->textAreaWebServerPassword, LV_STATE_DISABLED); + lv_obj_add_flag(app->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); + } else { + lv_obj_add_state(app->textAreaWebServerUsername, LV_STATE_DISABLED); + lv_obj_remove_flag(app->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); + + lv_obj_add_state(app->textAreaWebServerPassword, LV_STATE_DISABLED); + lv_obj_remove_flag(app->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); + } + } + + getMainDispatcher().dispatch([app, enabled] { + app->wsSettings.webServerAuthEnabled = enabled; + app->updated = true; + }); + } + + static void onCredentialChanged(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + getMainDispatcher().dispatch([app] { + app->updated = true; + }); + } + + static void onApPasswordChanged(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + getMainDispatcher().dispatch([app] { + app->updated = true; + app->wifiSettingsChanged = true; + }); + } + + static void onApOpenNetworkSwitch(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + bool openNetwork = lv_obj_has_state(app->switchApOpenNetwork, LV_STATE_CHECKED); + + if (app->textAreaApPassword) { + if (openNetwork) { + lv_obj_add_state(app->textAreaApPassword, LV_STATE_DISABLED); + lv_obj_remove_flag(app->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); + } else { + lv_obj_remove_state(app->textAreaApPassword, LV_STATE_DISABLED); + lv_obj_add_flag(app->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); + } + } + + getMainDispatcher().dispatch([app, openNetwork] { + app->wsSettings.apOpenNetwork = openNetwork; + app->updated = true; + app->wifiSettingsChanged = true; + }); + } + + static void onSyncAssets(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + auto* btn = static_cast(lv_event_get_target_obj(e)); + lv_obj_add_state(btn, LV_STATE_DISABLED); + LOGGER.info("Manual asset sync triggered"); + + getMainDispatcher().dispatch([app, btn]{ + bool success = service::webserver::syncAssets(); + if (success) { + LOGGER.info("Asset sync completed successfully"); + } else { + LOGGER.error("Asset sync failed"); + } + // Only re-enable if button still exists (user hasn't navigated away) + if (lv_obj_is_valid(btn)) { + lv_obj_remove_state(btn, LV_STATE_DISABLED); + } + }); + } + + void updateUrlDisplay() { + if (!labelUrlValue) return; + + if (!wsSettings.webServerEnabled) { + lv_label_set_text(labelUrlValue, "Disabled"); + return; + } + + std::string url = "http://"; + + if (wsSettings.wifiMode == settings::webserver::WiFiMode::AccessPoint) { + // AP mode - always 192.168.4.1 + url += "192.168.4.1"; + } else { + // Station mode - try to get actual IP + esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (netif != nullptr) { + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) { + char ip_str[16]; + snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); + url += ip_str; + } else { + url = "Connecting..."; + } + } else { + url = "Not connected"; + } + } + + if (url.starts_with("http://")) { + if (wsSettings.webServerPort != 80) { + url += ":" + std::to_string(wsSettings.webServerPort); + } + } + + lv_label_set_text(labelUrlValue, url.c_str()); + } + +public: + void onCreate(TT_UNUSED AppContext& app) override { + wsSettings = settings::webserver::loadOrGetDefault(); + originalSettings = wsSettings; + } + + void onShow(AppContext& app, lv_obj_t* parent) override { + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + lv_obj_t* toolbar = lvgl::toolbar_create(parent, app); + + // Web Server Enable toggle + switchWebServerEnabled = lvgl::toolbar_add_switch_action(toolbar); + if (wsSettings.webServerEnabled) { + lv_obj_add_state(switchWebServerEnabled, LV_STATE_CHECKED); + } + lv_obj_add_event_cb(switchWebServerEnabled, onWebServerEnabledSwitch, LV_EVENT_VALUE_CHANGED, this); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + // WiFi Mode dropdown + auto* wifi_mode_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(wifi_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(wifi_mode_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(wifi_mode_wrapper, 0, LV_STATE_DEFAULT); + auto* wifi_mode_label = lv_label_create(wifi_mode_wrapper); + lv_label_set_text(wifi_mode_label, "WiFi Mode"); + lv_obj_align(wifi_mode_label, LV_ALIGN_LEFT_MID, 0, 0); + dropdownWifiMode = lv_dropdown_create(wifi_mode_wrapper); + lv_obj_align(dropdownWifiMode, LV_ALIGN_RIGHT_MID, 0, 0); + lv_dropdown_set_options(dropdownWifiMode, "Station\nAccess Point"); + lv_dropdown_set_selected(dropdownWifiMode, static_cast(wsSettings.wifiMode)); + lv_obj_add_event_cb(dropdownWifiMode, onWifiModeChanged, LV_EVENT_VALUE_CHANGED, this); + + // AP Open Network toggle + auto* ap_open_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ap_open_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ap_open_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ap_open_wrapper, 0, LV_STATE_DEFAULT); + auto* ap_open_label = lv_label_create(ap_open_wrapper); + lv_label_set_text(ap_open_label, "AP Open Network"); + lv_obj_align(ap_open_label, LV_ALIGN_LEFT_MID, 0, 0); + switchApOpenNetwork = lv_switch_create(ap_open_wrapper); + if (wsSettings.apOpenNetwork) lv_obj_add_state(switchApOpenNetwork, LV_STATE_CHECKED); + lv_obj_align(switchApOpenNetwork, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, this); + + // AP Password + auto* ap_pass_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ap_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ap_pass_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ap_pass_wrapper, 0, LV_STATE_DEFAULT); + auto* ap_pass_label = lv_label_create(ap_pass_wrapper); + lv_label_set_text(ap_pass_label, "AP Password"); + lv_obj_align(ap_pass_label, LV_ALIGN_LEFT_MID, 0, 0); + textAreaApPassword = lv_textarea_create(ap_pass_wrapper); + lv_obj_set_width(textAreaApPassword, 120); + lv_obj_align(textAreaApPassword, LV_ALIGN_RIGHT_MID, 0, 0); + lv_textarea_set_one_line(textAreaApPassword, true); + lv_textarea_set_max_length(textAreaApPassword, 64); + lv_textarea_set_password_mode(textAreaApPassword, true); + lv_textarea_set_text(textAreaApPassword, wsSettings.apPassword.c_str()); + lv_obj_add_event_cb(textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, this); + // Disable password field if open network is enabled + if (wsSettings.apOpenNetwork) { + lv_obj_add_state(textAreaApPassword, LV_STATE_DISABLED); + lv_obj_remove_flag(textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); + } + + // Web Server Authentication Enable toggle + auto* ws_auth_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ws_auth_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ws_auth_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ws_auth_wrapper, 0, LV_STATE_DEFAULT); + auto* ws_auth_label = lv_label_create(ws_auth_wrapper); + lv_label_set_text(ws_auth_label, "Require Authentication"); + lv_obj_align(ws_auth_label, LV_ALIGN_LEFT_MID, 0, 0); + switchWebServerAuthEnabled = lv_switch_create(ws_auth_wrapper); + if (wsSettings.webServerAuthEnabled) lv_obj_add_state(switchWebServerAuthEnabled, LV_STATE_CHECKED); + lv_obj_align(switchWebServerAuthEnabled, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, this); + + // WebServer Username + auto* ws_user_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ws_user_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ws_user_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ws_user_wrapper, 0, LV_STATE_DEFAULT); + auto* ws_user_label = lv_label_create(ws_user_wrapper); + lv_label_set_text(ws_user_label, "Username"); + lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0); + textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper); + lv_obj_set_width(textAreaWebServerUsername, 120); + lv_obj_align(textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0); + lv_textarea_set_one_line(textAreaWebServerUsername, true); + lv_textarea_set_max_length(textAreaWebServerUsername, 32); + lv_textarea_set_text(textAreaWebServerUsername, wsSettings.webServerUsername.c_str()); + lv_obj_add_event_cb(textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, this); + + // WebServer Password + auto* ws_pass_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ws_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ws_pass_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ws_pass_wrapper, 0, LV_STATE_DEFAULT); + auto* ws_pass_label = lv_label_create(ws_pass_wrapper); + lv_label_set_text(ws_pass_label, "Password"); + lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0); + textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper); + lv_obj_set_width(textAreaWebServerPassword, 120); + lv_obj_align(textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0); + lv_textarea_set_one_line(textAreaWebServerPassword, true); + lv_textarea_set_max_length(textAreaWebServerPassword, 64); + lv_textarea_set_password_mode(textAreaWebServerPassword, true); + lv_textarea_set_text(textAreaWebServerPassword, wsSettings.webServerPassword.c_str()); + lv_obj_add_event_cb(textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, this); + + // URL Display + auto* url_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(url_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(url_wrapper, 10, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(url_wrapper, 1, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(url_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_flex_cross_place(url_wrapper, LV_FLEX_ALIGN_START, 0); + + labelUrl = lv_label_create(url_wrapper); + lv_label_set_text(labelUrl, "Web Server URL:"); + + labelUrlValue = lv_label_create(url_wrapper); + lv_obj_set_style_text_color(labelUrlValue, lv_palette_main(LV_PALETTE_BLUE), 0); + + updateUrlDisplay(); + + // Sync Assets button + auto* sync_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(sync_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(sync_wrapper, 10, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(sync_wrapper, 1, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(sync_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_flex_cross_place(sync_wrapper, LV_FLEX_ALIGN_START, 0); + + auto* sync_label = lv_label_create(sync_wrapper); + lv_label_set_text(sync_label, "Asset Synchronization"); + lv_obj_set_style_text_font(sync_label, &lv_font_montserrat_14, 0); + + auto* sync_info = lv_label_create(sync_wrapper); + lv_label_set_long_mode(sync_info, LV_LABEL_LONG_WRAP); + lv_obj_set_width(sync_info, LV_PCT(95)); + lv_obj_set_style_text_color(sync_info, lv_palette_main(LV_PALETTE_GREY), 0); + lv_label_set_text(sync_info, "Sync web assets between Data partition and SD card backup"); + + auto* sync_button = lv_btn_create(sync_wrapper); + lv_obj_set_width(sync_button, LV_SIZE_CONTENT); + auto* sync_button_label = lv_label_create(sync_button); + lv_label_set_text(sync_button_label, "Sync Assets Now"); + lv_obj_center(sync_button_label); + lv_obj_add_event_cb(sync_button, onSyncAssets, LV_EVENT_CLICKED, this); + + // Info text + auto* info_label = lv_label_create(main_wrapper); + lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP); + lv_obj_set_width(info_label, LV_PCT(95)); + lv_obj_set_style_text_color(info_label, lv_palette_main(LV_PALETTE_GREY), 0); + lv_label_set_text(info_label, + "WiFi Station credentials are managed separately.\n" + "Use the WiFi menu to connect to networks.\n\n" + "AP mode uses the password configured above."); + } + + void onHide(TT_UNUSED AppContext& app) override { + if (updated) { + // Read values from text areas + if (textAreaApPassword) { + wsSettings.apPassword = lv_textarea_get_text(textAreaApPassword); + } + if (textAreaWebServerUsername) { + wsSettings.webServerUsername = lv_textarea_get_text(textAreaWebServerUsername); + } + if (textAreaWebServerPassword) { + wsSettings.webServerPassword = lv_textarea_get_text(textAreaWebServerPassword); + } + + // Save to flash only (settings sync at boot handles SD restore) + const auto copy = wsSettings; + const bool wifiChanged = wifiSettingsChanged; + const bool webServerChanged = webServerEnabledChanged; + + getMainDispatcher().dispatch([copy, wifiChanged, webServerChanged]{ + // Save to flash (fast, low memory pressure) + if (!settings::webserver::save(copy)) { + LOGGER.warn("Failed to persist WebServer settings; changes may be lost on reboot"); + } + + // Publish event immediately after save so WebServer cache refreshes BEFORE requests arrive + service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); + + // Only reconnect WiFi if WiFi settings actually changed + if (wifiChanged) { + LOGGER.info("WiFi mode changed to {}", copy.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station"); + } + + // Control WebServer service immediately + if (webServerChanged) { + LOGGER.info("WebServer {}", copy.webServerEnabled ? "enabling..." : "disabling..."); + service::webserver::setWebServerEnabled(copy.webServerEnabled); + } + }); + } + } +}; + +extern const AppManifest manifest = { + .appId = "WebServerSettings", + .appName = "Web Server", + .appIcon = TT_ASSETS_APP_ICON_SETTINGS, + .appCategory = Category::System, + .createApp = create +}; + +} + +#endif diff --git a/Tactility/Source/network/HttpServer.cpp b/Tactility/Source/network/HttpServer.cpp index 03792700..d6b9b673 100644 --- a/Tactility/Source/network/HttpServer.cpp +++ b/Tactility/Source/network/HttpServer.cpp @@ -9,39 +9,58 @@ namespace tt::network { static const auto LOGGER = Logger("HttpServer"); +static constexpr size_t INTERNAL_URI_HANDLER_COUNT = 2; + bool HttpServer::startInternal() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = stackSize; config.server_port = port; config.uri_match_fn = matchUri; + config.max_uri_handlers = handlers.size() + INTERNAL_URI_HANDLER_COUNT; if (httpd_start(&server, &config) != ESP_OK) { LOGGER.error("Failed to start http server on port {}", port); return false; } + bool allRegistered = true; for (std::vector::reference handler : handlers) { - httpd_register_uri_handler(server, &handler); + if (httpd_register_uri_handler(server, &handler) != ESP_OK) { + LOGGER.error("Failed to register URI handler: {}", handler.uri); + allRegistered = false; + } + } + if (!allRegistered) { + httpd_stop(server); + server = nullptr; + return false; } LOGGER.info("Started on port {}", config.server_port); - return true; } void HttpServer::stopInternal() { LOGGER.info("Stopping server"); - if (server != nullptr && httpd_stop(server) != ESP_OK) { - LOGGER.warn("Error while stopping"); - server = nullptr; + if (server != nullptr) { + if (httpd_stop(server) == ESP_OK) { + server = nullptr; + } else { + LOGGER.warn("Error while stopping"); + } } } -void HttpServer::start() { +bool HttpServer::start() { auto lock = mutex.asScopedLock(); lock.lock(); - startInternal(); + if (isStarted()) { + LOGGER.warn("Already started"); + return true; + } + + return startInternal(); } void HttpServer::stop() { @@ -50,6 +69,7 @@ void HttpServer::stop() { if (!isStarted()) { LOGGER.warn("Not started"); + return; } stopInternal(); diff --git a/Tactility/Source/network/HttpdReq.cpp b/Tactility/Source/network/HttpdReq.cpp index d142f2fb..49b211f5 100644 --- a/Tactility/Source/network/HttpdReq.cpp +++ b/Tactility/Source/network/HttpdReq.cpp @@ -83,18 +83,35 @@ std::unique_ptr receiveByteArray(httpd_req_t* request, size_t length, si return nullptr; } + constexpr int MAX_TIMEOUT_RETRIES = 5; + int timeout_retries = 0; while (bytesRead < length) { size_t read_size = length - bytesRead; - size_t bytes_received = httpd_req_recv(request, buffer + bytesRead, read_size); + int bytes_received = httpd_req_recv(request, buffer + bytesRead, read_size); + if (bytes_received == HTTPD_SOCK_ERR_TIMEOUT) { + // Timeout - retry with backoff + timeout_retries++; + if (timeout_retries >= MAX_TIMEOUT_RETRIES) { + LOGGER.warn("Recv timeout after {} retries, read {}/{} bytes", timeout_retries, bytesRead, length); + free(buffer); + return nullptr; + } + LOGGER.warn("Recv timeout, retry {}/{}", timeout_retries, MAX_TIMEOUT_RETRIES); + vTaskDelay(pdMS_TO_TICKS(100 * timeout_retries)); // Exponential backoff + continue; + } if (bytes_received <= 0) { LOGGER.warn("Received error {} after reading {}/{} bytes", bytes_received, bytesRead, length); + free(buffer); return nullptr; } + // Successful read - reset timeout counter + timeout_retries = 0; bytesRead += bytes_received; } - return std::unique_ptr(std::move(buffer)); + return std::unique_ptr(buffer); } std::string receiveTextUntil(httpd_req_t* request, const std::string& terminator) { @@ -131,7 +148,7 @@ std::map parseContentDisposition(const std::vectorsubstr(prefix.size()); auto parts = string::split(parseable, "; "); - for (auto part : parts) { + for (const auto& part : parts) { auto key_value = string::split(part, "="); if (key_value.size() == 2) { // Trim trailing newlines @@ -150,7 +167,7 @@ std::map parseContentDisposition(const std::vector + +#include +#include + +#include +#include +#include +#include +#include + +namespace tt::service::webserver { + +static const auto LOGGER = tt::Logger("AssetVersion"); +constexpr auto* DATA_VERSION_FILE = "/data/webserver/version.json"; +constexpr auto* SD_VERSION_FILE = "/sdcard/tactility/webserver/version.json"; +constexpr auto* DATA_ASSETS_DIR = "/data/webserver"; +constexpr auto* SD_ASSETS_DIR = "/sdcard/tactility/webserver"; + +static bool loadVersionFromFile(const char* path, AssetVersion& version) { + if (!file::isFile(path)) { + LOGGER.warn("Version file not found: {}", path); + return false; + } + + // Read file content + std::string content; + { + auto lock = file::getLock(path); + lock->lock(portMAX_DELAY); + + FILE* fp = fopen(path, "r"); + if (!fp) { + LOGGER.error("Failed to open version file: {}", path); + lock->unlock(); + return false; + } + + char buffer[256]; + size_t bytesRead = fread(buffer, 1, sizeof(buffer) - 1, fp); + bool readError = ferror(fp) != 0; + fclose(fp); + lock->unlock(); + + if (readError) { + LOGGER.error("Error reading version file: {}", path); + return false; + } + if (bytesRead == 0) { + LOGGER.error("Version file is empty: {}", path); + return false; + } + buffer[bytesRead] = '\0'; + content = buffer; + } + + // Parse JSON + cJSON* json = cJSON_Parse(content.c_str()); + if (json == nullptr) { + LOGGER.error("Failed to parse version JSON: {}", path); + return false; + } + + cJSON* versionItem = cJSON_GetObjectItem(json, "version"); + if (versionItem == nullptr || !cJSON_IsNumber(versionItem)) { + LOGGER.error("Invalid version JSON format: {}", path); + cJSON_Delete(json); + return false; + } + + double versionValue = versionItem->valuedouble; + if (versionValue < 0 || versionValue > UINT32_MAX) { + LOGGER.error("Version out of valid range [0, {}]: {}", UINT32_MAX, path); + cJSON_Delete(json); + return false; + } + version.version = static_cast(versionValue); + cJSON_Delete(json); + + LOGGER.info("Loaded version {} from {}", version.version, path); + return true; +} + +static bool saveVersionToFile(const char* path, const AssetVersion& version) { + // Create directory if it doesn't exist + std::string dirPath(path); + size_t lastSlash = dirPath.find_last_of('/'); + if (lastSlash != std::string::npos) { + dirPath = dirPath.substr(0, lastSlash); + if (!file::isDirectory(dirPath.c_str())) { + if (!file::findOrCreateDirectory(dirPath.c_str(), 0755)) { + LOGGER.error("Failed to create directory: {}", dirPath); + return false; + } + } + } + + // Create JSON + cJSON* json = cJSON_CreateObject(); + if (json == nullptr) { + LOGGER.error("Failed to create JSON object for version"); + return false; + } + cJSON_AddNumberToObject(json, "version", version.version); + + char* jsonString = cJSON_Print(json); + if (jsonString == nullptr) { + LOGGER.error("Failed to serialize version JSON"); + cJSON_Delete(json); + return false; + } + + // Write to file + bool success = false; + { + auto lock = file::getLock(path); + lock->lock(portMAX_DELAY); + + FILE* fp = fopen(path, "w"); + if (fp) { + size_t len = strlen(jsonString); + size_t written = fwrite(jsonString, 1, len, fp); + success = (written == len); + if (success) { + if (fflush(fp) != 0) { + LOGGER.error("Failed to flush version file: {}", path); + success = false; + } else { + int fd = fileno(fp); + if (fd >= 0 && fsync(fd) != 0) { + LOGGER.error("Failed to fsync version file: {}", path); + success = false; + } + } + } + fclose(fp); + } + lock->unlock(); + } + + cJSON_free(jsonString); + cJSON_Delete(json); + + if (success) { + LOGGER.info("Saved version {} to {}", version.version, path); + } else { + LOGGER.error("Failed to write version file: {}", path); + } + + return success; +} + +bool loadDataVersion(AssetVersion& version) { + return loadVersionFromFile(DATA_VERSION_FILE, version); +} + +bool loadSdVersion(AssetVersion& version) { + return loadVersionFromFile(SD_VERSION_FILE, version); +} + +bool saveDataVersion(const AssetVersion& version) { + return saveVersionToFile(DATA_VERSION_FILE, version); +} + +bool saveSdVersion(const AssetVersion& version) { + return saveVersionToFile(SD_VERSION_FILE, version); +} + +bool hasDataAssets() { + return file::isDirectory(DATA_ASSETS_DIR); +} + +bool hasSdAssets() { + return file::isDirectory(SD_ASSETS_DIR); +} + +static bool copyDirectory(const char* src, const char* dst, int depth = 0) { + constexpr int MAX_DEPTH = 16; + if (depth >= MAX_DEPTH) { + LOGGER.error("Max directory depth exceeded: {}", src); + return false; + } + LOGGER.info("Copying directory: {} -> {}", src, dst); + + // Create destination directory + if (!file::isDirectory(dst)) { + if (!file::findOrCreateDirectory(dst, 0755)) { + LOGGER.error("Failed to create destination directory: {}", dst); + return false; + } + } + + // List source directory and copy each entry + bool copySuccess = true; + bool listSuccess = file::listDirectory(src, [&](const dirent& entry) { + // Skip "." and ".." entries (though listDirectory should already filter these) + if (strcmp(entry.d_name, ".") == 0 || strcmp(entry.d_name, "..") == 0) { + return; + } + + std::string srcPath = file::getChildPath(src, entry.d_name); + std::string dstPath = file::getChildPath(dst, entry.d_name); + + if (entry.d_type == file::TT_DT_DIR) { + // Recursively copy subdirectory + if (!copyDirectory(srcPath.c_str(), dstPath.c_str(), depth + 1)) { + copySuccess = false; + } + } else if (entry.d_type == file::TT_DT_REG) { + // Copy file using atomic temp file approach + auto lock = file::getLock(srcPath); + lock->lock(portMAX_DELAY); + + // Generate unique temp file path + std::string tempPath = std::format("{}.tmp.{}", dstPath, esp_random()); + + FILE* srcFile = fopen(srcPath.c_str(), "rb"); + if (!srcFile) { + LOGGER.error("Failed to open source file: {}", srcPath); + lock->unlock(); + copySuccess = false; + return; + } + + FILE* tempFile = fopen(tempPath.c_str(), "wb"); + if (!tempFile) { + LOGGER.error("Failed to create temp file: {}", tempPath); + fclose(srcFile); + lock->unlock(); + copySuccess = false; + return; + } + + // Copy in chunks + char buffer[512]; + size_t bytesRead; + bool fileCopySuccess = true; + while ((bytesRead = fread(buffer, 1, sizeof(buffer), srcFile)) > 0) { + size_t bytesWritten = fwrite(buffer, 1, bytesRead, tempFile); + if (bytesWritten != bytesRead) { + LOGGER.error("Failed to write to temp file: {}", tempPath); + fileCopySuccess = false; + copySuccess = false; + break; + } + } + + if (fileCopySuccess && ferror(srcFile)) { + LOGGER.error("Error reading source file: {}", srcPath); + fileCopySuccess = false; + copySuccess = false; + } + + fclose(srcFile); + + // Flush and sync temp file before closing + if (fileCopySuccess) { + if (fflush(tempFile) != 0) { + LOGGER.error("Failed to flush temp file: {}", tempPath); + fileCopySuccess = false; + copySuccess = false; + } else { + int fd = fileno(tempFile); + if (fd >= 0 && fsync(fd) != 0) { + LOGGER.error("Failed to fsync temp file: {}", tempPath); + fileCopySuccess = false; + copySuccess = false; + } + } + } + + fclose(tempFile); + + if (fileCopySuccess) { + // Atomically rename temp file to destination + if (rename(tempPath.c_str(), dstPath.c_str()) != 0) { + LOGGER.error("Failed to rename temp file {} to {}", tempPath, dstPath); + remove(tempPath.c_str()); + fileCopySuccess = false; + copySuccess = false; + } + } else { + // Clean up temp file on failure + remove(tempPath.c_str()); + } + + lock->unlock(); + + if (fileCopySuccess) { + LOGGER.info("Copied file: {}", entry.d_name); + } + } + }); + + if (!listSuccess) { + LOGGER.error("Failed to list source directory: {}", src); + return false; + } + + return copySuccess; +} + +bool syncAssets() { + LOGGER.info("Starting asset synchronization..."); + + // Check if Data partition and SD card exist + bool dataExists = hasDataAssets(); + bool sdExists = hasSdAssets(); + + // FIRST BOOT SCENARIO: Data has version 0, SD card is missing + if (dataExists && !sdExists) { + LOGGER.info("First boot - Data exists but SD card backup missing"); + LOGGER.warn("Skipping SD backup during boot - will be created on first settings save"); + LOGGER.warn("This avoids watchdog timeout if SD card is slow or corrupted"); + return true; // Don't block boot - defer copy to runtime + } + + // NO SD CARD: Just ensure Data has default structure + if (!sdExists) { + LOGGER.warn("No SD card available - creating default Data structure if needed"); + if (!dataExists) { + if (!file::findOrCreateDirectory(DATA_ASSETS_DIR, 0755)) { + LOGGER.error("Failed to create Data assets directory"); + return false; + } + AssetVersion defaultVersion(0); // Start at version 0 - SD card updates will be version 1+ + if (!saveDataVersion(defaultVersion)) { + LOGGER.error("Failed to save default Data version"); + return false; + } + LOGGER.info("Created default Data assets structure (version 0)"); + } + return true; + } + + // POST-FLASH RECOVERY: Data empty but SD card exists + if (!dataExists) { + LOGGER.info("Data partition empty - copying from SD card (recovery mode)"); + if (!copyDirectory(SD_ASSETS_DIR, DATA_ASSETS_DIR)) { + LOGGER.error("Failed to copy assets from SD card to Data"); + return false; + } + LOGGER.info("Recovery complete - assets restored from SD card"); + return true; + } + + // NORMAL OPERATION: Both exist - compare versions + AssetVersion dataVersion, sdVersion; + bool hasDataVer = loadDataVersion(dataVersion); + bool hasSdVer = loadSdVersion(sdVersion); + + if (!hasDataVer) { + LOGGER.warn("No Data version.json - assuming version 0"); + dataVersion.version = 0; + if (!saveDataVersion(dataVersion)) { + LOGGER.warn("Failed to save default Data version (non-fatal)"); + } + } + + if (!hasSdVer) { + LOGGER.warn("No SD version.json - assuming version 0"); + sdVersion.version = 0; + // DON'T save to SD during boot - defer to runtime + LOGGER.warn("Skipping SD version.json creation during boot - will be created on first settings save"); + } + + LOGGER.info("Version comparison - Data: {}, SD: {}", dataVersion.version, sdVersion.version); + + if (sdVersion.version > dataVersion.version) { + // Firmware update - copy SD -> Data + LOGGER.info("SD card newer (v{} > v{}) - copying assets SD -> Data (firmware update)", + sdVersion.version, dataVersion.version); + if (!copyDirectory(SD_ASSETS_DIR, DATA_ASSETS_DIR)) { + LOGGER.error("Failed to copy assets from SD to Data"); + return false; + } + LOGGER.info("Firmware update complete - assets updated from SD card"); + } else if (dataVersion.version > sdVersion.version) { + // User customization - backup Data -> SD + LOGGER.warn("Data newer (v{} > v{}) - deferring SD backup to avoid boot watchdog", + dataVersion.version, sdVersion.version); + LOGGER.warn("SD backup will occur on first WebServer settings save"); + return true; // Don't block boot - defer copy to runtime + } else { + LOGGER.info("Versions match (v{}) - no sync needed", dataVersion.version); + } + + return true; +} + +} // namespace + +#endif diff --git a/Tactility/Source/service/webserver/WebServerService.cpp b/Tactility/Source/service/webserver/WebServerService.cpp new file mode 100644 index 00000000..a8689059 --- /dev/null +++ b/Tactility/Source/service/webserver/WebServerService.cpp @@ -0,0 +1,1862 @@ +#ifdef ESP_PLATFORM + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#if TT_FEATURE_SCREENSHOT_ENABLED +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tt::service::webserver { + +static const auto LOGGER = tt::Logger("WebServerService"); + +// Helper to convert chip model enum to human-readable string +static const char* getChipModelName(esp_chip_model_t model) { + switch (model) { + case CHIP_ESP32: return "ESP32"; + case CHIP_ESP32S2: return "ESP32-S2"; + case CHIP_ESP32S3: return "ESP32-S3"; + case CHIP_ESP32C3: return "ESP32-C3"; + case CHIP_ESP32C2: return "ESP32-C2"; + case CHIP_ESP32C6: return "ESP32-C6"; + case CHIP_ESP32H2: return "ESP32-H2"; +#ifdef CHIP_ESP32P4 + case CHIP_ESP32P4: return "ESP32-P4"; +#endif +#ifdef CHIP_ESP32C5 + case CHIP_ESP32C5: return "ESP32-C5"; +#endif +#ifdef CHIP_ESP32C61 + case CHIP_ESP32C61: return "ESP32-C61"; +#endif + default: return "Unknown"; + } +} + +// Cached settings to avoid SD card reads on every HTTP request +static Mutex g_settingsMutex; +static settings::webserver::WebServerSettings g_cachedSettings; +static bool g_settingsCached = false; + +// Global instance pointer for controlling the service (atomic to prevent TOCTOU races) +static std::atomic g_webServerInstance{nullptr}; + +constexpr int MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MB limit + +static void publish_event(WebServerService* webserver, WebServerEvent event) { + webserver->getPubsub()->publish(event); +} + +std::shared_ptr> getPubsub() { + WebServerService* webserver = g_webServerInstance.load(); + if (webserver == nullptr) { + tt_crash("Service not running"); + } + + return webserver->getPubsub(); +} + +static bool secureCompare(const std::string& a, const std::string& b) { + size_t maxLen = std::max(a.size(), b.size()); + volatile unsigned char result = 0; + result |= (a.size() != b.size()); + for (size_t i = 0; i < maxLen; ++i) { + unsigned char ca = (i < a.size()) ? static_cast(a[i]) : 0; + unsigned char cb = (i < b.size()) ? static_cast(b[i]) : 0; + result |= ca ^ cb; + } + return result == 0; +} + +// Helper to send 401 Unauthorized response with WWW-Authenticate header +static esp_err_t sendUnauthorized(httpd_req_t* request, const char* message) { + httpd_resp_set_hdr(request, "WWW-Authenticate", "Basic realm=\"Tactility\""); + httpd_resp_send_err(request, HTTPD_401_UNAUTHORIZED, message); + return ESP_OK; // Response was sent successfully +} + +// Helper to validate HTTP Basic Auth on sensitive endpoints +// Returns ESP_OK with authPassed=true if auth succeeded or is disabled +// Returns ESP_OK with authPassed=false if auth failed (401 response already sent) +static esp_err_t validateRequestAuth(httpd_req_t* request, bool& authPassed) { + authPassed = false; + + // Copy settings under lock to avoid race with settings update callback + settings::webserver::WebServerSettings settings; + { + auto lock = g_settingsMutex.asScopedLock(); + lock.lock(); + settings = g_cachedSettings; + } + + if (!settings.webServerAuthEnabled) { + authPassed = true; + return ESP_OK; // Auth disabled, allow request + } + + // Get Authorization header + size_t auth_len = httpd_req_get_hdr_value_len(request, "Authorization"); + if (auth_len == 0) { + return sendUnauthorized(request, "Authorization required"); + } + + std::string auth_header(auth_len + 1, '\0'); + if (httpd_req_get_hdr_value_str(request, "Authorization", auth_header.data(), auth_len + 1) != ESP_OK) { + LOGGER.warn("Failed to read Authorization header"); + return sendUnauthorized(request, "Authorization required"); + } + auth_header.resize(auth_len); // Remove null terminator from string length + + // Check for "Basic " prefix + if (auth_header.rfind("Basic ", 0) != 0) { + LOGGER.warn("Authorization header is not Basic auth"); + return sendUnauthorized(request, "Basic authorization required"); + } + + // Extract base64 encoded credentials + std::string base64_creds = auth_header.substr(6); + + // Decode base64 using mbedtls (available in ESP-IDF) + size_t decoded_len = 0; + // First pass to get length + mbedtls_base64_decode(nullptr, 0, &decoded_len, + reinterpret_cast(base64_creds.c_str()), + base64_creds.length()); + + std::string decoded(decoded_len, '\0'); + size_t actual_len = 0; + int ret = mbedtls_base64_decode(reinterpret_cast(decoded.data()), + decoded_len, &actual_len, + reinterpret_cast(base64_creds.c_str()), + base64_creds.length()); + if (ret != 0) { + LOGGER.warn("Failed to decode base64 credentials"); + return sendUnauthorized(request, "Invalid credentials format"); + } + decoded.resize(actual_len); + + // Parse username:password + size_t colon_pos = decoded.find(':'); + if (colon_pos == std::string::npos) { + LOGGER.warn("Invalid credentials format (no colon separator)"); + return sendUnauthorized(request, "Invalid credentials format"); + } + + std::string username = decoded.substr(0, colon_pos); + std::string password = decoded.substr(colon_pos + 1); + + // Validate against cached settings + bool usernameMatch = secureCompare(username, settings.webServerUsername); + bool passwordMatch = secureCompare(password, settings.webServerPassword); + if (!usernameMatch || !passwordMatch) { + LOGGER.warn("Invalid credentials for user '{}'", username); + return sendUnauthorized(request, "Invalid credentials"); + } + + authPassed = true; + return ESP_OK; // Auth successful +} + +bool WebServerService::onStart(ServiceContext& service) { + LOGGER.info("Starting WebServer service..."); + + // Register global instance + g_webServerInstance.store(this); + + // Create statusbar icon (hidden initially, shown when server actually starts) + statusbarIconId = lvgl::statusbar_icon_add(); + lvgl::statusbar_icon_set_visibility(statusbarIconId, false); + + // Run asset synchronization on startup + if (!syncAssets()) { + LOGGER.warn("Asset sync failed, but continuing with available assets"); + } + + // Load and cache settings once at boot + bool serverEnabled; + { + auto lock = g_settingsMutex.asScopedLock(); + lock.lock(); + g_cachedSettings = settings::webserver::loadOrGetDefault(); + g_settingsCached = true; + serverEnabled = g_cachedSettings.webServerEnabled; + } + // Subscribe to settings change events to refresh cache + settingsEventSubscription = pubsub->subscribe([](WebServerEvent event) { + if (event == WebServerEvent::WebServerSettingsChanged) { + auto lock = g_settingsMutex.asScopedLock(); + lock.lock(); + g_cachedSettings = settings::webserver::loadOrGetDefault(); + g_settingsCached = true; + } + }); + + // Start HTTP server only if enabled in settings (default: OFF to save memory) + if (serverEnabled) { + LOGGER.info("WebServer enabled in settings, starting HTTP server..."); + setEnabled(true); + } else { + LOGGER.info("WebServer disabled in settings, NOT starting HTTP server (saves ~10KB RAM)"); + setEnabled(false); + } + + return true; +} + +void WebServerService::onStop(ServiceContext& service) { + g_webServerInstance.store(nullptr); + + pubsub->unsubscribe(settingsEventSubscription); + settingsEventSubscription = 0; + + setEnabled(false); + + // Remove statusbar icon + if (statusbarIconId >= 0) { + lvgl::statusbar_icon_remove(statusbarIconId); + statusbarIconId = -1; + } +} + +// region Enable/Disable + +void WebServerService::setEnabled(bool enabled) { + auto lock = mutex.asScopedLock(); + lock.lock(); + + if (enabled) { + if (!httpServer || !httpServer->isStarted()) { + startServer(); + } + } else { + if (httpServer && httpServer->isStarted()) { + stopServer(); + } + } +} + +bool WebServerService::isEnabled() const { + auto lock = mutex.asScopedLock(); + lock.lock(); + return httpServer && httpServer->isStarted(); +} + +// region AP Mode WiFi Management + +bool WebServerService::startApMode() { + // Copy settings locally + settings::webserver::WebServerSettings settings; + { + auto lock = g_settingsMutex.asScopedLock(); + lock.lock(); + settings = g_cachedSettings; + } + + if (settings.wifiMode != settings::webserver::WiFiMode::AccessPoint) { + LOGGER.info("Not in AP mode, skipping AP WiFi initialization"); + return true; // Not an error, just not needed + } + + LOGGER.info("Starting WiFi in Access Point mode..."); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + if (esp_wifi_init(&cfg) != ESP_OK) { + LOGGER.error("esp_wifi_init() failed"); + return false; + } + apWifiInitialized = true; + + // Create the AP network interface + apNetif = esp_netif_create_default_wifi_ap(); + if (apNetif == nullptr) { + LOGGER.error("esp_netif_create_default_wifi_ap() failed"); + esp_wifi_deinit(); + apWifiInitialized = false; + return false; + } + + if (esp_wifi_set_mode(WIFI_MODE_AP) != ESP_OK) { + LOGGER.error("esp_wifi_set_mode(AP) failed"); + stopApMode(); + return false; + } + + // Configure static IP for AP: 192.168.4.1/24 + esp_netif_ip_info_t ip_info; + memset(&ip_info, 0, sizeof(esp_netif_ip_info_t)); + ip_info.ip.addr = ipaddr_addr("192.168.4.1"); + ip_info.gw.addr = ipaddr_addr("192.168.4.1"); + ip_info.netmask.addr = ipaddr_addr("255.255.255.0"); + + if (esp_netif_dhcps_stop(apNetif) != ESP_OK) { + LOGGER.error("esp_netif_dhcps_stop() failed"); + stopApMode(); + return false; + } + + if (esp_netif_set_ip_info(apNetif, &ip_info) != ESP_OK) { + LOGGER.error("esp_netif_set_ip_info() failed"); + stopApMode(); + return false; + } + + if (esp_netif_dhcps_start(apNetif) != ESP_OK) { + LOGGER.error("esp_netif_dhcps_start() failed"); + stopApMode(); + return false; + } + + // Configure WiFi AP settings + wifi_config_t wifi_config; + memset(&wifi_config, 0, sizeof(wifi_config_t)); + + // Set SSID + strncpy(reinterpret_cast(wifi_config.ap.ssid), settings.apSsid.c_str(), sizeof(wifi_config.ap.ssid) - 1); + wifi_config.ap.ssid[sizeof(wifi_config.ap.ssid) - 1] = '\0'; + wifi_config.ap.ssid_len = static_cast(settings.apSsid.length()); + + // Set password and auth mode + if (settings.apOpenNetwork) { + // User explicitly chose an open network + wifi_config.ap.authmode = WIFI_AUTH_OPEN; + LOGGER.info("AP configured with OPEN authentication (user choice)"); + } else if (settings.apPassword.length() >= 8 && settings.apPassword.length() <= 63) { + wifi_config.ap.authmode = WIFI_AUTH_WPA2_PSK; + strncpy(reinterpret_cast(wifi_config.ap.password), settings.apPassword.c_str(), sizeof(wifi_config.ap.password) - 1); + wifi_config.ap.password[sizeof(wifi_config.ap.password) - 1] = '\0'; + LOGGER.info("AP configured with WPA2-PSK authentication"); + } else { + if (!settings.apPassword.empty()) { + LOGGER.warn("AP password invalid (must be 8-63 chars, got {}) - using OPEN mode", settings.apPassword.length()); + } + wifi_config.ap.authmode = WIFI_AUTH_OPEN; + LOGGER.warn("AP configured with OPEN authentication (no password)"); + } + + wifi_config.ap.max_connection = 4; + wifi_config.ap.channel = settings.apChannel; + + if (esp_wifi_set_config(WIFI_IF_AP, &wifi_config) != ESP_OK) { + LOGGER.error("esp_wifi_set_config(AP) failed"); + stopApMode(); + return false; + } + + if (esp_wifi_start() != ESP_OK) { + LOGGER.error("esp_wifi_start() failed"); + stopApMode(); + return false; + } + + LOGGER.info("WiFi AP started - SSID: '{}', Channel: {}, IP: 192.168.4.1", settings.apSsid, settings.apChannel); + return true; +} + +void WebServerService::stopApMode() { + if (apWifiInitialized) { + esp_err_t err; + err = esp_wifi_stop(); + if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_STARTED) { + LOGGER.warn("esp_wifi_stop() in cleanup: {}", esp_err_to_name(err)); + } + LOGGER.info("WiFi AP stopped"); + + err = esp_wifi_set_mode(WIFI_MODE_STA); + if (err != ESP_OK) { + LOGGER.warn("esp_wifi_set_mode() in cleanup: {}", esp_err_to_name(err)); + } + LOGGER.info("Wifi mode set back to STA"); + + apWifiInitialized = false; + } + + if (apNetif != nullptr) { + esp_netif_destroy(apNetif); + apNetif = nullptr; + } +} + +// endregion + +bool WebServerService::startServer() { + // Copy settings locally to minimize lock duration + settings::webserver::WebServerSettings settings; + { + auto lock = g_settingsMutex.asScopedLock(); + lock.lock(); + settings = g_cachedSettings; + } + + // Start AP mode WiFi if configured + if (settings.wifiMode == settings::webserver::WiFiMode::AccessPoint) { + if (!startApMode()) { + LOGGER.error("Failed to start AP mode WiFi - HTTP server will not start"); + return false; + } + } + + // NOTE: If you see 'no slots left for registering handler', increase CONFIG_HTTPD_MAX_URI_HANDLERS in sdkconfig (default is 8, 16+ recommended for many endpoints) + void* ctx = this; // Avoid IDE warnings about 'this' in designated initializers + std::vector handlers = { + { + .uri = "/", + .method = HTTP_GET, + .handler = handleRoot, + .user_ctx = ctx + }, + // Note: /upload removed in favor of POST /fs/upload handled by /fs/* dispatcher + { + .uri = "/filebrowser", + .method = HTTP_GET, + .handler = handleFileBrowser, + .user_ctx = ctx + }, + // Consolidated /fs/* handlers (dispatch internally) to save uri handler slots + { + .uri = "/fs/*", + .method = HTTP_GET, + .handler = handleFsGenericGet, + .user_ctx = ctx + }, + { + .uri = "/fs/*", + .method = HTTP_POST, + .handler = handleFsGenericPost, + .user_ctx = ctx + }, + // Consolidated admin POST endpoints to save handler slots + { + .uri = "/admin/*", + .method = HTTP_POST, + .handler = handleAdminPost, + .user_ctx = ctx + }, + // API endpoints for system info, apps, wifi, etc + { + .uri = "/api/*", + .method = HTTP_GET, + .handler = handleApiGet, + .user_ctx = ctx + }, + { + .uri = "/api/*", + .method = HTTP_POST, + .handler = handleApiPost, + .user_ctx = ctx + }, + { + .uri = "/api/*", + .method = HTTP_PUT, + .handler = handleApiPut, + .user_ctx = ctx + }, + { + .uri = "/*", // Catch-all for dynamic assets + .method = HTTP_GET, + .handler = handleAssets, + .user_ctx = ctx + } + }; + + httpServer = std::make_unique( + settings.webServerPort, + "0.0.0.0", + handlers, + 8192 // Stack size + ); + + httpServer->start(); + if (!httpServer->isStarted()) { + LOGGER.error("Failed to start HTTP server on port {}", settings.webServerPort); + httpServer.reset(); + return false; + } + + LOGGER.info("HTTP server started successfully on port {}", settings.webServerPort); + publish_event(this, WebServerEvent::WebServerStarted); + + // Show statusbar icon (different icon for AP vs Station mode) + if (statusbarIconId >= 0) { + const char* icon_name = (settings.wifiMode == settings::webserver::WiFiMode::AccessPoint) + ? "webserver_ap_white.png" + : "webserver_station_white.png"; + auto icon_path = std::string("A:/system/service/Statusbar/assets/") + icon_name; + lvgl::statusbar_icon_set_image(statusbarIconId, icon_path); + lvgl::statusbar_icon_set_visibility(statusbarIconId, true); + LOGGER.info("WebServer statusbar icon shown ({} mode)", + settings.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station"); + } + + return true; +} + +void WebServerService::stopServer() { + if (!httpServer) { + return; + } + + httpServer->stop(); + httpServer.reset(); + + // Stop AP mode WiFi if we started it + if (apWifiInitialized || apNetif != nullptr) { + stopApMode(); + } + + LOGGER.info("HTTP server stopped"); + publish_event(this, WebServerEvent::WebServerStopped); + + if (statusbarIconId >= 0) { + lvgl::statusbar_icon_set_visibility(statusbarIconId, false); + } +} + +// region Endpoints + + + +esp_err_t WebServerService::handleRoot(httpd_req_t* request) { + LOGGER.info("GET / -> redirecting to /dashboard.html"); + httpd_resp_set_status(request, "302 Found"); + httpd_resp_set_hdr(request, "Location", "/dashboard.html"); + return httpd_resp_send(request, nullptr, 0); +} + +// region File Browser helpers & handlers + +// Helper to determine content type from file extension +static const char* getContentType(const std::string& path) { + // Check from the end to avoid matching extensions in directory names + auto endsWith = [&path](const char* ext) { + size_t extLen = strlen(ext); + return path.length() >= extLen && + path.compare(path.length() - extLen, extLen, ext) == 0; + }; + + // HTML/Text + if (endsWith(".html") || endsWith(".htm")) return "text/html"; + if (endsWith(".css")) return "text/css"; + if (endsWith(".js")) return "application/javascript"; + if (endsWith(".json")) return "application/json"; + if (endsWith(".xml")) return "application/xml"; + if (endsWith(".txt")) return "text/plain"; + + // Images + if (endsWith(".png")) return "image/png"; + if (endsWith(".jpg") || endsWith(".jpeg")) return "image/jpeg"; + if (endsWith(".gif")) return "image/gif"; + if (endsWith(".svg")) return "image/svg+xml"; + if (endsWith(".ico")) return "image/x-icon"; + if (endsWith(".webp")) return "image/webp"; + + // Fonts + if (endsWith(".woff")) return "font/woff"; + if (endsWith(".woff2")) return "font/woff2"; + if (endsWith(".ttf")) return "font/ttf"; + if (endsWith(".otf")) return "font/otf"; + if (endsWith(".eot")) return "application/vnd.ms-fontobject"; + + // Audio/Video + if (endsWith(".mp3")) return "audio/mpeg"; + if (endsWith(".wav")) return "audio/wav"; + if (endsWith(".ogg")) return "audio/ogg"; + if (endsWith(".mp4")) return "video/mp4"; + if (endsWith(".webm")) return "video/webm"; + + // Archives/Documents + if (endsWith(".pdf")) return "application/pdf"; + if (endsWith(".zip")) return "application/zip"; + if (endsWith(".gz")) return "application/gzip"; + + // Default + return "application/octet-stream"; +} + +static bool isAllowedBasePath(const std::string& path, bool allowRoot = false) { + if (path.empty()) return false; + // Check for ".." as a complete path component + if (path == ".." || path.starts_with("../") || + path.find("/../") != std::string::npos || path.ends_with("/..")) { + return false; + } + if (allowRoot && path == "/") return true; + return path == "/data" || path.starts_with("/data/") || path == "/sdcard" || path.starts_with("/sdcard/"); +} + +// Normalize client-supplied path: URL-decode, trim quotes/control chars, ensure leading slash, collapse duplicate slashes +static std::string normalizePath(const std::string& raw) { + // Helper: hex to int + auto hexVal = [](char c)->int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return -1; + }; + + std::string s = raw; + // Remove surrounding single or double quotes + if (s.size() >= 2 && ((s.front() == '\'' && s.back() == '\'') || (s.front() == '"' && s.back() == '"'))) { + s = s.substr(1, s.size() - 2); + } + + // URL-decode: %xx and '+' -> ' ' + std::string decoded; + decoded.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + char c = s[i]; + if (c == '%') { + if (i + 2 < s.size()) { + int hi = hexVal(s[i+1]); + int lo = hexVal(s[i+2]); + if (hi >= 0 && lo >= 0) { + decoded.push_back(static_cast((hi << 4) | lo)); + i += 2; + continue; + } + } + // malformed %, keep it + decoded.push_back(c); + } else if (c == '+') { + decoded.push_back(' '); + } else { + // strip control characters + if (static_cast(c) > 31) decoded.push_back(c); + } + } + + // Trim whitespace from ends + size_t start = 0; + while (start < decoded.size() && isspace((unsigned char)decoded[start])) ++start; + size_t end = decoded.size(); + while (end > start && isspace((unsigned char)decoded[end-1])) --end; + std::string trimmed = decoded.substr(start, end - start); + + // Ensure leading slash + if (!trimmed.empty() && trimmed.front() != '/') trimmed = '/' + trimmed; + if (trimmed.empty()) trimmed = "/"; + + // Collapse duplicate slashes + std::string out; + out.reserve(trimmed.size()); + bool lastSlash = false; + for (char c : trimmed) { + if (c == '/') { + if (!lastSlash) { out.push_back(c); lastSlash = true; } + } else { out.push_back(c); lastSlash = false; } + } + + return out; +} + +static std::string escapeJson(const std::string& s) { + std::ostringstream o; + for (char c : s) { + switch (c) { + case '"': o << "\\\""; break; + case '\\': o << "\\\\"; break; + case '\n': o << "\\n"; break; + case '\r': o << "\\r"; break; + case '\t': o << "\\t"; break; + default: + if (static_cast(c) < 0x20) { + o << "\\u" << std::hex << std::setw(4) << std::setfill('0') << (int)c; + } else { + o << c; + } + } + } + return o.str(); +} + +static bool getQueryParam(httpd_req_t* req, const char* key, std::string& out) { + size_t len = httpd_req_get_url_query_len(req) + 1; + if (len <= 1) return false; + std::unique_ptr buf(new char[len]); + if (httpd_req_get_url_query_str(req, buf.get(), len) != ESP_OK) return false; + // Allocate buffer large enough for the entire query string (worst case) + std::unique_ptr value(new char[len]); + if (httpd_query_key_value(buf.get(), key, value.get(), len) == ESP_OK) { + out = value.get(); + return true; + } + return false; +} + +static bool uriMatches(const char* uri, const char* route) { + const size_t n = strlen(route); + return strncmp(uri, route, n) == 0 && (uri[n] == '\0' || uri[n] == '?' || uri[n] == '/'); +} + +esp_err_t WebServerService::handleFileBrowser(httpd_req_t* request) { + LOGGER.info("GET /filebrowser -> redirecting to /dashboard.html#files"); + httpd_resp_set_status(request, "302 Found"); + httpd_resp_set_hdr(request, "Location", "/dashboard.html#files"); + return httpd_resp_send(request, nullptr, 0); +} + +esp_err_t WebServerService::handleFsList(httpd_req_t* request) { + std::string path; + // Log raw query string for diagnostics + size_t qlen = httpd_req_get_url_query_len(request) + 1; + if (qlen > 1) { + std::unique_ptr qbuf(new char[qlen]); + if (httpd_req_get_url_query_str(request, qbuf.get(), qlen) == ESP_OK) { + LOGGER.info("GET /fs/list raw query: {}", qbuf.get()); + } + } + + if (!getQueryParam(request, "path", path) || path.empty()) path = "/"; + std::string norm = normalizePath(path); + LOGGER.info("GET /fs/list decoded path: '{}' normalized: '{}'", path, norm); + + // Allow root path for listing mount points + if (!isAllowedBasePath(norm, true)) { + LOGGER.warn("GET /fs/list - invalid path requested: '{}' normalized: '{}'", path, norm); + httpd_resp_set_type(request, "application/json"); + httpd_resp_sendstr(request, "{\"error\":\"invalid path\"}"); + return ESP_OK; + } + + std::ostringstream json; + json << "{\"path\":\"" << norm << "\",\"entries\":["; + + // Special handling for root: show available mount points + if (norm == "/") { + // Always show /data + json << "{\"name\":\"data\",\"type\":\"dir\",\"size\":0}"; + + // Show /sdcard if mounted + const auto sdcard_devices = hal::findDevices(hal::Device::Type::SdCard); + for (const auto& sdcard : sdcard_devices) { + if (sdcard->isMounted()) { + json << ",{\"name\":\"sdcard\",\"type\":\"dir\",\"size\":0}"; + break; + } + } + json << "]}"; + } else { + std::vector entries; + int res = file::scandir(norm, entries, file::direntFilterDotEntries, nullptr); + if (res < 0) { + httpd_resp_set_type(request, "application/json"); + httpd_resp_sendstr(request, "{\"error\":\"scan failed\"}"); + return ESP_OK; + } + bool first = true; + for (auto& e : entries) { + if (!first) json << ','; else first = false; + std::string name = e.d_name; + bool is_dir = (e.d_type == file::TT_DT_DIR || e.d_type == file::TT_DT_CHR); + std::string full = norm + "/" + name; + long size = 0; + if (!is_dir) { + struct stat st; + if (stat(full.c_str(), &st) == 0) { + size = st.st_size; + } + } + json << "{\"name\":\"" << escapeJson(name) << "\",\"type\":\"" << (is_dir?"dir":"file") << "\",\"size\":" << size << "}"; + } + json << "]}"; + } + + httpd_resp_set_type(request, "application/json"); + httpd_resp_sendstr(request, json.str().c_str()); + return ESP_OK; +} + +esp_err_t WebServerService::handleFsDownload(httpd_req_t* request) { + std::string path; + if (!getQueryParam(request, "path", path) || path.empty()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required"); + return ESP_FAIL; + } + std::string norm = normalizePath(path); + if (!isAllowedBasePath(norm) || !file::isFile(norm)) { + LOGGER.warn("GET /fs/download - not found or invalid path: '{}' normalized: '{}'", path, norm); + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found"); + return ESP_FAIL; + } + httpd_resp_set_type(request, getContentType(norm)); + // Suggest download - build header into a local string so it remains valid + std::string fname = file::getLastPathSegment(norm); + std::string disposition = std::string("attachment; filename=\"") + fname + "\""; + // RFC5987 fallback (filename*): percent-encode UTF-8 bytes for wider browser compatibility + auto pctEncode = [](const std::string& s)->std::string{ + std::ostringstream oss; + for (unsigned char c : s) { + if (std::isalnum(c) || c=='-' || c=='.' || c=='_' || c=='~') { + oss << c; + } else { + oss << '%'; + std::ostringstream hex; + hex << std::uppercase << std::hex << std::setw(2) << std::setfill('0') << (int)c; + oss << hex.str(); + } + } + return oss.str(); + }; + std::string pct = pctEncode(fname); + if (!pct.empty()) { + disposition += std::string("; filename*=UTF-8''") + pct; + } + // Set single Content-Disposition header (avoid adding duplicate headers) + httpd_resp_set_hdr(request, "Content-Disposition", disposition.c_str()); + FILE* fp = fopen(norm.c_str(), "rb"); + if (!fp) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "open failed"); return ESP_FAIL; } + char buf[512]; size_t n; + while ((n = fread(buf,1,sizeof(buf),fp))>0) { + if (httpd_resp_send_chunk(request, buf, n) != ESP_OK) { fclose(fp); return ESP_FAIL; } + } + fclose(fp); + httpd_resp_send_chunk(request, nullptr, 0); + return ESP_OK; +} + +esp_err_t WebServerService::handleFsUpload(httpd_req_t* request) { + std::string path; + + // Log raw query and decoded path for diagnostics + size_t qlen = httpd_req_get_url_query_len(request) + 1; + if (qlen > 1) { + std::unique_ptr qbuf(new char[qlen]); + if (httpd_req_get_url_query_str(request, qbuf.get(), qlen) == ESP_OK) { + LOGGER.info("POST /fs/upload raw query: {}", qbuf.get()); + } + } + + if (!getQueryParam(request, "path", path) || path.empty()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required"); + return ESP_FAIL; + } + + // Log decoded path and headers + char content_type[64] = {0}; + httpd_req_get_hdr_value_str(request, "Content-Type", content_type, sizeof(content_type)); + std::string norm = normalizePath(path); + LOGGER.info("POST /fs/upload decoded path: '{}' normalized: '{}' Content-Length: {} Content-Type: {}", path, norm, (int)request->content_len, content_type[0] ? content_type : "(null)"); + + if (!isAllowedBasePath(norm)) { + LOGGER.warn("POST /fs/upload - invalid path requested: '{}' normalized: '{}'", path, norm); + httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid path"); + return ESP_FAIL; + } + + if (request->content_len > MAX_UPLOAD_SIZE) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "file too large"); + return ESP_FAIL; + } + + // Ensure parent directory exists (after size check to avoid creating dirs for rejected uploads) + if (!file::findOrCreateParentDirectory(norm, 0755)) { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "failed to create parent directory"); + return ESP_FAIL; + } + FILE* fp = fopen(norm.c_str(), "wb"); + if (!fp) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "open failed"); return ESP_FAIL; } + char buf[512]; int remaining = request->content_len; int received=0; + constexpr int MAX_TIMEOUT_RETRIES = 5; + int timeout_retries = 0; + while (remaining > 0) { + int to_read = remaining > (int)sizeof(buf) ? (int)sizeof(buf) : remaining; + int ret = httpd_req_recv(request, buf, to_read); + if (ret == HTTPD_SOCK_ERR_TIMEOUT) { + // Timeout - retry with backoff + timeout_retries++; + if (timeout_retries >= MAX_TIMEOUT_RETRIES) { + LOGGER.error("Upload recv timeout after {} retries", timeout_retries); + fclose(fp); + remove(norm.c_str()); // Clean up partial file + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "recv timeout"); + return ESP_FAIL; + } + LOGGER.warn("Upload recv timeout, retry {}/{}", timeout_retries, MAX_TIMEOUT_RETRIES); + vTaskDelay(pdMS_TO_TICKS(100 * timeout_retries)); // Linear backoff + continue; + } + if (ret <= 0) { + LOGGER.error("Upload recv failed with error {}", ret); + fclose(fp); + remove(norm.c_str()); // Clean up partial file + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "recv failed"); + return ESP_FAIL; + } + // Successful read - reset timeout counter + timeout_retries = 0; + size_t written = fwrite(buf, 1, ret, fp); + if (written != (size_t)ret) { + fclose(fp); + remove(norm.c_str()); + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "write failed"); + return ESP_FAIL; + } + remaining -= ret; + received += ret; + } + fclose(fp); + httpd_resp_set_type(request, "text/plain"); + std::string msg = std::string("Uploaded ") + std::to_string(received) + " bytes"; + httpd_resp_sendstr(request, msg.c_str()); + return ESP_OK; +} + +// Generic GET dispatcher for /fs/* URIs +esp_err_t WebServerService::handleFsGenericGet(httpd_req_t* request) { + // Auth check for all /fs/* endpoints (file system access is sensitive) + bool authPassed = false; + esp_err_t authResult = validateRequestAuth(request, authPassed); + if (!authPassed) { + return authResult; + } + + const char* uri = request->uri; + if (uriMatches(uri, "/fs/list")) return handleFsList(request); + if (uriMatches(uri, "/fs/download")) return handleFsDownload(request); + if (uriMatches(uri, "/fs/tree")) return handleFsTree(request); + LOGGER.warn("GET {} - not found in fs generic dispatcher", uri); + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found"); + return ESP_FAIL; +} + +// Generic POST dispatcher for /fs/* URIs +esp_err_t WebServerService::handleFsGenericPost(httpd_req_t* request) { + // Auth check for all /fs/* endpoints (file system access is sensitive) + bool authPassed = false; + esp_err_t authResult = validateRequestAuth(request, authPassed); + if (!authPassed) { + return authResult; + } + + const char* uri = request->uri; + if (uriMatches(uri, "/fs/mkdir")) return handleFsMkdir(request); + if (uriMatches(uri, "/fs/delete")) return handleFsDelete(request); + if (uriMatches(uri, "/fs/rename")) return handleFsRename(request); + if (uriMatches(uri, "/fs/upload")) return handleFsUpload(request); + LOGGER.warn("POST {} - not found in fs generic dispatcher", uri); + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found"); + return ESP_FAIL; +} + +// Admin dispatcher for consolidated small POST endpoints (e.g. sync, reboot) +esp_err_t WebServerService::handleAdminPost(httpd_req_t* request) { + // Auth check for all /admin/* endpoints (admin actions are sensitive) + bool authPassed = false; + esp_err_t authResult = validateRequestAuth(request, authPassed); + if (!authPassed) { + return authResult; + } + + const char* uri = request->uri; + if (strncmp(uri, "/admin/sync", 11) == 0) return handleSync(request); + if (strncmp(uri, "/admin/reboot", 13) == 0) return handleReboot(request); + LOGGER.info("POST {} - not found in admin dispatcher", uri); + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found"); + return ESP_FAIL; +} + +// API GET dispatcher - returns JSON system information +// Note: /api/sysinfo is intentionally public for monitoring use cases +esp_err_t WebServerService::handleApiGet(httpd_req_t* request) { + const char* uri = request->uri; + + // Public endpoint: sysinfo (basic device info for monitoring) + if (strncmp(uri, "/api/sysinfo", 12) == 0) { + return handleApiSysinfo(request); + } + + // Protected endpoints require authentication + bool authPassed = false; + esp_err_t authResult = validateRequestAuth(request, authPassed); + if (!authPassed) { + return authResult; + } + + // Auth-protected endpoints + if (strncmp(uri, "/api/apps", 9) == 0) { + return handleApiApps(request); + } + if (strncmp(uri, "/api/wifi", 9) == 0) { + return handleApiWifi(request); + } + if (strncmp(uri, "/api/screenshot", 15) == 0) { + return handleApiScreenshot(request); + } + + LOGGER.warn("GET {} - not found in api dispatcher", uri); + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found"); + return ESP_FAIL; +} + +// API POST dispatcher - all POST endpoints require authentication +esp_err_t WebServerService::handleApiPost(httpd_req_t* request) { + bool authPassed = false; + esp_err_t authResult = validateRequestAuth(request, authPassed); + if (!authPassed) { + return authResult; + } + + const char* uri = request->uri; + if (strncmp(uri, "/api/apps/run", 13) == 0) { + return handleApiAppsRun(request); + } + if (strncmp(uri, "/api/apps/uninstall", 19) == 0) { + return handleApiAppsUninstall(request); + } + + LOGGER.warn("POST {} - not found in api dispatcher", uri); + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found"); + return ESP_FAIL; +} + +// API PUT dispatcher - all PUT endpoints require authentication +esp_err_t WebServerService::handleApiPut(httpd_req_t* request) { + bool authPassed = false; + esp_err_t authResult = validateRequestAuth(request, authPassed); + if (!authPassed) { + return authResult; + } + + const char* uri = request->uri; + if (strncmp(uri, "/api/apps/install", 17) == 0) { + return handleApiAppsInstall(request); + } + + LOGGER.warn("PUT {} - not found in api dispatcher", uri); + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found"); + return ESP_FAIL; +} + +esp_err_t WebServerService::handleApiSysinfo(httpd_req_t* request) { + LOGGER.info("GET /api/sysinfo"); + + std::ostringstream json; + json << "{"; + + // Firmware info + json << "\"firmware\":{"; + json << "\"version\":\"" << TT_VERSION << "\","; + json << "\"idf_version\":\"" << ESP_IDF_VERSION_MAJOR << "." << ESP_IDF_VERSION_MINOR << "." << ESP_IDF_VERSION_PATCH << "\""; + json << "},"; + + // Chip info + esp_chip_info_t chip_info; + esp_chip_info(&chip_info); + json << "\"chip\":{"; + json << "\"model\":\"" << getChipModelName(chip_info.model) << "\","; + json << "\"cores\":" << (int)chip_info.cores << ","; + json << "\"revision\":" << (int)chip_info.revision << ","; + + // Decode features into an array of strings + json << "\"features\":["; + bool first_feature = true; + if (chip_info.features & CHIP_FEATURE_EMB_FLASH) { + json << "\"Embedded Flash\""; + first_feature = false; + } + if (chip_info.features & CHIP_FEATURE_WIFI_BGN) { + if (!first_feature) json << ","; + json << "\"WiFi 2.4GHz\""; + first_feature = false; + } + if (chip_info.features & CHIP_FEATURE_BLE) { + if (!first_feature) json << ","; + json << "\"BLE\""; + first_feature = false; + } + if (chip_info.features & CHIP_FEATURE_BT) { + if (!first_feature) json << ","; + json << "\"Bluetooth Classic\""; + first_feature = false; + } + if (chip_info.features & CHIP_FEATURE_IEEE802154) { + if (!first_feature) json << ","; + json << "\"IEEE 802.15.4\""; + first_feature = false; + } + if (chip_info.features & CHIP_FEATURE_EMB_PSRAM) { + if (!first_feature) json << ","; + json << "\"Embedded PSRAM\""; + } + json << "],"; + + // Internal flash size + uint32_t flash_size = 0; + esp_flash_get_size(nullptr, &flash_size); + json << "\"flash_size\":" << flash_size; + json << "},"; + + // Memory - Internal heap + size_t heap_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + size_t heap_total = heap_caps_get_total_size(MALLOC_CAP_INTERNAL); + size_t heap_min_free = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL); + size_t heap_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL); + + json << "\"heap\":{"; + json << "\"free\":" << heap_free << ","; + json << "\"total\":" << heap_total << ","; + json << "\"min_free\":" << heap_min_free << ","; + json << "\"largest_block\":" << heap_largest; + json << "},"; + + // Memory - PSRAM (external) + size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + size_t psram_total = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); + size_t psram_min_free = heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM); + size_t psram_largest = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM); + + json << "\"psram\":{"; + json << "\"free\":" << psram_free << ","; + json << "\"total\":" << psram_total << ","; + json << "\"min_free\":" << psram_min_free << ","; + json << "\"largest_block\":" << psram_largest; + json << "},"; + + // Storage info + json << "\"storage\":{"; + uint64_t storage_total = 0, storage_free = 0; + + // Data partition + json << "\"data\":{"; + if (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK) { + json << "\"free\":" << storage_free << ","; + json << "\"total\":" << storage_total << ","; + json << "\"mounted\":true"; + } else { + json << "\"mounted\":false"; + } + json << "},"; + + // SD card - check all sdcard devices + json << "\"sdcard\":{"; + bool sdcard_found = false; + const auto sdcard_devices = hal::findDevices(hal::Device::Type::SdCard); + for (const auto& sdcard : sdcard_devices) { + if (sdcard->isMounted() && esp_vfs_fat_info(sdcard->getMountPath().c_str(), &storage_total, &storage_free) == ESP_OK) { + json << "\"free\":" << storage_free << ","; + json << "\"total\":" << storage_total << ","; + json << "\"mounted\":true"; + sdcard_found = true; + break; + } + } + if (!sdcard_found) { + json << "\"mounted\":false"; + } + json << "}"; + + json << "},"; // end storage + + // Uptime (in seconds) + TickType_t ticks = xTaskGetTickCount(); + float uptime_sec = static_cast(ticks) / configTICK_RATE_HZ; + json << "\"uptime\":" << static_cast(uptime_sec) << ","; + + // Task count + UBaseType_t task_count = uxTaskGetNumberOfTasks(); + json << "\"task_count\":" << task_count << ","; + + // Feature flags + json << "\"features_enabled\":{"; +#if TT_FEATURE_SCREENSHOT_ENABLED + json << "\"screenshot\":true"; +#else + json << "\"screenshot\":false"; +#endif + json << "}"; + + json << "}"; + + httpd_resp_set_type(request, "application/json"); + httpd_resp_sendstr(request, json.str().c_str()); + return ESP_OK; +} + +// GET /api/apps - List installed apps +esp_err_t WebServerService::handleApiApps(httpd_req_t* request) { + LOGGER.info("GET /api/apps"); + + auto manifests = app::getAppManifests(); + + std::ostringstream json; + json << "{\"apps\":["; + + bool first = true; + for (const auto& manifest : manifests) { + if (!first) json << ","; + first = false; + + json << "{"; + json << "\"id\":\"" << escapeJson(manifest->appId) << "\","; + json << "\"name\":\"" << escapeJson(manifest->appName) << "\","; + json << "\"version\":\"" << escapeJson(manifest->appVersionName) << "\","; + + const char* category = "user"; + if (manifest->appCategory == app::Category::System) category = "system"; + else if (manifest->appCategory == app::Category::Settings) category = "settings"; + json << "\"category\":\"" << category << "\","; + + json << "\"isExternal\":" << (manifest->appLocation.isExternal() ? "true" : "false") << ","; + json << "\"hidden\":" << ((manifest->appFlags & app::AppManifest::Flags::Hidden) ? "true" : "false"); + + if (!manifest->appIcon.empty()) { + json << ",\"icon\":\"" << escapeJson(manifest->appIcon) << "\""; + } + + json << "}"; + } + + json << "]}"; + + httpd_resp_set_type(request, "application/json"); + httpd_resp_sendstr(request, json.str().c_str()); + return ESP_OK; +} + +// POST /api/apps/run?id=xxx - Run an app +esp_err_t WebServerService::handleApiAppsRun(httpd_req_t* request) { + LOGGER.info("POST /api/apps/run"); + + std::string appId; + if (!getQueryParam(request, "id", appId) || appId.empty()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "id parameter required"); + return ESP_FAIL; + } + + auto manifest = app::findAppManifestById(appId); + if (!manifest) { + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "app not found"); + return ESP_FAIL; + } + + // Stop if already running + if (app::isRunning(appId)) { + app::stopAll(appId); + } + + app::start(appId); + + LOGGER.info("[200] /api/apps/run {}", appId); + httpd_resp_sendstr(request, "ok"); + return ESP_OK; +} + +// POST /api/apps/uninstall?id=xxx - Uninstall an app +esp_err_t WebServerService::handleApiAppsUninstall(httpd_req_t* request) { + LOGGER.info("POST /api/apps/uninstall"); + + std::string appId; + if (!getQueryParam(request, "id", appId) || appId.empty()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "id parameter required"); + return ESP_FAIL; + } + + auto manifest = app::findAppManifestById(appId); + if (!manifest) { + LOGGER.info("[200] /api/apps/uninstall {} (app wasn't installed)", appId); + httpd_resp_sendstr(request, "ok"); + return ESP_OK; + } + + // Only allow uninstalling external apps + if (manifest->appLocation.isInternal()) { + httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "cannot uninstall system apps"); + return ESP_FAIL; + } + + if (app::uninstall(appId)) { + LOGGER.info("[200] /api/apps/uninstall {}", appId); + httpd_resp_sendstr(request, "ok"); + return ESP_OK; + } else { + LOGGER.warn("[500] /api/apps/uninstall {}", appId); + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "uninstall failed"); + return ESP_FAIL; + } +} + +// PUT /api/apps/install - Install an app from multipart form upload +esp_err_t WebServerService::handleApiAppsInstall(httpd_req_t* request) { + LOGGER.info("PUT /api/apps/install"); + + std::string boundary; + if (!network::getMultiPartBoundaryOrSendError(request, boundary)) { + return ESP_FAIL; + } + + size_t content_left = request->content_len; + constexpr size_t MAX_APP_UPLOAD_SIZE = 20 * 1024 * 1024; + + // Read headers until empty line (skip boundary line first) + auto content_headers_data = network::receiveTextUntil(request, "\r\n\r\n"); + content_left -= content_headers_data.length(); + + // Split headers into lines and filter empty ones + auto content_headers = string::split(content_headers_data, "\r\n") + | std::views::filter([](const std::string& line) { + return line.length() > 0; + }) + | std::ranges::to(); + + auto content_disposition_map = network::parseContentDisposition(content_headers); + if (content_disposition_map.empty()) { + LOGGER.warn("parseContentDisposition returned empty map for: {}", content_headers_data); + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid content disposition"); + return ESP_FAIL; + } + + auto filename_entry = content_disposition_map.find("filename"); + if (filename_entry == content_disposition_map.end()) { + LOGGER.warn("filename not found in content disposition map"); + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "filename parameter missing"); + return ESP_FAIL; + } + + // Calculate file size + auto boundary_and_newlines_after_file = std::format("\r\n--{}--\r\n", boundary); + if (content_left <= boundary_and_newlines_after_file.length()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid multipart payload"); + return ESP_FAIL; + } + + auto file_size = content_left - boundary_and_newlines_after_file.length(); + if (file_size == 0 || file_size > MAX_APP_UPLOAD_SIZE) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "file too large"); + return ESP_FAIL; + } + + // Create tmp directory + const std::string tmp_path = getTempPath(); + if (!file::findOrCreateDirectory(tmp_path, 0777)) { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "failed to create temp directory"); + return ESP_FAIL; + } + + std::string safe_name = file::getLastPathSegment(filename_entry->second); + if (safe_name.empty() || safe_name.find("..") != std::string::npos || + safe_name.find('/') != std::string::npos || safe_name.find('\\') != std::string::npos) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid filename"); + return ESP_FAIL; + } + auto file_path = std::format("{}/{}", tmp_path, safe_name); + + if (network::receiveFile(request, file_size, file_path) != file_size) { + file::deleteFile(file_path); + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "failed to save file"); + return ESP_FAIL; + } + + content_left -= file_size; + + // Read and discard trailing boundary + if (!network::readAndDiscardOrSendError(request, boundary_and_newlines_after_file)) { + return ESP_FAIL; + } + + // Install the app + if (!app::install(file_path)) { + file::deleteFile(file_path); + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "installation failed"); + return ESP_FAIL; + } + + // Cleanup temp file + if (!file::deleteFile(file_path)) { + LOGGER.warn("Failed to delete temp file {}", file_path); + } + + LOGGER.info("[200] /api/apps/install -> {}", file_path); + httpd_resp_sendstr(request, "ok"); + return ESP_OK; +} + +// Helper to convert radio state to string +static const char* radioStateToJsonString(wifi::RadioState state) { + switch (state) { + case wifi::RadioState::On: return "on"; + case wifi::RadioState::OnPending: return "turning_on"; + case wifi::RadioState::Off: return "off"; + case wifi::RadioState::OffPending: return "turning_off"; + case wifi::RadioState::ConnectionPending: return "connecting"; + case wifi::RadioState::ConnectionActive: return "connected"; + default: return "unknown"; + } +} + +// GET /api/wifi - WiFi status +esp_err_t WebServerService::handleApiWifi(httpd_req_t* request) { + LOGGER.info("GET /api/wifi"); + + auto state = wifi::getRadioState(); + auto ip = wifi::getIp(); + auto ssid = wifi::getConnectionTarget(); + auto rssi = wifi::getRssi(); + bool secure = wifi::isConnectionSecure(); + + std::ostringstream json; + json << "{"; + json << "\"state\":\"" << radioStateToJsonString(state) << "\","; + json << "\"ip\":\"" << escapeJson(ip) << "\","; + json << "\"ssid\":\"" << escapeJson(ssid) << "\","; + json << "\"rssi\":" << rssi << ","; + json << "\"secure\":" << (secure ? "true" : "false"); + json << "}"; + + httpd_resp_set_type(request, "application/json"); + httpd_resp_sendstr(request, json.str().c_str()); + return ESP_OK; +} + +// GET /api/screenshot - Capture and return screenshot as PNG +// Screenshots are saved to SD card root (if available) or /data with incrementing numbers +esp_err_t WebServerService::handleApiScreenshot(httpd_req_t* request) { + LOGGER.info("GET /api/screenshot"); + +#if TT_FEATURE_SCREENSHOT_ENABLED + // Determine save location: prefer SD card root if mounted, otherwise /data + std::string save_path; + auto sdcard_devices = hal::findDevices(hal::Device::Type::SdCard); + for (const auto& sdcard : sdcard_devices) { + if (sdcard->isMounted()) { + save_path = sdcard->getMountPath(); + break; + } + } + if (save_path.empty()) { + save_path = file::MOUNT_POINT_DATA; + } + + // Find next available filename with incrementing number + std::string screenshot_path; + bool found_slot = false; + for (int i = 1; i <= 9999; ++i) { + screenshot_path = std::format("{}/webscreenshot{}.png", save_path, i); + if (!file::isFile(screenshot_path)) { + found_slot = true; + break; + } + } + if (!found_slot) { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "no available screenshot slots"); + return ESP_FAIL; + } + + LOGGER.info("Screenshot will be saved to: {}", screenshot_path); + + // LVGL's lodepng uses lv_fs which requires the "A:" prefix + std::string lvgl_screenshot_path = lvgl::PATH_PREFIX + screenshot_path; + + // Capture screenshot using LVGL + if (lvgl::lock(pdMS_TO_TICKS(100))) { + bool success = lv_screenshot_create(lv_scr_act(), LV_100ASK_SCREENSHOT_SV_PNG, lvgl_screenshot_path.c_str()); + lvgl::unlock(); + + if (!success) { + LOGGER.error("lv_screenshot_create failed for path: {}", lvgl_screenshot_path); + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "screenshot capture failed"); + return ESP_FAIL; + } + LOGGER.info("Screenshot captured successfully"); + } else { + LOGGER.error("Could not acquire LVGL lock within 100ms"); + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "could not acquire LVGL lock"); + return ESP_FAIL; + } + + // Send the file (use regular path for fopen, not LVGL path) + httpd_resp_set_type(request, "image/png"); + + FILE* fp = fopen(screenshot_path.c_str(), "rb"); + if (!fp) { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "failed to open screenshot"); + return ESP_FAIL; + } + + char buf[512]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { + if (httpd_resp_send_chunk(request, buf, n) != ESP_OK) { + fclose(fp); + return ESP_FAIL; + } + } + fclose(fp); + httpd_resp_send_chunk(request, nullptr, 0); + + // File is kept on storage (not deleted) for user access + LOGGER.info("[200] /api/screenshot -> {}", screenshot_path); + return ESP_OK; +#else + httpd_resp_send_err(request, HTTPD_501_METHOD_NOT_IMPLEMENTED, "screenshot feature not enabled"); + return ESP_FAIL; +#endif +} + +esp_err_t WebServerService::handleFsTree(httpd_req_t* request) { + + LOGGER.info("GET /fs/tree"); + + std::ostringstream json; + json << "{"; + // Gather mount points + auto mounts = file::getMountPoints(); + json << "\"mounts\": ["; + bool firstMount = true; + for (auto& m : mounts) { + if (!firstMount) json << ','; else firstMount = false; + std::string name = m.d_name; + std::string path = (name == std::string("data") || name == std::string("/data")) ? std::string("/data") : std::string("/") + name; + // normalize possible duplicate slash + if (!path.starts_with("/")) path = std::string("/") + path; + json << "{\"name\":\"" << escapeJson(name) << "\",\"path\":\"" << escapeJson(path) << "\",\"entries\": ["; + + std::vector entries; + int res = file::scandir(path, entries, file::direntFilterDotEntries, nullptr); + if (res > 0) { + bool first = true; + for (auto& e : entries) { + if (!first) json << ','; else first = false; + std::string en = e.d_name; + bool is_dir = (e.d_type == file::TT_DT_DIR || e.d_type == file::TT_DT_CHR); + json << "{\"name\":\"" << escapeJson(en) << "\",\"type\":\"" << (is_dir?"dir":"file") << "\"}"; + } + } + + json << "]}"; + } + json << "]}"; + + httpd_resp_set_type(request, "application/json"); + httpd_resp_sendstr(request, json.str().c_str()); + return ESP_OK; +} + +// Create a directory at the specified path (POST /fs/mkdir?path=/data/newdir) +esp_err_t WebServerService::handleFsMkdir(httpd_req_t* request) { + std::string path; + if (!getQueryParam(request, "path", path) || path.empty()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required"); + return ESP_FAIL; + } + std::string norm = normalizePath(path); + LOGGER.info("POST /fs/mkdir requested: '{}' normalized: '{}'", path, norm); + if (!isAllowedBasePath(norm)) { + httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid path"); + return ESP_FAIL; + } + bool ok = file::findOrCreateDirectory(norm, 0755); + if (!ok) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "mkdir failed"); return ESP_FAIL; } + httpd_resp_sendstr(request, "ok"); + return ESP_OK; +} + +static bool isRootMountPoint(const std::string& path) { + return path == "/data" || path == "/sdcard"; +} + +// Delete a file or directory (POST /fs/delete?path=/data/foo) +esp_err_t WebServerService::handleFsDelete(httpd_req_t* request) { + std::string path; + if (!getQueryParam(request, "path", path) || path.empty()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required"); + return ESP_FAIL; + } + std::string norm = normalizePath(path); + LOGGER.info("POST /fs/delete requested: '{}' normalized: '{}'", path, norm); + if (!isAllowedBasePath(norm)) { + httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid path"); + return ESP_FAIL; + } + if (isRootMountPoint(norm)) { + httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "cannot delete mount point"); + return ESP_FAIL; + } + bool ok = true; + if (file::isDirectory(norm)) ok = file::deleteRecursively(norm); + else if (file::isFile(norm)) ok = file::deleteFile(norm); + else ok = false; + if (!ok) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "delete failed"); return ESP_FAIL; } + httpd_resp_sendstr(request, "ok"); + return ESP_OK; +} + +// Rename a file or folder (POST /fs/rename?path=/data/oldname&newName=newname) +esp_err_t WebServerService::handleFsRename(httpd_req_t* request) { + std::string path; + std::string newName; + if (!getQueryParam(request, "path", path) || path.empty()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required"); + return ESP_FAIL; + } + if (!getQueryParam(request, "newName", newName) || newName.empty()) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "newName required"); + return ESP_FAIL; + } + std::string norm = normalizePath(path); + LOGGER.info("POST /fs/rename requested: '{}' normalized: '{}' -> newName: '{}'", path.c_str(), norm.c_str(), newName.c_str()); + if (!isAllowedBasePath(norm)) { + httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid path"); + return ESP_FAIL; + } + + // Basic validation of newName: must not contain path separators or '..' + // Trim whitespace from newName + auto trim = [](std::string& s){ size_t st=0; while (stst && isspace((unsigned char)s[ed-1])) --ed; s = s.substr(st, ed-st); }; + trim(newName); + if (newName.empty() || newName.find('/') != std::string::npos || newName.find('\\') != std::string::npos || newName.find("..") != std::string::npos) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid newName"); + return ESP_FAIL; + } + + // compute parent directory + std::string parent = "/"; + size_t pos = norm.find_last_of('/'); + if (pos != std::string::npos) { + parent = (pos == 0) ? std::string("/") : norm.substr(0, pos); + } + + if (!isAllowedBasePath(parent)) { + httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid target parent"); + return ESP_FAIL; + } + + std::string target = file::getChildPath(parent, newName); + + // Prevent overwrite: fail if target exists + if (file::isFile(target) || file::isDirectory(target)) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "target exists"); + return ESP_FAIL; + } + + // perform rename + int r = rename(norm.c_str(), target.c_str()); + if (r != 0) { + int e = errno; + LOGGER.warn("rename failed errno={} ({}) -> {} -> {}", e, strerror(e), norm, target); + // Return errno string to client to aid debugging + std::string msg = std::string("rename failed: ") + strerror(e); + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, msg.c_str()); + return ESP_FAIL; + } + httpd_resp_sendstr(request, "ok"); + return ESP_OK; +} + +// endregion + +esp_err_t WebServerService::handleSync(httpd_req_t* request) { + + LOGGER.info("POST /sync"); + + bool success = syncAssets(); + + if (success) { + httpd_resp_sendstr(request, "Assets synchronized successfully"); + } else { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Asset sync failed"); + } + + return success ? ESP_OK : ESP_FAIL; +} + +esp_err_t WebServerService::handleReboot(httpd_req_t* request) { + + LOGGER.info("POST /reboot"); + + httpd_resp_sendstr(request, "Rebooting..."); + + // Reboot after a short delay to allow response to be sent + vTaskDelay(pdMS_TO_TICKS(2000)); + esp_restart(); + + return ESP_OK; // Unreachable, but satisfies function signature +} + +esp_err_t WebServerService::handleAssets(httpd_req_t* request) { + // Auth check for UI access control + bool authPassed = false; + esp_err_t authResult = validateRequestAuth(request, authPassed); + if (!authPassed) { + return authResult; + } + + const char* uri = request->uri; + LOGGER.info("GET {}", uri); + + // Special case: serve favicon from system assets + if (strcmp(uri, "/favicon.ico") == 0) { + const char* faviconPath = "/data/system/spinner.png"; + if (file::isFile(faviconPath)) { + httpd_resp_set_type(request, "image/png"); + httpd_resp_set_hdr(request, "Cache-Control", "public, max-age=86400"); + + auto lock = file::getLock(faviconPath); + lock->lock(portMAX_DELAY); + + FILE* fp = fopen(faviconPath, "rb"); + if (fp) { + char buffer[512]; + size_t bytesRead; + while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) { + if (httpd_resp_send_chunk(request, buffer, bytesRead) != ESP_OK) { + fclose(fp); + lock->unlock(); + return ESP_FAIL; + } + } + fclose(fp); + lock->unlock(); + httpd_resp_send_chunk(request, nullptr, 0); + LOGGER.info("[200] {} (favicon)", uri); + return ESP_OK; + } + lock->unlock(); + } + // If favicon not found, return 404 silently (browsers handle this gracefully) + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "Not found"); + return ESP_FAIL; + } + + // Special case: if requesting dashboard.html but it doesn't exist, serve default.html + std::string requestedPath = uri; + if (auto qpos = requestedPath.find('?'); qpos != std::string::npos) { + requestedPath = requestedPath.substr(0, qpos); + } + requestedPath = normalizePath(requestedPath); + if (requestedPath == "/.." || requestedPath.ends_with("/..") || requestedPath.find("/../") != std::string::npos) { + httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid path"); + return ESP_FAIL; + } + + std::string dataPath = std::string("/data/webserver") + requestedPath; + + if (requestedPath == "/dashboard.html" && !file::isFile(dataPath.c_str())) { + // Dashboard doesn't exist, try default.html + dataPath = "/data/webserver/default.html"; + LOGGER.info("dashboard.html not found, serving default.html"); + } + + // Try to serve from Data partition first + if (file::isFile(dataPath.c_str())) { + httpd_resp_set_type(request, getContentType(dataPath)); + + // Read and send file using standard C FILE* operations + auto lock = file::getLock(dataPath); + lock->lock(portMAX_DELAY); + + FILE* fp = fopen(dataPath.c_str(), "rb"); + if (fp) { + char buffer[512]; + size_t bytesRead; + while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) { + if (httpd_resp_send_chunk(request, buffer, bytesRead) != ESP_OK) { + fclose(fp); + lock->unlock(); + return ESP_FAIL; + } + } + fclose(fp); + lock->unlock(); + + httpd_resp_send_chunk(request, nullptr, 0); // End of chunks + LOGGER.info("[200] {} (from Data)", uri); + return ESP_OK; + } + lock->unlock(); + } + + // Fallback to SD card + std::string sdPath = std::string("/sdcard/tactility/webserver") + requestedPath; + if (file::isFile(sdPath.c_str())) { + httpd_resp_set_type(request, getContentType(sdPath)); + + auto lock = file::getLock(sdPath); + lock->lock(portMAX_DELAY); + + FILE* fp = fopen(sdPath.c_str(), "rb"); + if (fp) { + char buffer[512]; + size_t bytesRead; + while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) { + if (httpd_resp_send_chunk(request, buffer, bytesRead) != ESP_OK) { + fclose(fp); + lock->unlock(); + return ESP_FAIL; + } + } + fclose(fp); + lock->unlock(); + + httpd_resp_send_chunk(request, nullptr, 0); // End of chunks + LOGGER.info("[200] {} (from SD)", uri); + return ESP_OK; + } + lock->unlock(); + } + + // File not found + LOGGER.warn("[404] {}", uri); + httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "File not found"); + return ESP_FAIL; +} + +extern const ServiceManifest manifest = { + .id = "WebServer", + .createService = create +}; + +void setWebServerEnabled(bool enabled) { + WebServerService* instance = g_webServerInstance.load(); + if (instance != nullptr) { + instance->setEnabled(enabled); + // Don't log here - startServer()/stopServer() already log the actual result + } else { + LOGGER.warn("WebServer service not available, cannot {}", enabled ? "start" : "stop"); + } +} + +} // namespace + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/settings/WebServerSettings.cpp b/Tactility/Source/settings/WebServerSettings.cpp new file mode 100644 index 00000000..4029fbf9 --- /dev/null +++ b/Tactility/Source/settings/WebServerSettings.cpp @@ -0,0 +1,260 @@ +#include +#include +#include +#include + +#include +#include +#include + +#ifdef ESP_PLATFORM +#include +#include +#include +#else +#include +#endif + +namespace tt::settings::webserver { + +static const auto LOGGER = tt::Logger("WebServerSettings"); +constexpr auto* SETTINGS_FILE = "/data/service/webserver/settings.properties"; + +// Property keys +constexpr auto* KEY_WIFI_ENABLED = "wifiEnabled"; +constexpr auto* KEY_WIFI_MODE = "wifiMode"; +constexpr auto* KEY_AP_SSID = "apSsid"; +constexpr auto* KEY_AP_PASSWORD = "apPassword"; +constexpr auto* KEY_AP_OPEN_NETWORK = "apOpenNetwork"; +constexpr auto* KEY_AP_CHANNEL = "apChannel"; +constexpr auto* KEY_WEBSERVER_ENABLED = "webServerEnabled"; +constexpr auto* KEY_WEBSERVER_PORT = "webServerPort"; +constexpr auto* KEY_WEBSERVER_AUTH_ENABLED = "webServerAuthEnabled"; +constexpr auto* KEY_WEBSERVER_USERNAME = "webServerUsername"; +constexpr auto* KEY_WEBSERVER_PASSWORD = "webServerPassword"; + +std::string generateDefaultApSsid() { +#ifdef ESP_PLATFORM + uint8_t mac[6]; + if (esp_read_mac(mac, ESP_MAC_WIFI_STA) != ESP_OK) { + return "Tactility-0000"; + } + char ssid[16]; + snprintf(ssid, sizeof(ssid), "Tactility-%02X%02X", mac[2] ^ mac[3], mac[4] ^ mac[5]); + return std::string(ssid); +#else + return "Tactility-0000"; +#endif +} + +/** + * @brief Generate a cryptographically secure random string for credentials + * @param length The desired length of the string + * @return A random alphanumeric string + */ +static std::string generateRandomCredential(size_t length) { + static constexpr char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + static constexpr size_t charsetSize = sizeof(charset) - 1; + + std::string result; + result.reserve(length); + +#ifdef ESP_PLATFORM + for (size_t i = 0; i < length; ++i) { + uint32_t randomValue = esp_random(); + result += charset[randomValue % charsetSize]; + } +#else + static std::random_device rd; + static std::mt19937 gen(rd()); + std::uniform_int_distribution<> dis(0, charsetSize - 1); + for (size_t i = 0; i < length; ++i) { + result += charset[dis(gen)]; + } +#endif + + return result; +} + +/** + * @brief Check if a credential value is empty (needs auto-generation) + * @param value The credential value to check + * @return true if the credential is empty and needs generation + */ +static bool isEmptyCredential(const std::string& value) { + return value.empty(); +} + +bool load(WebServerSettings& settings) { + std::map map; + if (!file::loadPropertiesFile(SETTINGS_FILE, map)) { + return false; + } + + // Parse all settings from the map + auto wifi_enabled = map.find(KEY_WIFI_ENABLED); + auto wifi_mode = map.find(KEY_WIFI_MODE); + auto ap_ssid = map.find(KEY_AP_SSID); + auto ap_password = map.find(KEY_AP_PASSWORD); + auto ap_open_network = map.find(KEY_AP_OPEN_NETWORK); + auto ap_channel = map.find(KEY_AP_CHANNEL); + auto webserver_enabled = map.find(KEY_WEBSERVER_ENABLED); + auto webserver_port = map.find(KEY_WEBSERVER_PORT); + auto webserver_auth_enabled = map.find(KEY_WEBSERVER_AUTH_ENABLED); + auto webserver_username = map.find(KEY_WEBSERVER_USERNAME); + auto webserver_password = map.find(KEY_WEBSERVER_PASSWORD); + + // WiFi settings + settings.wifiEnabled = (wifi_enabled != map.end()) + ? (wifi_enabled->second == "1" || wifi_enabled->second == "true") + : false; // Default disabled + + settings.wifiMode = (wifi_mode != map.end() && wifi_mode->second == "1") + ? WiFiMode::AccessPoint + : WiFiMode::Station; + + auto parseInt = [](const std::string& value, int min, int max, int fallback) -> int { + int v = 0; + auto [ptr, ec] = std::from_chars(value.data(), value.data() + value.size(), v); + if (ec != std::errc{} || v < min || v > max) { + return fallback; + } + return v; + }; + + // AP mode settings + settings.apSsid = (ap_ssid != map.end() && !ap_ssid->second.empty()) + ? ap_ssid->second + : generateDefaultApSsid(); + settings.apPassword = (ap_password != map.end()) ? ap_password->second : ""; + settings.apOpenNetwork = (ap_open_network != map.end()) + ? (ap_open_network->second == "1" || ap_open_network->second == "true") + : false; + settings.apChannel = (ap_channel != map.end()) + ? static_cast(parseInt(ap_channel->second, 1, 13, 1)) + : 1; + + // Security: If AP password is empty, generate a strong random password. + // Skip this if user explicitly wants an open network. + // Note: We only auto-generate for EMPTY passwords, not user-set ones. + if (!settings.apOpenNetwork && isEmptyCredential(settings.apPassword)) { + LOGGER.info("AP password is empty - generating secure random password"); + + // Generate 12-character random password (alphanumeric, ~71 bits of entropy) + // WPA2 requires 8-63 characters, so 12 is well within range + settings.apPassword = generateRandomCredential(12); + + // Persist the generated password immediately + map[KEY_AP_PASSWORD] = settings.apPassword; + if (file::savePropertiesFile(SETTINGS_FILE, map)) { + LOGGER.info("Generated and saved new secure AP password"); + } else { + LOGGER.error("Failed to save generated AP password"); + } + } + + // Web server settings + settings.webServerEnabled = (webserver_enabled != map.end()) + ? (webserver_enabled->second == "1" || webserver_enabled->second == "true") + : false; + + settings.webServerPort = (webserver_port != map.end()) + ? static_cast(parseInt(webserver_port->second, 1, 65535, 80)) + : 80; + + // Web server auth settings + settings.webServerAuthEnabled = (webserver_auth_enabled != map.end()) + ? (webserver_auth_enabled->second == "1" || webserver_auth_enabled->second == "true") + : false; + + // Load credentials from file, defaulting to empty if not present + settings.webServerUsername = (webserver_username != map.end()) ? webserver_username->second : ""; + settings.webServerPassword = (webserver_password != map.end()) ? webserver_password->second : ""; + + // Security: If auth is enabled but credentials are empty, + // generate strong random credentials and persist them immediately. + // Note: We only auto-generate for EMPTY credentials, allowing users to set their own. + if (settings.webServerAuthEnabled && + (isEmptyCredential(settings.webServerUsername) || isEmptyCredential(settings.webServerPassword))) { + + LOGGER.info("Auth enabled with empty credentials - generating secure random credentials"); + + // Generate 12-character random credentials (alphanumeric, ~71 bits of entropy each) + settings.webServerUsername = generateRandomCredential(12); + settings.webServerPassword = generateRandomCredential(12); + + // Persist the generated credentials immediately + // We need to save these to the file so they're consistent across reboots + map[KEY_WEBSERVER_USERNAME] = settings.webServerUsername; + map[KEY_WEBSERVER_PASSWORD] = settings.webServerPassword; + if (file::savePropertiesFile(SETTINGS_FILE, map)) { + LOGGER.info("Generated and saved new secure credentials"); + } else { + LOGGER.error("Failed to save generated credentials - auth may be inconsistent across reboots"); + } + } + + return true; +} + +WebServerSettings getDefault() { + return WebServerSettings{ + .wifiEnabled = false, // Default WiFi OFF + .wifiMode = WiFiMode::Station, + .apSsid = generateDefaultApSsid(), + .apPassword = "", // Empty - will be auto-generated on first use (unless apOpenNetwork) + .apOpenNetwork = false, // Default to secured network + .apChannel = 1, + .webServerEnabled = false, // Default WebServer OFF for security + .webServerPort = 80, + .webServerAuthEnabled = false, // Auth disabled by default + .webServerUsername = "", // Empty - will be generated if auth is enabled + .webServerPassword = "" // Empty - will be generated if auth is enabled + }; +} + +WebServerSettings loadOrGetDefault() { + WebServerSettings settings; + + bool loadedFromFlash = load(settings); + if (!loadedFromFlash) { + // First boot - use defaults (WiFi OFF, WebServer OFF) + settings = getDefault(); + // Save defaults to flash so toggle states persist + if (save(settings)) { + LOGGER.info("First boot - saved default settings (WiFi OFF WebServer OFF)"); + } else { + LOGGER.warn("First boot - failed to save default settings to flash"); + } + } + + return settings; +} + +bool save(const WebServerSettings& settings) { + std::map map; + + // WiFi settings + map[KEY_WIFI_ENABLED] = settings.wifiEnabled ? "1" : "0"; + map[KEY_WIFI_MODE] = (settings.wifiMode == WiFiMode::AccessPoint) ? "1" : "0"; + + // AP mode settings + map[KEY_AP_SSID] = settings.apSsid; + map[KEY_AP_PASSWORD] = settings.apPassword; + map[KEY_AP_OPEN_NETWORK] = settings.apOpenNetwork ? "1" : "0"; + map[KEY_AP_CHANNEL] = std::to_string(settings.apChannel); + + // Web server settings + map[KEY_WEBSERVER_ENABLED] = settings.webServerEnabled ? "1" : "0"; + map[KEY_WEBSERVER_PORT] = std::to_string(settings.webServerPort); + + // Web server auth settings + map[KEY_WEBSERVER_AUTH_ENABLED] = settings.webServerAuthEnabled ? "1" : "0"; + map[KEY_WEBSERVER_USERNAME] = settings.webServerUsername; + map[KEY_WEBSERVER_PASSWORD] = settings.webServerPassword; + + // Save to flash storage only (no SD backup - settings sync at boot handles restore) + return file::savePropertiesFile(SETTINGS_FILE, map); +} + +} diff --git a/TactilityC/Source/symbols/freertos.cpp b/TactilityC/Source/symbols/freertos.cpp index 7f94cb21..f3829e1d 100644 --- a/TactilityC/Source/symbols/freertos.cpp +++ b/TactilityC/Source/symbols/freertos.cpp @@ -26,6 +26,8 @@ const esp_elfsym freertos_symbols[] = { ESP_ELFSYM_EXPORT(vTaskSetThreadLocalStoragePointer), ESP_ELFSYM_EXPORT(vTaskSetThreadLocalStoragePointerAndDelCallback), ESP_ELFSYM_EXPORT(vTaskGetInfo), + ESP_ELFSYM_EXPORT(vTaskResume), + ESP_ELFSYM_EXPORT(vTaskSuspend), ESP_ELFSYM_EXPORT(xTaskCreate), ESP_ELFSYM_EXPORT(xTaskAbortDelay), ESP_ELFSYM_EXPORT(xTaskCheckForTimeOut), @@ -77,6 +79,7 @@ const esp_elfsym freertos_symbols[] = { ESP_ELFSYM_EXPORT(xQueueGenericSend), ESP_ELFSYM_EXPORT(xQueueGenericSendFromISR), ESP_ELFSYM_EXPORT(xQueueSemaphoreTake), + ESP_ELFSYM_EXPORT(xQueueReceive), // Timer ESP_ELFSYM_EXPORT(pvTimerGetTimerID), ESP_ELFSYM_EXPORT(xTimerCreate), diff --git a/TactilityC/Source/tt_init.cpp b/TactilityC/Source/tt_init.cpp index dfe71611..68a66188 100644 --- a/TactilityC/Source/tt_init.cpp +++ b/TactilityC/Source/tt_init.cpp @@ -177,6 +177,7 @@ const esp_elfsym main_symbols[] { ESP_ELFSYM_EXPORT(esp_log), ESP_ELFSYM_EXPORT(esp_log_write), ESP_ELFSYM_EXPORT(esp_log_timestamp), + ESP_ELFSYM_EXPORT(esp_err_to_name), // Tactility ESP_ELFSYM_EXPORT(tt_app_start), ESP_ELFSYM_EXPORT(tt_app_start_with_bundle), @@ -385,11 +386,8 @@ const esp_elfsym main_symbols[] { ESP_ELFSYM_EXPORT(lv_obj_set_style_pad_right), ESP_ELFSYM_EXPORT(lv_obj_set_style_pad_column), ESP_ELFSYM_EXPORT(lv_obj_set_style_pad_row), - ESP_ELFSYM_EXPORT(lv_obj_set_style_border_width), - ESP_ELFSYM_EXPORT(lv_obj_set_style_border_opa), ESP_ELFSYM_EXPORT(lv_obj_set_style_border_post), ESP_ELFSYM_EXPORT(lv_obj_set_style_border_side), - ESP_ELFSYM_EXPORT(lv_obj_set_style_border_color), ESP_ELFSYM_EXPORT(lv_obj_set_style_text_opa), ESP_ELFSYM_EXPORT(lv_obj_set_style_text_align), ESP_ELFSYM_EXPORT(lv_obj_set_style_text_color), @@ -412,6 +410,10 @@ const esp_elfsym main_symbols[] { ESP_ELFSYM_EXPORT(lv_obj_set_style_outline_width), ESP_ELFSYM_EXPORT(lv_obj_set_style_outline_pad), ESP_ELFSYM_EXPORT(lv_obj_set_style_outline_opa), + ESP_ELFSYM_EXPORT(lv_obj_scroll_to_y), + ESP_ELFSYM_EXPORT(lv_obj_set_scrollbar_mode), + ESP_ELFSYM_EXPORT(lv_obj_get_child_count), + ESP_ELFSYM_EXPORT(lv_obj_get_child), // lv_font ESP_ELFSYM_EXPORT(lv_font_get_default), // lv_theme @@ -506,6 +508,9 @@ const esp_elfsym main_symbols[] { ESP_ELFSYM_EXPORT(lv_textarea_set_placeholder_text), ESP_ELFSYM_EXPORT(lv_textarea_set_text), ESP_ELFSYM_EXPORT(lv_textarea_set_text_selection), + ESP_ELFSYM_EXPORT(lv_textarea_set_max_length), + ESP_ELFSYM_EXPORT(lv_textarea_set_cursor_click_pos), + ESP_ELFSYM_EXPORT(lv_textarea_add_text), // lv_palette ESP_ELFSYM_EXPORT(lv_palette_main), ESP_ELFSYM_EXPORT(lv_palette_darken), @@ -574,6 +579,10 @@ const esp_elfsym main_symbols[] { ESP_ELFSYM_EXPORT(lv_line_set_points_mutable), // lv_group ESP_ELFSYM_EXPORT(lv_group_remove_obj), + ESP_ELFSYM_EXPORT(lv_group_focus_obj), + ESP_ELFSYM_EXPORT(lv_group_get_default), + ESP_ELFSYM_EXPORT(lv_group_add_obj), + ESP_ELFSYM_EXPORT(lv_group_set_default), // lv_mem ESP_ELFSYM_EXPORT(lv_free), ESP_ELFSYM_EXPORT(lv_malloc),