mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-02-18 10:53:17 +00:00
Webserver addition and TactilityC symbols (#451)
This commit is contained in:
parent
c98cb2bf10
commit
01ffe420eb
27
Data/data/service/webserver/settings.properties
Normal file
27
Data/data/service/webserver/settings.properties
Normal 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=
|
||||
1147
Data/data/webserver/dashboard.html
Normal file
1147
Data/data/webserver/dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
3
Data/data/webserver/version.json
Normal file
3
Data/data/webserver/version.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
BIN
Data/system/service/Statusbar/assets/webserver_ap_white.png
Normal file
BIN
Data/system/service/Statusbar/assets/webserver_ap_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 663 B |
BIN
Data/system/service/Statusbar/assets/webserver_station_white.png
Normal file
BIN
Data/system/service/Statusbar/assets/webserver_station_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 755 B |
63
Data/webserver/default.html
Normal file
63
Data/webserver/default.html
Normal 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>
|
||||
3
Data/webserver/version.json
Normal file
3
Data/webserver/version.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
515
Documentation/webserver.md
Normal file
515
Documentation/webserver.md
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -52,10 +52,10 @@ public:
|
||||
address(address),
|
||||
stackSize(stackSize),
|
||||
matchUri(matchUri),
|
||||
handlers(handlers)
|
||||
handlers(std::move(handlers))
|
||||
{}
|
||||
|
||||
void start();
|
||||
bool start();
|
||||
|
||||
void stop();
|
||||
|
||||
|
||||
72
Tactility/Include/Tactility/service/webserver/AssetVersion.h
Normal file
72
Tactility/Include/Tactility/service/webserver/AssetVersion.h
Normal 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
|
||||
66
Tactility/Include/Tactility/settings/WebServerSettings.h
Normal file
66
Tactility/Include/Tactility/settings/WebServerSettings.h
Normal 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();
|
||||
|
||||
}
|
||||
105
Tactility/Private/Tactility/service/webserver/WebServerService.h
Normal file
105
Tactility/Private/Tactility/service/webserver/WebServerService.h
Normal 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
|
||||
@ -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,6 +107,9 @@ 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; }
|
||||
#endif
|
||||
@ -112,9 +118,6 @@ namespace app {
|
||||
#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) {
|
||||
|
||||
408
Tactility/Source/app/webserversettings/WebServerSettings.cpp
Normal file
408
Tactility/Source/app/webserversettings/WebServerSettings.cpp
Normal 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
|
||||
@ -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<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);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void HttpServer::stopInternal() {
|
||||
LOGGER.info("Stopping server");
|
||||
if (server != nullptr && httpd_stop(server) != ESP_OK) {
|
||||
LOGGER.warn("Error while stopping");
|
||||
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();
|
||||
|
||||
@ -83,18 +83,35 @@ std::unique_ptr<char[]> 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<char[]>(std::move(buffer));
|
||||
return std::unique_ptr<char[]>(buffer);
|
||||
}
|
||||
|
||||
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 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<std::string, std::string> parseContentDisposition(const std::vector<std
|
||||
bool readAndDiscardOrSendError(httpd_req_t* request, const std::string& toRead) {
|
||||
size_t 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");
|
||||
return false;
|
||||
}
|
||||
@ -184,7 +201,7 @@ size_t receiveFile(httpd_req_t* request, size_t length, const std::string& fileP
|
||||
LOGGER.error("Receive failed");
|
||||
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");
|
||||
break;
|
||||
}
|
||||
|
||||
395
Tactility/Source/service/webserver/AssetVersion.cpp
Normal file
395
Tactility/Source/service/webserver/AssetVersion.cpp
Normal 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
|
||||
1862
Tactility/Source/service/webserver/WebServerService.cpp
Normal file
1862
Tactility/Source/service/webserver/WebServerService.cpp
Normal file
File diff suppressed because it is too large
Load Diff
260
Tactility/Source/settings/WebServerSettings.cpp
Normal file
260
Tactility/Source/settings/WebServerSettings.cpp
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user