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:
Ken Van Hoeylandt
2026-08-13 20:30:47 +02:00
committed by GitHub
parent cc8be3faef
commit d6b1d15e56
53 changed files with 603 additions and 1089 deletions
@@ -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;
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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;
}
+192
View File
@@ -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.
+16 -3
View File
@@ -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) {
+13
View File
@@ -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
+10 -1
View File
@@ -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 ||