Various improvements (#614)
- Auto-select widgets in Launcher and apps with toolbars on devices without touch. - Improved USB HID input reliability, cleanup - Updated PSRAM settings to improve boot stability on supported devices. - Prevented duplicate Wi-Fi event subscriptions during screen rebuilds. - Updated docs - Fixes in WifiManage and WifiConnect - Reduced main task stack size - Moved USB HID stack size to PSRAM when available - app_manager_find_manifest() now returns a copy instead of a pointer
This commit is contained in:
committed by
GitHub
parent
cc8be3faef
commit
d6b1d15e56
@@ -514,9 +514,7 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) {
|
||||
.on_start = onLvglStarted,
|
||||
.on_stop = onLvglStopped,
|
||||
.task_priority = THREAD_PRIORITY_HIGHER,
|
||||
/** Minimum seems to be about 3500. In some scenarios, the WiFi app crashes at 8192,
|
||||
* so we now have 9120 to run in a stable manner. We should figure out a way to avoid this.
|
||||
* Perhaps we can give apps their own stack space and deal with lvgl callback handlers in a clever way. */
|
||||
// TODO: Remove Wi-Fi driver callback mechanism and use subscribe/await from wifi app to be able to reduce callstack
|
||||
.task_stack_size = 9120,
|
||||
#ifdef ESP_PLATFORM
|
||||
.task_affinity = getCpuAffinityConfiguration().graphics
|
||||
|
||||
@@ -109,7 +109,11 @@ int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.targetAppId = (argc > 0) ? argv[0] : std::string();
|
||||
ctx.targetManifest = *app_manager_find_manifest(ctx.targetAppId.c_str());
|
||||
if (app_manager_find_manifest(ctx.targetAppId.c_str(), &ctx.targetManifest) != ERROR_NONE) {
|
||||
LOG_W(TAG, "App %s not found", ctx.targetAppId.c_str());
|
||||
app_manager_finish(appInstanceId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
|
||||
@@ -99,7 +99,8 @@ void showApps(Context* ctx) {
|
||||
for (int i = 0; i < ctx->entries.size(); i++) {
|
||||
auto& entry = ctx->entries[i];
|
||||
LOG_I(TAG, "Adding %s", entry.appName.c_str());
|
||||
const char* icon = app_manager_find_manifest(entry.appId.c_str()) != nullptr ? LV_SYMBOL_OK : nullptr;
|
||||
AppManifest manifest;
|
||||
const char* icon = app_manager_find_manifest(entry.appId.c_str(), &manifest) == ERROR_NONE ? LV_SYMBOL_OK : nullptr;
|
||||
auto* entry_button = lv_list_add_button(list, icon, entry.appName.c_str());
|
||||
auto int_as_voidptr = reinterpret_cast<void*>(i);
|
||||
lv_obj_set_user_data(entry_button, int_as_voidptr);
|
||||
|
||||
@@ -159,7 +159,8 @@ void updateApp(Context* ctx) {
|
||||
void updateViews(Context* ctx) {
|
||||
lvgl_toolbar_clear_actions(ctx->toolbar);
|
||||
auto app_id = ctx->entry.appId.c_str();
|
||||
const auto manifest = app_manager_find_manifest(app_id);
|
||||
AppManifest manifest;
|
||||
bool is_installed = app_manager_find_manifest(app_id, &manifest) == ERROR_NONE;
|
||||
ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar);
|
||||
lv_obj_add_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
@@ -177,7 +178,7 @@ void updateViews(Context* ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (manifest != nullptr) {
|
||||
if (is_installed) {
|
||||
if (metadata.app_version_code < ctx->entry.appVersionCode) {
|
||||
ctx->updateButton = lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, ctx);
|
||||
lv_obj_remove_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
@@ -149,7 +149,8 @@ std::string getLauncherAppId() {
|
||||
}
|
||||
|
||||
// If the app in the boot.properties does not exist, return default
|
||||
if (app_manager_find_manifest(boot_properties.launcherAppId.c_str()) == nullptr) {
|
||||
AppManifest manifest;
|
||||
if (app_manager_find_manifest(boot_properties.launcherAppId.c_str(), &manifest) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Launcher app %s not found", boot_properties.launcherAppId.c_str());
|
||||
return CONFIG_TT_LAUNCHER_APP_ID;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Chat App
|
||||
|
||||
ESP-NOW-based chat application with channel-based messaging. Devices with the same encryption key can communicate in real-time without requiring a WiFi access point or internet connection.
|
||||
|
||||
## Features
|
||||
|
||||
- **Channel-based messaging**: Join named channels (e.g. `#general`, `#random`) to organize conversations
|
||||
- **Broadcast support**: Messages with empty target are visible in all channels
|
||||
- **Configurable nickname**: Identify yourself with a custom name (max 23 characters)
|
||||
- **Unique sender ID**: Each device gets a random 32-bit ID on first launch for future DM support
|
||||
- **Encryption key**: Optional shared key for private group communication
|
||||
- **Persistent settings**: Sender ID, nickname, key, and current chat channel are saved across reboots
|
||||
|
||||
## Requirements
|
||||
|
||||
- ESP32 with WiFi support (not available on ESP32-P4)
|
||||
- ESP-NOW service enabled
|
||||
|
||||
## UI Layout
|
||||
|
||||
```text
|
||||
+------------------------------------------+
|
||||
| [Back] Chat: #general [List] [Gear] |
|
||||
+------------------------------------------+
|
||||
| alice: hello everyone |
|
||||
| bob: hey alice! |
|
||||
| You: hi there |
|
||||
| (scrollable message list) |
|
||||
+------------------------------------------+
|
||||
| [____input textarea____] [Send] |
|
||||
+------------------------------------------+
|
||||
```
|
||||
|
||||
- **Toolbar title**: Shows `Chat: <channel>` with the current channel name
|
||||
- **List icon**: Opens channel selector to switch channels
|
||||
- **Gear icon**: Opens settings panel (nickname, encryption key)
|
||||
- **Message list**: Shows messages matching the current channel or broadcast messages
|
||||
- **Input bar**: Type and send messages to the current channel
|
||||
|
||||
## Channel Selector
|
||||
|
||||
Tap the list icon to change channels. Enter a channel name (e.g. `#general`, `#team1`) and press OK. The message list refreshes to show only messages matching the new channel.
|
||||
|
||||
Messages are sent with the current channel as the target. Only devices viewing the same channel will display the message. Broadcast messages (empty target) appear in all channels.
|
||||
|
||||
## First Launch
|
||||
|
||||
On first launch (when no settings file exists), the settings panel opens automatically so users can configure their nickname before chatting. A unique sender ID is also generated using the hardware RNG.
|
||||
|
||||
## Settings
|
||||
|
||||
Tap the gear icon to configure:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Nickname | Your display name (max 23 chars) | `Device` |
|
||||
| Key | Encryption key as 32 hex characters (16 bytes) | All zeros (empty field) |
|
||||
|
||||
Settings are stored in `/data/settings/chat.properties`. The encryption key is stored encrypted using AES-256-CBC. The sender ID is stored as a decimal number.
|
||||
|
||||
When the key field is left empty, the default all-zeros key is used. All devices using the default key can communicate without configuration.
|
||||
|
||||
Changing the encryption key causes ESP-NOW to restart with the new configuration.
|
||||
|
||||
## Wire Protocol v2
|
||||
|
||||
Compact variable-length packets broadcast over ESP-NOW:
|
||||
|
||||
### Header (16 bytes)
|
||||
|
||||
```text
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 magic (0x54435432 "TCT2")
|
||||
4 2 protocol_version (2)
|
||||
6 4 from (sender ID, random uint32)
|
||||
10 4 to (recipient ID, 0 = broadcast/channel)
|
||||
14 1 payload_type (1 = TextMessage)
|
||||
15 1 payload_size (length of payload)
|
||||
```
|
||||
|
||||
### Text Message Payload (variable)
|
||||
|
||||
```text
|
||||
[nickname\0][target\0][message bytes]
|
||||
```
|
||||
|
||||
- `nickname`: Null-terminated sender display name (2-23 chars + null; single-letter names rejected)
|
||||
- `target`: Null-terminated channel or empty for broadcast (0-23 chars + null)
|
||||
- Empty string (`\0`): broadcast to all channels
|
||||
- Channel name (e.g. `#general`): visible only when viewing that channel
|
||||
- `message`: Remaining bytes, NOT null-terminated, minimum 1 byte (length = `payload_size - strlen(nickname) - 1 - strlen(target) - 1`)
|
||||
|
||||
**Minimum packet size for TextMessage:** 16 (header) + 2 (min nickname) + 1 (null) + 0 (empty target) + 1 (null) + 1 (min message) = **21 bytes**
|
||||
|
||||
**Example calculation:** If nickname is "Alice" (5 chars) and target is "#general" (8 chars):
|
||||
- Overhead: 5 + 1 + 8 + 1 = 15 bytes
|
||||
- Max message: 255 - 15 = 240 bytes
|
||||
|
||||
### Example
|
||||
|
||||
"Alice" sends "Hi!" to #general:
|
||||
- Header: 16 bytes
|
||||
- Payload: `Alice\0#general\0Hi!` = 18 bytes
|
||||
- **Total: 34 bytes**
|
||||
|
||||
### Size Limits
|
||||
|
||||
| Constraint | Min | Max |
|
||||
|------------|-----|-----|
|
||||
| Header size | 16 bytes | 16 bytes |
|
||||
| Payload (uint8_t) | 5 bytes | 255 bytes |
|
||||
| Nickname | 2 characters | 23 characters |
|
||||
| Channel/target | 0 (broadcast) | 23 characters |
|
||||
| Message (wire) | 1 byte | up to 251 bytes (varies by overhead) |
|
||||
| Message (UI) | 1 character | 200 characters |
|
||||
| Total packet (TextMessage) | 21 bytes | 271 bytes |
|
||||
|
||||
### Payload Types
|
||||
|
||||
| Type | Value | Description |
|
||||
|------|-------|-------------|
|
||||
| TextMessage | 1 | Chat message with nickname, target, and text |
|
||||
| (reserved) | 2+ | Future: Position, Telemetry, etc. |
|
||||
|
||||
### Target Field Semantics
|
||||
|
||||
| `to` Value | `target` Field | Meaning |
|
||||
|------------|----------------|---------|
|
||||
| 0 | `""` (empty) | Broadcast - visible in all channels |
|
||||
| 0 | `#channel` | Channel message - visible only when viewing that channel |
|
||||
| non-zero | `nickname` | Direct message (future - requires address discovery protocol) |
|
||||
|
||||
Messages with incorrect magic/version or invalid payload are silently discarded.
|
||||
|
||||
> **Note:** Direct messaging (non-zero `to`) will require an address discovery mechanism, such as periodic broadcasts announcing nickname→sender_id mappings, before devices can address each other directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
ChatApp - App lifecycle, ESP-NOW send/receive, settings management
|
||||
ChatState - Message storage (deque, max 100), channel filtering, mutex-protected
|
||||
ChatView - LVGL UI: toolbar, message list, input bar, settings/channel panels
|
||||
ChatProtocol - MessageHeader struct, serialize/deserialize, PayloadType enum
|
||||
ChatSettings - Properties file load/save with encrypted key storage, sender ID generation
|
||||
```
|
||||
|
||||
All files are guarded with `#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)` to exclude from P4 builds.
|
||||
|
||||
## Message Flow
|
||||
|
||||
### Sending
|
||||
|
||||
1. User types message and taps Send
|
||||
2. `serializeTextMessage()` builds compact packet with sender ID, nickname, channel, message
|
||||
3. Broadcast via ESP-NOW to nearby devices
|
||||
4. Own message stored and displayed locally
|
||||
|
||||
### Receiving
|
||||
|
||||
1. ESP-NOW callback fires with raw data
|
||||
2. Validate packet:
|
||||
- Minimum size: 21 bytes (16 header + 2 min nickname + 1 null + 0 min target + 1 null + 1 min message)
|
||||
- Magic bytes: must be `0x54435432` ("TCT2")
|
||||
- Protocol version: must be 2
|
||||
- Payload size: `header.payload_size` must equal `received_length - 16`
|
||||
3. Parse null-terminated nickname and target from payload
|
||||
4. Validate minimum lengths: nickname >= 2 chars, message >= 1 byte
|
||||
5. Extract message from remaining bytes (length derived from payload_size)
|
||||
6. Store in message deque with sender ID
|
||||
7. Display if target matches current channel or is broadcast (empty)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Maximum 100 stored messages (oldest discarded when full)
|
||||
- Nickname: 23 characters max
|
||||
- Channel name: 23 characters max
|
||||
- Message text: 200 characters max (UI limit; actual wire limit varies by nickname/target length)
|
||||
- No message persistence across app restarts (messages are in-memory only)
|
||||
- All communication is broadcast; channel filtering is client-side only
|
||||
- Sender ID collisions: 32-bit random IDs have ~50% collision probability at ~77,000 active devices (birthday paradox); no collision detection/resolution implemented
|
||||
|
||||
## Security Considerations
|
||||
|
||||
The chat protocol relies on ESP-NOW's built-in encryption (when configured) but has additional security limitations:
|
||||
|
||||
- **No message authentication**: No MAC/HMAC to verify message integrity or sender authenticity beyond the sender ID
|
||||
- **No replay protection**: No sequence numbers or timestamps; messages can be replayed
|
||||
- **Sender ID spoofing**: Any device knowing the encryption key can forge messages with arbitrary sender IDs
|
||||
- **No forward secrecy**: Compromise of the shared key exposes all past and future messages
|
||||
|
||||
These tradeoffs are acceptable for casual local communication but should be understood before using for sensitive applications.
|
||||
@@ -1,3 +1,6 @@
|
||||
#include "tactility/drivers/pointer.h"
|
||||
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
@@ -165,7 +168,7 @@ void createWidgets(lv_obj_t* parent, void*) {
|
||||
? computeButtonMargin(lv_display_get_horizontal_resolution(display), total_button_size)
|
||||
: computeButtonMargin(lv_display_get_vertical_resolution(display), total_button_size);
|
||||
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_APPS, "AppList", margin, is_landscape_display);
|
||||
auto* app_list_button = createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_APPS, "AppList", margin, is_landscape_display);
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_FOLDER, "Files", margin, is_landscape_display);
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_SETTINGS, "Settings", margin, is_landscape_display);
|
||||
|
||||
@@ -189,14 +192,24 @@ void createWidgets(lv_obj_t* parent, void*) {
|
||||
lv_label_set_text(power_label, LV_SYMBOL_POWER);
|
||||
lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
|
||||
}
|
||||
|
||||
// If we don't have a touch device, we assume there's some other kind of input like a keyboard, an encoder or button control
|
||||
// In that scenario we want to automatically have the app list button selected so the user doesn't have to press the widget selection
|
||||
// an extra time.
|
||||
if (!device_has_active_by_type(&POINTER_TYPE)) {
|
||||
// lv_obj_update_layout(parent); // Resolve flex layout first, so focus/state invalidate against final coords
|
||||
lv_group_focus_obj(app_list_button);
|
||||
lv_obj_add_state(app_list_button, LV_STATE_FOCUS_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
void runAutoStart() {
|
||||
settings::BootSettings boot_properties;
|
||||
AppManifest manifest;
|
||||
if (
|
||||
// Auto-start due to built-in requirement
|
||||
strcmp(CONFIG_TT_AUTO_START_APP_ID, "") != 0 &&
|
||||
app_manager_find_manifest(CONFIG_TT_AUTO_START_APP_ID) != nullptr
|
||||
app_manager_find_manifest(CONFIG_TT_AUTO_START_APP_ID, &manifest) == ERROR_NONE
|
||||
) {
|
||||
LOG_I(TAG, "Starting %s", CONFIG_TT_AUTO_START_APP_ID);
|
||||
uint32_t app_launch_id;
|
||||
@@ -205,7 +218,7 @@ void runAutoStart() {
|
||||
// Auto-start due to user configuration
|
||||
settings::loadBootSettings(boot_properties) &&
|
||||
!boot_properties.autoStartAppId.empty() &&
|
||||
app_manager_find_manifest(boot_properties.autoStartAppId.c_str()) != nullptr
|
||||
app_manager_find_manifest(boot_properties.autoStartAppId.c_str(), &manifest) == ERROR_NONE
|
||||
) {
|
||||
LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str());
|
||||
uint32_t app_launch_id;
|
||||
|
||||
@@ -105,6 +105,10 @@ void updateBusySpinner(Context* ctx) {
|
||||
}
|
||||
|
||||
void updateViews(Context* ctx) {
|
||||
if (ctx->connectButton == nullptr) {
|
||||
// Buried (e.g. the forget confirmation dialog opened on top) - see destroyWidgets().
|
||||
return;
|
||||
}
|
||||
updateConnectButton(ctx);
|
||||
updateBusySpinner(ctx);
|
||||
}
|
||||
@@ -115,13 +119,18 @@ void requestViewUpdate(Context* ctx) {
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
// Runs with the LVGL lock already held, possibly on another app's thread - see
|
||||
// WindowDestroyWidgetsFn's warnings. Must stay lock-free: only nulls pointers.
|
||||
void destroyWidgets(void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
ctx->busySpinner = nullptr;
|
||||
ctx->connectButton = nullptr;
|
||||
ctx->disconnectButton = nullptr;
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
ctx->wifiSubscription = service::wifi::getPubsub()->subscribe([ctx](auto) {
|
||||
requestViewUpdate(ctx);
|
||||
});
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
@@ -202,7 +211,14 @@ int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
// Subscribed once here, not in createWidgets(): that callback re-runs on every
|
||||
// burial/resurface rebuild, and re-subscribing there would leak the previous subscription
|
||||
// (and its captured ctx pointer) every time, only the last of which shutdown ever cleans up.
|
||||
ctx.wifiSubscription = service::wifi::getPubsub()->subscribe([&ctx](auto) {
|
||||
requestViewUpdate(&ctx);
|
||||
});
|
||||
|
||||
WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
|
||||
@@ -120,6 +120,11 @@ void setLoading(Context* ctx, bool loading) {
|
||||
}
|
||||
|
||||
void updateView(Context* ctx) {
|
||||
if (ctx->connect_button == nullptr) {
|
||||
// Buried (e.g. this window's own connecting state closed it, or a future dialog opens
|
||||
// on top) - see destroyWidgets().
|
||||
return;
|
||||
}
|
||||
if (ctx->connectionError) {
|
||||
setLoading(ctx, false);
|
||||
resetErrors(ctx);
|
||||
@@ -194,14 +199,24 @@ void createBottomButtons(Context* ctx, lv_obj_t* parent) {
|
||||
lv_obj_add_event_cb(ctx->connect_button, onConnectPressed, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
}
|
||||
|
||||
// Runs with the LVGL lock already held, possibly on another app's thread - see
|
||||
// WindowDestroyWidgetsFn's warnings. Must stay lock-free: only nulls pointers.
|
||||
void destroyWidgets(void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
ctx->ssid_textarea = nullptr;
|
||||
ctx->ssid_error = nullptr;
|
||||
ctx->password_textarea = nullptr;
|
||||
ctx->password_error = nullptr;
|
||||
ctx->connect_button = nullptr;
|
||||
ctx->remember_switch = nullptr;
|
||||
ctx->connecting_spinner = nullptr;
|
||||
ctx->connection_error = nullptr;
|
||||
}
|
||||
|
||||
// TODO: Standardize dialogs
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
ctx->wifiSubscription = service::wifi::getPubsub()->subscribe([ctx](auto event) {
|
||||
onWifiEvent(ctx, event);
|
||||
});
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
@@ -302,7 +317,14 @@ int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
// Subscribed once here, not in createWidgets(): that callback re-runs on every
|
||||
// burial/resurface rebuild, and re-subscribing there would leak the previous subscription
|
||||
// (and its captured ctx pointer) every time, only the last of which shutdown ever cleans up.
|
||||
ctx.wifiSubscription = service::wifi::getPubsub()->subscribe([&ctx](auto event) {
|
||||
onWifiEvent(&ctx, event);
|
||||
});
|
||||
|
||||
WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
|
||||
@@ -323,10 +323,23 @@ void View::init(uint32_t newAppInstanceId, lv_obj_t* parent) {
|
||||
}
|
||||
|
||||
void View::update() {
|
||||
if (root == nullptr) {
|
||||
// Buried (or not yet built) - see reset().
|
||||
return;
|
||||
}
|
||||
updateWifiToggle();
|
||||
updateScanning();
|
||||
updateNetworkList();
|
||||
updateConnectToHidden();
|
||||
}
|
||||
|
||||
void View::reset() {
|
||||
root = nullptr;
|
||||
enable_switch = nullptr;
|
||||
enable_on_boot_switch = nullptr;
|
||||
scanning_spinner = nullptr;
|
||||
networks_list = nullptr;
|
||||
connect_to_hidden = nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -65,6 +65,8 @@ static void onConnectToHidden() {
|
||||
void requestViewUpdate(Context* ctx) {
|
||||
ctx->lock();
|
||||
lvgl_lock();
|
||||
// Safe even while buried (e.g. WifiApSettings/WifiConnect opened on top): destroyWidgets()
|
||||
// nulls the view's widget pointers before they're deleted, and update() no-ops on that.
|
||||
ctx->view.update();
|
||||
lvgl_unlock();
|
||||
ctx->unlock();
|
||||
@@ -103,6 +105,13 @@ void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
ctx->unlock();
|
||||
}
|
||||
|
||||
// Runs with the LVGL lock already held, possibly on another app's thread - see
|
||||
// WindowDestroyWidgetsFn's warnings. Must stay lock-free: View::reset() only nulls pointers.
|
||||
void destroyWidgets(void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
ctx->view.reset();
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx;
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
@@ -127,7 +136,7 @@ int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
|
||||
|
||||
service::wifi::RadioState radio_state = service::wifi::getRadioState();
|
||||
bool can_scan = radio_state == service::wifi::RadioState::On ||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/usb_host_hid.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/memory.h>
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/queue.h>
|
||||
@@ -43,6 +44,9 @@ struct UsbHidInputCtx {
|
||||
QueueHandle_t key_queue = nullptr;
|
||||
TaskHandle_t task = nullptr;
|
||||
SemaphoreHandle_t task_done = nullptr;
|
||||
// Task control block must stay in internal RAM; only the stack may live in SPIRAM
|
||||
StackType_t* task_stack = nullptr;
|
||||
StaticTask_t* task_tcb = nullptr;
|
||||
std::atomic<bool> running{false};
|
||||
std::atomic<bool> subscribed{false};
|
||||
|
||||
@@ -148,24 +152,12 @@ static void usbHidInputTask(void* arg) {
|
||||
auto* ctx = static_cast<UsbHidInputCtx*>(arg);
|
||||
LOG_I(TAG, "started");
|
||||
|
||||
// TODO: Implement time-out
|
||||
while (!lv_is_initialized()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
}
|
||||
|
||||
// The mouse cursor image (loaded from the flash-backed asset filesystem) is created by
|
||||
// startUsbHidInput() on the caller's stack, before this task exists: this task's stack may
|
||||
// live in SPIRAM, and touching flash I/O from a SPIRAM stack crashes when the flash cache
|
||||
// gets disabled mid-read.
|
||||
lvgl_lock();
|
||||
|
||||
// Without a registered display, lv_layer_sys() is NULL: creating the cursor image on it trips
|
||||
// an LVGL assert whose default handler is an infinite loop (while(1);), hanging this task while
|
||||
// it holds the LVGL lock. Only create the cursor when a system layer actually exists.
|
||||
lv_obj_t* sys_layer = lv_layer_sys();
|
||||
if (sys_layer != nullptr) {
|
||||
ctx->mouse_cursor = lv_image_create(sys_layer);
|
||||
lv_obj_remove_flag(ctx->mouse_cursor, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_image_set_src(ctx->mouse_cursor, TT_ASSETS_UI_CURSOR);
|
||||
lv_obj_add_flag(ctx->mouse_cursor, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
ctx->mouse_indev = lv_indev_create();
|
||||
lv_indev_set_type(ctx->mouse_indev, LV_INDEV_TYPE_POINTER);
|
||||
lv_indev_set_read_cb(ctx->mouse_indev, mouse_read_cb);
|
||||
@@ -282,7 +274,13 @@ static void usbHidInputTask(void* arg) {
|
||||
|
||||
LOG_I(TAG, "stopped");
|
||||
xSemaphoreGive(ctx->task_done);
|
||||
vTaskDelete(nullptr);
|
||||
|
||||
// Never self-delete: vTaskDelete(NULL) can only defer its TCB/stack cleanup to the idle
|
||||
// task, which would still be touching task_stack/task_tcb after stopUsbHidInput() frees
|
||||
// them. Suspending instead leaves this task parked (never running again) so
|
||||
// stopUsbHidInput() can delete it from its own task context, where a non-running target
|
||||
// makes vTaskDelete() free everything synchronously, before it touches those buffers.
|
||||
vTaskSuspend(nullptr);
|
||||
}
|
||||
|
||||
void startUsbHidInput() {
|
||||
@@ -314,6 +312,22 @@ void startUsbHidInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Created here (not in usbHidInputTask) because loading the cursor image touches the
|
||||
// flash-backed asset filesystem, which the task's (potentially SPIRAM-backed) stack must
|
||||
// never do - see the comment in usbHidInputTask.
|
||||
lvgl_lock();
|
||||
// Without a registered display, lv_layer_sys() is NULL: creating the cursor image on it trips
|
||||
// an LVGL assert whose default handler is an infinite loop (while(1);). Only create the
|
||||
// cursor when a system layer actually exists.
|
||||
lv_obj_t* sys_layer = lv_layer_sys();
|
||||
if (sys_layer != nullptr) {
|
||||
ctx->mouse_cursor = lv_image_create(sys_layer);
|
||||
lv_obj_remove_flag(ctx->mouse_cursor, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_image_set_src(ctx->mouse_cursor, TT_ASSETS_UI_CURSOR);
|
||||
lv_obj_add_flag(ctx->mouse_cursor, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
lvgl_unlock();
|
||||
|
||||
Device* hid_dev = nullptr;
|
||||
if (device_get_first_active_by_type(&USB_HOST_HID_TYPE, &hid_dev) == ERROR_NONE) {
|
||||
ctx->subscribed = usb_host_hid_subscribe(hid_dev, ctx->hid_queue);
|
||||
@@ -321,7 +335,23 @@ void startUsbHidInput() {
|
||||
}
|
||||
|
||||
ctx->running = true;
|
||||
if (xTaskCreate(usbHidInputTask, "usb_hid_inp", TASK_STACK, ctx, TASK_PRIORITY, &ctx->task) != pdPASS) {
|
||||
|
||||
static constexpr MemoryPolicy STACK_POLICY = { 0, MEMORY_CAPABILITY_EXTERNAL, 0 };
|
||||
ctx->task_stack = static_cast<StackType_t*>(memory_alloc_with_policy(TASK_STACK * sizeof(StackType_t), &STACK_POLICY));
|
||||
if (ctx->task_stack != nullptr) {
|
||||
static constexpr MemoryPolicy TCB_POLICY = { MEMORY_CAPABILITY_INTERNAL, 0, 0 };
|
||||
ctx->task_tcb = static_cast<StaticTask_t*>(memory_alloc_with_policy(sizeof(StaticTask_t), &TCB_POLICY));
|
||||
}
|
||||
|
||||
if (ctx->task_tcb != nullptr) {
|
||||
ctx->task = xTaskCreateStatic(usbHidInputTask, "usb_hid_inp", TASK_STACK, ctx, TASK_PRIORITY, ctx->task_stack, ctx->task_tcb);
|
||||
} else {
|
||||
memory_free(ctx->task_stack);
|
||||
ctx->task_stack = nullptr;
|
||||
xTaskCreate(usbHidInputTask, "usb_hid_inp", TASK_STACK, ctx, TASK_PRIORITY, &ctx->task);
|
||||
}
|
||||
|
||||
if (ctx->task == nullptr) {
|
||||
LOG_E(TAG, "failed to create task");
|
||||
ctx->running = false;
|
||||
if (ctx->subscribed) {
|
||||
@@ -331,6 +361,13 @@ void startUsbHidInput() {
|
||||
device_put(cleanup_dev);
|
||||
}
|
||||
}
|
||||
memory_free(ctx->task_stack);
|
||||
memory_free(ctx->task_tcb);
|
||||
if (ctx->mouse_cursor != nullptr) {
|
||||
lvgl_lock();
|
||||
lv_obj_delete(ctx->mouse_cursor);
|
||||
lvgl_unlock();
|
||||
}
|
||||
vQueueDelete(ctx->hid_queue);
|
||||
vQueueDelete(ctx->key_queue);
|
||||
vSemaphoreDelete(ctx->task_done);
|
||||
@@ -351,21 +388,36 @@ void stopUsbHidInput() {
|
||||
|
||||
if (xSemaphoreTake(ctx->task_done, pdMS_TO_TICKS(STOP_TIMEOUT_MS)) != pdTRUE) {
|
||||
LOG_W(TAG, "task stop timed out, force terminating");
|
||||
vTaskDelete(ctx->task);
|
||||
// Task was killed before it could clean up LVGL objects; do it here to
|
||||
// prevent mouse_read_cb / keyboard_read_cb from running with a freed ctx.
|
||||
if (lvgl_try_lock(pdMS_TO_TICKS(200))) {
|
||||
if (ctx->mouse_indev) { lv_indev_delete(ctx->mouse_indev); ctx->mouse_indev = nullptr; }
|
||||
if (ctx->mouse_cursor) { lv_obj_delete(ctx->mouse_cursor); ctx->mouse_cursor = nullptr; }
|
||||
if (ctx->kb_indev) {
|
||||
lvgl_hardware_keyboard_remove_custom(ctx->kb_indev);
|
||||
lv_indev_delete(ctx->kb_indev);
|
||||
ctx->kb_indev = nullptr;
|
||||
}
|
||||
lvgl_unlock();
|
||||
// Task hasn't reached its own cleanup/vTaskSuspend() yet - it may even be blocked inside
|
||||
// its own lvgl_lock() (usbHidInputTask's post-loop cleanup), which leaves it eBlocked
|
||||
// rather than eRunning. If we gave up here on a failed try-lock, the eTaskGetState()
|
||||
// loop below would see that same eBlocked state, treat the task as done, and delete()
|
||||
// ctx below while the indevs still hold it as user_data. Block for as long as it takes
|
||||
// to get the lock instead - the task's own cleanup is idempotent (guarded by these same
|
||||
// null checks) so it's harmless if it also runs this after us.
|
||||
lvgl_lock();
|
||||
if (ctx->mouse_indev) { lv_indev_delete(ctx->mouse_indev); ctx->mouse_indev = nullptr; }
|
||||
if (ctx->mouse_cursor) { lv_obj_delete(ctx->mouse_cursor); ctx->mouse_cursor = nullptr; }
|
||||
if (ctx->kb_indev) {
|
||||
lvgl_hardware_keyboard_remove_custom(ctx->kb_indev);
|
||||
lv_indev_delete(ctx->kb_indev);
|
||||
ctx->kb_indev = nullptr;
|
||||
}
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
// usbHidInputTask() always ends by suspending itself (never self-deletes), so it's
|
||||
// guaranteed to still exist here. Wait until it's actually not running before deleting it:
|
||||
// vTaskDelete() on a non-running target runs its TCB/stack cleanup synchronously instead
|
||||
// of deferring it to the idle task, which is what makes it safe to free task_stack/
|
||||
// task_tcb right below - a deferred cleanup would still be touching them.
|
||||
while (eTaskGetState(ctx->task) == eRunning) {
|
||||
taskYIELD();
|
||||
}
|
||||
vTaskDelete(ctx->task);
|
||||
ctx->task = nullptr;
|
||||
memory_free(ctx->task_stack);
|
||||
memory_free(ctx->task_tcb);
|
||||
|
||||
if (ctx->subscribed) {
|
||||
Device* hid_dev;
|
||||
|
||||
@@ -225,7 +225,8 @@ esp_err_t DevelopmentService::handleAppUninstall(httpd_req_t* request) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (!app_manager_find_manifest(id_key_pos->second.c_str())) {
|
||||
AppManifest manifest;
|
||||
if (app_manager_find_manifest(id_key_pos->second.c_str(), &manifest) != ERROR_NONE) {
|
||||
LOG_I(TAG, "[200] /app/uninstall %s (app wasn't installed)", id_key_pos->second.c_str());
|
||||
httpd_resp_send(request, nullptr, 0);
|
||||
return ESP_OK;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# DisplayIdle Service
|
||||
|
||||
The DisplayIdle service manages screen timeout, screensavers, and backlight control for Tactility devices.
|
||||
|
||||
## Features
|
||||
|
||||
### Screen Timeout
|
||||
When enabled, the display will automatically dim after a configurable period of inactivity. Timeout options:
|
||||
- 15 seconds
|
||||
- 30 seconds
|
||||
- 1 minute
|
||||
- 2 minutes
|
||||
- 5 minutes
|
||||
- Never
|
||||
|
||||
### Screensavers
|
||||
Four screensaver options are available:
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| **None** | Black screen only, backlight turns off immediately |
|
||||
| **Bouncing Balls** | Colored balls bouncing around the screen |
|
||||
| **Mystify** | Classic Windows-style polygon trails with color-changing effects |
|
||||
| **Matrix Rain** | Digital rain effect with terminal-style grid movement, 6-color gradient trails, glow effects, and random character flicker |
|
||||
|
||||
### Auto-Off Feature
|
||||
After 5 minutes of screensaver activity, the screensaver animation stops and the backlight turns off completely to save power. Touching the screen restores normal operation.
|
||||
|
||||
## Public API
|
||||
|
||||
The service exposes a public header for external control:
|
||||
|
||||
```cpp
|
||||
#include <Tactility/service/displayidle/DisplayIdleService.h>
|
||||
|
||||
// Get service instance
|
||||
auto displayIdle = tt::service::displayidle::findService();
|
||||
|
||||
// Force start screensaver immediately
|
||||
displayIdle->startScreensaver();
|
||||
|
||||
// Force stop screensaver and restore backlight
|
||||
displayIdle->stopScreensaver();
|
||||
|
||||
// Check if screensaver is currently active
|
||||
bool active = displayIdle->isScreensaverActive();
|
||||
|
||||
// Reload settings (call after external settings changes)
|
||||
displayIdle->reloadSettings();
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `DisplayIdleService.h` | Public header with service interface |
|
||||
| `DisplayIdle.cpp` | Service implementation |
|
||||
| `Screensaver.h` | Base class for screensaver implementations |
|
||||
| `BouncingBallsScreensaver.h/cpp` | Bouncing balls screensaver |
|
||||
| `MystifyScreensaver.h/cpp` | Mystify polygon screensaver |
|
||||
| `MatrixRainScreensaver.h/cpp` | Matrix digital rain screensaver |
|
||||
|
||||
### Screensaver Base Class
|
||||
|
||||
All screensavers inherit from the `Screensaver` base class:
|
||||
|
||||
```cpp
|
||||
class Screensaver {
|
||||
public:
|
||||
virtual void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) = 0;
|
||||
virtual void stop() = 0;
|
||||
virtual void update(lv_coord_t screenW, lv_coord_t screenH) = 0;
|
||||
};
|
||||
```
|
||||
|
||||
### Adding a New Screensaver
|
||||
|
||||
1. Create header and implementation files inheriting from `Screensaver`
|
||||
2. Add enum value to `ScreensaverType` in `DisplaySettings.h` (before `Count` sentinel)
|
||||
3. Add string conversion in `DisplaySettings.cpp` (`toString` and `fromString`)
|
||||
4. Add dropdown option in `Display.cpp` (order must match enum order)
|
||||
5. Add case in `DisplayIdle.cpp` `activateScreensaver()` switch
|
||||
6. Include the new header in `DisplayIdle.cpp`
|
||||
|
||||
**Note:** The `ScreensaverType::Count` sentinel must always be the last enum value - it's used for bounds checking in the UI.
|
||||
|
||||
## Settings Integration
|
||||
|
||||
Settings are stored in `/data/settings/display.properties` and managed through `DisplaySettings.h`:
|
||||
|
||||
```cpp
|
||||
struct DisplaySettings {
|
||||
Orientation orientation;
|
||||
uint8_t gammaCurve;
|
||||
uint8_t backlightDuty;
|
||||
bool backlightTimeoutEnabled;
|
||||
uint32_t backlightTimeoutMs;
|
||||
ScreensaverType screensaverType;
|
||||
};
|
||||
```
|
||||
|
||||
The Display app (`Display.cpp`) provides the UI for configuring these settings and notifies the DisplayIdle service when settings change via `reloadSettings()`.
|
||||
|
||||
## Timing
|
||||
|
||||
- Service tick interval: 50ms
|
||||
- Wake activity threshold: 100ms
|
||||
- Screensaver auto-off: 5 minutes (6000 ticks)
|
||||
@@ -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
|
||||
@@ -1261,8 +1261,8 @@ esp_err_t WebServerService::handleApiAppsRun(httpd_req_t* request) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
auto* manifest = app_manager_find_manifest(appId.c_str());
|
||||
if (manifest == nullptr) {
|
||||
AppManifest manifest;
|
||||
if (app_manager_find_manifest(appId.c_str(), &manifest) != ERROR_NONE) {
|
||||
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "app not found");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -1287,15 +1287,15 @@ esp_err_t WebServerService::handleApiAppsUninstall(httpd_req_t* request) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
auto* manifest = app_manager_find_manifest(appId.c_str());
|
||||
if (manifest == nullptr) {
|
||||
AppManifest manifest;
|
||||
if (app_manager_find_manifest(appId.c_str(), &manifest) != ERROR_NONE) {
|
||||
LOG_I(TAG, "[200] /api/apps/uninstall %s (app wasn't installed)", appId.c_str());
|
||||
httpd_resp_sendstr(request, "ok");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Only allow uninstalling external (side-loaded) apps
|
||||
if (manifest->location.type != APP_LOCATION_PATH) {
|
||||
if (manifest.location.type != APP_LOCATION_PATH) {
|
||||
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "cannot uninstall system apps");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user