mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-02-18 10:53:17 +00:00
Chat app update, EspNow v2 & GPS Info (#460)
This commit is contained in:
parent
dfe2c865d1
commit
10381b10cd
192
Documentation/chat.md
Normal file
192
Documentation/chat.md
Normal file
@ -0,0 +1,192 @@
|
||||
# Chat App
|
||||
|
||||
ESP-NOW-based chat application with channel-based messaging. Devices with the same encryption key can communicate in real-time without requiring a WiFi access point or internet connection.
|
||||
|
||||
## Features
|
||||
|
||||
- **Channel-based messaging**: Join named channels (e.g. `#general`, `#random`) to organize conversations
|
||||
- **Broadcast support**: Messages with empty target are visible in all channels
|
||||
- **Configurable nickname**: Identify yourself with a custom name (max 23 characters)
|
||||
- **Unique sender ID**: Each device gets a random 32-bit ID on first launch for future DM support
|
||||
- **Encryption key**: Optional shared key for private group communication
|
||||
- **Persistent settings**: Sender ID, nickname, key, and current chat channel are saved across reboots
|
||||
|
||||
## Requirements
|
||||
|
||||
- ESP32 with WiFi support (not available on ESP32-P4)
|
||||
- ESP-NOW service enabled
|
||||
|
||||
## UI Layout
|
||||
|
||||
```text
|
||||
+------------------------------------------+
|
||||
| [Back] Chat: #general [List] [Gear] |
|
||||
+------------------------------------------+
|
||||
| alice: hello everyone |
|
||||
| bob: hey alice! |
|
||||
| You: hi there |
|
||||
| (scrollable message list) |
|
||||
+------------------------------------------+
|
||||
| [____input textarea____] [Send] |
|
||||
+------------------------------------------+
|
||||
```
|
||||
|
||||
- **Toolbar title**: Shows `Chat: <channel>` with the current channel name
|
||||
- **List icon**: Opens channel selector to switch channels
|
||||
- **Gear icon**: Opens settings panel (nickname, encryption key)
|
||||
- **Message list**: Shows messages matching the current channel or broadcast messages
|
||||
- **Input bar**: Type and send messages to the current channel
|
||||
|
||||
## Channel Selector
|
||||
|
||||
Tap the list icon to change channels. Enter a channel name (e.g. `#general`, `#team1`) and press OK. The message list refreshes to show only messages matching the new channel.
|
||||
|
||||
Messages are sent with the current channel as the target. Only devices viewing the same channel will display the message. Broadcast messages (empty target) appear in all channels.
|
||||
|
||||
## First Launch
|
||||
|
||||
On first launch (when no settings file exists), the settings panel opens automatically so users can configure their nickname before chatting. A unique sender ID is also generated using the hardware RNG.
|
||||
|
||||
## Settings
|
||||
|
||||
Tap the gear icon to configure:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Nickname | Your display name (max 23 chars) | `Device` |
|
||||
| Key | Encryption key as 32 hex characters (16 bytes) | All zeros (empty field) |
|
||||
|
||||
Settings are stored in `/data/settings/chat.properties`. The encryption key is stored encrypted using AES-256-CBC. The sender ID is stored as a decimal number.
|
||||
|
||||
When the key field is left empty, the default all-zeros key is used. All devices using the default key can communicate without configuration.
|
||||
|
||||
Changing the encryption key causes ESP-NOW to restart with the new configuration.
|
||||
|
||||
## Wire Protocol v2
|
||||
|
||||
Compact variable-length packets broadcast over ESP-NOW:
|
||||
|
||||
### Header (16 bytes)
|
||||
|
||||
```text
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 magic (0x54435432 "TCT2")
|
||||
4 2 protocol_version (2)
|
||||
6 4 from (sender ID, random uint32)
|
||||
10 4 to (recipient ID, 0 = broadcast/channel)
|
||||
14 1 payload_type (1 = TextMessage)
|
||||
15 1 payload_size (length of payload)
|
||||
```
|
||||
|
||||
### Text Message Payload (variable)
|
||||
|
||||
```text
|
||||
[nickname\0][target\0][message bytes]
|
||||
```
|
||||
|
||||
- `nickname`: Null-terminated sender display name (2-23 chars + null; single-letter names rejected)
|
||||
- `target`: Null-terminated channel or empty for broadcast (0-23 chars + null)
|
||||
- Empty string (`\0`): broadcast to all channels
|
||||
- Channel name (e.g. `#general`): visible only when viewing that channel
|
||||
- `message`: Remaining bytes, NOT null-terminated, minimum 1 byte (length = `payload_size - strlen(nickname) - 1 - strlen(target) - 1`)
|
||||
|
||||
**Minimum packet size for TextMessage:** 16 (header) + 2 (min nickname) + 1 (null) + 0 (empty target) + 1 (null) + 1 (min message) = **21 bytes**
|
||||
|
||||
**Example calculation:** If nickname is "Alice" (5 chars) and target is "#general" (8 chars):
|
||||
- Overhead: 5 + 1 + 8 + 1 = 15 bytes
|
||||
- Max message: 255 - 15 = 240 bytes
|
||||
|
||||
### Example
|
||||
|
||||
"Alice" sends "Hi!" to #general:
|
||||
- Header: 16 bytes
|
||||
- Payload: `Alice\0#general\0Hi!` = 18 bytes
|
||||
- **Total: 34 bytes**
|
||||
|
||||
### Size Limits
|
||||
|
||||
| Constraint | Min | Max |
|
||||
|------------|-----|-----|
|
||||
| Header size | 16 bytes | 16 bytes |
|
||||
| Payload (uint8_t) | 5 bytes | 255 bytes |
|
||||
| Nickname | 2 characters | 23 characters |
|
||||
| Channel/target | 0 (broadcast) | 23 characters |
|
||||
| Message (wire) | 1 byte | up to 251 bytes (varies by overhead) |
|
||||
| Message (UI) | 1 character | 200 characters |
|
||||
| Total packet (TextMessage) | 21 bytes | 271 bytes |
|
||||
|
||||
### Payload Types
|
||||
|
||||
| Type | Value | Description |
|
||||
|------|-------|-------------|
|
||||
| TextMessage | 1 | Chat message with nickname, target, and text |
|
||||
| (reserved) | 2+ | Future: Position, Telemetry, etc. |
|
||||
|
||||
### Target Field Semantics
|
||||
|
||||
| `to` Value | `target` Field | Meaning |
|
||||
|------------|----------------|---------|
|
||||
| 0 | `""` (empty) | Broadcast - visible in all channels |
|
||||
| 0 | `#channel` | Channel message - visible only when viewing that channel |
|
||||
| non-zero | `nickname` | Direct message (future - requires address discovery protocol) |
|
||||
|
||||
Messages with incorrect magic/version or invalid payload are silently discarded.
|
||||
|
||||
> **Note:** Direct messaging (non-zero `to`) will require an address discovery mechanism, such as periodic broadcasts announcing nickname→sender_id mappings, before devices can address each other directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
ChatApp - App lifecycle, ESP-NOW send/receive, settings management
|
||||
ChatState - Message storage (deque, max 100), channel filtering, mutex-protected
|
||||
ChatView - LVGL UI: toolbar, message list, input bar, settings/channel panels
|
||||
ChatProtocol - MessageHeader struct, serialize/deserialize, PayloadType enum
|
||||
ChatSettings - Properties file load/save with encrypted key storage, sender ID generation
|
||||
```
|
||||
|
||||
All files are guarded with `#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)` to exclude from P4 builds.
|
||||
|
||||
## Message Flow
|
||||
|
||||
### Sending
|
||||
|
||||
1. User types message and taps Send
|
||||
2. `serializeTextMessage()` builds compact packet with sender ID, nickname, channel, message
|
||||
3. Broadcast via ESP-NOW to nearby devices
|
||||
4. Own message stored and displayed locally
|
||||
|
||||
### Receiving
|
||||
|
||||
1. ESP-NOW callback fires with raw data
|
||||
2. Validate packet:
|
||||
- Minimum size: 21 bytes (16 header + 2 min nickname + 1 null + 0 min target + 1 null + 1 min message)
|
||||
- Magic bytes: must be `0x54435432` ("TCT2")
|
||||
- Protocol version: must be 2
|
||||
- Payload size: `header.payload_size` must equal `received_length - 16`
|
||||
3. Parse null-terminated nickname and target from payload
|
||||
4. Validate minimum lengths: nickname >= 2 chars, message >= 1 byte
|
||||
5. Extract message from remaining bytes (length derived from payload_size)
|
||||
6. Store in message deque with sender ID
|
||||
7. Display if target matches current channel or is broadcast (empty)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Maximum 100 stored messages (oldest discarded when full)
|
||||
- Nickname: 23 characters max
|
||||
- Channel name: 23 characters max
|
||||
- Message text: 200 characters max (UI limit; actual wire limit varies by nickname/target length)
|
||||
- No message persistence across app restarts (messages are in-memory only)
|
||||
- All communication is broadcast; channel filtering is client-side only
|
||||
- Sender ID collisions: 32-bit random IDs have ~50% collision probability at ~77,000 active devices (birthday paradox); no collision detection/resolution implemented
|
||||
|
||||
## Security Considerations
|
||||
|
||||
The chat protocol relies on ESP-NOW's built-in encryption (when configured) but has additional security limitations:
|
||||
|
||||
- **No message authentication**: No MAC/HMAC to verify message integrity or sender authenticity beyond the sender ID
|
||||
- **No replay protection**: No sequence numbers or timestamps; messages can be replayed
|
||||
- **Sender ID spoofing**: Any device knowing the encryption key can forge messages with arbitrary sender IDs
|
||||
- **No forward secrecy**: Compromise of the shared key exposes all past and future messages
|
||||
|
||||
These tradeoffs are acceptable for casual local communication but should be understood before using for sensitive applications.
|
||||
@ -16,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<void(const esp_now_recv_inf
|
||||
|
||||
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). Returns 0 if service not running. */
|
||||
size_t getMaxDataLength();
|
||||
|
||||
}
|
||||
|
||||
#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED
|
||||
@ -21,6 +21,9 @@ class GpsService final : public Service {
|
||||
minmea_sentence_rmc rmcRecord;
|
||||
TickType_t rmcTime = 0;
|
||||
|
||||
minmea_sentence_gga ggaRecord;
|
||||
TickType_t ggaTime = 0;
|
||||
|
||||
RecursiveMutex mutex;
|
||||
Mutex stateMutex;
|
||||
std::vector<GpsDeviceRecord> deviceRecords;
|
||||
@ -58,6 +61,7 @@ public:
|
||||
|
||||
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<PubSub<State>> getStatePubsub() const { return statePubSub; }
|
||||
|
||||
46
Tactility/Private/Tactility/app/chat/ChatAppPrivate.h
Normal file
46
Tactility/Private/Tactility/app/chat/ChatAppPrivate.h
Normal file
@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include "ChatState.h"
|
||||
#include "ChatView.h"
|
||||
#include "ChatSettings.h"
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/service/espnow/EspNow.h>
|
||||
|
||||
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
|
||||
97
Tactility/Private/Tactility/app/chat/ChatProtocol.h
Normal file
97
Tactility/Private/Tactility/app/chat/ChatProtocol.h
Normal file
@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t>& 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
|
||||
34
Tactility/Private/Tactility/app/chat/ChatSettings.h
Normal file
34
Tactility/Private/Tactility/app/chat/ChatSettings.h
Normal file
@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include <esp_now.h>
|
||||
|
||||
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<uint8_t, ESP_NOW_KEY_LEN> 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
|
||||
59
Tactility/Private/Tactility/app/chat/ChatState.h
Normal file
59
Tactility/Private/Tactility/app/chat/ChatState.h
Normal file
@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include <Tactility/RecursiveMutex.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<StoredMessage> 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<StoredMessage> getFilteredMessages() const;
|
||||
};
|
||||
|
||||
} // namespace tt::app::chat
|
||||
|
||||
#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED
|
||||
79
Tactility/Private/Tactility/app/chat/ChatView.h
Normal file
79
Tactility/Private/Tactility/app/chat/ChatView.h
Normal file
@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include "ChatState.h"
|
||||
#include "ChatSettings.h"
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
|
||||
#include <esp_now.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
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
|
||||
@ -31,6 +31,7 @@ class EspNowService final : public Service {
|
||||
std::vector<ReceiverSubscriptionData> 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 @@ public:
|
||||
|
||||
void unsubscribeReceiver(ReceiverSubscription subscription);
|
||||
|
||||
uint32_t getVersion() const;
|
||||
|
||||
// region Internal API
|
||||
};
|
||||
|
||||
|
||||
@ -4,141 +4,179 @@
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include <Tactility/app/chat/ChatAppPrivate.h>
|
||||
#include <Tactility/app/chat/ChatProtocol.h>
|
||||
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/Assets.h>
|
||||
#include <Tactility/Logger.h>
|
||||
#include <Tactility/service/espnow/EspNow.h>
|
||||
#include <Tactility/lvgl/LvglSync.h>
|
||||
|
||||
#include "Tactility/lvgl/LvglSync.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <esp_wifi.h>
|
||||
#include <lvgl.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <vector>
|
||||
|
||||
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 };
|
||||
static constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
|
||||
|
||||
class ChatApp : public App {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
void ChatApp::disableEspNow() {
|
||||
if (service::espnow::isEnabled()) {
|
||||
service::espnow::disable();
|
||||
}
|
||||
}
|
||||
|
||||
static void onSendClicked(lv_event_t* e) {
|
||||
auto* self = static_cast<ChatApp*>(lv_event_get_user_data(e));
|
||||
auto* msg = lv_textarea_get_text(self->input_field);
|
||||
const auto msg_len = strlen(msg);
|
||||
|
||||
if (self->msg_list && msg && msg_len) {
|
||||
self->addMessage(msg);
|
||||
|
||||
if (!service::espnow::send(BROADCAST_ADDRESS, reinterpret_cast<const uint8_t*>(msg), msg_len)) {
|
||||
LOGGER.error("Failed to send message");
|
||||
}
|
||||
|
||||
lv_textarea_set_text(self->input_field, "");
|
||||
}
|
||||
void ChatApp::onCreate(AppContext& appContext) {
|
||||
isFirstLaunch = !settingsFileExists();
|
||||
settings = loadSettings();
|
||||
state.setLocalNickname(settings.nickname);
|
||||
if (!settings.chatChannel.empty()) {
|
||||
state.setCurrentChannel(settings.chatChannel);
|
||||
}
|
||||
enableEspNow();
|
||||
|
||||
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<char*>(malloc(length + 1));
|
||||
memcpy(buffer, data, length);
|
||||
buffer[length] = 0x00;
|
||||
const std::string message_prefixed = std::string("Received: ") + buffer;
|
||||
|
||||
lvgl::getSyncLock()->lock();
|
||||
addMessage(message_prefixed.c_str());
|
||||
lvgl::getSyncLock()->unlock();
|
||||
|
||||
free(buffer);
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
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<uint8_t*>(key),
|
||||
service::espnow::Mode::Station,
|
||||
1,
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
service::espnow::enable(config);
|
||||
|
||||
receiveSubscription = service::espnow::subscribeReceiver([this](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
|
||||
receiveSubscription = service::espnow::subscribeReceiver(
|
||||
[this](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
|
||||
onReceive(receiveInfo, data, length);
|
||||
});
|
||||
}
|
||||
|
||||
void onDestroy(AppContext& appContext) override {
|
||||
service::espnow::unsubscribeReceiver(receiveSubscription);
|
||||
|
||||
if (service::espnow::isEnabled()) {
|
||||
service::espnow::disable();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void ChatApp::onDestroy(AppContext& appContext) {
|
||||
service::espnow::unsubscribeReceiver(receiveSubscription);
|
||||
disableEspNow();
|
||||
}
|
||||
|
||||
void ChatApp::onShow(AppContext& context, lv_obj_t* parent) {
|
||||
view.init(context, parent);
|
||||
if (isFirstLaunch) {
|
||||
view.showSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
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, static_cast<size_t>(length), parsed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
StoredMessage msg;
|
||||
msg.displayText = parsed.senderName + ": " + parsed.message;
|
||||
msg.target = parsed.target;
|
||||
msg.isOwn = false;
|
||||
|
||||
lvgl::toolbar_create(parent, context);
|
||||
state.addMessage(msg);
|
||||
|
||||
// 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);
|
||||
{
|
||||
auto lock = lvgl::getSyncLock()->asScopedLock();
|
||||
lock.lock();
|
||||
view.displayMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
void ChatApp::sendMessage(const std::string& text) {
|
||||
if (text.empty()) return;
|
||||
|
||||
// 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);
|
||||
std::string nickname = state.getLocalNickname();
|
||||
std::string channel = state.getCurrentChannel();
|
||||
|
||||
// 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);
|
||||
std::vector<uint8_t> wireMsg;
|
||||
if (!serializeTextMessage(settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) {
|
||||
LOGGER.error("Failed to serialize message");
|
||||
return;
|
||||
}
|
||||
|
||||
~ChatApp() override = default;
|
||||
};
|
||||
if (!service::espnow::send(BROADCAST_ADDRESS, wireMsg.data(), wireMsg.size())) {
|
||||
LOGGER.error("Failed to send message");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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<uint8_t>(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());
|
||||
}
|
||||
|
||||
state.setLocalNickname(settings.nickname);
|
||||
saveSettings(settings);
|
||||
|
||||
if (needRestart) {
|
||||
disableEspNow();
|
||||
enableEspNow();
|
||||
}
|
||||
}
|
||||
|
||||
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<ChatApp>
|
||||
};
|
||||
|
||||
}
|
||||
} // namespace tt::app::chat
|
||||
|
||||
#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED
|
||||
|
||||
174
Tactility/Source/app/chat/ChatProtocol.cpp
Normal file
174
Tactility/Source/app/chat/ChatProtocol.cpp
Normal file
@ -0,0 +1,174 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include <Tactility/app/chat/ChatProtocol.h>
|
||||
#include <Tactility/service/espnow/EspNow.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
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<uint8_t>& 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<uint8_t>(PayloadType::TextMessage),
|
||||
.payload_size = static_cast<uint8_t>(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<uint8_t>(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<const char*>(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<const char*>(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<const char*>(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
|
||||
183
Tactility/Source/app/chat/ChatSettings.cpp
Normal file
183
Tactility/Source/app/chat/ChatSettings.cpp
Normal file
@ -0,0 +1,183 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include <Tactility/app/chat/ChatSettings.h>
|
||||
#include <Tactility/app/chat/ChatProtocol.h>
|
||||
|
||||
#include <Tactility/crypt/Crypt.h>
|
||||
#include <Tactility/file/PropertiesFile.h>
|
||||
#include <Tactility/Logger.h>
|
||||
|
||||
#include <esp_random.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
#include <unistd.h>
|
||||
|
||||
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<int>(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<uint8_t>(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<std::string, std::string> 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<uint32_t>(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<std::string, std::string> 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
|
||||
60
Tactility/Source/app/chat/ChatState.cpp
Normal file
60
Tactility/Source/app/chat/ChatState.cpp
Normal file
@ -0,0 +1,60 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include <Tactility/app/chat/ChatState.h>
|
||||
|
||||
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<StoredMessage> ChatState::getFilteredMessages() const {
|
||||
auto lock = mutex.asScopedLock();
|
||||
lock.lock();
|
||||
std::vector<StoredMessage> 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
|
||||
295
Tactility/Source/app/chat/ChatView.cpp
Normal file
295
Tactility/Source/app/chat/ChatView.cpp
Normal file
@ -0,0 +1,295 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
|
||||
#include <Tactility/app/chat/ChatView.h>
|
||||
#include <Tactility/app/chat/ChatAppPrivate.h>
|
||||
#include <Tactility/app/chat/ChatProtocol.h>
|
||||
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
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<ChatView*>(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<ChatView*>(lv_event_get_user_data(e));
|
||||
self->showSettings(self->app->getSettings());
|
||||
}
|
||||
|
||||
void ChatView::onSettingsSave(lv_event_t* e) {
|
||||
auto* self = static_cast<ChatView*>(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<ChatView*>(lv_event_get_user_data(e));
|
||||
self->hideSettings();
|
||||
}
|
||||
|
||||
void ChatView::onChannelClicked(lv_event_t* e) {
|
||||
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
|
||||
self->showChannelSelector();
|
||||
}
|
||||
|
||||
void ChatView::onChannelSave(lv_event_t* e) {
|
||||
auto* self = static_cast<ChatView*>(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<ChatView*>(lv_event_get_user_data(e));
|
||||
self->hideChannelSelector();
|
||||
}
|
||||
|
||||
} // namespace tt::app::chat
|
||||
|
||||
#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED
|
||||
@ -30,6 +30,12 @@ class GpsSettingsApp final : public App {
|
||||
std::shared_ptr<GpsSettingsApp*> appReference = std::make_shared<GpsSettingsApp*>(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 @@ public:
|
||||
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();
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<EspNowService> findService() {
|
||||
return std::static_pointer_cast<EspNowService>(
|
||||
findServiceById(manifest.id)
|
||||
|
||||
@ -37,7 +37,7 @@ void GpsService::addGpsDevice(const std::shared_ptr<GpsDevice>& 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<GpsDevice>& 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<GpsService> findGpsService() {
|
||||
auto service = findServiceById(manifest.id);
|
||||
assert(service != nullptr);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user