From de9fb010f20c9c1c97cc2874385edfba0d929dce Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Sun, 25 Jan 2026 23:11:11 +1000 Subject: [PATCH 01/10] Chat app update, EspNow v2 & GPS Info --- Documentation/chat.md | 130 ++++++++ Documentation/espnow-v2.md | 92 ++++++ .../Include/Tactility/service/espnow/EspNow.h | 10 + .../Tactility/service/gps/GpsService.h | 4 + .../Tactility/app/chat/ChatAppPrivate.h | 46 +++ .../Private/Tactility/app/chat/ChatProtocol.h | 54 ++++ .../Private/Tactility/app/chat/ChatSettings.h | 33 ++ .../Private/Tactility/app/chat/ChatState.h | 59 ++++ .../Private/Tactility/app/chat/ChatView.h | 79 +++++ .../Tactility/service/espnow/EspNowService.h | 3 + Tactility/Source/app/chat/ChatApp.cpp | 249 ++++++++------- Tactility/Source/app/chat/ChatProtocol.cpp | 70 +++++ Tactility/Source/app/chat/ChatSettings.cpp | 160 ++++++++++ Tactility/Source/app/chat/ChatState.cpp | 60 ++++ Tactility/Source/app/chat/ChatView.cpp | 295 ++++++++++++++++++ .../Source/app/gpssettings/GpsSettings.cpp | 104 +++++- Tactility/Source/service/espnow/EspNow.cpp | 12 + .../Source/service/espnow/EspNowService.cpp | 12 + Tactility/Source/service/gps/GpsService.cpp | 20 +- 19 files changed, 1379 insertions(+), 113 deletions(-) create mode 100644 Documentation/chat.md create mode 100644 Documentation/espnow-v2.md create mode 100644 Tactility/Private/Tactility/app/chat/ChatAppPrivate.h create mode 100644 Tactility/Private/Tactility/app/chat/ChatProtocol.h create mode 100644 Tactility/Private/Tactility/app/chat/ChatSettings.h create mode 100644 Tactility/Private/Tactility/app/chat/ChatState.h create mode 100644 Tactility/Private/Tactility/app/chat/ChatView.h create mode 100644 Tactility/Source/app/chat/ChatProtocol.cpp create mode 100644 Tactility/Source/app/chat/ChatSettings.cpp create mode 100644 Tactility/Source/app/chat/ChatState.cpp create mode 100644 Tactility/Source/app/chat/ChatView.cpp diff --git a/Documentation/chat.md b/Documentation/chat.md new file mode 100644 index 000000000..b1f0d3ae3 --- /dev/null +++ b/Documentation/chat.md @@ -0,0 +1,130 @@ +# 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) +- **Encryption key**: Optional shared key for private group communication +- **Persistent settings**: 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 + +``` ++------------------------------------------+ +| [Back] Chat: #general [List] [Gear] | ++------------------------------------------+ +| alice: hello everyone | +| bob: hey alice! | +| You: hi there | +| (scrollable message list) | ++------------------------------------------+ +| [____input textarea____] [Send] | ++------------------------------------------+ +``` + +- **Toolbar title**: Shows `Chat: ` 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. + +## 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. + +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 + +Variable-length packed struct broadcast over ESP-NOW (ESP-NOW v2.0): + +``` +Offset Size Field +------ ---- ----- +0 4 header (magic: 0x31544354 "TCT1") +4 1 protocol_version (0x01) +5 24 sender_name (null-terminated, zero-padded) +29 24 target (null-terminated, zero-padded) +53 1-1417 message (null-terminated, variable length) +``` + +- **Minimum packet**: 54 bytes (header + 1 byte message) +- **Maximum packet**: 1470 bytes (ESP-NOW v2.0 limit) +- **v1.0 compatibility**: Messages < 250 bytes work with ESP-NOW v1.0 devices + +Messages with incorrect magic/version or invalid length are silently discarded. + +### Target Field Semantics + +| Target Value | Meaning | +|-------------|---------| +| `""` (empty) | Broadcast - visible in all channels | +| `#channel` | Channel message - visible only when viewing that channel | +| `username` | Direct message | + +## Architecture + +``` +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 - Variable-length Message struct, serialize/deserialize (v2.0 support) +ChatSettings - Properties file load/save with encrypted key storage +``` + +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. Message serialized into `Message` struct with current nickname and channel as target +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: size within valid range (54-1470 bytes), magic and version must match +3. Copy into aligned local struct (avoids unaligned access on embedded platforms) +4. Extract sender name, target, and message as strings +5. Store in message deque +6. 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: 1416 characters max (ESP-NOW v2.0) +- No message persistence across app restarts (messages are in-memory only) +- All communication is broadcast; channel filtering is client-side only +- Messages > 250 bytes only received by devices running ESP-NOW v2.0 diff --git a/Documentation/espnow-v2.md b/Documentation/espnow-v2.md new file mode 100644 index 000000000..55e0aad91 --- /dev/null +++ b/Documentation/espnow-v2.md @@ -0,0 +1,92 @@ +# ESP-NOW v2.0 Support & Chat App Enhancements + +## Summary + +- Add ESP-NOW v2.0 support with version detection and larger payload capability +- Update Chat app to use variable-length messages (up to 1416 characters) +- Maintain backwards compatibility with ESP-NOW v1.0 devices + +## ESP-NOW Service Changes + +### New API + +```cpp +// Get the ESP-NOW protocol version (1 or 2) +uint32_t espnow::getVersion(); + +// Get max payload size for current version (250 or 1470 bytes) +size_t espnow::getMaxDataLength(); + +// Constants +constexpr size_t MAX_DATA_LEN_V1 = 250; +constexpr size_t MAX_DATA_LEN_V2 = 1470; +``` + +### Version Detection + +ESP-NOW version is queried on initialization and logged: +``` +I (15620) ESPNOW: espnow [version: 2.0] init +I [EspNowService] ESP-NOW version: 2.0 +``` + +### Files Modified + +| File | Change | +|------|--------| +| `Tactility/Include/Tactility/service/espnow/EspNow.h` | Added constants and `getVersion()`, `getMaxDataLength()` | +| `Tactility/Source/service/espnow/EspNow.cpp` | Implemented new functions | +| `Tactility/Private/Tactility/service/espnow/EspNowService.h` | Added `espnowVersion` member | +| `Tactility/Source/service/espnow/EspNowService.cpp` | Query version after init | + +## Chat App Changes + +### Larger Messages + +- Message size increased from **127 to 1416 characters** +- Variable-length packet transmission (only sends actual message length) +- Backwards compatible: messages < 197 chars still work with v1.0 devices + +### Wire Protocol + +``` +Offset Size Field +------ ---- ----- +0 4 header (magic: 0x31544354) +4 1 protocol_version (0x01) +5 24 sender_name +29 24 target +53 1-1417 message (variable length) +``` + +- **Min packet**: 54 bytes +- **Max packet**: 1470 bytes (v2.0 limit) + +### Files Modified + +| File | Change | +|------|--------| +| `Tactility/Private/Tactility/app/chat/ChatProtocol.h` | `MESSAGE_SIZE` = 1417, added header constants | +| `Tactility/Source/app/chat/ChatProtocol.cpp` | Variable-length serialize/deserialize | +| `Tactility/Source/app/chat/ChatApp.cpp` | Send actual packet size | +| `Tactility/Source/app/chat/ChatView.cpp` | Input field max length = 1416 | +| `Documentation/chat.md` | Updated protocol documentation | + +## Compatibility + +| Scenario | Result | +|----------|--------| +| v2.0 device sends short message (< 250 bytes) | v1.0 and v2.0 devices receive | +| v2.0 device sends long message (> 250 bytes) | Only v2.0 devices receive | +| v1.0 device sends message | v1.0 and v2.0 devices receive | + +## Requirements + +- ESP-IDF v5.4 or later (for ESP-NOW v2.0 support) + +## Test Plan + +- [ ] Verify "ESP-NOW version: 2.0" appears in logs on startup +- [ ] Send short message between two v2.0 devices +- [ ] Send long message (> 200 chars) between two v2.0 devices +- [ ] Verify `espnow::getMaxDataLength()` returns 1470 diff --git a/Tactility/Include/Tactility/service/espnow/EspNow.h b/Tactility/Include/Tactility/service/espnow/EspNow.h index 429a56645..eecbb6d4c 100644 --- a/Tactility/Include/Tactility/service/espnow/EspNow.h +++ b/Tactility/Include/Tactility/service/espnow/EspNow.h @@ -16,6 +16,10 @@ namespace tt::service::espnow { typedef int ReceiverSubscription; constexpr ReceiverSubscription NO_SUBSCRIPTION = -1; +// ESP-NOW version payload limits +constexpr size_t MAX_DATA_LEN_V1 = 250; // ESP-NOW v1.0 max payload +constexpr size_t MAX_DATA_LEN_V2 = 1470; // ESP-NOW v2.0 max payload (requires ESP-IDF v5.4+) + enum class Mode { Station, AccessPoint @@ -54,6 +58,12 @@ ReceiverSubscription subscribeReceiver(std::function deviceRecords; @@ -58,6 +61,7 @@ class GpsService final : public Service { bool hasCoordinates() const; bool getCoordinates(minmea_sentence_rmc& rmc) const; + bool getGga(minmea_sentence_gga& gga) const; /** @return GPS service pubsub that broadcasts State* objects */ std::shared_ptr> getStatePubsub() const { return statePubSub; } diff --git a/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h b/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h new file mode 100644 index 000000000..7d1f83e55 --- /dev/null +++ b/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h @@ -0,0 +1,46 @@ +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include "ChatState.h" +#include "ChatView.h" +#include "ChatSettings.h" + +#include +#include + +namespace tt::app::chat { + +class ChatApp final : public App { + + ChatState state; + ChatView view = ChatView(this, &state); + service::espnow::ReceiverSubscription receiveSubscription = -1; + ChatSettingsData settings; + bool isFirstLaunch = false; + + void onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length); + void enableEspNow(); + void disableEspNow(); + +public: + void onCreate(AppContext& appContext) override; + void onDestroy(AppContext& appContext) override; + void onShow(AppContext& context, lv_obj_t* parent) override; + + void sendMessage(const std::string& text); + void applySettings(const std::string& nickname, const std::string& keyHex); + void switchChannel(const std::string& chatChannel); + + const ChatSettingsData& getSettings() const { return settings; } + + ~ChatApp() override = default; +}; + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/app/chat/ChatProtocol.h b/Tactility/Private/Tactility/app/chat/ChatProtocol.h new file mode 100644 index 000000000..2998981ba --- /dev/null +++ b/Tactility/Private/Tactility/app/chat/ChatProtocol.h @@ -0,0 +1,54 @@ +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include +#include +#include + +namespace tt::app::chat { + +constexpr uint32_t CHAT_MAGIC_HEADER = 0x31544354; // "TCT1" +constexpr uint8_t PROTOCOL_VERSION = 0x01; +constexpr size_t SENDER_NAME_SIZE = 24; +constexpr size_t TARGET_SIZE = 24; +constexpr size_t MESSAGE_SIZE = 1417; // Max for ESP-NOW v2.0 (1470 - 53 header bytes) + +// Header size = offset to message field (header + version + sender_name + target) +constexpr size_t MESSAGE_HEADER_SIZE = 4 + 1 + SENDER_NAME_SIZE + TARGET_SIZE; // 53 bytes +constexpr size_t MIN_PACKET_SIZE = MESSAGE_HEADER_SIZE + 1; // At least 1 byte of message + +struct __attribute__((packed)) Message { + uint32_t header; + uint8_t protocol_version; + char sender_name[SENDER_NAME_SIZE]; + char target[TARGET_SIZE]; // empty=broadcast, "#channel" or "username" + char message[MESSAGE_SIZE]; +}; + +static_assert(sizeof(Message) == 1470, "Message struct must be exactly ESP-NOW v2.0 max payload"); +static_assert(MESSAGE_HEADER_SIZE == offsetof(Message, message), "Header size calculation mismatch"); + +struct ParsedMessage { + std::string senderName; + std::string target; + std::string message; +}; + +/** Serialize fields into the wire format. + * Returns the actual packet size to send (variable length), or 0 on failure. + * Short messages (< 250 bytes total) are compatible with ESP-NOW v1.0 devices. */ +size_t serializeMessage(const std::string& senderName, const std::string& target, + const std::string& message, Message& out); + +/** Deserialize a received buffer into a ParsedMessage. + * Returns true if valid (correct magic, version, and minimum length). */ +bool deserializeMessage(const uint8_t* data, int length, ParsedMessage& out); + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/app/chat/ChatSettings.h b/Tactility/Private/Tactility/app/chat/ChatSettings.h new file mode 100644 index 000000000..c7249e3b3 --- /dev/null +++ b/Tactility/Private/Tactility/app/chat/ChatSettings.h @@ -0,0 +1,33 @@ +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include +#include +#include + +#include + +namespace tt::app::chat { + +constexpr auto* CHAT_SETTINGS_FILE = "/data/settings/chat.properties"; + +struct ChatSettingsData { + std::string nickname = "Device"; + std::array encryptionKey = {}; + bool hasEncryptionKey = false; + std::string chatChannel = "#general"; +}; + +ChatSettingsData loadSettings(); +bool saveSettings(const ChatSettingsData& settings); +ChatSettingsData getDefaultSettings(); +bool settingsFileExists(); + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/app/chat/ChatState.h b/Tactility/Private/Tactility/app/chat/ChatState.h new file mode 100644 index 000000000..eb0d239d5 --- /dev/null +++ b/Tactility/Private/Tactility/app/chat/ChatState.h @@ -0,0 +1,59 @@ +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include + +#include +#include +#include +#include + +namespace tt::app::chat { + +constexpr size_t MAX_MESSAGES = 100; + +struct StoredMessage { + std::string displayText; + std::string target; // for channel filtering + bool isOwn; +}; + +/** Thread safety: All public methods are mutex-protected. + * LVGL sync lock must be held separately when updating UI. */ +class ChatState { + + mutable RecursiveMutex mutex; + + std::deque messages; + std::string currentChannel = "#general"; + std::string localNickname = "Device"; + +public: + ChatState() = default; + ~ChatState() = default; + + ChatState(const ChatState&) = delete; + ChatState& operator=(const ChatState&) = delete; + ChatState(ChatState&&) = delete; + ChatState& operator=(ChatState&&) = delete; + + void setLocalNickname(const std::string& nickname); + std::string getLocalNickname() const; + + void setCurrentChannel(const std::string& channel); + std::string getCurrentChannel() const; + + void addMessage(const StoredMessage& msg); + + /** Returns messages matching the current channel (or broadcast). */ + std::vector getFilteredMessages() const; +}; + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/app/chat/ChatView.h b/Tactility/Private/Tactility/app/chat/ChatView.h new file mode 100644 index 000000000..e246aaf02 --- /dev/null +++ b/Tactility/Private/Tactility/app/chat/ChatView.h @@ -0,0 +1,79 @@ +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include "ChatState.h" +#include "ChatSettings.h" + +#include + +#include +#include + +namespace tt::app::chat { + +class ChatApp; + +class ChatView { + + ChatApp* app; + ChatState* state; + + lv_obj_t* toolbar = nullptr; + lv_obj_t* msgList = nullptr; + lv_obj_t* inputWrapper = nullptr; + lv_obj_t* inputField = nullptr; + + // Settings panel widgets + lv_obj_t* settingsPanel = nullptr; + lv_obj_t* nicknameInput = nullptr; + lv_obj_t* keyInput = nullptr; + + // Channel selector panel widgets + lv_obj_t* channelPanel = nullptr; + lv_obj_t* channelInput = nullptr; + + void createInputBar(lv_obj_t* parent); + void createSettingsPanel(lv_obj_t* parent); + void createChannelPanel(lv_obj_t* parent); + + void updateToolbarTitle(); + + static void addMessageToList(lv_obj_t* msgList, const StoredMessage& msg); + + static void onSendClicked(lv_event_t* e); + static void onSettingsClicked(lv_event_t* e); + static void onSettingsSave(lv_event_t* e); + static void onSettingsCancel(lv_event_t* e); + static void onChannelClicked(lv_event_t* e); + static void onChannelSave(lv_event_t* e); + static void onChannelCancel(lv_event_t* e); + +public: + ChatView(ChatApp* app, ChatState* state) : app(app), state(state) {} + ~ChatView() = default; + + ChatView(const ChatView&) = delete; + ChatView& operator=(const ChatView&) = delete; + ChatView(ChatView&&) = delete; + ChatView& operator=(ChatView&&) = delete; + + void init(AppContext& appContext, lv_obj_t* parent); + + void displayMessage(const StoredMessage& msg); + void refreshMessageList(); + + void showSettings(const ChatSettingsData& current); + void hideSettings(); + + void showChannelSelector(); + void hideChannelSelector(); +}; + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/service/espnow/EspNowService.h b/Tactility/Private/Tactility/service/espnow/EspNowService.h index d3478240f..c54ec35d8 100644 --- a/Tactility/Private/Tactility/service/espnow/EspNowService.h +++ b/Tactility/Private/Tactility/service/espnow/EspNowService.h @@ -31,6 +31,7 @@ class EspNowService final : public Service { std::vector subscriptions; ReceiverSubscription lastSubscriptionId = 0; bool enabled = false; + uint32_t espnowVersion = 0; // Dispatcher calls this and forwards to non-static function void enableFromDispatcher(const EspNowConfig& config); @@ -65,6 +66,8 @@ class EspNowService final : public Service { void unsubscribeReceiver(ReceiverSubscription subscription); + uint32_t getVersion() const; + // region Internal API }; diff --git a/Tactility/Source/app/chat/ChatApp.cpp b/Tactility/Source/app/chat/ChatApp.cpp index aa349748a..47ff26143 100644 --- a/Tactility/Source/app/chat/ChatApp.cpp +++ b/Tactility/Source/app/chat/ChatApp.cpp @@ -4,141 +4,178 @@ #if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#include +#include + #include -#include #include #include -#include - -#include "Tactility/lvgl/LvglSync.h" +#include -#include -#include -#include -#include +#include +#include namespace tt::app::chat { static const auto LOGGER = Logger("ChatApp"); -constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; - -class ChatApp : public App { - - lv_obj_t* msg_list = nullptr; - lv_obj_t* input_field = nullptr; - service::espnow::ReceiverSubscription receiveSubscription; - - void addMessage(const char* message) { - lv_obj_t* msg_label = lv_label_create(msg_list); - lv_label_set_text(msg_label, message); - lv_obj_set_width(msg_label, lv_pct(100)); - lv_label_set_long_mode(msg_label, LV_LABEL_LONG_WRAP); - lv_obj_set_style_text_align(msg_label, LV_TEXT_ALIGN_LEFT, 0); - lv_obj_set_style_pad_all(msg_label, 2, 0); - lv_obj_scroll_to_y(msg_list, lv_obj_get_scroll_y(msg_list) + 20, LV_ANIM_ON); +static constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; + +void ChatApp::enableEspNow() { + static uint8_t defaultKey[ESP_NOW_KEY_LEN] = {}; + auto config = service::espnow::EspNowConfig( + settings.hasEncryptionKey ? settings.encryptionKey.data() : defaultKey, + service::espnow::Mode::Station, + 1, // Channel 1 default; actual channel determined by WiFi if connected + false, + false + ); + service::espnow::enable(config); +} + +void ChatApp::disableEspNow() { + if (service::espnow::isEnabled()) { + service::espnow::disable(); } +} - static void onSendClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - auto* msg = lv_textarea_get_text(self->input_field); - const auto msg_len = strlen(msg); +void ChatApp::onCreate(AppContext& appContext) { + isFirstLaunch = !settingsFileExists(); + settings = loadSettings(); + state.setLocalNickname(settings.nickname); + if (!settings.chatChannel.empty()) { + state.setCurrentChannel(settings.chatChannel); + } + enableEspNow(); + + receiveSubscription = service::espnow::subscribeReceiver( + [this](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { + onReceive(receiveInfo, data, length); + } + ); +} - if (self->msg_list && msg && msg_len) { - self->addMessage(msg); +void ChatApp::onDestroy(AppContext& appContext) { + service::espnow::unsubscribeReceiver(receiveSubscription); + disableEspNow(); +} - if (!service::espnow::send(BROADCAST_ADDRESS, reinterpret_cast(msg), msg_len)) { - LOGGER.error("Failed to send message"); - } +void ChatApp::onShow(AppContext& context, lv_obj_t* parent) { + view.init(context, parent); + if (isFirstLaunch) { + view.showSettings(settings); + } +} - lv_textarea_set_text(self->input_field, ""); - } +void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { + ParsedMessage parsed; + if (!deserializeMessage(data, length, parsed)) { + return; } - void onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { - // Append \0 to make it a string - auto buffer = static_cast(malloc(length + 1)); - memcpy(buffer, data, length); - buffer[length] = 0x00; - const std::string message_prefixed = std::string("Received: ") + buffer; + StoredMessage msg; + msg.displayText = parsed.senderName + ": " + parsed.message; + msg.target = parsed.target; + msg.isOwn = false; - lvgl::getSyncLock()->lock(); - addMessage(message_prefixed.c_str()); - lvgl::getSyncLock()->unlock(); + state.addMessage(msg); - free(buffer); + { + auto lock = lvgl::getSyncLock()->asScopedLock(); + lock.lock(); + view.displayMessage(msg); } +} -public: +void ChatApp::sendMessage(const std::string& text) { + if (text.empty()) return; - void onCreate(AppContext& appContext) override { - // TODO: Move this to a configuration screen/app - static const uint8_t key[ESP_NOW_KEY_LEN] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; - auto config = service::espnow::EspNowConfig( - const_cast(key), - service::espnow::Mode::Station, - 1, - false, - false - ); + std::string nickname = state.getLocalNickname(); + std::string channel = state.getCurrentChannel(); - service::espnow::enable(config); + Message wireMsg; + size_t packetSize = serializeMessage(nickname, channel, text, wireMsg); + if (packetSize == 0) { + LOGGER.error("Failed to serialize message"); + return; + } - receiveSubscription = service::espnow::subscribeReceiver([this](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { - onReceive(receiveInfo, data, length); - }); + if (!service::espnow::send(BROADCAST_ADDRESS, reinterpret_cast(&wireMsg), packetSize)) { + LOGGER.error("Failed to send message"); + return; } - void onDestroy(AppContext& appContext) override { - service::espnow::unsubscribeReceiver(receiveSubscription); + StoredMessage msg; + msg.displayText = nickname + ": " + text; + msg.target = channel; + msg.isOwn = true; - if (service::espnow::isEnabled()) { - service::espnow::disable(); + state.addMessage(msg); + + { + auto lock = lvgl::getSyncLock()->asScopedLock(); + lock.lock(); + view.displayMessage(msg); + } +} + +void ChatApp::applySettings(const std::string& nickname, const std::string& keyHex) { + bool needRestart = false; + + settings.nickname = nickname; + + // Parse hex key + if (keyHex.size() == ESP_NOW_KEY_LEN * 2) { + bool validHex = true; + for (char c : keyHex) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + validHex = false; + break; + } + } + if (validHex) { + uint8_t newKey[ESP_NOW_KEY_LEN]; + for (int i = 0; i < ESP_NOW_KEY_LEN; i++) { + char hex[3] = { keyHex[i * 2], keyHex[i * 2 + 1], 0 }; + newKey[i] = static_cast(strtoul(hex, nullptr, 16)); + } + if (!std::equal(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin())) { + std::copy(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin()); + needRestart = true; + } + settings.hasEncryptionKey = true; + } else { + LOGGER.warn("Invalid hex characters in encryption key"); + } + } else if (keyHex.empty()) { + if (settings.hasEncryptionKey) { + settings.encryptionKey.fill(0); + settings.hasEncryptionKey = false; + needRestart = true; } + } else { + LOGGER.warn("Key must be exactly {} hex characters, got {}", ESP_NOW_KEY_LEN * 2, keyHex.size()); } - void onShow(AppContext& context, 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); - - lvgl::toolbar_create(parent, context); - - // Message list - msg_list = lv_list_create(parent); - lv_obj_set_flex_grow(msg_list, 1); - lv_obj_set_width(msg_list, LV_PCT(100)); - lv_obj_set_flex_grow(msg_list, 1); - lv_obj_set_style_bg_color(msg_list, lv_color_hex(0x262626), 0); - lv_obj_set_style_border_width(msg_list, 1, 0); - lv_obj_set_style_pad_ver(msg_list, 0, 0); - lv_obj_set_style_pad_hor(msg_list, 4, 0); - - // Input panel - auto* bottom_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(bottom_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_size(bottom_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(bottom_wrapper, 0, 0); - lv_obj_set_style_pad_column(bottom_wrapper, 4, 0); - lv_obj_set_style_border_opa(bottom_wrapper, 0, LV_STATE_DEFAULT); - - // Input field - input_field = lv_textarea_create(bottom_wrapper); - lv_obj_set_flex_grow(input_field, 1); - lv_textarea_set_placeholder_text(input_field, "Type a message..."); - lv_textarea_set_one_line(input_field, true); - - // Send button - auto* send_btn = lv_button_create(bottom_wrapper); - lv_obj_set_style_margin_all(send_btn, 0, LV_STATE_DEFAULT); - lv_obj_set_style_margin_top(send_btn, 2, LV_STATE_DEFAULT); // Hack to fix alignment - lv_obj_add_event_cb(send_btn, onSendClicked, LV_EVENT_CLICKED, this); - - auto* btn_label = lv_label_create(send_btn); - lv_label_set_text(btn_label, "Send"); - lv_obj_center(btn_label); + state.setLocalNickname(nickname); + saveSettings(settings); + + if (needRestart) { + disableEspNow(); + enableEspNow(); } +} - ~ChatApp() override = default; -}; +void ChatApp::switchChannel(const std::string& chatChannel) { + state.setCurrentChannel(chatChannel); + settings.chatChannel = chatChannel; + saveSettings(settings); + + { + auto lock = lvgl::getSyncLock()->asScopedLock(); + lock.lock(); + view.refreshMessageList(); + } +} extern const AppManifest manifest = { .appId = "Chat", @@ -147,6 +184,6 @@ extern const AppManifest manifest = { .createApp = create }; -} +} // namespace tt::app::chat #endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatProtocol.cpp b/Tactility/Source/app/chat/ChatProtocol.cpp new file mode 100644 index 000000000..6a3641368 --- /dev/null +++ b/Tactility/Source/app/chat/ChatProtocol.cpp @@ -0,0 +1,70 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include + +#include + +namespace tt::app::chat { + +size_t serializeMessage(const std::string& senderName, const std::string& target, + const std::string& message, Message& out) { + if (senderName.size() >= SENDER_NAME_SIZE || + target.size() >= TARGET_SIZE || + message.size() >= MESSAGE_SIZE) { + return 0; // Signal truncation would occur + } + memset(&out, 0, sizeof(out)); + out.header = CHAT_MAGIC_HEADER; + out.protocol_version = PROTOCOL_VERSION; + strncpy(out.sender_name, senderName.c_str(), SENDER_NAME_SIZE - 1); + strncpy(out.target, target.c_str(), TARGET_SIZE - 1); + strncpy(out.message, message.c_str(), MESSAGE_SIZE - 1); + + // Return actual packet size: header + message length + null terminator + return MESSAGE_HEADER_SIZE + message.size() + 1; +} + +bool deserializeMessage(const uint8_t* data, int length, ParsedMessage& out) { + // Accept variable-length packets (min header + 1 byte message, max full struct) + if (length < static_cast(MIN_PACKET_SIZE) || length > static_cast(sizeof(Message))) { + return false; + } + + // Copy into aligned local struct to avoid unaligned access on embedded platforms + Message msg; + memset(&msg, 0, sizeof(msg)); + memcpy(&msg, data, length); + + if (msg.header != CHAT_MAGIC_HEADER) { + return false; + } + + if (msg.protocol_version != PROTOCOL_VERSION) { + return false; + } + + // Ensure null-termination for each field + msg.sender_name[SENDER_NAME_SIZE - 1] = '\0'; + msg.target[TARGET_SIZE - 1] = '\0'; + + // Calculate actual message length from packet size and ensure null termination + size_t msgLen = length - MESSAGE_HEADER_SIZE; + if (msgLen > 0 && msgLen < MESSAGE_SIZE) { + msg.message[msgLen] = '\0'; // Ensure null at end of received data + } + msg.message[MESSAGE_SIZE - 1] = '\0'; // Safety: ensure buffer is always terminated + + out.senderName = msg.sender_name; + out.target = msg.target; + out.message = msg.message; + + return true; +} + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatSettings.cpp b/Tactility/Source/app/chat/ChatSettings.cpp new file mode 100644 index 000000000..1e517071d --- /dev/null +++ b/Tactility/Source/app/chat/ChatSettings.cpp @@ -0,0 +1,160 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace tt::app::chat { + +static const auto LOGGER = Logger("ChatSettings"); + +constexpr auto* KEY_NICKNAME = "nickname"; +constexpr auto* KEY_ENCRYPTION_KEY = "encryptionKey"; +constexpr auto* KEY_CHAT_CHANNEL = "chatChannel"; + +// IV_SEED provides basic obfuscation for stored encryption keys, not strong encryption. +// The device master key (from crypt::getIv) provides the actual security. +static constexpr auto* IV_SEED = "chat_key"; + +static std::string toHexString(const uint8_t* data, size_t length) { + std::stringstream stream; + stream << std::hex; + for (size_t i = 0; i < length; ++i) { + stream << std::setw(2) << std::setfill('0') << static_cast(data[i]); + } + return stream.str(); +} + +static bool readHex(const std::string& input, uint8_t* buffer, size_t length) { + if (input.size() != length * 2) { + LOGGER.error("readHex() length mismatch"); + return false; + } + + char hex[3] = { 0 }; + for (size_t i = 0; i < length; i++) { + hex[0] = input[i * 2]; + hex[1] = input[i * 2 + 1]; + char* endptr; + unsigned long val = strtoul(hex, &endptr, 16); + if (endptr != hex + 2) { + LOGGER.error("readHex() invalid hex character"); + return false; + } + buffer[i] = static_cast(val); + } + return true; +} + +static bool encryptKey(const uint8_t key[ESP_NOW_KEY_LEN], std::string& hexOutput) { + uint8_t iv[16]; + crypt::getIv(IV_SEED, strlen(IV_SEED), iv); + + uint8_t encrypted[ESP_NOW_KEY_LEN]; + if (crypt::encrypt(iv, key, encrypted, ESP_NOW_KEY_LEN) != 0) { + LOGGER.error("Failed to encrypt key"); + return false; + } + + hexOutput = toHexString(encrypted, ESP_NOW_KEY_LEN); + return true; +} + +static bool decryptKey(const std::string& hexInput, uint8_t key[ESP_NOW_KEY_LEN]) { + if (hexInput.size() != ESP_NOW_KEY_LEN * 2) { + return false; + } + + uint8_t encrypted[ESP_NOW_KEY_LEN]; + if (!readHex(hexInput, encrypted, ESP_NOW_KEY_LEN)) { + return false; + } + + uint8_t iv[16]; + crypt::getIv(IV_SEED, strlen(IV_SEED), iv); + + if (crypt::decrypt(iv, encrypted, key, ESP_NOW_KEY_LEN) != 0) { + LOGGER.error("Failed to decrypt key"); + return false; + } + return true; +} + +ChatSettingsData getDefaultSettings() { + return ChatSettingsData{ + .nickname = "Device", + .encryptionKey = {}, + .hasEncryptionKey = false, + .chatChannel = "#general" + }; +} + +ChatSettingsData loadSettings() { + ChatSettingsData settings = getDefaultSettings(); + + std::map map; + if (!file::loadPropertiesFile(CHAT_SETTINGS_FILE, map)) { + return settings; + } + + auto it = map.find(KEY_NICKNAME); + if (it != map.end() && !it->second.empty()) { + settings.nickname = it->second.substr(0, SENDER_NAME_SIZE - 1); + } + + it = map.find(KEY_ENCRYPTION_KEY); + if (it != map.end() && !it->second.empty()) { + if (decryptKey(it->second, settings.encryptionKey.data())) { + settings.hasEncryptionKey = true; + } + } + + it = map.find(KEY_CHAT_CHANNEL); + if (it != map.end() && !it->second.empty()) { + settings.chatChannel = it->second.substr(0, TARGET_SIZE - 1); + } + + return settings; +} + +bool saveSettings(const ChatSettingsData& settings) { + std::map map; + + map[KEY_NICKNAME] = settings.nickname; + map[KEY_CHAT_CHANNEL] = settings.chatChannel; + + if (settings.hasEncryptionKey) { + std::string encryptedHex; + if (!encryptKey(settings.encryptionKey.data(), encryptedHex)) { + return false; + } + map[KEY_ENCRYPTION_KEY] = encryptedHex; + } else { + map[KEY_ENCRYPTION_KEY] = ""; + } + + return file::savePropertiesFile(CHAT_SETTINGS_FILE, map); +} + +bool settingsFileExists() { + return access(CHAT_SETTINGS_FILE, F_OK) == 0; +} + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatState.cpp b/Tactility/Source/app/chat/ChatState.cpp new file mode 100644 index 000000000..100650e27 --- /dev/null +++ b/Tactility/Source/app/chat/ChatState.cpp @@ -0,0 +1,60 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include + +namespace tt::app::chat { + +void ChatState::setLocalNickname(const std::string& nickname) { + auto lock = mutex.asScopedLock(); + lock.lock(); + localNickname = nickname; +} + +std::string ChatState::getLocalNickname() const { + auto lock = mutex.asScopedLock(); + lock.lock(); + return localNickname; +} + +void ChatState::setCurrentChannel(const std::string& channel) { + auto lock = mutex.asScopedLock(); + lock.lock(); + currentChannel = channel; +} + +std::string ChatState::getCurrentChannel() const { + auto lock = mutex.asScopedLock(); + lock.lock(); + return currentChannel; +} + +void ChatState::addMessage(const StoredMessage& msg) { + auto lock = mutex.asScopedLock(); + lock.lock(); + if (messages.size() >= MAX_MESSAGES) { + messages.pop_front(); + } + messages.push_back(msg); +} + +std::vector ChatState::getFilteredMessages() const { + auto lock = mutex.asScopedLock(); + lock.lock(); + std::vector result; + result.reserve(messages.size()); // Avoid reallocations; may over-allocate slightly + for (const auto& msg : messages) { + // Show if broadcast (empty target) or matches current channel + if (msg.target.empty() || msg.target == currentChannel) { + result.push_back(msg); + } + } + return result; +} + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatView.cpp b/Tactility/Source/app/chat/ChatView.cpp new file mode 100644 index 000000000..0a9773fa3 --- /dev/null +++ b/Tactility/Source/app/chat/ChatView.cpp @@ -0,0 +1,295 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include +#include +#include + +#include + +#include +#include + +namespace tt::app::chat { + +void ChatView::addMessageToList(lv_obj_t* list, const StoredMessage& msg) { + auto* label = lv_label_create(list); + lv_label_set_text(label, msg.displayText.c_str()); + lv_obj_set_width(label, lv_pct(100)); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, 0); + lv_obj_set_style_pad_all(label, 2, 0); + + if (msg.isOwn) { + lv_obj_set_style_text_color(label, lv_color_hex(0x80C0FF), 0); + } +} + +void ChatView::updateToolbarTitle() { + if (!state || !toolbar) return; + std::string channel = state->getCurrentChannel(); + std::string title = "Chat: " + channel; + lvgl::toolbar_set_title(toolbar, title); +} + +void ChatView::createInputBar(lv_obj_t* parent) { + inputWrapper = lv_obj_create(parent); + auto* wrapper = inputWrapper; + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(wrapper, 0, 0); + lv_obj_set_style_pad_column(wrapper, 4, 0); + lv_obj_set_style_border_opa(wrapper, 0, LV_STATE_DEFAULT); + + inputField = lv_textarea_create(wrapper); + lv_obj_set_flex_grow(inputField, 1); + lv_textarea_set_placeholder_text(inputField, "Type a message..."); + lv_textarea_set_one_line(inputField, true); + lv_textarea_set_max_length(inputField, MESSAGE_SIZE - 1); + + auto* sendBtn = lv_button_create(wrapper); + lv_obj_set_style_margin_all(sendBtn, 0, LV_STATE_DEFAULT); + lv_obj_set_style_margin_top(sendBtn, 2, LV_STATE_DEFAULT); + lv_obj_add_event_cb(sendBtn, onSendClicked, LV_EVENT_CLICKED, this); + + auto* btnLabel = lv_label_create(sendBtn); + lv_label_set_text(btnLabel, "Send"); + lv_obj_center(btnLabel); +} + +void ChatView::createSettingsPanel(lv_obj_t* parent) { + settingsPanel = lv_obj_create(parent); + lv_obj_set_width(settingsPanel, LV_PCT(100)); + lv_obj_set_flex_grow(settingsPanel, 1); + lv_obj_set_flex_flow(settingsPanel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(settingsPanel, 8, 0); + lv_obj_set_style_pad_row(settingsPanel, 6, 0); + lv_obj_add_flag(settingsPanel, LV_OBJ_FLAG_HIDDEN); + + // Nickname + auto* nickLabel = lv_label_create(settingsPanel); + lv_label_set_text(nickLabel, "Nickname (max 23):"); + + nicknameInput = lv_textarea_create(settingsPanel); + lv_obj_set_width(nicknameInput, LV_PCT(100)); + lv_textarea_set_one_line(nicknameInput, true); + lv_textarea_set_max_length(nicknameInput, SENDER_NAME_SIZE - 1); + + // Encryption key + auto* keyLabel = lv_label_create(settingsPanel); + lv_label_set_text(keyLabel, "Key (32 hex chars):"); + + keyInput = lv_textarea_create(settingsPanel); + lv_obj_set_width(keyInput, LV_PCT(100)); + lv_textarea_set_one_line(keyInput, true); + lv_textarea_set_max_length(keyInput, ESP_NOW_KEY_LEN * 2); + lv_textarea_set_placeholder_text(keyInput, "empty = all zeros"); + + // Buttons + auto* btnRow = lv_obj_create(settingsPanel); + lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW); + lv_obj_set_size(btnRow, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(btnRow, 0, 0); + lv_obj_set_style_pad_column(btnRow, 8, 0); + lv_obj_set_style_border_opa(btnRow, 0, 0); + + auto* saveBtn = lv_button_create(btnRow); + lv_obj_add_event_cb(saveBtn, onSettingsSave, LV_EVENT_CLICKED, this); + auto* saveLbl = lv_label_create(saveBtn); + lv_label_set_text(saveLbl, "Save"); + + auto* cancelBtn = lv_button_create(btnRow); + lv_obj_add_event_cb(cancelBtn, onSettingsCancel, LV_EVENT_CLICKED, this); + auto* cancelLbl = lv_label_create(cancelBtn); + lv_label_set_text(cancelLbl, "Cancel"); +} + +void ChatView::createChannelPanel(lv_obj_t* parent) { + channelPanel = lv_obj_create(parent); + lv_obj_set_width(channelPanel, LV_PCT(100)); + lv_obj_set_flex_grow(channelPanel, 1); + lv_obj_set_flex_flow(channelPanel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(channelPanel, 8, 0); + lv_obj_set_style_pad_row(channelPanel, 6, 0); + lv_obj_add_flag(channelPanel, LV_OBJ_FLAG_HIDDEN); + + auto* label = lv_label_create(channelPanel); + lv_label_set_text(label, "Channel (e.g. #general):"); + + channelInput = lv_textarea_create(channelPanel); + lv_obj_set_width(channelInput, LV_PCT(100)); + lv_textarea_set_one_line(channelInput, true); + lv_textarea_set_max_length(channelInput, TARGET_SIZE - 1); + + auto* btnRow = lv_obj_create(channelPanel); + lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW); + lv_obj_set_size(btnRow, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(btnRow, 0, 0); + lv_obj_set_style_pad_column(btnRow, 8, 0); + lv_obj_set_style_border_opa(btnRow, 0, 0); + + auto* okBtn = lv_button_create(btnRow); + lv_obj_add_event_cb(okBtn, onChannelSave, LV_EVENT_CLICKED, this); + auto* okLbl = lv_label_create(okBtn); + lv_label_set_text(okLbl, "OK"); + + auto* cancelBtn = lv_button_create(btnRow); + lv_obj_add_event_cb(cancelBtn, onChannelCancel, LV_EVENT_CLICKED, this); + auto* cancelLbl = lv_label_create(cancelBtn); + lv_label_set_text(cancelLbl, "Cancel"); +} + +void ChatView::init(AppContext& appContext, lv_obj_t* parent) { + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + toolbar = lvgl::toolbar_create(parent, appContext); + lvgl::toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onChannelClicked, this); + lvgl::toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsClicked, this); + updateToolbarTitle(); + + // Message list + msgList = lv_list_create(parent); + lv_obj_set_flex_grow(msgList, 1); + lv_obj_set_width(msgList, LV_PCT(100)); + lv_obj_set_style_bg_color(msgList, lv_color_hex(0x262626), 0); + lv_obj_set_style_border_width(msgList, 0, 0); + lv_obj_set_style_pad_ver(msgList, 2, 0); + lv_obj_set_style_pad_hor(msgList, 4, 0); + + // Input bar + createInputBar(parent); + + // Overlay panels (hidden by default) + createSettingsPanel(parent); + createChannelPanel(parent); +} + +void ChatView::displayMessage(const StoredMessage& msg) { + if (!msgList || !state) return; + + // Only show if matches current channel or broadcast + std::string channel = state->getCurrentChannel(); + if (!msg.target.empty() && msg.target != channel) { + return; + } + addMessageToList(msgList, msg); + lv_obj_scroll_to_y(msgList, LV_COORD_MAX, LV_ANIM_ON); +} + +void ChatView::refreshMessageList() { + if (!msgList || !state) return; + + lv_obj_clean(msgList); + auto filtered = state->getFilteredMessages(); + for (const auto& msg : filtered) { + addMessageToList(msgList, msg); + } + lv_obj_scroll_to_y(msgList, LV_COORD_MAX, LV_ANIM_OFF); + updateToolbarTitle(); +} + +void ChatView::showSettings(const ChatSettingsData& current) { + if (!settingsPanel) return; + + lv_textarea_set_text(nicknameInput, current.nickname.c_str()); + + if (current.hasEncryptionKey) { + char hexStr[ESP_NOW_KEY_LEN * 2 + 1] = {}; + for (size_t i = 0; i < ESP_NOW_KEY_LEN; i++) { + snprintf(hexStr + i * 2, 3, "%02x", current.encryptionKey[i]); + } + lv_textarea_set_text(keyInput, hexStr); + } else { + lv_textarea_set_text(keyInput, ""); + } + + lv_obj_add_flag(msgList, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(inputWrapper, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(settingsPanel, LV_OBJ_FLAG_HIDDEN); +} + +void ChatView::hideSettings() { + if (!settingsPanel || !msgList || !inputWrapper) return; + lv_obj_add_flag(settingsPanel, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(msgList, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(inputWrapper, LV_OBJ_FLAG_HIDDEN); +} + +void ChatView::showChannelSelector() { + if (!channelPanel || !state) return; + + std::string current = state->getCurrentChannel(); + lv_textarea_set_text(channelInput, current.c_str()); + + lv_obj_add_flag(msgList, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(inputWrapper, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(channelPanel, LV_OBJ_FLAG_HIDDEN); +} + +void ChatView::hideChannelSelector() { + if (!channelPanel || !msgList || !inputWrapper) return; + lv_obj_add_flag(channelPanel, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(msgList, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(inputWrapper, LV_OBJ_FLAG_HIDDEN); +} + +void ChatView::onSendClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + auto* text = lv_textarea_get_text(self->inputField); + if (text && strlen(text) > 0) { + self->app->sendMessage(std::string(text)); + lv_textarea_set_text(self->inputField, ""); + } +} + +void ChatView::onSettingsClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + self->showSettings(self->app->getSettings()); +} + +void ChatView::onSettingsSave(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + + auto* nickname = lv_textarea_get_text(self->nicknameInput); + auto* keyHex = lv_textarea_get_text(self->keyInput); + + if (nickname && strlen(nickname) > 0) { + self->app->applySettings( + std::string(nickname), + keyHex ? std::string(keyHex) : std::string() + ); + } + self->hideSettings(); +} + +void ChatView::onSettingsCancel(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + self->hideSettings(); +} + +void ChatView::onChannelClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + self->showChannelSelector(); +} + +void ChatView::onChannelSave(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + auto* text = lv_textarea_get_text(self->channelInput); + if (text && strlen(text) > 0) { + self->app->switchChannel(std::string(text)); + } + self->hideChannelSelector(); +} + +void ChatView::onChannelCancel(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + self->hideChannelSelector(); +} + +} // namespace tt::app::chat + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/gpssettings/GpsSettings.cpp b/Tactility/Source/app/gpssettings/GpsSettings.cpp index 2316648b1..9e7bd73ca 100644 --- a/Tactility/Source/app/gpssettings/GpsSettings.cpp +++ b/Tactility/Source/app/gpssettings/GpsSettings.cpp @@ -30,6 +30,12 @@ class GpsSettingsApp final : public App { std::shared_ptr appReference = std::make_shared(this); lv_obj_t* statusWrapper = nullptr; lv_obj_t* statusLabelWidget = nullptr; + lv_obj_t* statusLatitudeValue = nullptr; + lv_obj_t* statusLongitudeValue = nullptr; + lv_obj_t* statusAltitudeValue = nullptr; + lv_obj_t* statusSpeedValue = nullptr; + lv_obj_t* statusHeadingValue = nullptr; + lv_obj_t* statusSatellitesValue = nullptr; lv_obj_t* switchWidget = nullptr; lv_obj_t* spinnerWidget = nullptr; lv_obj_t* infoContainerWidget = nullptr; @@ -203,14 +209,71 @@ class GpsSettingsApp final : public App { } minmea_sentence_rmc rmc; + char buffer[64]; if (service->getCoordinates(rmc)) { + lv_label_set_text(statusLabelWidget, "Lock acquired"); + lv_obj_set_style_text_color(statusLabelWidget, lv_color_hex(0x00ff00), 0); + minmea_float latitude = { rmc.latitude.value, rmc.latitude.scale }; minmea_float longitude = { rmc.longitude.value, rmc.longitude.scale }; - auto label_text = std::format("LAT {}\nLON {}", minmea_tocoord(&latitude), minmea_tocoord(&longitude)); - lv_label_set_text(statusLabelWidget, label_text.c_str()); + + double latCoord = minmea_tocoord(&latitude); + double lonCoord = minmea_tocoord(&longitude); + const char* latDir = (latCoord >= 0) ? "N" : "S"; + const char* lonDir = (lonCoord >= 0) ? "E" : "W"; + + snprintf(buffer, sizeof(buffer), "%.6f %s", std::abs(latCoord), latDir); + lv_label_set_text(statusLatitudeValue, buffer); + + snprintf(buffer, sizeof(buffer), "%.6f %s", std::abs(lonCoord), lonDir); + lv_label_set_text(statusLongitudeValue, buffer); + + float speedKnots = minmea_tofloat(&rmc.speed); + if (!isnan(speedKnots)) { + float speedKmh = speedKnots * 1.852f; + snprintf(buffer, sizeof(buffer), "%.1f km/h", speedKmh); + lv_label_set_text(statusSpeedValue, buffer); + } else { + lv_label_set_text(statusSpeedValue, "--"); + } + + float heading = minmea_tofloat(&rmc.course); + if (!isnan(heading)) { + const char* dirs[] = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}; + // Calculate cardinal direction index (0-7) + int idx = (int)((heading + 22.5f) / 45.0f) % 8; + snprintf(buffer, sizeof(buffer), "%.0f° %s", heading, dirs[idx]); + lv_label_set_text(statusHeadingValue, buffer); + } else { + lv_label_set_text(statusHeadingValue, "--"); + } + } else { lv_label_set_text(statusLabelWidget, "Acquiring lock..."); + lv_obj_set_style_text_color(statusLabelWidget, lv_color_hex(0xffaa00), 0); + lv_label_set_text(statusLatitudeValue, "--"); + lv_label_set_text(statusLongitudeValue, "--"); + lv_label_set_text(statusSpeedValue, "--"); + lv_label_set_text(statusHeadingValue, "--"); + } + + minmea_sentence_gga gga; + if (service->getGga(gga)) { + float altitude = minmea_tofloat(&gga.altitude); + if (!isnan(altitude)) { + snprintf(buffer, sizeof(buffer), "%.1f m", altitude); + lv_label_set_text(statusAltitudeValue, buffer); + } else { + lv_label_set_text(statusAltitudeValue, "--"); + } + + snprintf(buffer, sizeof(buffer), "%d", gga.satellites_tracked); + lv_label_set_text(statusSatellitesValue, buffer); + } else { + lv_label_set_text(statusAltitudeValue, "--"); + lv_label_set_text(statusSatellitesValue, "--"); } + lv_obj_remove_flag(statusLabelWidget, LV_OBJ_FLAG_HIDDEN); } else { if (hasSetInfo) { @@ -266,6 +329,28 @@ class GpsSettingsApp final : public App { } } + lv_obj_t* createInfoRow(lv_obj_t* parent, const char* labelText, lv_color_t color) { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_right(row, 10, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + + lv_obj_t* label = lv_label_create(row); + lv_label_set_text(label, labelText); + lv_obj_set_style_text_color(label, lv_color_hex(0x888888), 0); + + lv_obj_t* value = lv_label_create(row); + lv_label_set_text(value, "--"); + lv_obj_set_style_text_color(value, color, 0); + + return value; + } + public: GpsSettingsApp() { @@ -297,20 +382,29 @@ class GpsSettingsApp final : public App { statusWrapper = lv_obj_create(main_wrapper); lv_obj_set_width(statusWrapper, LV_PCT(100)); lv_obj_set_height(statusWrapper, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(statusWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(statusWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_all(statusWrapper, 0, 0); + lv_obj_set_style_pad_row(statusWrapper, 8, 0); lv_obj_set_style_border_width(statusWrapper, 0, 0); statusLabelWidget = lv_label_create(statusWrapper); - lv_obj_align(statusLabelWidget, LV_ALIGN_TOP_LEFT, 0, 0); infoContainerWidget = lv_obj_create(statusWrapper); - lv_obj_align_to(infoContainerWidget, statusLabelWidget, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 20); lv_obj_set_size(infoContainerWidget, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_flow(infoContainerWidget, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_border_width(infoContainerWidget, 0, 0); - lv_obj_set_style_pad_all(infoContainerWidget, 0, 0); + lv_obj_set_style_pad_row(infoContainerWidget, 5, 0); + lv_obj_set_style_pad_hor(infoContainerWidget, 10, 0); hasSetInfo = false; + statusLatitudeValue = createInfoRow(infoContainerWidget, "Latitude:", lv_color_hex(0x00ff00)); + statusLongitudeValue = createInfoRow(infoContainerWidget, "Longitude:", lv_color_hex(0x00ff00)); + statusAltitudeValue = createInfoRow(infoContainerWidget, "Altitude:", lv_color_hex(0x00ffff)); + statusSpeedValue = createInfoRow(infoContainerWidget, "Speed:", lv_color_hex(0xffff00)); + statusHeadingValue = createInfoRow(infoContainerWidget, "Heading:", lv_color_hex(0xff88ff)); + statusSatellitesValue = createInfoRow(infoContainerWidget, "Satellites:", lv_color_hex(0xffffff)); + serviceStateSubscription = service->getStatePubsub()->subscribe([this](auto) { onServiceStateChanged(); }); diff --git a/Tactility/Source/service/espnow/EspNow.cpp b/Tactility/Source/service/espnow/EspNow.cpp index 42c934e30..377d8d945 100644 --- a/Tactility/Source/service/espnow/EspNow.cpp +++ b/Tactility/Source/service/espnow/EspNow.cpp @@ -79,6 +79,18 @@ void unsubscribeReceiver(ReceiverSubscription subscription) { } } +uint32_t getVersion() { + auto service = findService(); + if (service != nullptr) { + return service->getVersion(); + } + return 0; +} + +size_t getMaxDataLength() { + return getVersion() >= 2 ? MAX_DATA_LEN_V2 : MAX_DATA_LEN_V1; +} + } #endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/service/espnow/EspNowService.cpp b/Tactility/Source/service/espnow/EspNowService.cpp index 476b1b7c3..f0c365e00 100644 --- a/Tactility/Source/service/espnow/EspNowService.cpp +++ b/Tactility/Source/service/espnow/EspNowService.cpp @@ -84,6 +84,12 @@ void EspNowService::enableFromDispatcher(const EspNowConfig& config) { return; } + if (esp_now_get_version(&espnowVersion) == ESP_OK) { + LOGGER.info("ESP-NOW version: {}.0", espnowVersion); + } else { + LOGGER.warn("Failed to get ESP-NOW version"); + } + // Add default unencrypted broadcast peer esp_now_peer_info_t broadcast_peer; memset(&broadcast_peer, 0, sizeof(esp_now_peer_info_t)); @@ -195,6 +201,12 @@ void EspNowService::unsubscribeReceiver(ReceiverSubscription subscriptionId) { std::erase_if(subscriptions, [subscriptionId](auto& subscription) { return subscription.id == subscriptionId; }); } +uint32_t EspNowService::getVersion() const { + auto lock = mutex.asScopedLock(); + lock.lock(); + return espnowVersion; +} + std::shared_ptr findService() { return std::static_pointer_cast( findServiceById(manifest.id) diff --git a/Tactility/Source/service/gps/GpsService.cpp b/Tactility/Source/service/gps/GpsService.cpp index 69cc251c7..545c92eb4 100644 --- a/Tactility/Source/service/gps/GpsService.cpp +++ b/Tactility/Source/service/gps/GpsService.cpp @@ -37,7 +37,7 @@ void GpsService::addGpsDevice(const std::shared_ptr& device) { GpsDeviceRecord record = {.device = device}; - if (getState() == State::On) { // Ignore during OnPending due to risk of data corruptiohn + if (getState() == State::On) { // Ignore during OnPending due to risk of data corruption startGpsDevice(record); } @@ -50,7 +50,7 @@ void GpsService::removeGpsDevice(const std::shared_ptr& device) { GpsDeviceRecord* record = findGpsRecord(device); - if (getState() == State::On) { // Ignore during OnPending due to risk of data corruptiohn + if (getState() == State::On) { // Ignore during OnPending due to risk of data corruption stopGpsDevice(*record); } @@ -87,6 +87,10 @@ bool GpsService::startGpsDevice(GpsDeviceRecord& record) { record.satelliteSubscriptionId = device->subscribeGga([this](hal::Device::Id deviceId, auto& record) { mutex.lock(); + if (record.fix_quality > 0) { + ggaRecord = record; + ggaTime = kernel::getTicks(); + } onGgaSentence(deviceId, record); mutex.unlock(); }); @@ -163,6 +167,7 @@ bool GpsService::startReceiving() { } rmcTime = 0; + ggaTime = 0; if (started_one_or_more) { setState(State::On); @@ -186,6 +191,7 @@ void GpsService::stopReceiving() { } rmcTime = 0; + ggaTime = 0; setState(State::Off); } @@ -227,6 +233,16 @@ bool GpsService::getCoordinates(minmea_sentence_rmc& rmc) const { } } +bool GpsService::getGga(minmea_sentence_gga& gga) const { + auto lock = mutex.asScopedLock(); + lock.lock(); + if (getState() == State::On && ggaTime != 0 && !hasTimeElapsed(kernel::getTicks(), ggaTime, kernel::secondsToTicks(10))) { + gga = ggaRecord; + return true; + } + return false; +} + std::shared_ptr findGpsService() { auto service = findServiceById(manifest.id); assert(service != nullptr); From bdf60e2d4373b115bb44372e552959dfca551b58 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Sun, 25 Jan 2026 23:56:13 +1000 Subject: [PATCH 02/10] Fixes, Adjustments --- Documentation/chat.md | 4 ++-- Documentation/espnow-v2.md | 4 ++-- Tactility/Source/app/chat/ChatApp.cpp | 9 ++------- Tactility/Source/app/chat/ChatSettings.cpp | 4 ++-- .../Source/app/gpssettings/GpsSettings.cpp | 20 +++++++++++++------ Tactility/Source/service/espnow/EspNow.cpp | 2 ++ .../Source/service/espnow/EspNowService.cpp | 1 + 7 files changed, 25 insertions(+), 19 deletions(-) diff --git a/Documentation/chat.md b/Documentation/chat.md index b1f0d3ae3..f50c3212c 100644 --- a/Documentation/chat.md +++ b/Documentation/chat.md @@ -17,7 +17,7 @@ ESP-NOW based chat application with channel-based messaging. Devices with the sa ## UI Layout -``` +```text +------------------------------------------+ | [Back] Chat: #general [List] [Gear] | +------------------------------------------+ @@ -65,7 +65,7 @@ Changing the encryption key causes ESP-NOW to restart with the new configuration Variable-length packed struct broadcast over ESP-NOW (ESP-NOW v2.0): -``` +```text Offset Size Field ------ ---- ----- 0 4 header (magic: 0x31544354 "TCT1") diff --git a/Documentation/espnow-v2.md b/Documentation/espnow-v2.md index 55e0aad91..ff586da9b 100644 --- a/Documentation/espnow-v2.md +++ b/Documentation/espnow-v2.md @@ -25,7 +25,7 @@ constexpr size_t MAX_DATA_LEN_V2 = 1470; ### Version Detection ESP-NOW version is queried on initialization and logged: -``` +```text I (15620) ESPNOW: espnow [version: 2.0] init I [EspNowService] ESP-NOW version: 2.0 ``` @@ -49,7 +49,7 @@ I [EspNowService] ESP-NOW version: 2.0 ### Wire Protocol -``` +```text Offset Size Field ------ ---- ----- 0 4 header (magic: 0x31544354) diff --git a/Tactility/Source/app/chat/ChatApp.cpp b/Tactility/Source/app/chat/ChatApp.cpp index 47ff26143..706c1367c 100644 --- a/Tactility/Source/app/chat/ChatApp.cpp +++ b/Tactility/Source/app/chat/ChatApp.cpp @@ -14,6 +14,7 @@ #include #include +#include namespace tt::app::chat { @@ -125,13 +126,7 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH // Parse hex key if (keyHex.size() == ESP_NOW_KEY_LEN * 2) { - bool validHex = true; - for (char c : keyHex) { - if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { - validHex = false; - break; - } - } + bool validHex = std::all_of(keyHex.begin(), keyHex.end(), [](unsigned char c) { return std::isxdigit(c); }); if (validHex) { uint8_t newKey[ESP_NOW_KEY_LEN]; for (int i = 0; i < ESP_NOW_KEY_LEN; i++) { diff --git a/Tactility/Source/app/chat/ChatSettings.cpp b/Tactility/Source/app/chat/ChatSettings.cpp index 1e517071d..3ac812da6 100644 --- a/Tactility/Source/app/chat/ChatSettings.cpp +++ b/Tactility/Source/app/chat/ChatSettings.cpp @@ -63,7 +63,7 @@ static bool readHex(const std::string& input, uint8_t* buffer, size_t length) { static bool encryptKey(const uint8_t key[ESP_NOW_KEY_LEN], std::string& hexOutput) { uint8_t iv[16]; - crypt::getIv(IV_SEED, strlen(IV_SEED), iv); + crypt::getIv(IV_SEED, sizeof(IV_SEED) - 1, iv); uint8_t encrypted[ESP_NOW_KEY_LEN]; if (crypt::encrypt(iv, key, encrypted, ESP_NOW_KEY_LEN) != 0) { @@ -86,7 +86,7 @@ static bool decryptKey(const std::string& hexInput, uint8_t key[ESP_NOW_KEY_LEN] } uint8_t iv[16]; - crypt::getIv(IV_SEED, strlen(IV_SEED), iv); + crypt::getIv(IV_SEED, sizeof(IV_SEED) - 1, iv); if (crypt::decrypt(iv, encrypted, key, ESP_NOW_KEY_LEN) != 0) { LOGGER.error("Failed to decrypt key"); diff --git a/Tactility/Source/app/gpssettings/GpsSettings.cpp b/Tactility/Source/app/gpssettings/GpsSettings.cpp index 9e7bd73ca..0cd08ebf8 100644 --- a/Tactility/Source/app/gpssettings/GpsSettings.cpp +++ b/Tactility/Source/app/gpssettings/GpsSettings.cpp @@ -219,14 +219,19 @@ class GpsSettingsApp final : public App { double latCoord = minmea_tocoord(&latitude); double lonCoord = minmea_tocoord(&longitude); - const char* latDir = (latCoord >= 0) ? "N" : "S"; - const char* lonDir = (lonCoord >= 0) ? "E" : "W"; + if (isnan(latCoord) || isnan(lonCoord)) { + lv_label_set_text(statusLatitudeValue, "--"); + lv_label_set_text(statusLongitudeValue, "--"); + } else { + const char* latDir = (latCoord >= 0) ? "N" : "S"; + const char* lonDir = (lonCoord >= 0) ? "E" : "W"; - snprintf(buffer, sizeof(buffer), "%.6f %s", std::abs(latCoord), latDir); - lv_label_set_text(statusLatitudeValue, buffer); + snprintf(buffer, sizeof(buffer), "%.6f %s", std::abs(latCoord), latDir); + lv_label_set_text(statusLatitudeValue, buffer); - snprintf(buffer, sizeof(buffer), "%.6f %s", std::abs(lonCoord), lonDir); - lv_label_set_text(statusLongitudeValue, buffer); + snprintf(buffer, sizeof(buffer), "%.6f %s", std::abs(lonCoord), lonDir); + lv_label_set_text(statusLongitudeValue, buffer); + } float speedKnots = minmea_tofloat(&rmc.speed); if (!isnan(speedKnots)) { @@ -239,6 +244,9 @@ class GpsSettingsApp final : public App { float heading = minmea_tofloat(&rmc.course); if (!isnan(heading)) { + // Normalize heading to [0, 360) range + heading = fmodf(heading, 360.0f); + if (heading < 0) heading += 360.0f; const char* dirs[] = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}; // Calculate cardinal direction index (0-7) int idx = (int)((heading + 22.5f) / 45.0f) % 8; diff --git a/Tactility/Source/service/espnow/EspNow.cpp b/Tactility/Source/service/espnow/EspNow.cpp index 377d8d945..f76a54b21 100644 --- a/Tactility/Source/service/espnow/EspNow.cpp +++ b/Tactility/Source/service/espnow/EspNow.cpp @@ -36,6 +36,7 @@ bool isEnabled() { if (service != nullptr) { return service->isEnabled(); } else { + LOGGER.error("Service not found"); return false; } } @@ -84,6 +85,7 @@ uint32_t getVersion() { if (service != nullptr) { return service->getVersion(); } + LOGGER.error("Service not found"); return 0; } diff --git a/Tactility/Source/service/espnow/EspNowService.cpp b/Tactility/Source/service/espnow/EspNowService.cpp index f0c365e00..3ebd612de 100644 --- a/Tactility/Source/service/espnow/EspNowService.cpp +++ b/Tactility/Source/service/espnow/EspNowService.cpp @@ -125,6 +125,7 @@ void EspNowService::disableFromDispatcher() { LOGGER.error("deinitWifi() failed"); } + espnowVersion = 0; enabled = false; } From 22d1d518376d49c2538717a12d73442069949f63 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Mon, 26 Jan 2026 00:00:10 +1000 Subject: [PATCH 03/10] Update GpsService.cpp --- Tactility/Source/service/gps/GpsService.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tactility/Source/service/gps/GpsService.cpp b/Tactility/Source/service/gps/GpsService.cpp index 545c92eb4..359b02691 100644 --- a/Tactility/Source/service/gps/GpsService.cpp +++ b/Tactility/Source/service/gps/GpsService.cpp @@ -160,15 +160,16 @@ bool GpsService::startReceiving() { addGpsDevice(device); } + // Reset times before starting devices to avoid race with incoming data + rmcTime = 0; + ggaTime = 0; + bool started_one_or_more = false; for (auto& record: deviceRecords) { started_one_or_more |= startGpsDevice(record); } - rmcTime = 0; - ggaTime = 0; - if (started_one_or_more) { setState(State::On); return true; From 4a2fe7315f1762f2830f069e8428d75b947fe87b Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Mon, 26 Jan 2026 00:36:54 +1000 Subject: [PATCH 04/10] and some more... --- Documentation/espnow-v2.md | 4 ++-- .../Include/Tactility/service/espnow/EspNow.h | 2 +- Tactility/Source/app/chat/ChatApp.cpp | 14 +++++++++----- Tactility/Source/app/chat/ChatProtocol.cpp | 2 +- Tactility/Source/app/chat/ChatSettings.cpp | 5 ++--- Tactility/Source/service/espnow/EspNowService.cpp | 9 +++++---- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Documentation/espnow-v2.md b/Documentation/espnow-v2.md index ff586da9b..d433776a3 100644 --- a/Documentation/espnow-v2.md +++ b/Documentation/espnow-v2.md @@ -43,7 +43,7 @@ I [EspNowService] ESP-NOW version: 2.0 ### Larger Messages -- Message size increased from **127 to 1416 characters** +- Message size increased from **127 to 1417 characters** - Variable-length packet transmission (only sends actual message length) - Backwards compatible: messages < 197 chars still work with v1.0 devices @@ -69,7 +69,7 @@ Offset Size Field | `Tactility/Private/Tactility/app/chat/ChatProtocol.h` | `MESSAGE_SIZE` = 1417, added header constants | | `Tactility/Source/app/chat/ChatProtocol.cpp` | Variable-length serialize/deserialize | | `Tactility/Source/app/chat/ChatApp.cpp` | Send actual packet size | -| `Tactility/Source/app/chat/ChatView.cpp` | Input field max length = 1416 | +| `Tactility/Source/app/chat/ChatView.cpp` | Input field max length = 1417 | | `Documentation/chat.md` | Updated protocol documentation | ## Compatibility diff --git a/Tactility/Include/Tactility/service/espnow/EspNow.h b/Tactility/Include/Tactility/service/espnow/EspNow.h index eecbb6d4c..d29ef61d4 100644 --- a/Tactility/Include/Tactility/service/espnow/EspNow.h +++ b/Tactility/Include/Tactility/service/espnow/EspNow.h @@ -61,7 +61,7 @@ void unsubscribeReceiver(ReceiverSubscription subscription); /** Get the ESP-NOW protocol version (1 for v1.0, 2 for v2.0). Returns 0 if service not running. */ uint32_t getVersion(); -/** Get the maximum data length for current ESP-NOW version (250 for v1.0, 1470 for v2.0). */ +/** Get the maximum data length for current ESP-NOW version (250 for v1.0, 1470 for v2.0). Returns 0 if service not running. */ size_t getMaxDataLength(); } diff --git a/Tactility/Source/app/chat/ChatApp.cpp b/Tactility/Source/app/chat/ChatApp.cpp index 706c1367c..cadafad5f 100644 --- a/Tactility/Source/app/chat/ChatApp.cpp +++ b/Tactility/Source/app/chat/ChatApp.cpp @@ -122,7 +122,8 @@ void ChatApp::sendMessage(const std::string& text) { void ChatApp::applySettings(const std::string& nickname, const std::string& keyHex) { bool needRestart = false; - settings.nickname = nickname; + // Trim nickname to protocol limit + settings.nickname = nickname.substr(0, SENDER_NAME_SIZE - 1); // Parse hex key if (keyHex.size() == ESP_NOW_KEY_LEN * 2) { @@ -133,7 +134,9 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH char hex[3] = { keyHex[i * 2], keyHex[i * 2 + 1], 0 }; newKey[i] = static_cast(strtoul(hex, nullptr, 16)); } - if (!std::equal(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin())) { + // Restart if key changed OR if encryption is being enabled + bool wasEnabled = settings.hasEncryptionKey; + if (!wasEnabled || !std::equal(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin())) { std::copy(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin()); needRestart = true; } @@ -151,7 +154,7 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH LOGGER.warn("Key must be exactly {} hex characters, got {}", ESP_NOW_KEY_LEN * 2, keyHex.size()); } - state.setLocalNickname(nickname); + state.setLocalNickname(settings.nickname); saveSettings(settings); if (needRestart) { @@ -161,8 +164,9 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH } void ChatApp::switchChannel(const std::string& chatChannel) { - state.setCurrentChannel(chatChannel); - settings.chatChannel = chatChannel; + const auto trimmedChannel = chatChannel.substr(0, TARGET_SIZE - 1); + state.setCurrentChannel(trimmedChannel); + settings.chatChannel = trimmedChannel; saveSettings(settings); { diff --git a/Tactility/Source/app/chat/ChatProtocol.cpp b/Tactility/Source/app/chat/ChatProtocol.cpp index 6a3641368..ab462c3ce 100644 --- a/Tactility/Source/app/chat/ChatProtocol.cpp +++ b/Tactility/Source/app/chat/ChatProtocol.cpp @@ -54,7 +54,7 @@ bool deserializeMessage(const uint8_t* data, int length, ParsedMessage& out) { // Calculate actual message length from packet size and ensure null termination size_t msgLen = length - MESSAGE_HEADER_SIZE; if (msgLen > 0 && msgLen < MESSAGE_SIZE) { - msg.message[msgLen] = '\0'; // Ensure null at end of received data + msg.message[msgLen] = '\0'; // Handle malformed packets missing null terminator } msg.message[MESSAGE_SIZE - 1] = '\0'; // Safety: ensure buffer is always terminated diff --git a/Tactility/Source/app/chat/ChatSettings.cpp b/Tactility/Source/app/chat/ChatSettings.cpp index 3ac812da6..319921b8b 100644 --- a/Tactility/Source/app/chat/ChatSettings.cpp +++ b/Tactility/Source/app/chat/ChatSettings.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include namespace tt::app::chat { @@ -63,7 +62,7 @@ static bool readHex(const std::string& input, uint8_t* buffer, size_t length) { static bool encryptKey(const uint8_t key[ESP_NOW_KEY_LEN], std::string& hexOutput) { uint8_t iv[16]; - crypt::getIv(IV_SEED, sizeof(IV_SEED) - 1, iv); + crypt::getIv(IV_SEED, std::strlen(IV_SEED), iv); uint8_t encrypted[ESP_NOW_KEY_LEN]; if (crypt::encrypt(iv, key, encrypted, ESP_NOW_KEY_LEN) != 0) { @@ -86,7 +85,7 @@ static bool decryptKey(const std::string& hexInput, uint8_t key[ESP_NOW_KEY_LEN] } uint8_t iv[16]; - crypt::getIv(IV_SEED, sizeof(IV_SEED) - 1, iv); + crypt::getIv(IV_SEED, std::strlen(IV_SEED), iv); if (crypt::decrypt(iv, encrypted, key, ESP_NOW_KEY_LEN) != 0) { LOGGER.error("Failed to decrypt key"); diff --git a/Tactility/Source/service/espnow/EspNowService.cpp b/Tactility/Source/service/espnow/EspNowService.cpp index 3ebd612de..432f33440 100644 --- a/Tactility/Source/service/espnow/EspNowService.cpp +++ b/Tactility/Source/service/espnow/EspNowService.cpp @@ -74,16 +74,17 @@ void EspNowService::enableFromDispatcher(const EspNowConfig& config) { return; } -//#if CONFIG_ESPNOW_ENABLE_POWER_SAVE -// ESP_ERROR_CHECK( esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW) ); -// ESP_ERROR_CHECK( esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL) ); -//#endif + //#if CONFIG_ESPNOW_ENABLE_POWER_SAVE + // ESP_ERROR_CHECK( esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW) ); + // ESP_ERROR_CHECK( esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL) ); + //#endif if (esp_now_set_pmk(config.masterKey) != ESP_OK) { LOGGER.error("esp_now_set_pmk() failed"); return; } + espnowVersion = 0; if (esp_now_get_version(&espnowVersion) == ESP_OK) { LOGGER.info("ESP-NOW version: {}.0", espnowVersion); } else { From 2666b73243133d626a55e7fb501ec9a9219903d1 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Mon, 26 Jan 2026 01:15:28 +1000 Subject: [PATCH 05/10] and another --- Documentation/espnow-v2.md | 4 ++-- Tactility/Source/app/chat/ChatApp.cpp | 2 +- Tactility/Source/service/espnow/EspNow.cpp | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Documentation/espnow-v2.md b/Documentation/espnow-v2.md index d433776a3..ff586da9b 100644 --- a/Documentation/espnow-v2.md +++ b/Documentation/espnow-v2.md @@ -43,7 +43,7 @@ I [EspNowService] ESP-NOW version: 2.0 ### Larger Messages -- Message size increased from **127 to 1417 characters** +- Message size increased from **127 to 1416 characters** - Variable-length packet transmission (only sends actual message length) - Backwards compatible: messages < 197 chars still work with v1.0 devices @@ -69,7 +69,7 @@ Offset Size Field | `Tactility/Private/Tactility/app/chat/ChatProtocol.h` | `MESSAGE_SIZE` = 1417, added header constants | | `Tactility/Source/app/chat/ChatProtocol.cpp` | Variable-length serialize/deserialize | | `Tactility/Source/app/chat/ChatApp.cpp` | Send actual packet size | -| `Tactility/Source/app/chat/ChatView.cpp` | Input field max length = 1417 | +| `Tactility/Source/app/chat/ChatView.cpp` | Input field max length = 1416 | | `Documentation/chat.md` | Updated protocol documentation | ## Compatibility diff --git a/Tactility/Source/app/chat/ChatApp.cpp b/Tactility/Source/app/chat/ChatApp.cpp index cadafad5f..d1b958e0e 100644 --- a/Tactility/Source/app/chat/ChatApp.cpp +++ b/Tactility/Source/app/chat/ChatApp.cpp @@ -28,7 +28,7 @@ void ChatApp::enableEspNow() { service::espnow::Mode::Station, 1, // Channel 1 default; actual channel determined by WiFi if connected false, - false + settings.hasEncryptionKey ); service::espnow::enable(config); } diff --git a/Tactility/Source/service/espnow/EspNow.cpp b/Tactility/Source/service/espnow/EspNow.cpp index f76a54b21..2fd1a06a0 100644 --- a/Tactility/Source/service/espnow/EspNow.cpp +++ b/Tactility/Source/service/espnow/EspNow.cpp @@ -90,7 +90,9 @@ uint32_t getVersion() { } size_t getMaxDataLength() { - return getVersion() >= 2 ? MAX_DATA_LEN_V2 : MAX_DATA_LEN_V1; + auto v = getVersion(); + if (v == 0) return 0; + return v >= 2 ? MAX_DATA_LEN_V2 : MAX_DATA_LEN_V1; } } From dc2d50f94a70c2a46a20649c988378339a5a2807 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Mon, 26 Jan 2026 02:19:23 +1000 Subject: [PATCH 06/10] and some more! --- Documentation/espnow-v2.md | 92 ------------------- .../Source/app/gpssettings/GpsSettings.cpp | 14 +-- 2 files changed, 7 insertions(+), 99 deletions(-) delete mode 100644 Documentation/espnow-v2.md diff --git a/Documentation/espnow-v2.md b/Documentation/espnow-v2.md deleted file mode 100644 index ff586da9b..000000000 --- a/Documentation/espnow-v2.md +++ /dev/null @@ -1,92 +0,0 @@ -# ESP-NOW v2.0 Support & Chat App Enhancements - -## Summary - -- Add ESP-NOW v2.0 support with version detection and larger payload capability -- Update Chat app to use variable-length messages (up to 1416 characters) -- Maintain backwards compatibility with ESP-NOW v1.0 devices - -## ESP-NOW Service Changes - -### New API - -```cpp -// Get the ESP-NOW protocol version (1 or 2) -uint32_t espnow::getVersion(); - -// Get max payload size for current version (250 or 1470 bytes) -size_t espnow::getMaxDataLength(); - -// Constants -constexpr size_t MAX_DATA_LEN_V1 = 250; -constexpr size_t MAX_DATA_LEN_V2 = 1470; -``` - -### Version Detection - -ESP-NOW version is queried on initialization and logged: -```text -I (15620) ESPNOW: espnow [version: 2.0] init -I [EspNowService] ESP-NOW version: 2.0 -``` - -### Files Modified - -| File | Change | -|------|--------| -| `Tactility/Include/Tactility/service/espnow/EspNow.h` | Added constants and `getVersion()`, `getMaxDataLength()` | -| `Tactility/Source/service/espnow/EspNow.cpp` | Implemented new functions | -| `Tactility/Private/Tactility/service/espnow/EspNowService.h` | Added `espnowVersion` member | -| `Tactility/Source/service/espnow/EspNowService.cpp` | Query version after init | - -## Chat App Changes - -### Larger Messages - -- Message size increased from **127 to 1416 characters** -- Variable-length packet transmission (only sends actual message length) -- Backwards compatible: messages < 197 chars still work with v1.0 devices - -### Wire Protocol - -```text -Offset Size Field ------- ---- ----- -0 4 header (magic: 0x31544354) -4 1 protocol_version (0x01) -5 24 sender_name -29 24 target -53 1-1417 message (variable length) -``` - -- **Min packet**: 54 bytes -- **Max packet**: 1470 bytes (v2.0 limit) - -### Files Modified - -| File | Change | -|------|--------| -| `Tactility/Private/Tactility/app/chat/ChatProtocol.h` | `MESSAGE_SIZE` = 1417, added header constants | -| `Tactility/Source/app/chat/ChatProtocol.cpp` | Variable-length serialize/deserialize | -| `Tactility/Source/app/chat/ChatApp.cpp` | Send actual packet size | -| `Tactility/Source/app/chat/ChatView.cpp` | Input field max length = 1416 | -| `Documentation/chat.md` | Updated protocol documentation | - -## Compatibility - -| Scenario | Result | -|----------|--------| -| v2.0 device sends short message (< 250 bytes) | v1.0 and v2.0 devices receive | -| v2.0 device sends long message (> 250 bytes) | Only v2.0 devices receive | -| v1.0 device sends message | v1.0 and v2.0 devices receive | - -## Requirements - -- ESP-IDF v5.4 or later (for ESP-NOW v2.0 support) - -## Test Plan - -- [ ] Verify "ESP-NOW version: 2.0" appears in logs on startup -- [ ] Send short message between two v2.0 devices -- [ ] Send long message (> 200 chars) between two v2.0 devices -- [ ] Verify `espnow::getMaxDataLength()` returns 1470 diff --git a/Tactility/Source/app/gpssettings/GpsSettings.cpp b/Tactility/Source/app/gpssettings/GpsSettings.cpp index 0cd08ebf8..cbcceb1b1 100644 --- a/Tactility/Source/app/gpssettings/GpsSettings.cpp +++ b/Tactility/Source/app/gpssettings/GpsSettings.cpp @@ -350,7 +350,7 @@ class GpsSettingsApp final : public App { lv_obj_t* label = lv_label_create(row); lv_label_set_text(label, labelText); - lv_obj_set_style_text_color(label, lv_color_hex(0x888888), 0); + lv_obj_set_style_text_color(label, lv_palette_lighten(LV_PALETTE_GREY, 5), 0); lv_obj_t* value = lv_label_create(row); lv_label_set_text(value, "--"); @@ -406,12 +406,12 @@ class GpsSettingsApp final : public App { lv_obj_set_style_pad_hor(infoContainerWidget, 10, 0); hasSetInfo = false; - statusLatitudeValue = createInfoRow(infoContainerWidget, "Latitude:", lv_color_hex(0x00ff00)); - statusLongitudeValue = createInfoRow(infoContainerWidget, "Longitude:", lv_color_hex(0x00ff00)); - statusAltitudeValue = createInfoRow(infoContainerWidget, "Altitude:", lv_color_hex(0x00ffff)); - statusSpeedValue = createInfoRow(infoContainerWidget, "Speed:", lv_color_hex(0xffff00)); - statusHeadingValue = createInfoRow(infoContainerWidget, "Heading:", lv_color_hex(0xff88ff)); - statusSatellitesValue = createInfoRow(infoContainerWidget, "Satellites:", lv_color_hex(0xffffff)); + statusLatitudeValue = createInfoRow(infoContainerWidget, "Latitude", lv_color_hex(0x00ff00)); + statusLongitudeValue = createInfoRow(infoContainerWidget, "Longitude", lv_color_hex(0x00ff00)); + statusAltitudeValue = createInfoRow(infoContainerWidget, "Altitude", lv_color_hex(0x00ffff)); + statusSpeedValue = createInfoRow(infoContainerWidget, "Speed", lv_color_hex(0xffff00)); + statusHeadingValue = createInfoRow(infoContainerWidget, "Heading", lv_color_hex(0xff88ff)); + statusSatellitesValue = createInfoRow(infoContainerWidget, "Satellites", lv_color_hex(0xffffff)); serviceStateSubscription = service->getStatePubsub()->subscribe([this](auto) { onServiceStateChanged(); From 95f0c51632e7669ca301f94ee98ab4eec014b064 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Mon, 26 Jan 2026 04:19:15 +1000 Subject: [PATCH 07/10] Update ChatProtocol.h --- Tactility/Private/Tactility/app/chat/ChatProtocol.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tactility/Private/Tactility/app/chat/ChatProtocol.h b/Tactility/Private/Tactility/app/chat/ChatProtocol.h index 2998981ba..a0bbd5ddd 100644 --- a/Tactility/Private/Tactility/app/chat/ChatProtocol.h +++ b/Tactility/Private/Tactility/app/chat/ChatProtocol.h @@ -16,7 +16,7 @@ constexpr uint32_t CHAT_MAGIC_HEADER = 0x31544354; // "TCT1" constexpr uint8_t PROTOCOL_VERSION = 0x01; constexpr size_t SENDER_NAME_SIZE = 24; constexpr size_t TARGET_SIZE = 24; -constexpr size_t MESSAGE_SIZE = 1417; // Max for ESP-NOW v2.0 (1470 - 53 header bytes) +constexpr size_t MESSAGE_SIZE = 200; //1417 Max for ESP-NOW v2.0 (1470 - 53 header bytes) // Header size = offset to message field (header + version + sender_name + target) constexpr size_t MESSAGE_HEADER_SIZE = 4 + 1 + SENDER_NAME_SIZE + TARGET_SIZE; // 53 bytes From c9d457dfaf6b8f76f6882258b6dfce7006e1c49f Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Mon, 26 Jan 2026 15:59:22 +1000 Subject: [PATCH 08/10] protocol v2 --- Documentation/chat.md | 111 +++++++++---- .../Private/Tactility/app/chat/ChatProtocol.h | 84 +++++++--- .../Private/Tactility/app/chat/ChatSettings.h | 1 + Tactility/Source/app/chat/ChatApp.cpp | 18 +- Tactility/Source/app/chat/ChatProtocol.cpp | 154 ++++++++++++++---- Tactility/Source/app/chat/ChatSettings.cpp | 30 +++- Tactility/Source/app/chat/ChatView.cpp | 6 +- 7 files changed, 302 insertions(+), 102 deletions(-) diff --git a/Documentation/chat.md b/Documentation/chat.md index f50c3212c..b5b060ca7 100644 --- a/Documentation/chat.md +++ b/Documentation/chat.md @@ -7,8 +7,9 @@ ESP-NOW based chat application with channel-based messaging. Devices with the sa - **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**: Nickname, key, and current chat channel are saved across reboots +- **Persistent settings**: Sender ID, nickname, key, and current chat channel are saved across reboots ## Requirements @@ -44,7 +45,7 @@ Messages are sent with the current channel as the target. Only devices viewing t ## First Launch -On first launch (when no settings file exists), the settings panel opens automatically so users can configure their nickname before chatting. +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 @@ -55,39 +56,78 @@ Tap the gear icon to configure: | 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. +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 +## Wire Protocol v2 -Variable-length packed struct broadcast over ESP-NOW (ESP-NOW v2.0): +Compact variable-length packets broadcast over ESP-NOW: + +### Header (16 bytes) ```text Offset Size Field ------ ---- ----- -0 4 header (magic: 0x31544354 "TCT1") -4 1 protocol_version (0x01) -5 24 sender_name (null-terminated, zero-padded) -29 24 target (null-terminated, zero-padded) -53 1-1417 message (null-terminated, variable length) +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] ``` -- **Minimum packet**: 54 bytes (header + 1 byte message) -- **Maximum packet**: 1470 bytes (ESP-NOW v2.0 limit) -- **v1.0 compatibility**: Messages < 250 bytes work with ESP-NOW v1.0 devices +- `nickname`: Null-terminated sender display name (max 23 chars + null) +- `target`: Null-terminated channel (e.g. `#general`) or empty for broadcast (max 23 chars + null) +- `message`: Remaining bytes, NOT null-terminated (length = `payload_size - strlen(nickname) - 1 - strlen(target) - 1`) + +**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 -Messages with incorrect magic/version or invalid length are silently discarded. +| Constraint | Value | +|------------|-------| +| Header size | 16 bytes | +| Max payload (uint8_t) | 255 bytes | +| Max nickname | 23 characters | +| Max channel/target | 23 characters | +| Max message | 200-251 bytes (varies by nickname/target length) | + +### Payload Types + +| Type | Value | Description | +|------|-------|-------------| +| TextMessage | 1 | Chat message with nickname, target, and text | +| (reserved) | 2+ | Future: Position, Telemetry, etc. | ### Target Field Semantics -| Target Value | Meaning | -|-------------|---------| -| `""` (empty) | Broadcast - visible in all channels | -| `#channel` | Channel message - visible only when viewing that channel | -| `username` | Direct message | +| `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 @@ -95,8 +135,8 @@ Messages with incorrect magic/version or invalid length are silently discarded. 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 - Variable-length Message struct, serialize/deserialize (v2.0 support) -ChatSettings - Properties file load/save with encrypted key storage +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. @@ -106,17 +146,21 @@ All files are guarded with `#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(C ### Sending 1. User types message and taps Send -2. Message serialized into `Message` struct with current nickname and channel as target +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: size within valid range (54-1470 bytes), magic and version must match -3. Copy into aligned local struct (avoids unaligned access on embedded platforms) -4. Extract sender name, target, and message as strings -5. Store in message deque +2. Validate packet: + - Minimum size: 18 bytes (16 header + 2 null terminators) + - 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. Extract message from remaining bytes (length derived from payload_size) +5. Store in message deque with sender ID 6. Display if target matches current channel or is broadcast (empty) ## Limitations @@ -124,7 +168,18 @@ All files are guarded with `#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(C - Maximum 100 stored messages (oldest discarded when full) - Nickname: 23 characters max - Channel name: 23 characters max -- Message text: 1416 characters max (ESP-NOW v2.0) +- 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 -- Messages > 250 bytes only received by devices running ESP-NOW v2.0 +- 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. diff --git a/Tactility/Private/Tactility/app/chat/ChatProtocol.h b/Tactility/Private/Tactility/app/chat/ChatProtocol.h index a0bbd5ddd..5cf013123 100644 --- a/Tactility/Private/Tactility/app/chat/ChatProtocol.h +++ b/Tactility/Private/Tactility/app/chat/ChatProtocol.h @@ -9,45 +9,79 @@ #include #include #include +#include namespace tt::app::chat { -constexpr uint32_t CHAT_MAGIC_HEADER = 0x31544354; // "TCT1" -constexpr uint8_t PROTOCOL_VERSION = 0x01; -constexpr size_t SENDER_NAME_SIZE = 24; -constexpr size_t TARGET_SIZE = 24; -constexpr size_t MESSAGE_SIZE = 200; //1417 Max for ESP-NOW v2.0 (1470 - 53 header bytes) - -// Header size = offset to message field (header + version + sender_name + target) -constexpr size_t MESSAGE_HEADER_SIZE = 4 + 1 + SENDER_NAME_SIZE + TARGET_SIZE; // 53 bytes -constexpr size_t MIN_PACKET_SIZE = MESSAGE_HEADER_SIZE + 1; // At least 1 byte of message - -struct __attribute__((packed)) Message { - uint32_t header; - uint8_t protocol_version; - char sender_name[SENDER_NAME_SIZE]; - char target[TARGET_SIZE]; // empty=broadcast, "#channel" or "username" - char message[MESSAGE_SIZE]; +// Protocol identification +constexpr uint32_t CHAT_MAGIC_V2 = 0x54435432; // "TCT2" +constexpr uint16_t PROTOCOL_VERSION = 2; + +// Broadcast/channel target ID +constexpr uint32_t BROADCAST_ID = 0; + +// Payload types +enum class PayloadType : uint8_t { + TextMessage = 1, + // Future: Position = 2, Telemetry = 3, etc. +}; + +// Wire format header (16 bytes) +struct __attribute__((packed)) MessageHeader { + uint32_t magic; // CHAT_MAGIC_V2 + uint16_t protocol_version; // PROTOCOL_VERSION + uint32_t from; // Sender ID (random, stored in settings) + uint32_t to; // Recipient ID (0 = broadcast/channel) + uint8_t payload_type; // PayloadType enum + uint8_t payload_size; // Size of payload following header }; -static_assert(sizeof(Message) == 1470, "Message struct must be exactly ESP-NOW v2.0 max payload"); -static_assert(MESSAGE_HEADER_SIZE == offsetof(Message, message), "Header size calculation mismatch"); +static_assert(sizeof(MessageHeader) == 16, "MessageHeader must be 16 bytes"); +// Size limits +constexpr size_t HEADER_SIZE = sizeof(MessageHeader); +constexpr size_t MAX_PAYLOAD_V1 = 250 - HEADER_SIZE; // 234 bytes for ESP-NOW v1 +constexpr size_t MAX_PAYLOAD_V2 = 1470 - HEADER_SIZE; // 1454 bytes for ESP-NOW v2 +constexpr size_t MAX_NICKNAME_LEN = 23; // Max nickname length (excluding null) +constexpr size_t MAX_TARGET_LEN = 23; // Max target/channel length (excluding null) + +// Max message length: 255 (uint8_t payload_size) - nickname - null - target - null +// Using max lengths: 255 - 23 - 1 - 23 - 1 = 207, rounded down for safety +constexpr size_t MAX_MESSAGE_LEN = 200; + +// Parsed message for application use struct ParsedMessage { + uint32_t senderId; + uint32_t targetId; std::string senderName; std::string target; std::string message; }; -/** Serialize fields into the wire format. - * Returns the actual packet size to send (variable length), or 0 on failure. - * Short messages (< 250 bytes total) are compatible with ESP-NOW v1.0 devices. */ -size_t serializeMessage(const std::string& senderName, const std::string& target, - const std::string& message, Message& out); +/** Serialize a text message into wire format. + * @param senderId Sender's unique ID + * @param targetId Recipient ID (0 for broadcast/channel) + * @param senderName Sender's display name + * @param target Channel name (e.g. "#general") or empty for broadcast + * @param message The message text + * @param out Output buffer (will be resized to fit) + * @return true on success, false if inputs exceed limits + */ +bool serializeTextMessage(uint32_t senderId, uint32_t targetId, + const std::string& senderName, const std::string& target, + const std::string& message, std::vector& out); /** Deserialize a received buffer into a ParsedMessage. - * Returns true if valid (correct magic, version, and minimum length). */ -bool deserializeMessage(const uint8_t* data, int length, ParsedMessage& out); + * @param data Raw received data + * @param length Length of received data + * @param out Parsed message output + * @return true if valid (correct magic, version, and format) + */ +bool deserializeMessage(const uint8_t* data, size_t length, ParsedMessage& out); + +/** Get maximum message length for current ESP-NOW version. + * Accounts for header + nickname + target overhead. */ +size_t getMaxMessageLength(size_t nicknameLen, size_t targetLen); } // namespace tt::app::chat diff --git a/Tactility/Private/Tactility/app/chat/ChatSettings.h b/Tactility/Private/Tactility/app/chat/ChatSettings.h index c7249e3b3..a504c9ac4 100644 --- a/Tactility/Private/Tactility/app/chat/ChatSettings.h +++ b/Tactility/Private/Tactility/app/chat/ChatSettings.h @@ -17,6 +17,7 @@ namespace tt::app::chat { constexpr auto* CHAT_SETTINGS_FILE = "/data/settings/chat.properties"; struct ChatSettingsData { + uint32_t senderId = 0; // Unique device ID (randomly generated on first launch) std::string nickname = "Device"; std::array encryptionKey = {}; bool hasEncryptionKey = false; diff --git a/Tactility/Source/app/chat/ChatApp.cpp b/Tactility/Source/app/chat/ChatApp.cpp index d1b958e0e..20355c419 100644 --- a/Tactility/Source/app/chat/ChatApp.cpp +++ b/Tactility/Source/app/chat/ChatApp.cpp @@ -13,8 +13,9 @@ #include #include -#include #include +#include +#include namespace tt::app::chat { @@ -68,8 +69,10 @@ void ChatApp::onShow(AppContext& context, lv_obj_t* parent) { } void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { + if (length <= 0) return; + ParsedMessage parsed; - if (!deserializeMessage(data, length, parsed)) { + if (!deserializeMessage(data, static_cast(length), parsed)) { return; } @@ -93,14 +96,13 @@ void ChatApp::sendMessage(const std::string& text) { std::string nickname = state.getLocalNickname(); std::string channel = state.getCurrentChannel(); - Message wireMsg; - size_t packetSize = serializeMessage(nickname, channel, text, wireMsg); - if (packetSize == 0) { + std::vector wireMsg; + if (!serializeTextMessage(settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) { LOGGER.error("Failed to serialize message"); return; } - if (!service::espnow::send(BROADCAST_ADDRESS, reinterpret_cast(&wireMsg), packetSize)) { + if (!service::espnow::send(BROADCAST_ADDRESS, wireMsg.data(), wireMsg.size())) { LOGGER.error("Failed to send message"); return; } @@ -123,7 +125,7 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH bool needRestart = false; // Trim nickname to protocol limit - settings.nickname = nickname.substr(0, SENDER_NAME_SIZE - 1); + settings.nickname = nickname.substr(0, MAX_NICKNAME_LEN); // Parse hex key if (keyHex.size() == ESP_NOW_KEY_LEN * 2) { @@ -164,7 +166,7 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH } void ChatApp::switchChannel(const std::string& chatChannel) { - const auto trimmedChannel = chatChannel.substr(0, TARGET_SIZE - 1); + const auto trimmedChannel = chatChannel.substr(0, MAX_TARGET_LEN); state.setCurrentChannel(trimmedChannel); settings.chatChannel = trimmedChannel; saveSettings(settings); diff --git a/Tactility/Source/app/chat/ChatProtocol.cpp b/Tactility/Source/app/chat/ChatProtocol.cpp index ab462c3ce..59c6cd621 100644 --- a/Tactility/Source/app/chat/ChatProtocol.cpp +++ b/Tactility/Source/app/chat/ChatProtocol.cpp @@ -5,66 +5,150 @@ #if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include +#include +#include #include namespace tt::app::chat { -size_t serializeMessage(const std::string& senderName, const std::string& target, - const std::string& message, Message& out) { - if (senderName.size() >= SENDER_NAME_SIZE || - target.size() >= TARGET_SIZE || - message.size() >= MESSAGE_SIZE) { - return 0; // Signal truncation would occur +bool serializeTextMessage(uint32_t senderId, uint32_t targetId, + const std::string& senderName, const std::string& target, + const std::string& message, std::vector& out) { + // Validate input lengths + if (senderName.size() > MAX_NICKNAME_LEN || target.size() > MAX_TARGET_LEN) { + return false; + } + + // Calculate payload size: nickname + null + target + null + message + size_t payloadSize = senderName.size() + 1 + target.size() + 1 + message.size(); + + // Check against ESP-NOW limits (guard against underflow if getMaxDataLength < HEADER_SIZE) + size_t maxData = service::espnow::getMaxDataLength(); + if (maxData <= HEADER_SIZE) { + return false; + } + size_t maxPayload = maxData - HEADER_SIZE; + if (payloadSize > maxPayload || payloadSize > 255) { + return false; // payload_size is uint8_t } - memset(&out, 0, sizeof(out)); - out.header = CHAT_MAGIC_HEADER; - out.protocol_version = PROTOCOL_VERSION; - strncpy(out.sender_name, senderName.c_str(), SENDER_NAME_SIZE - 1); - strncpy(out.target, target.c_str(), TARGET_SIZE - 1); - strncpy(out.message, message.c_str(), MESSAGE_SIZE - 1); - - // Return actual packet size: header + message length + null terminator - return MESSAGE_HEADER_SIZE + message.size() + 1; + + // Allocate output buffer + out.resize(HEADER_SIZE + payloadSize); + + // Build header + MessageHeader header; + header.magic = CHAT_MAGIC_V2; + header.protocol_version = PROTOCOL_VERSION; + header.from = senderId; + header.to = targetId; + header.payload_type = static_cast(PayloadType::TextMessage); + header.payload_size = static_cast(payloadSize); + + // Copy header to output + memcpy(out.data(), &header, HEADER_SIZE); + + // Build payload: nickname\0 + target\0 + message + uint8_t* payload = out.data() + HEADER_SIZE; + size_t offset = 0; + + memcpy(payload + offset, senderName.c_str(), senderName.size() + 1); + offset += senderName.size() + 1; + + memcpy(payload + offset, target.c_str(), target.size() + 1); + offset += target.size() + 1; + + memcpy(payload + offset, message.c_str(), message.size()); + // Note: message is NOT null-terminated in wire format (length is implicit) + + return true; } -bool deserializeMessage(const uint8_t* data, int length, ParsedMessage& out) { - // Accept variable-length packets (min header + 1 byte message, max full struct) - if (length < static_cast(MIN_PACKET_SIZE) || length > static_cast(sizeof(Message))) { +bool deserializeMessage(const uint8_t* data, size_t length, ParsedMessage& out) { + // Minimum: header + at least 2 null terminators (empty nickname + empty target) + if (length < HEADER_SIZE + 2) { return false; } - // Copy into aligned local struct to avoid unaligned access on embedded platforms - Message msg; - memset(&msg, 0, sizeof(msg)); - memcpy(&msg, data, length); + // Copy header to aligned struct + MessageHeader header; + memcpy(&header, data, HEADER_SIZE); - if (msg.header != CHAT_MAGIC_HEADER) { + // Validate header + if (header.magic != CHAT_MAGIC_V2) { return false; } - if (msg.protocol_version != PROTOCOL_VERSION) { + if (header.protocol_version != PROTOCOL_VERSION) { return false; } - // Ensure null-termination for each field - msg.sender_name[SENDER_NAME_SIZE - 1] = '\0'; - msg.target[TARGET_SIZE - 1] = '\0'; + // Validate payload size + if (header.payload_size != length - HEADER_SIZE) { + return false; + } + + // Only handle text messages for now + if (header.payload_type != static_cast(PayloadType::TextMessage)) { + return false; + } + + // Parse payload + const uint8_t* payload = data + HEADER_SIZE; + size_t payloadLen = header.payload_size; + + // Find nickname (null-terminated) + const char* nicknameStart = reinterpret_cast(payload); + size_t nicknameLen = strnlen(nicknameStart, payloadLen); + if (nicknameLen >= payloadLen) { + return false; // No null terminator found + } + + size_t offset = nicknameLen + 1; + size_t remaining = payloadLen - offset; - // Calculate actual message length from packet size and ensure null termination - size_t msgLen = length - MESSAGE_HEADER_SIZE; - if (msgLen > 0 && msgLen < MESSAGE_SIZE) { - msg.message[msgLen] = '\0'; // Handle malformed packets missing null terminator + // Find target (null-terminated) + const char* targetStart = reinterpret_cast(payload + offset); + size_t targetLen = strnlen(targetStart, remaining); + if (targetLen >= remaining) { + return false; // No null terminator found } - msg.message[MESSAGE_SIZE - 1] = '\0'; // Safety: ensure buffer is always terminated - out.senderName = msg.sender_name; - out.target = msg.target; - out.message = msg.message; + offset += targetLen + 1; + remaining = payloadLen - offset; + + // Rest is the message (not null-terminated) + const char* messageStart = reinterpret_cast(payload + offset); + + // Populate output + out.senderId = header.from; + out.targetId = header.to; + out.senderName = std::string(nicknameStart, nicknameLen); + out.target = std::string(targetStart, targetLen); + out.message = std::string(messageStart, remaining); return true; } +size_t getMaxMessageLength(size_t nicknameLen, size_t targetLen) { + // Guard against underflow if getMaxDataLength < HEADER_SIZE + size_t maxData = service::espnow::getMaxDataLength(); + if (maxData <= HEADER_SIZE) { + return 0; + } + size_t maxPayload = maxData - HEADER_SIZE; + + // Payload: nickname + null + target + null + message + size_t overhead = nicknameLen + 1 + targetLen + 1; + if (overhead >= maxPayload || overhead > 255) { + return 0; + } + // Cap at 255 since payload_size is uint8_t + size_t maxFromEspNow = maxPayload - overhead; + size_t maxFromPayloadSize = 255 - overhead; + return std::min(maxFromEspNow, maxFromPayloadSize); +} + } // namespace tt::app::chat #endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatSettings.cpp b/Tactility/Source/app/chat/ChatSettings.cpp index 319921b8b..f4ac84179 100644 --- a/Tactility/Source/app/chat/ChatSettings.cpp +++ b/Tactility/Source/app/chat/ChatSettings.cpp @@ -11,6 +11,8 @@ #include #include +#include + #include #include #include @@ -22,6 +24,7 @@ namespace tt::app::chat { static const auto LOGGER = Logger("ChatSettings"); +constexpr auto* KEY_SENDER_ID = "senderId"; constexpr auto* KEY_NICKNAME = "nickname"; constexpr auto* KEY_ENCRYPTION_KEY = "encryptionKey"; constexpr auto* KEY_CHAT_CHANNEL = "chatChannel"; @@ -94,8 +97,18 @@ static bool decryptKey(const std::string& hexInput, uint8_t key[ESP_NOW_KEY_LEN] return true; } +/** Generate a non-zero random sender ID using hardware RNG. */ +static uint32_t generateSenderId() { + uint32_t id; + do { + id = esp_random(); + } while (id == 0); + return id; +} + ChatSettingsData getDefaultSettings() { return ChatSettingsData{ + .senderId = 0, .nickname = "Device", .encryptionKey = {}, .hasEncryptionKey = false, @@ -108,12 +121,22 @@ ChatSettingsData loadSettings() { std::map map; if (!file::loadPropertiesFile(CHAT_SETTINGS_FILE, map)) { + settings.senderId = generateSenderId(); return settings; } - auto it = map.find(KEY_NICKNAME); + auto it = map.find(KEY_SENDER_ID); + if (it != map.end() && !it->second.empty()) { + settings.senderId = static_cast(strtoul(it->second.c_str(), nullptr, 10)); + } + // Generate sender ID if missing or zero + if (settings.senderId == 0) { + settings.senderId = generateSenderId(); + } + + it = map.find(KEY_NICKNAME); if (it != map.end() && !it->second.empty()) { - settings.nickname = it->second.substr(0, SENDER_NAME_SIZE - 1); + settings.nickname = it->second.substr(0, MAX_NICKNAME_LEN); } it = map.find(KEY_ENCRYPTION_KEY); @@ -125,7 +148,7 @@ ChatSettingsData loadSettings() { it = map.find(KEY_CHAT_CHANNEL); if (it != map.end() && !it->second.empty()) { - settings.chatChannel = it->second.substr(0, TARGET_SIZE - 1); + settings.chatChannel = it->second.substr(0, MAX_TARGET_LEN); } return settings; @@ -134,6 +157,7 @@ ChatSettingsData loadSettings() { bool saveSettings(const ChatSettingsData& settings) { std::map map; + map[KEY_SENDER_ID] = std::to_string(settings.senderId); map[KEY_NICKNAME] = settings.nickname; map[KEY_CHAT_CHANNEL] = settings.chatChannel; diff --git a/Tactility/Source/app/chat/ChatView.cpp b/Tactility/Source/app/chat/ChatView.cpp index 0a9773fa3..6372800bb 100644 --- a/Tactility/Source/app/chat/ChatView.cpp +++ b/Tactility/Source/app/chat/ChatView.cpp @@ -48,7 +48,7 @@ void ChatView::createInputBar(lv_obj_t* parent) { lv_obj_set_flex_grow(inputField, 1); lv_textarea_set_placeholder_text(inputField, "Type a message..."); lv_textarea_set_one_line(inputField, true); - lv_textarea_set_max_length(inputField, MESSAGE_SIZE - 1); + lv_textarea_set_max_length(inputField, MAX_MESSAGE_LEN); auto* sendBtn = lv_button_create(wrapper); lv_obj_set_style_margin_all(sendBtn, 0, LV_STATE_DEFAULT); @@ -76,7 +76,7 @@ void ChatView::createSettingsPanel(lv_obj_t* parent) { nicknameInput = lv_textarea_create(settingsPanel); lv_obj_set_width(nicknameInput, LV_PCT(100)); lv_textarea_set_one_line(nicknameInput, true); - lv_textarea_set_max_length(nicknameInput, SENDER_NAME_SIZE - 1); + lv_textarea_set_max_length(nicknameInput, MAX_NICKNAME_LEN); // Encryption key auto* keyLabel = lv_label_create(settingsPanel); @@ -122,7 +122,7 @@ void ChatView::createChannelPanel(lv_obj_t* parent) { channelInput = lv_textarea_create(channelPanel); lv_obj_set_width(channelInput, LV_PCT(100)); lv_textarea_set_one_line(channelInput, true); - lv_textarea_set_max_length(channelInput, TARGET_SIZE - 1); + lv_textarea_set_max_length(channelInput, MAX_TARGET_LEN); auto* btnRow = lv_obj_create(channelPanel); lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW); From ca8cf0bfabd22c7135f08ade44b4183513d689c6 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Mon, 26 Jan 2026 22:19:09 +1000 Subject: [PATCH 09/10] updoot --- Documentation/chat.md | 33 +++++++------ .../Private/Tactility/app/chat/ChatProtocol.h | 9 ++++ Tactility/Source/app/chat/ChatProtocol.cpp | 46 +++++++++++++------ 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/Documentation/chat.md b/Documentation/chat.md index b5b060ca7..6b4957cd2 100644 --- a/Documentation/chat.md +++ b/Documentation/chat.md @@ -85,9 +85,13 @@ Offset Size Field [nickname\0][target\0][message bytes] ``` -- `nickname`: Null-terminated sender display name (max 23 chars + null) -- `target`: Null-terminated channel (e.g. `#general`) or empty for broadcast (max 23 chars + null) -- `message`: Remaining bytes, NOT null-terminated (length = `payload_size - strlen(nickname) - 1 - strlen(target) - 1`) +- `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 @@ -102,13 +106,15 @@ Offset Size Field ### Size Limits -| Constraint | Value | -|------------|-------| -| Header size | 16 bytes | -| Max payload (uint8_t) | 255 bytes | -| Max nickname | 23 characters | -| Max channel/target | 23 characters | -| Max message | 200-251 bytes (varies by nickname/target length) | +| 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 @@ -159,9 +165,10 @@ All files are guarded with `#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(C - 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. Extract message from remaining bytes (length derived from payload_size) -5. Store in message deque with sender ID -6. Display if target matches current channel or is broadcast (empty) +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 diff --git a/Tactility/Private/Tactility/app/chat/ChatProtocol.h b/Tactility/Private/Tactility/app/chat/ChatProtocol.h index 5cf013123..d62812e9e 100644 --- a/Tactility/Private/Tactility/app/chat/ChatProtocol.h +++ b/Tactility/Private/Tactility/app/chat/ChatProtocol.h @@ -42,9 +42,18 @@ static_assert(sizeof(MessageHeader) == 16, "MessageHeader must be 16 bytes"); constexpr size_t HEADER_SIZE = sizeof(MessageHeader); constexpr size_t MAX_PAYLOAD_V1 = 250 - HEADER_SIZE; // 234 bytes for ESP-NOW v1 constexpr size_t MAX_PAYLOAD_V2 = 1470 - HEADER_SIZE; // 1454 bytes for ESP-NOW v2 + +// Nickname constraints +constexpr size_t MIN_NICKNAME_LEN = 2; // Single-letter names not allowed constexpr size_t MAX_NICKNAME_LEN = 23; // Max nickname length (excluding null) + +// Target/channel constraints (0 = broadcast allowed) +constexpr size_t MIN_TARGET_LEN = 0; // Empty = broadcast constexpr size_t MAX_TARGET_LEN = 23; // Max target/channel length (excluding null) +// Message constraints +constexpr size_t MIN_MESSAGE_LEN = 1; // At least 1 char (e.g. "?") + // Max message length: 255 (uint8_t payload_size) - nickname - null - target - null // Using max lengths: 255 - 23 - 1 - 23 - 1 = 207, rounded down for safety constexpr size_t MAX_MESSAGE_LEN = 200; diff --git a/Tactility/Source/app/chat/ChatProtocol.cpp b/Tactility/Source/app/chat/ChatProtocol.cpp index 59c6cd621..e3cc18035 100644 --- a/Tactility/Source/app/chat/ChatProtocol.cpp +++ b/Tactility/Source/app/chat/ChatProtocol.cpp @@ -15,8 +15,14 @@ namespace tt::app::chat { bool serializeTextMessage(uint32_t senderId, uint32_t targetId, const std::string& senderName, const std::string& target, const std::string& message, std::vector& out) { - // Validate input lengths - if (senderName.size() > MAX_NICKNAME_LEN || target.size() > MAX_TARGET_LEN) { + // Validate input lengths (min and max) + if (senderName.size() < MIN_NICKNAME_LEN || senderName.size() > MAX_NICKNAME_LEN) { + return false; + } + if (target.size() > MAX_TARGET_LEN) { + return false; // MIN_TARGET_LEN is 0, so empty (broadcast) is allowed + } + if (message.size() < MIN_MESSAGE_LEN) { return false; } @@ -33,18 +39,19 @@ bool serializeTextMessage(uint32_t senderId, uint32_t targetId, return false; // payload_size is uint8_t } + // Build header + MessageHeader header = { + .magic = CHAT_MAGIC_V2, + .protocol_version = PROTOCOL_VERSION, + .from = senderId, + .to = targetId, + .payload_type = static_cast(PayloadType::TextMessage), + .payload_size = static_cast(payloadSize) + }; + // Allocate output buffer out.resize(HEADER_SIZE + payloadSize); - // Build header - MessageHeader header; - header.magic = CHAT_MAGIC_V2; - header.protocol_version = PROTOCOL_VERSION; - header.from = senderId; - header.to = targetId; - header.payload_type = static_cast(PayloadType::TextMessage); - header.payload_size = static_cast(payloadSize); - // Copy header to output memcpy(out.data(), &header, HEADER_SIZE); @@ -65,8 +72,10 @@ bool serializeTextMessage(uint32_t senderId, uint32_t targetId, } bool deserializeMessage(const uint8_t* data, size_t length, ParsedMessage& out) { - // Minimum: header + at least 2 null terminators (empty nickname + empty target) - if (length < HEADER_SIZE + 2) { + // Minimum: header + min_nickname + null + min_target + null + min_message + // = 16 + 2 + 1 + 0 + 1 + 1 = 21 bytes + constexpr size_t MIN_PACKET_SIZE = HEADER_SIZE + MIN_NICKNAME_LEN + 1 + MIN_TARGET_LEN + 1 + MIN_MESSAGE_LEN; + if (length < MIN_PACKET_SIZE) { return false; } @@ -120,6 +129,17 @@ bool deserializeMessage(const uint8_t* data, size_t length, ParsedMessage& out) // Rest is the message (not null-terminated) const char* messageStart = reinterpret_cast(payload + offset); + // Validate field lengths (min and max) + if (nicknameLen < MIN_NICKNAME_LEN || nicknameLen > MAX_NICKNAME_LEN) { + return false; + } + if (targetLen > MAX_TARGET_LEN) { + return false; + } + if (remaining < MIN_MESSAGE_LEN) { + return false; + } + // Populate output out.senderId = header.from; out.targetId = header.to; From 2da1f611cb7d89491b7a2818a74a4d791b0cafbb Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Mon, 26 Jan 2026 22:28:43 +1000 Subject: [PATCH 10/10] Update chat.md --- Documentation/chat.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation/chat.md b/Documentation/chat.md index 6b4957cd2..563d995e9 100644 --- a/Documentation/chat.md +++ b/Documentation/chat.md @@ -1,6 +1,6 @@ # 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. +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 @@ -137,7 +137,7 @@ Messages with incorrect magic/version or invalid payload are silently discarded. ## 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 @@ -160,7 +160,7 @@ All files are guarded with `#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(C 1. ESP-NOW callback fires with raw data 2. Validate packet: - - Minimum size: 18 bytes (16 header + 2 null terminators) + - 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`