Chat app update, EspNow v2 & GPS Info (#460)

This commit is contained in:
Shadowtrance 2026-01-27 02:32:57 +10:00 committed by GitHub
parent dfe2c865d1
commit 10381b10cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1547 additions and 124 deletions

192
Documentation/chat.md Normal file
View File

@ -0,0 +1,192 @@
# Chat App
ESP-NOW-based chat application with channel-based messaging. Devices with the same encryption key can communicate in real-time without requiring a WiFi access point or internet connection.
## Features
- **Channel-based messaging**: Join named channels (e.g. `#general`, `#random`) to organize conversations
- **Broadcast support**: Messages with empty target are visible in all channels
- **Configurable nickname**: Identify yourself with a custom name (max 23 characters)
- **Unique sender ID**: Each device gets a random 32-bit ID on first launch for future DM support
- **Encryption key**: Optional shared key for private group communication
- **Persistent settings**: Sender ID, nickname, key, and current chat channel are saved across reboots
## Requirements
- ESP32 with WiFi support (not available on ESP32-P4)
- ESP-NOW service enabled
## UI Layout
```text
+------------------------------------------+
| [Back] Chat: #general [List] [Gear] |
+------------------------------------------+
| alice: hello everyone |
| bob: hey alice! |
| You: hi there |
| (scrollable message list) |
+------------------------------------------+
| [____input textarea____] [Send] |
+------------------------------------------+
```
- **Toolbar title**: Shows `Chat: <channel>` with the current channel name
- **List icon**: Opens channel selector to switch channels
- **Gear icon**: Opens settings panel (nickname, encryption key)
- **Message list**: Shows messages matching the current channel or broadcast messages
- **Input bar**: Type and send messages to the current channel
## Channel Selector
Tap the list icon to change channels. Enter a channel name (e.g. `#general`, `#team1`) and press OK. The message list refreshes to show only messages matching the new channel.
Messages are sent with the current channel as the target. Only devices viewing the same channel will display the message. Broadcast messages (empty target) appear in all channels.
## First Launch
On first launch (when no settings file exists), the settings panel opens automatically so users can configure their nickname before chatting. A unique sender ID is also generated using the hardware RNG.
## Settings
Tap the gear icon to configure:
| Setting | Description | Default |
|---------|-------------|---------|
| Nickname | Your display name (max 23 chars) | `Device` |
| Key | Encryption key as 32 hex characters (16 bytes) | All zeros (empty field) |
Settings are stored in `/data/settings/chat.properties`. The encryption key is stored encrypted using AES-256-CBC. The sender ID is stored as a decimal number.
When the key field is left empty, the default all-zeros key is used. All devices using the default key can communicate without configuration.
Changing the encryption key causes ESP-NOW to restart with the new configuration.
## Wire Protocol v2
Compact variable-length packets broadcast over ESP-NOW:
### Header (16 bytes)
```text
Offset Size Field
------ ---- -----
0 4 magic (0x54435432 "TCT2")
4 2 protocol_version (2)
6 4 from (sender ID, random uint32)
10 4 to (recipient ID, 0 = broadcast/channel)
14 1 payload_type (1 = TextMessage)
15 1 payload_size (length of payload)
```
### Text Message Payload (variable)
```text
[nickname\0][target\0][message bytes]
```
- `nickname`: Null-terminated sender display name (2-23 chars + null; single-letter names rejected)
- `target`: Null-terminated channel or empty for broadcast (0-23 chars + null)
- Empty string (`\0`): broadcast to all channels
- Channel name (e.g. `#general`): visible only when viewing that channel
- `message`: Remaining bytes, NOT null-terminated, minimum 1 byte (length = `payload_size - strlen(nickname) - 1 - strlen(target) - 1`)
**Minimum packet size for TextMessage:** 16 (header) + 2 (min nickname) + 1 (null) + 0 (empty target) + 1 (null) + 1 (min message) = **21 bytes**
**Example calculation:** If nickname is "Alice" (5 chars) and target is "#general" (8 chars):
- Overhead: 5 + 1 + 8 + 1 = 15 bytes
- Max message: 255 - 15 = 240 bytes
### Example
"Alice" sends "Hi!" to #general:
- Header: 16 bytes
- Payload: `Alice\0#general\0Hi!` = 18 bytes
- **Total: 34 bytes**
### Size Limits
| Constraint | Min | Max |
|------------|-----|-----|
| Header size | 16 bytes | 16 bytes |
| Payload (uint8_t) | 5 bytes | 255 bytes |
| Nickname | 2 characters | 23 characters |
| Channel/target | 0 (broadcast) | 23 characters |
| Message (wire) | 1 byte | up to 251 bytes (varies by overhead) |
| Message (UI) | 1 character | 200 characters |
| Total packet (TextMessage) | 21 bytes | 271 bytes |
### Payload Types
| Type | Value | Description |
|------|-------|-------------|
| TextMessage | 1 | Chat message with nickname, target, and text |
| (reserved) | 2+ | Future: Position, Telemetry, etc. |
### Target Field Semantics
| `to` Value | `target` Field | Meaning |
|------------|----------------|---------|
| 0 | `""` (empty) | Broadcast - visible in all channels |
| 0 | `#channel` | Channel message - visible only when viewing that channel |
| non-zero | `nickname` | Direct message (future - requires address discovery protocol) |
Messages with incorrect magic/version or invalid payload are silently discarded.
> **Note:** Direct messaging (non-zero `to`) will require an address discovery mechanism, such as periodic broadcasts announcing nickname→sender_id mappings, before devices can address each other directly.
## Architecture
```text
ChatApp - App lifecycle, ESP-NOW send/receive, settings management
ChatState - Message storage (deque, max 100), channel filtering, mutex-protected
ChatView - LVGL UI: toolbar, message list, input bar, settings/channel panels
ChatProtocol - MessageHeader struct, serialize/deserialize, PayloadType enum
ChatSettings - Properties file load/save with encrypted key storage, sender ID generation
```
All files are guarded with `#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)` to exclude from P4 builds.
## Message Flow
### Sending
1. User types message and taps Send
2. `serializeTextMessage()` builds compact packet with sender ID, nickname, channel, message
3. Broadcast via ESP-NOW to nearby devices
4. Own message stored and displayed locally
### Receiving
1. ESP-NOW callback fires with raw data
2. Validate packet:
- Minimum size: 21 bytes (16 header + 2 min nickname + 1 null + 0 min target + 1 null + 1 min message)
- Magic bytes: must be `0x54435432` ("TCT2")
- Protocol version: must be 2
- Payload size: `header.payload_size` must equal `received_length - 16`
3. Parse null-terminated nickname and target from payload
4. Validate minimum lengths: nickname >= 2 chars, message >= 1 byte
5. Extract message from remaining bytes (length derived from payload_size)
6. Store in message deque with sender ID
7. Display if target matches current channel or is broadcast (empty)
## Limitations
- Maximum 100 stored messages (oldest discarded when full)
- Nickname: 23 characters max
- Channel name: 23 characters max
- Message text: 200 characters max (UI limit; actual wire limit varies by nickname/target length)
- No message persistence across app restarts (messages are in-memory only)
- All communication is broadcast; channel filtering is client-side only
- Sender ID collisions: 32-bit random IDs have ~50% collision probability at ~77,000 active devices (birthday paradox); no collision detection/resolution implemented
## Security Considerations
The chat protocol relies on ESP-NOW's built-in encryption (when configured) but has additional security limitations:
- **No message authentication**: No MAC/HMAC to verify message integrity or sender authenticity beyond the sender ID
- **No replay protection**: No sequence numbers or timestamps; messages can be replayed
- **Sender ID spoofing**: Any device knowing the encryption key can forge messages with arbitrary sender IDs
- **No forward secrecy**: Compromise of the shared key exposes all past and future messages
These tradeoffs are acceptable for casual local communication but should be understood before using for sensitive applications.

View File

@ -16,6 +16,10 @@ namespace tt::service::espnow {
typedef int ReceiverSubscription; typedef int ReceiverSubscription;
constexpr ReceiverSubscription NO_SUBSCRIPTION = -1; 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 { enum class Mode {
Station, Station,
AccessPoint AccessPoint
@ -54,6 +58,12 @@ ReceiverSubscription subscribeReceiver(std::function<void(const esp_now_recv_inf
void unsubscribeReceiver(ReceiverSubscription subscription); 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 #endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED

View File

@ -21,6 +21,9 @@ class GpsService final : public Service {
minmea_sentence_rmc rmcRecord; minmea_sentence_rmc rmcRecord;
TickType_t rmcTime = 0; TickType_t rmcTime = 0;
minmea_sentence_gga ggaRecord;
TickType_t ggaTime = 0;
RecursiveMutex mutex; RecursiveMutex mutex;
Mutex stateMutex; Mutex stateMutex;
std::vector<GpsDeviceRecord> deviceRecords; std::vector<GpsDeviceRecord> deviceRecords;
@ -58,6 +61,7 @@ public:
bool hasCoordinates() const; bool hasCoordinates() const;
bool getCoordinates(minmea_sentence_rmc& rmc) const; bool getCoordinates(minmea_sentence_rmc& rmc) const;
bool getGga(minmea_sentence_gga& gga) const;
/** @return GPS service pubsub that broadcasts State* objects */ /** @return GPS service pubsub that broadcasts State* objects */
std::shared_ptr<PubSub<State>> getStatePubsub() const { return statePubSub; } std::shared_ptr<PubSub<State>> getStatePubsub() const { return statePubSub; }

View 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

View 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

View 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

View 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

View 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

View File

@ -31,6 +31,7 @@ class EspNowService final : public Service {
std::vector<ReceiverSubscriptionData> subscriptions; std::vector<ReceiverSubscriptionData> subscriptions;
ReceiverSubscription lastSubscriptionId = 0; ReceiverSubscription lastSubscriptionId = 0;
bool enabled = false; bool enabled = false;
uint32_t espnowVersion = 0;
// Dispatcher calls this and forwards to non-static function // Dispatcher calls this and forwards to non-static function
void enableFromDispatcher(const EspNowConfig& config); void enableFromDispatcher(const EspNowConfig& config);
@ -65,6 +66,8 @@ public:
void unsubscribeReceiver(ReceiverSubscription subscription); void unsubscribeReceiver(ReceiverSubscription subscription);
uint32_t getVersion() const;
// region Internal API // region Internal API
}; };

View File

@ -4,141 +4,179 @@
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #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/app/AppManifest.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/Assets.h> #include <Tactility/Assets.h>
#include <Tactility/Logger.h> #include <Tactility/Logger.h>
#include <Tactility/service/espnow/EspNow.h> #include <Tactility/lvgl/LvglSync.h>
#include "Tactility/lvgl/LvglSync.h" #include <algorithm>
#include <cctype>
#include <cstdio> #include <cstdlib>
#include <cstring> #include <vector>
#include <esp_wifi.h>
#include <lvgl.h>
namespace tt::app::chat { namespace tt::app::chat {
static const auto LOGGER = Logger("ChatApp"); 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] = {};
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 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 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( auto config = service::espnow::EspNowConfig(
const_cast<uint8_t*>(key), settings.hasEncryptionKey ? settings.encryptionKey.data() : defaultKey,
service::espnow::Mode::Station, service::espnow::Mode::Station,
1, 1, // Channel 1 default; actual channel determined by WiFi if connected
false, false,
false settings.hasEncryptionKey
); );
service::espnow::enable(config); service::espnow::enable(config);
}
receiveSubscription = service::espnow::subscribeReceiver([this](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { void ChatApp::disableEspNow() {
onReceive(receiveInfo, data, length);
});
}
void onDestroy(AppContext& appContext) override {
service::espnow::unsubscribeReceiver(receiveSubscription);
if (service::espnow::isEnabled()) { if (service::espnow::isEnabled()) {
service::espnow::disable(); 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);
}
);
}
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 { StoredMessage msg;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); msg.displayText = parsed.senderName + ": " + parsed.message;
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); msg.target = parsed.target;
msg.isOwn = false;
lvgl::toolbar_create(parent, context); state.addMessage(msg);
// Message list {
msg_list = lv_list_create(parent); auto lock = lvgl::getSyncLock()->asScopedLock();
lv_obj_set_flex_grow(msg_list, 1); lock.lock();
lv_obj_set_width(msg_list, LV_PCT(100)); view.displayMessage(msg);
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 void ChatApp::sendMessage(const std::string& text) {
auto* bottom_wrapper = lv_obj_create(parent); if (text.empty()) return;
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 std::string nickname = state.getLocalNickname();
input_field = lv_textarea_create(bottom_wrapper); std::string channel = state.getCurrentChannel();
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 std::vector<uint8_t> wireMsg;
auto* send_btn = lv_button_create(bottom_wrapper); if (!serializeTextMessage(settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) {
lv_obj_set_style_margin_all(send_btn, 0, LV_STATE_DEFAULT); LOGGER.error("Failed to serialize message");
lv_obj_set_style_margin_top(send_btn, 2, LV_STATE_DEFAULT); // Hack to fix alignment return;
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);
} }
~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 = { extern const AppManifest manifest = {
.appId = "Chat", .appId = "Chat",
@ -147,6 +185,6 @@ extern const AppManifest manifest = {
.createApp = create<ChatApp> .createApp = create<ChatApp>
}; };
} } // namespace tt::app::chat
#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED #endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED

View 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

View 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

View 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

View 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

View File

@ -30,6 +30,12 @@ class GpsSettingsApp final : public App {
std::shared_ptr<GpsSettingsApp*> appReference = std::make_shared<GpsSettingsApp*>(this); std::shared_ptr<GpsSettingsApp*> appReference = std::make_shared<GpsSettingsApp*>(this);
lv_obj_t* statusWrapper = nullptr; lv_obj_t* statusWrapper = nullptr;
lv_obj_t* statusLabelWidget = 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* switchWidget = nullptr;
lv_obj_t* spinnerWidget = nullptr; lv_obj_t* spinnerWidget = nullptr;
lv_obj_t* infoContainerWidget = nullptr; lv_obj_t* infoContainerWidget = nullptr;
@ -203,14 +209,79 @@ class GpsSettingsApp final : public App {
} }
minmea_sentence_rmc rmc; minmea_sentence_rmc rmc;
char buffer[64];
if (service->getCoordinates(rmc)) { 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 latitude = { rmc.latitude.value, rmc.latitude.scale };
minmea_float longitude = { rmc.longitude.value, rmc.longitude.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 { } else {
lv_label_set_text(statusLabelWidget, "Acquiring lock..."); 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); lv_obj_remove_flag(statusLabelWidget, LV_OBJ_FLAG_HIDDEN);
} else { } else {
if (hasSetInfo) { 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: public:
GpsSettingsApp() { GpsSettingsApp() {
@ -297,20 +390,29 @@ public:
statusWrapper = lv_obj_create(main_wrapper); statusWrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(statusWrapper, LV_PCT(100)); lv_obj_set_width(statusWrapper, LV_PCT(100));
lv_obj_set_height(statusWrapper, LV_SIZE_CONTENT); 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_all(statusWrapper, 0, 0);
lv_obj_set_style_pad_row(statusWrapper, 8, 0);
lv_obj_set_style_border_width(statusWrapper, 0, 0); lv_obj_set_style_border_width(statusWrapper, 0, 0);
statusLabelWidget = lv_label_create(statusWrapper); statusLabelWidget = lv_label_create(statusWrapper);
lv_obj_align(statusLabelWidget, LV_ALIGN_TOP_LEFT, 0, 0);
infoContainerWidget = lv_obj_create(statusWrapper); 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_size(infoContainerWidget, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(infoContainerWidget, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(infoContainerWidget, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(infoContainerWidget, 0, 0); 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; 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) { serviceStateSubscription = service->getStatePubsub()->subscribe([this](auto) {
onServiceStateChanged(); onServiceStateChanged();
}); });

View File

@ -36,6 +36,7 @@ bool isEnabled() {
if (service != nullptr) { if (service != nullptr) {
return service->isEnabled(); return service->isEnabled();
} else { } else {
LOGGER.error("Service not found");
return false; 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 #endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED

View File

@ -74,16 +74,23 @@ void EspNowService::enableFromDispatcher(const EspNowConfig& config) {
return; return;
} }
//#if CONFIG_ESPNOW_ENABLE_POWER_SAVE //#if CONFIG_ESPNOW_ENABLE_POWER_SAVE
// ESP_ERROR_CHECK( esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW) ); // 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) ); // ESP_ERROR_CHECK( esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL) );
//#endif //#endif
if (esp_now_set_pmk(config.masterKey) != ESP_OK) { if (esp_now_set_pmk(config.masterKey) != ESP_OK) {
LOGGER.error("esp_now_set_pmk() failed"); LOGGER.error("esp_now_set_pmk() failed");
return; 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 // Add default unencrypted broadcast peer
esp_now_peer_info_t broadcast_peer; esp_now_peer_info_t broadcast_peer;
memset(&broadcast_peer, 0, sizeof(esp_now_peer_info_t)); memset(&broadcast_peer, 0, sizeof(esp_now_peer_info_t));
@ -119,6 +126,7 @@ void EspNowService::disableFromDispatcher() {
LOGGER.error("deinitWifi() failed"); LOGGER.error("deinitWifi() failed");
} }
espnowVersion = 0;
enabled = false; enabled = false;
} }
@ -195,6 +203,12 @@ void EspNowService::unsubscribeReceiver(ReceiverSubscription subscriptionId) {
std::erase_if(subscriptions, [subscriptionId](auto& subscription) { return subscription.id == 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() { std::shared_ptr<EspNowService> findService() {
return std::static_pointer_cast<EspNowService>( return std::static_pointer_cast<EspNowService>(
findServiceById(manifest.id) findServiceById(manifest.id)

View File

@ -37,7 +37,7 @@ void GpsService::addGpsDevice(const std::shared_ptr<GpsDevice>& device) {
GpsDeviceRecord record = {.device = 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); startGpsDevice(record);
} }
@ -50,7 +50,7 @@ void GpsService::removeGpsDevice(const std::shared_ptr<GpsDevice>& device) {
GpsDeviceRecord* record = findGpsRecord(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); stopGpsDevice(*record);
} }
@ -87,6 +87,10 @@ bool GpsService::startGpsDevice(GpsDeviceRecord& record) {
record.satelliteSubscriptionId = device->subscribeGga([this](hal::Device::Id deviceId, auto& record) { record.satelliteSubscriptionId = device->subscribeGga([this](hal::Device::Id deviceId, auto& record) {
mutex.lock(); mutex.lock();
if (record.fix_quality > 0) {
ggaRecord = record;
ggaTime = kernel::getTicks();
}
onGgaSentence(deviceId, record); onGgaSentence(deviceId, record);
mutex.unlock(); mutex.unlock();
}); });
@ -156,14 +160,16 @@ bool GpsService::startReceiving() {
addGpsDevice(device); addGpsDevice(device);
} }
// Reset times before starting devices to avoid race with incoming data
rmcTime = 0;
ggaTime = 0;
bool started_one_or_more = false; bool started_one_or_more = false;
for (auto& record: deviceRecords) { for (auto& record: deviceRecords) {
started_one_or_more |= startGpsDevice(record); started_one_or_more |= startGpsDevice(record);
} }
rmcTime = 0;
if (started_one_or_more) { if (started_one_or_more) {
setState(State::On); setState(State::On);
return true; return true;
@ -186,6 +192,7 @@ void GpsService::stopReceiving() {
} }
rmcTime = 0; rmcTime = 0;
ggaTime = 0;
setState(State::Off); 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() { std::shared_ptr<GpsService> findGpsService() {
auto service = findServiceById(manifest.id); auto service = findServiceById(manifest.id);
assert(service != nullptr); assert(service != nullptr);