diff --git a/Documentation/chat.md b/Documentation/chat.md new file mode 100644 index 000000000..563d995e9 --- /dev/null +++ b/Documentation/chat.md @@ -0,0 +1,192 @@ +# Chat App + +ESP-NOW-based chat application with channel-based messaging. Devices with the same encryption key can communicate in real-time without requiring a WiFi access point or internet connection. + +## Features + +- **Channel-based messaging**: Join named channels (e.g. `#general`, `#random`) to organize conversations +- **Broadcast support**: Messages with empty target are visible in all channels +- **Configurable nickname**: Identify yourself with a custom name (max 23 characters) +- **Unique sender ID**: Each device gets a random 32-bit ID on first launch for future DM support +- **Encryption key**: Optional shared key for private group communication +- **Persistent settings**: Sender ID, nickname, key, and current chat channel are saved across reboots + +## Requirements + +- ESP32 with WiFi support (not available on ESP32-P4) +- ESP-NOW service enabled + +## UI Layout + +```text ++------------------------------------------+ +| [Back] Chat: #general [List] [Gear] | ++------------------------------------------+ +| alice: hello everyone | +| bob: hey alice! | +| You: hi there | +| (scrollable message list) | ++------------------------------------------+ +| [____input textarea____] [Send] | ++------------------------------------------+ +``` + +- **Toolbar title**: Shows `Chat: ` with the current channel name +- **List icon**: Opens channel selector to switch channels +- **Gear icon**: Opens settings panel (nickname, encryption key) +- **Message list**: Shows messages matching the current channel or broadcast messages +- **Input bar**: Type and send messages to the current channel + +## Channel Selector + +Tap the list icon to change channels. Enter a channel name (e.g. `#general`, `#team1`) and press OK. The message list refreshes to show only messages matching the new channel. + +Messages are sent with the current channel as the target. Only devices viewing the same channel will display the message. Broadcast messages (empty target) appear in all channels. + +## First Launch + +On first launch (when no settings file exists), the settings panel opens automatically so users can configure their nickname before chatting. A unique sender ID is also generated using the hardware RNG. + +## Settings + +Tap the gear icon to configure: + +| Setting | Description | Default | +|---------|-------------|---------| +| Nickname | Your display name (max 23 chars) | `Device` | +| Key | Encryption key as 32 hex characters (16 bytes) | All zeros (empty field) | + +Settings are stored in `/data/settings/chat.properties`. The encryption key is stored encrypted using AES-256-CBC. The sender ID is stored as a decimal number. + +When the key field is left empty, the default all-zeros key is used. All devices using the default key can communicate without configuration. + +Changing the encryption key causes ESP-NOW to restart with the new configuration. + +## Wire Protocol v2 + +Compact variable-length packets broadcast over ESP-NOW: + +### Header (16 bytes) + +```text +Offset Size Field +------ ---- ----- +0 4 magic (0x54435432 "TCT2") +4 2 protocol_version (2) +6 4 from (sender ID, random uint32) +10 4 to (recipient ID, 0 = broadcast/channel) +14 1 payload_type (1 = TextMessage) +15 1 payload_size (length of payload) +``` + +### Text Message Payload (variable) + +```text +[nickname\0][target\0][message bytes] +``` + +- `nickname`: Null-terminated sender display name (2-23 chars + null; single-letter names rejected) +- `target`: Null-terminated channel or empty for broadcast (0-23 chars + null) + - Empty string (`\0`): broadcast to all channels + - Channel name (e.g. `#general`): visible only when viewing that channel +- `message`: Remaining bytes, NOT null-terminated, minimum 1 byte (length = `payload_size - strlen(nickname) - 1 - strlen(target) - 1`) + +**Minimum packet size for TextMessage:** 16 (header) + 2 (min nickname) + 1 (null) + 0 (empty target) + 1 (null) + 1 (min message) = **21 bytes** + +**Example calculation:** If nickname is "Alice" (5 chars) and target is "#general" (8 chars): +- Overhead: 5 + 1 + 8 + 1 = 15 bytes +- Max message: 255 - 15 = 240 bytes + +### Example + +"Alice" sends "Hi!" to #general: +- Header: 16 bytes +- Payload: `Alice\0#general\0Hi!` = 18 bytes +- **Total: 34 bytes** + +### Size Limits + +| Constraint | Min | Max | +|------------|-----|-----| +| Header size | 16 bytes | 16 bytes | +| Payload (uint8_t) | 5 bytes | 255 bytes | +| Nickname | 2 characters | 23 characters | +| Channel/target | 0 (broadcast) | 23 characters | +| Message (wire) | 1 byte | up to 251 bytes (varies by overhead) | +| Message (UI) | 1 character | 200 characters | +| Total packet (TextMessage) | 21 bytes | 271 bytes | + +### Payload Types + +| Type | Value | Description | +|------|-------|-------------| +| TextMessage | 1 | Chat message with nickname, target, and text | +| (reserved) | 2+ | Future: Position, Telemetry, etc. | + +### Target Field Semantics + +| `to` Value | `target` Field | Meaning | +|------------|----------------|---------| +| 0 | `""` (empty) | Broadcast - visible in all channels | +| 0 | `#channel` | Channel message - visible only when viewing that channel | +| non-zero | `nickname` | Direct message (future - requires address discovery protocol) | + +Messages with incorrect magic/version or invalid payload are silently discarded. + +> **Note:** Direct messaging (non-zero `to`) will require an address discovery mechanism, such as periodic broadcasts announcing nickname→sender_id mappings, before devices can address each other directly. + +## Architecture + +```text +ChatApp - App lifecycle, ESP-NOW send/receive, settings management +ChatState - Message storage (deque, max 100), channel filtering, mutex-protected +ChatView - LVGL UI: toolbar, message list, input bar, settings/channel panels +ChatProtocol - MessageHeader struct, serialize/deserialize, PayloadType enum +ChatSettings - Properties file load/save with encrypted key storage, sender ID generation +``` + +All files are guarded with `#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)` to exclude from P4 builds. + +## Message Flow + +### Sending + +1. User types message and taps Send +2. `serializeTextMessage()` builds compact packet with sender ID, nickname, channel, message +3. Broadcast via ESP-NOW to nearby devices +4. Own message stored and displayed locally + +### Receiving + +1. ESP-NOW callback fires with raw data +2. Validate packet: + - Minimum size: 21 bytes (16 header + 2 min nickname + 1 null + 0 min target + 1 null + 1 min message) + - Magic bytes: must be `0x54435432` ("TCT2") + - Protocol version: must be 2 + - Payload size: `header.payload_size` must equal `received_length - 16` +3. Parse null-terminated nickname and target from payload +4. Validate minimum lengths: nickname >= 2 chars, message >= 1 byte +5. Extract message from remaining bytes (length derived from payload_size) +6. Store in message deque with sender ID +7. Display if target matches current channel or is broadcast (empty) + +## Limitations + +- Maximum 100 stored messages (oldest discarded when full) +- Nickname: 23 characters max +- Channel name: 23 characters max +- Message text: 200 characters max (UI limit; actual wire limit varies by nickname/target length) +- No message persistence across app restarts (messages are in-memory only) +- All communication is broadcast; channel filtering is client-side only +- Sender ID collisions: 32-bit random IDs have ~50% collision probability at ~77,000 active devices (birthday paradox); no collision detection/resolution implemented + +## Security Considerations + +The chat protocol relies on ESP-NOW's built-in encryption (when configured) but has additional security limitations: + +- **No message authentication**: No MAC/HMAC to verify message integrity or sender authenticity beyond the sender ID +- **No replay protection**: No sequence numbers or timestamps; messages can be replayed +- **Sender ID spoofing**: Any device knowing the encryption key can forge messages with arbitrary sender IDs +- **No forward secrecy**: Compromise of the shared key exposes all past and future messages + +These tradeoffs are acceptable for casual local communication but should be understood before using for sensitive applications. diff --git a/Tactility/Include/Tactility/service/espnow/EspNow.h b/Tactility/Include/Tactility/service/espnow/EspNow.h index 429a56645..d29ef61d4 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..d62812e9e --- /dev/null +++ b/Tactility/Private/Tactility/app/chat/ChatProtocol.h @@ -0,0 +1,97 @@ +#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 { + +// 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(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 + +// 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; + +// Parsed message for application use +struct ParsedMessage { + uint32_t senderId; + uint32_t targetId; + std::string senderName; + std::string target; + std::string message; +}; + +/** 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. + * @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 + +#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..a504c9ac4 --- /dev/null +++ b/Tactility/Private/Tactility/app/chat/ChatSettings.h @@ -0,0 +1,34 @@ +#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 { + uint32_t senderId = 0; // Unique device ID (randomly generated on first launch) + 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..20355c419 100644 --- a/Tactility/Source/app/chat/ChatApp.cpp +++ b/Tactility/Source/app/chat/ChatApp.cpp @@ -4,141 +4,179 @@ #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 +#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, + settings.hasEncryptionKey + ); + service::espnow::enable(config); +} + +void ChatApp::disableEspNow() { + if (service::espnow::isEnabled()) { + service::espnow::disable(); } +} + +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); + } + ); +} - 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::onDestroy(AppContext& appContext) { + service::espnow::unsubscribeReceiver(receiveSubscription); + disableEspNow(); +} - if (self->msg_list && msg && msg_len) { - self->addMessage(msg); +void ChatApp::onShow(AppContext& context, lv_obj_t* parent) { + view.init(context, parent); + if (isFirstLaunch) { + view.showSettings(settings); + } +} - if (!service::espnow::send(BROADCAST_ADDRESS, reinterpret_cast(msg), msg_len)) { - LOGGER.error("Failed to send message"); - } +void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { + if (length <= 0) return; - lv_textarea_set_text(self->input_field, ""); - } + ParsedMessage parsed; + if (!deserializeMessage(data, static_cast(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); + std::vector wireMsg; + if (!serializeTextMessage(settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) { + 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, wireMsg.data(), wireMsg.size())) { + 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; + + state.addMessage(msg); + + { + auto lock = lvgl::getSyncLock()->asScopedLock(); + lock.lock(); + view.displayMessage(msg); + } +} - if (service::espnow::isEnabled()) { - service::espnow::disable(); +void ChatApp::applySettings(const std::string& nickname, const std::string& keyHex) { + bool needRestart = false; + + // Trim nickname to protocol limit + settings.nickname = nickname.substr(0, MAX_NICKNAME_LEN); + + // Parse hex key + if (keyHex.size() == ESP_NOW_KEY_LEN * 2) { + 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++) { + char hex[3] = { keyHex[i * 2], keyHex[i * 2 + 1], 0 }; + newKey[i] = static_cast(strtoul(hex, nullptr, 16)); + } + // 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; + } + 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(settings.nickname); + saveSettings(settings); + + if (needRestart) { + disableEspNow(); + enableEspNow(); } +} - ~ChatApp() override = default; -}; +void ChatApp::switchChannel(const std::string& chatChannel) { + const auto trimmedChannel = chatChannel.substr(0, MAX_TARGET_LEN); + state.setCurrentChannel(trimmedChannel); + settings.chatChannel = trimmedChannel; + saveSettings(settings); + + { + auto lock = lvgl::getSyncLock()->asScopedLock(); + lock.lock(); + view.refreshMessageList(); + } +} extern const AppManifest manifest = { .appId = "Chat", @@ -147,6 +185,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..e3cc18035 --- /dev/null +++ b/Tactility/Source/app/chat/ChatProtocol.cpp @@ -0,0 +1,174 @@ +#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 { + +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 (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; + } + + // 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 + } + + // 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); + + // 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, size_t length, ParsedMessage& out) { + // 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; + } + + // Copy header to aligned struct + MessageHeader header; + memcpy(&header, data, HEADER_SIZE); + + // Validate header + if (header.magic != CHAT_MAGIC_V2) { + return false; + } + + if (header.protocol_version != PROTOCOL_VERSION) { + return false; + } + + // 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; + + // 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 + } + + offset += targetLen + 1; + remaining = payloadLen - offset; + + // 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; + 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 new file mode 100644 index 000000000..f4ac84179 --- /dev/null +++ b/Tactility/Source/app/chat/ChatSettings.cpp @@ -0,0 +1,183 @@ +#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_SENDER_ID = "senderId"; +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, std::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, std::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; +} + +/** 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, + .chatChannel = "#general" + }; +} + +ChatSettingsData loadSettings() { + ChatSettingsData settings = getDefaultSettings(); + + std::map map; + if (!file::loadPropertiesFile(CHAT_SETTINGS_FILE, map)) { + settings.senderId = generateSenderId(); + return settings; + } + + 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, MAX_NICKNAME_LEN); + } + + 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, MAX_TARGET_LEN); + } + + return settings; +} + +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; + + 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..6372800bb --- /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, MAX_MESSAGE_LEN); + + 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, MAX_NICKNAME_LEN); + + // 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, MAX_TARGET_LEN); + + 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..cbcceb1b1 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,79 @@ 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); + 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(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)) { + // 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; + 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 +337,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_palette_lighten(LV_PALETTE_GREY, 5), 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 +390,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..2fd1a06a0 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; } } @@ -79,6 +80,21 @@ void unsubscribeReceiver(ReceiverSubscription subscription) { } } +uint32_t getVersion() { + auto service = findService(); + if (service != nullptr) { + return service->getVersion(); + } + LOGGER.error("Service not found"); + return 0; +} + +size_t getMaxDataLength() { + auto v = getVersion(); + if (v == 0) return 0; + return v >= 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..432f33440 100644 --- a/Tactility/Source/service/espnow/EspNowService.cpp +++ b/Tactility/Source/service/espnow/EspNowService.cpp @@ -74,16 +74,23 @@ 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 { + 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)); @@ -119,6 +126,7 @@ void EspNowService::disableFromDispatcher() { LOGGER.error("deinitWifi() failed"); } + espnowVersion = 0; enabled = false; } @@ -195,6 +203,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..359b02691 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(); }); @@ -156,14 +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; - if (started_one_or_more) { setState(State::On); return true; @@ -186,6 +192,7 @@ void GpsService::stopReceiving() { } rmcTime = 0; + ggaTime = 0; setState(State::Off); } @@ -227,6 +234,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);