Webserver addition and TactilityC symbols (#451)

This commit is contained in:
Shadowtrance 2026-01-22 06:47:59 +10:00 committed by GitHub
parent c98cb2bf10
commit 01ffe420eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 5006 additions and 21 deletions

View File

@ -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=

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
{
"version": 0
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tactility Dashboard</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 20px auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
text-align: center;
}
.placeholder {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
margin: 20px 0;
}
.placeholder h2 {
color: #666;
}
.placeholder p {
color: #666;
line-height: 1.6;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<h1>Tactility Default Dashboard</h1>
<div class="placeholder">
<h2>Version 0 - Default Placeholder</h2>
<p>This is the default dashboard bundled with firmware.</p>
<p>To customize this interface:</p>
<ol style="text-align: left; display: inline-block;">
<li>Create your custom dashboard HTML/CSS/JS files</li>
<li>Add them to <code>/sdcard/tactility/webserver/</code></li>
<li>Create <code>version.json</code> with <code>{"version": 1}</code> or higher</li>
<li>Reboot or click "Sync Assets" on the <a href="/">Core Interface</a></li>
</ol>
<p><strong>Your custom assets will automatically replace this page!</strong></p>
</div>
<div class="placeholder">
<p><a href="/">← Back to Core Interface</a></p>
</div>
</body>
</html>

View File

@ -0,0 +1,3 @@
{
"version": 0
}

515
Documentation/webserver.md Normal file
View File

@ -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://<device-ip>/
```
**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://<device-ip>/admin/sync
```
**Response:** `Assets synchronized successfully`
### Programmatic Access
```cpp
#include <Tactility/service/webserver/AssetVersion.h>
// 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

View File

@ -27,6 +27,7 @@ if (DEFINED ENV{ESP_IDF_VERSION})
vfs vfs
fatfs fatfs
lwip lwip
spi_flash
) )
if ("${IDF_TARGET}" STREQUAL "esp32s3") if ("${IDF_TARGET}" STREQUAL "esp32s3")
list(APPEND REQUIRES_LIST esp_tinyusb) list(APPEND REQUIRES_LIST esp_tinyusb)

View File

@ -52,10 +52,10 @@ public:
address(address), address(address),
stackSize(stackSize), stackSize(stackSize),
matchUri(matchUri), matchUri(matchUri),
handlers(handlers) handlers(std::move(handlers))
{} {}
void start(); bool start();
void stop(); void stop();

View File

@ -0,0 +1,72 @@
#pragma once
#include <cstdint>
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

View File

@ -0,0 +1,66 @@
#pragma once
#include <cstdint>
#include <string>
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();
}

View File

@ -0,0 +1,105 @@
#pragma once
#ifdef ESP_PLATFORM
#include <Tactility/PubSub.h>
#include <Tactility/service/Service.h>
#include <Tactility/network/HttpServer.h>
#include <Tactility/RecursiveMutex.h>
#include <esp_http_server.h>
#include <esp_netif.h>
#include <string>
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<network::HttpServer> httpServer;
PubSub<WebServerEvent>::SubscriptionHandle settingsEventSubscription = nullptr;
std::shared_ptr<PubSub<WebServerEvent>> pubsub = std::make_shared<PubSub<WebServerEvent>>();
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<PubSub<WebServerEvent>> 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<PubSub<WebServerEvent>> getPubsub();
} // namespace
#endif

View File

@ -60,6 +60,9 @@ namespace service {
#if TT_FEATURE_SCREENSHOT_ENABLED #if TT_FEATURE_SCREENSHOT_ENABLED
namespace screenshot { extern const ServiceManifest manifest; } namespace screenshot { extern const ServiceManifest manifest; }
#endif #endif
#ifdef ESP_PLATFORM
namespace webserver { extern const ServiceManifest manifest; }
#endif
} }
@ -104,6 +107,9 @@ namespace app {
namespace wifiapsettings { extern const AppManifest manifest; } namespace wifiapsettings { extern const AppManifest manifest; }
namespace wificonnect { extern const AppManifest manifest; } namespace wificonnect { extern const AppManifest manifest; }
namespace wifimanage { 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 #if TT_FEATURE_SCREENSHOT_ENABLED
namespace screenshot { extern const AppManifest manifest; } namespace screenshot { extern const AppManifest manifest; }
#endif #endif
@ -112,9 +118,6 @@ namespace app {
#endif #endif
} }
#ifndef ESP_PLATFORM
#endif
// endregion // endregion
// List of all apps excluding Boot app (as Boot app calls this function indirectly) // 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::wifiapsettings::manifest);
addAppManifest(app::wificonnect::manifest); addAppManifest(app::wificonnect::manifest);
addAppManifest(app::wifimanage::manifest); addAppManifest(app::wifimanage::manifest);
#ifdef ESP_PLATFORM
addAppManifest(app::webserversettings::manifest);
#endif
#if defined(CONFIG_TINYUSB_MSC_ENABLED) && CONFIG_TINYUSB_MSC_ENABLED #if defined(CONFIG_TINYUSB_MSC_ENABLED) && CONFIG_TINYUSB_MSC_ENABLED
addAppManifest(app::usbsettings::manifest); addAppManifest(app::usbsettings::manifest);
@ -263,6 +269,9 @@ static void registerAndStartPrimaryServices() {
#if defined(CONFIG_TT_WIFI_ENABLED) && !defined(CONFIG_ESP_WIFI_REMOTE_ENABLED) #if defined(CONFIG_TT_WIFI_ENABLED) && !defined(CONFIG_ESP_WIFI_REMOTE_ENABLED)
addService(service::espnow::manifest); addService(service::espnow::manifest);
#endif #endif
#ifdef ESP_PLATFORM
addService(service::webserver::manifest);
#endif
} }
void createTempDirectory(const std::string& rootPath) { void createTempDirectory(const std::string& rootPath) {

View File

@ -0,0 +1,408 @@
#ifdef ESP_PLATFORM
#include <Tactility/Tactility.h>
#include <Tactility/settings/WebServerSettings.h>
#include <Tactility/service/wifi/Wifi.h>
#include <Tactility/service/wifi/WifiApSettings.h>
#include <Tactility/service/webserver/AssetVersion.h>
#include <Tactility/service/webserver/WebServerService.h>
#include <Tactility/Assets.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/Logger.h>
#include <lvgl.h>
#include <esp_netif.h>
#include <esp_wifi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/timers.h>
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<WebServerSettingsApp*>(lv_event_get_user_data(e));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
auto index = lv_dropdown_get_selected(dropdown);
getMainDispatcher().dispatch([app, index] {
app->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index);
app->updated = true;
app->wifiSettingsChanged = true;
app->updateUrlDisplay();
});
}
static void onWebServerEnabledSwitch(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(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<WebServerSettingsApp*>(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<WebServerSettingsApp*>(lv_event_get_user_data(e));
getMainDispatcher().dispatch([app] {
app->updated = true;
});
}
static void onApPasswordChanged(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(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<WebServerSettingsApp*>(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<WebServerSettingsApp*>(lv_event_get_user_data(e));
auto* btn = static_cast<lv_obj_t*>(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<uint32_t>(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<WebServerSettingsApp>
};
}
#endif

View File

@ -9,39 +9,58 @@ namespace tt::network {
static const auto LOGGER = Logger("HttpServer"); static const auto LOGGER = Logger("HttpServer");
static constexpr size_t INTERNAL_URI_HANDLER_COUNT = 2;
bool HttpServer::startInternal() { bool HttpServer::startInternal() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = stackSize; config.stack_size = stackSize;
config.server_port = port; config.server_port = port;
config.uri_match_fn = matchUri; config.uri_match_fn = matchUri;
config.max_uri_handlers = handlers.size() + INTERNAL_URI_HANDLER_COUNT;
if (httpd_start(&server, &config) != ESP_OK) { if (httpd_start(&server, &config) != ESP_OK) {
LOGGER.error("Failed to start http server on port {}", port); LOGGER.error("Failed to start http server on port {}", port);
return false; return false;
} }
bool allRegistered = true;
for (std::vector<httpd_uri_t>::reference handler : handlers) { for (std::vector<httpd_uri_t>::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); LOGGER.info("Started on port {}", config.server_port);
return true; return true;
} }
void HttpServer::stopInternal() { void HttpServer::stopInternal() {
LOGGER.info("Stopping server"); LOGGER.info("Stopping server");
if (server != nullptr && httpd_stop(server) != ESP_OK) { if (server != nullptr) {
LOGGER.warn("Error while stopping"); if (httpd_stop(server) == ESP_OK) {
server = nullptr; server = nullptr;
} else {
LOGGER.warn("Error while stopping");
}
} }
} }
void HttpServer::start() { bool HttpServer::start() {
auto lock = mutex.asScopedLock(); auto lock = mutex.asScopedLock();
lock.lock(); lock.lock();
startInternal(); if (isStarted()) {
LOGGER.warn("Already started");
return true;
}
return startInternal();
} }
void HttpServer::stop() { void HttpServer::stop() {
@ -50,6 +69,7 @@ void HttpServer::stop() {
if (!isStarted()) { if (!isStarted()) {
LOGGER.warn("Not started"); LOGGER.warn("Not started");
return;
} }
stopInternal(); stopInternal();

View File

@ -83,18 +83,35 @@ std::unique_ptr<char[]> receiveByteArray(httpd_req_t* request, size_t length, si
return nullptr; return nullptr;
} }
constexpr int MAX_TIMEOUT_RETRIES = 5;
int timeout_retries = 0;
while (bytesRead < length) { while (bytesRead < length) {
size_t read_size = length - bytesRead; 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) { if (bytes_received <= 0) {
LOGGER.warn("Received error {} after reading {}/{} bytes", bytes_received, bytesRead, length); LOGGER.warn("Received error {} after reading {}/{} bytes", bytes_received, bytesRead, length);
free(buffer);
return nullptr; return nullptr;
} }
// Successful read - reset timeout counter
timeout_retries = 0;
bytesRead += bytes_received; bytesRead += bytes_received;
} }
return std::unique_ptr<char[]>(std::move(buffer)); return std::unique_ptr<char[]>(buffer);
} }
std::string receiveTextUntil(httpd_req_t* request, const std::string& terminator) { std::string receiveTextUntil(httpd_req_t* request, const std::string& terminator) {
@ -131,7 +148,7 @@ std::map<std::string, std::string> parseContentDisposition(const std::vector<std
auto parseable = content_disposition_header->substr(prefix.size()); auto parseable = content_disposition_header->substr(prefix.size());
auto parts = string::split(parseable, "; "); auto parts = string::split(parseable, "; ");
for (auto part : parts) { for (const auto& part : parts) {
auto key_value = string::split(part, "="); auto key_value = string::split(part, "=");
if (key_value.size() == 2) { if (key_value.size() == 2) {
// Trim trailing newlines // Trim trailing newlines
@ -150,7 +167,7 @@ std::map<std::string, std::string> parseContentDisposition(const std::vector<std
bool readAndDiscardOrSendError(httpd_req_t* request, const std::string& toRead) { bool readAndDiscardOrSendError(httpd_req_t* request, const std::string& toRead) {
size_t bytes_read; size_t bytes_read;
auto buffer = receiveByteArray(request, toRead.length(), bytes_read); auto buffer = receiveByteArray(request, toRead.length(), bytes_read);
if (bytes_read != toRead.length()) { if (buffer == nullptr || bytes_read != toRead.length()) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "failed to read discardable data"); httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "failed to read discardable data");
return false; return false;
} }
@ -184,7 +201,7 @@ size_t receiveFile(httpd_req_t* request, size_t length, const std::string& fileP
LOGGER.error("Receive failed"); LOGGER.error("Receive failed");
break; break;
} }
if (fwrite(buffer, 1, receive_chunk_size, file) != receive_chunk_size) { if (fwrite(buffer, 1, receive_chunk_size, file) != (size_t)receive_chunk_size) {
LOGGER.error("Failed to write all bytes"); LOGGER.error("Failed to write all bytes");
break; break;
} }

View File

@ -0,0 +1,395 @@
#ifdef ESP_PLATFORM
#include <Tactility/service/webserver/AssetVersion.h>
#include <Tactility/file/File.h>
#include <Tactility/Logger.h>
#include <cJSON.h>
#include <cstdio>
#include <cstring>
#include <format>
#include <esp_random.h>
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<uint32_t>(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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,260 @@
#include <Tactility/settings/WebServerSettings.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/file/File.h>
#include <Tactility/Logger.h>
#include <charconv>
#include <map>
#include <string>
#ifdef ESP_PLATFORM
#include <esp_mac.h>
#include <esp_wifi.h>
#include <esp_random.h>
#else
#include <random>
#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<std::string, std::string> 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<uint8_t>(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<uint16_t>(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<std::string, std::string> 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);
}
}

View File

@ -26,6 +26,8 @@ const esp_elfsym freertos_symbols[] = {
ESP_ELFSYM_EXPORT(vTaskSetThreadLocalStoragePointer), ESP_ELFSYM_EXPORT(vTaskSetThreadLocalStoragePointer),
ESP_ELFSYM_EXPORT(vTaskSetThreadLocalStoragePointerAndDelCallback), ESP_ELFSYM_EXPORT(vTaskSetThreadLocalStoragePointerAndDelCallback),
ESP_ELFSYM_EXPORT(vTaskGetInfo), ESP_ELFSYM_EXPORT(vTaskGetInfo),
ESP_ELFSYM_EXPORT(vTaskResume),
ESP_ELFSYM_EXPORT(vTaskSuspend),
ESP_ELFSYM_EXPORT(xTaskCreate), ESP_ELFSYM_EXPORT(xTaskCreate),
ESP_ELFSYM_EXPORT(xTaskAbortDelay), ESP_ELFSYM_EXPORT(xTaskAbortDelay),
ESP_ELFSYM_EXPORT(xTaskCheckForTimeOut), ESP_ELFSYM_EXPORT(xTaskCheckForTimeOut),
@ -77,6 +79,7 @@ const esp_elfsym freertos_symbols[] = {
ESP_ELFSYM_EXPORT(xQueueGenericSend), ESP_ELFSYM_EXPORT(xQueueGenericSend),
ESP_ELFSYM_EXPORT(xQueueGenericSendFromISR), ESP_ELFSYM_EXPORT(xQueueGenericSendFromISR),
ESP_ELFSYM_EXPORT(xQueueSemaphoreTake), ESP_ELFSYM_EXPORT(xQueueSemaphoreTake),
ESP_ELFSYM_EXPORT(xQueueReceive),
// Timer // Timer
ESP_ELFSYM_EXPORT(pvTimerGetTimerID), ESP_ELFSYM_EXPORT(pvTimerGetTimerID),
ESP_ELFSYM_EXPORT(xTimerCreate), ESP_ELFSYM_EXPORT(xTimerCreate),

View File

@ -177,6 +177,7 @@ const esp_elfsym main_symbols[] {
ESP_ELFSYM_EXPORT(esp_log), ESP_ELFSYM_EXPORT(esp_log),
ESP_ELFSYM_EXPORT(esp_log_write), ESP_ELFSYM_EXPORT(esp_log_write),
ESP_ELFSYM_EXPORT(esp_log_timestamp), ESP_ELFSYM_EXPORT(esp_log_timestamp),
ESP_ELFSYM_EXPORT(esp_err_to_name),
// Tactility // Tactility
ESP_ELFSYM_EXPORT(tt_app_start), ESP_ELFSYM_EXPORT(tt_app_start),
ESP_ELFSYM_EXPORT(tt_app_start_with_bundle), 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_right),
ESP_ELFSYM_EXPORT(lv_obj_set_style_pad_column), 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_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_post),
ESP_ELFSYM_EXPORT(lv_obj_set_style_border_side), 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_opa),
ESP_ELFSYM_EXPORT(lv_obj_set_style_text_align), ESP_ELFSYM_EXPORT(lv_obj_set_style_text_align),
ESP_ELFSYM_EXPORT(lv_obj_set_style_text_color), 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_width),
ESP_ELFSYM_EXPORT(lv_obj_set_style_outline_pad), ESP_ELFSYM_EXPORT(lv_obj_set_style_outline_pad),
ESP_ELFSYM_EXPORT(lv_obj_set_style_outline_opa), 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 // lv_font
ESP_ELFSYM_EXPORT(lv_font_get_default), ESP_ELFSYM_EXPORT(lv_font_get_default),
// lv_theme // 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_placeholder_text),
ESP_ELFSYM_EXPORT(lv_textarea_set_text), ESP_ELFSYM_EXPORT(lv_textarea_set_text),
ESP_ELFSYM_EXPORT(lv_textarea_set_text_selection), 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 // lv_palette
ESP_ELFSYM_EXPORT(lv_palette_main), ESP_ELFSYM_EXPORT(lv_palette_main),
ESP_ELFSYM_EXPORT(lv_palette_darken), ESP_ELFSYM_EXPORT(lv_palette_darken),
@ -574,6 +579,10 @@ const esp_elfsym main_symbols[] {
ESP_ELFSYM_EXPORT(lv_line_set_points_mutable), ESP_ELFSYM_EXPORT(lv_line_set_points_mutable),
// lv_group // lv_group
ESP_ELFSYM_EXPORT(lv_group_remove_obj), 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 // lv_mem
ESP_ELFSYM_EXPORT(lv_free), ESP_ELFSYM_EXPORT(lv_free),
ESP_ELFSYM_EXPORT(lv_malloc), ESP_ELFSYM_EXPORT(lv_malloc),