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
18 changed files with 1547 additions and 124 deletions
+149 -111
View File
@@ -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
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
+183
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
+60
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
+295
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
@@ -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();
});