diff --git a/src/config/BoardConfig.h b/src/config/BoardConfig.h index ea01cdc..961b090 100644 --- a/src/config/BoardConfig.h +++ b/src/config/BoardConfig.h @@ -22,10 +22,10 @@ // TCXO voltage: 1.8V for T-Deck Plus integrated SX1262 (Ratputer Cap LoRa uses 3.0V/0x06) #define LORA_TCXO_VOLTAGE 0x02 // MODE_TCXO_1_8V_6X #define LORA_DEFAULT_FREQ 915000000 -#define LORA_DEFAULT_BW 125000 +#define LORA_DEFAULT_BW 250000 // Balanced preset #define LORA_DEFAULT_SF 9 #define LORA_DEFAULT_CR 5 -#define LORA_DEFAULT_TX_POWER 22 +#define LORA_DEFAULT_TX_POWER 14 // Balanced preset #define LORA_DEFAULT_PREAMBLE 18 // --- Shared SPI Bus (display + LoRa + SD) --- diff --git a/src/config/Config.h b/src/config/Config.h index 6a333e2..4d3b4bf 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -6,8 +6,8 @@ #define RATDECK_VERSION_MAJOR 1 #define RATDECK_VERSION_MINOR 4 -#define RATDECK_VERSION_PATCH 0 -#define RATDECK_VERSION_STRING "1.4.0" +#define RATDECK_VERSION_PATCH 2 +#define RATDECK_VERSION_STRING "1.4.2" // --- Feature Flags --- #define HAS_DISPLAY true diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index d671121..fc3f8b5 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -26,7 +26,7 @@ struct UserSettings { long loraPreamble = LORA_DEFAULT_PREAMBLE; // WiFi - RatWiFiMode wifiMode = RAT_WIFI_AP; + RatWiFiMode wifiMode = RAT_WIFI_STA; String wifiAPSSID; String wifiAPPassword = WIFI_AP_PASSWORD; String wifiSTASSID; diff --git a/src/main.cpp b/src/main.cpp index 3129179..2b00791 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,6 +35,7 @@ #include "ui/screens/LvNodesScreen.h" #include "ui/screens/LvMessagesScreen.h" #include "ui/screens/LvMessageView.h" +#include "ui/screens/LvContactsScreen.h" #include "ui/screens/LvSettingsScreen.h" #include "ui/screens/LvHelpOverlay.h" // Map screen removed @@ -113,6 +114,7 @@ LvBootScreen lvBootScreen; LvHomeScreen lvHomeScreen; LvNodesScreen lvNodesScreen; LvMessagesScreen lvMessagesScreen; +LvContactsScreen lvContactsScreen; LvMessageView lvMessageView; LvSettingsScreen lvSettingsScreen; LvHelpOverlay lvHelpOverlay; @@ -156,13 +158,14 @@ static RNS::Bytes encodeAnnounceName(const String& name) { } static void announceWithName() { + Serial.println("[ANNOUNCE-TX] announceWithName() entry"); RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); rns.announce(appData); ui.statusBar().flashAnnounce(); ui.statusBar().showToast("Announce sent!"); ui.lvStatusBar().flashAnnounce(); ui.lvStatusBar().showToast("Announce sent!"); - Serial.println("[ANNOUNCE] Sent with display name"); + Serial.println("[ANNOUNCE-TX] announceWithName() exit"); } // ============================================================================= @@ -186,7 +189,8 @@ static void reloadTCPClients() { RNS::Transport::register_interface(tcpIfaces.back()); tcp->start(); tcpClients.push_back(tcp); - Serial.printf("[TCP] Created client: %s:%d\n", ep.host.c_str(), ep.port); + Serial.printf("[TCP] Created client: %s:%d (registered with Transport, mode=GATEWAY)\n", ep.host.c_str(), ep.port); + Serial.printf("[TCP] Total interfaces registered: %d\n", (int)RNS::Transport::get_interfaces().size()); } } } @@ -212,7 +216,7 @@ void onHotkeyNewMsg() { ui.setLvScreen(&lvMessagesScreen); } void onHotkeySettings() { - ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETUP); + ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETTINGS); ui.setLvScreen(&lvSettingsScreen); } void onHotkeyAnnounce() { @@ -352,11 +356,7 @@ void setup() { digitalWrite(LORA_CS, HIGH); delay(10); if (sdStore.begin(&sharedSPI, SD_CS)) { - sdStore.ensureDir("/ratputer"); - sdStore.ensureDir("/ratputer/config"); - sdStore.ensureDir("/ratputer/messages"); - sdStore.ensureDir("/ratputer/contacts"); - sdStore.ensureDir("/ratputer/identity"); + sdStore.formatForRatputer(); Serial.println("[SD] Card ready"); } else { Serial.println("[SD] Not detected"); @@ -530,6 +530,28 @@ void setup() { // (LVGL boot renders via lv_timer_handler in setProgress) userConfig.load(sdStore, flash); + // Sync display name between active identity slot and config. + // The identity slot is the source of truth for the name. + { + String slotName; + if (identityMgr.syncNameFromActive(slotName)) { + if (!slotName.isEmpty()) { + // Slot has a name — use it (overrides any stale config value) + if (userConfig.settings().displayName != slotName) { + Serial.printf("[BOOT] Name from identity slot: '%s'\n", slotName.c_str()); + userConfig.settings().displayName = slotName; + userConfig.save(sdStore, flash); + } + } else if (!userConfig.settings().displayName.isEmpty()) { + // Slot has no name but config does — seed the slot (first boot migration) + identityMgr.setDisplayName(identityMgr.activeIndex(), + userConfig.settings().displayName); + Serial.printf("[BOOT] Seeded identity slot name: '%s'\n", + userConfig.settings().displayName.c_str()); + } + } + } + // Step 20: Boot loop recovery if (bootLoopRecovery) { userConfig.settings().wifiMode = RAT_WIFI_OFF; @@ -658,8 +680,20 @@ void setup() { lvHomeScreen.setReticulumManager(&rns); lvHomeScreen.setRadio(&radio); lvHomeScreen.setUserConfig(&userConfig); + lvHomeScreen.setLXMFManager(&lxmf); + lvHomeScreen.setAnnounceManager(announceManager); + lvHomeScreen.setRadioOnline(radioOnline); lvHomeScreen.setAnnounceCallback([]() { announceWithName(); + Serial.println("[HOME] Announce triggered via Enter"); + }); + + lvContactsScreen.setAnnounceManager(announceManager); + lvContactsScreen.setUIManager(&ui); + lvContactsScreen.setNodeSelectedCallback([](const std::string& peerHex) { + lvMessageView.setPeerHex(peerHex); + ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS); + ui.setLvScreen(&lvMessageView); }); lvNodesScreen.setAnnounceManager(announceManager); @@ -680,6 +714,7 @@ void setup() { lvMessageView.setLXMFManager(&lxmf); lvMessageView.setAnnounceManager(announceManager); + lvMessageView.setUIManager(&ui); lvMessageView.setBackCallback([]() { ui.setLvScreen(&lvMessagesScreen); }); @@ -696,6 +731,7 @@ void setup() { lvSettingsScreen.setIdentityManager(&identityMgr); lvSettingsScreen.setUIManager(&ui); lvSettingsScreen.setIdentityHash(rns.identityHash()); + lvSettingsScreen.setDestinationHash(rns.destinationHashHex()); lvSettingsScreen.setSaveCallback([]() -> bool { bool ok = userConfig.save(sdStore, flash); Serial.printf("[CONFIG] Save %s\n", ok ? "OK" : "FAILED"); @@ -711,10 +747,11 @@ void setup() { lvHelpOverlay.create(); // Tab bar callbacks — LVGL - lvTabScreens[LvTabBar::TAB_HOME] = &lvHomeScreen; - lvTabScreens[LvTabBar::TAB_MSGS] = &lvMessagesScreen; - lvTabScreens[LvTabBar::TAB_NODES] = &lvNodesScreen; - lvTabScreens[LvTabBar::TAB_SETUP] = &lvSettingsScreen; + lvTabScreens[LvTabBar::TAB_HOME] = &lvHomeScreen; + lvTabScreens[LvTabBar::TAB_CONTACTS] = &lvContactsScreen; + lvTabScreens[LvTabBar::TAB_MSGS] = &lvMessagesScreen; + lvTabScreens[LvTabBar::TAB_NODES] = &lvNodesScreen; + lvTabScreens[LvTabBar::TAB_SETTINGS] = &lvSettingsScreen; ui.lvTabBar().setTabCallback([](int tab) { if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]); @@ -722,9 +759,19 @@ void setup() { // Name input screen (first boot only — when no display name is set) lvNameInputScreen.setDoneCallback([](const String& name) { - userConfig.settings().displayName = name; + String finalName = name; + if (finalName.isEmpty()) { + // Auto-generate: Ratspeak.org-xxx (first 3 chars of LXMF dest hash) + String dh = rns.destinationHashHex(); + finalName = "Ratspeak.org-" + dh.substring(0, 3); + } + userConfig.settings().displayName = finalName; userConfig.save(sdStore, flash); - Serial.printf("[BOOT] Display name set: '%s'\n", name.c_str()); + // Also save to active identity slot + if (identityMgr.activeIndex() >= 0) { + identityMgr.setDisplayName(identityMgr.activeIndex(), finalName); + } + Serial.printf("[BOOT] Display name set: '%s'\n", finalName.c_str()); // Transition to home screen ui.setBootMode(false); @@ -866,6 +913,13 @@ void loop() { // Create TCP clients (safe to call multiple times) if (tcpClients.empty()) { reloadTCPClients(); + // Announce over TCP now that it's available + if (!tcpClients.empty()) { + Serial.println("[TCP] Sending announce over new TCP connection..."); + RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); + rns.announce(appData); + lastAutoAnnounce = millis(); + } } } else if (!connected && wifiSTAConnected) { wifiSTAConnected = false; diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index b4543fc..6d5f4c9 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -125,6 +125,15 @@ void AnnounceManager::loop() { } } +int AnnounceManager::nodesOnlineSince(unsigned long maxAgeMs) const { + unsigned long now = millis(); + int count = 0; + for (const auto& n : _nodes) { + if (now - n.lastSeen <= maxAgeMs) count++; + } + return count; +} + const DiscoveredNode* AnnounceManager::findNode(const RNS::Bytes& hash) const { for (const auto& n : _nodes) { if (n.hash == hash) return &n; } return nullptr; diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h index 9e1ed62..bc5db9f 100644 --- a/src/reticulum/AnnounceManager.h +++ b/src/reticulum/AnnounceManager.h @@ -44,6 +44,7 @@ public: const std::vector& nodes() const { return _nodes; } int nodeCount() const { return _nodes.size(); } + int nodesOnlineSince(unsigned long maxAgeMs) const; const DiscoveredNode* findNode(const RNS::Bytes& hash) const; const DiscoveredNode* findNodeByHex(const std::string& hexHash) const; void addManualContact(const std::string& hexHash, const std::string& name); diff --git a/src/reticulum/IdentityManager.cpp b/src/reticulum/IdentityManager.cpp index 3e02a5c..89f23b3 100644 --- a/src/reticulum/IdentityManager.cpp +++ b/src/reticulum/IdentityManager.cpp @@ -61,7 +61,7 @@ int IdentityManager::createIdentity(const String& displayName) { IdentitySlot slot; slot.hash = newId.hexhash(); - slot.displayName = displayName; + slot.displayName = ""; // New identity starts unnamed slot.keyPath = keyPath; slot.active = false; _slots.push_back(slot); @@ -133,6 +133,17 @@ void IdentityManager::setDisplayName(int index, const String& name) { saveSlotMeta(); } +String IdentityManager::getDisplayName(int index) const { + if (index < 0 || index >= (int)_slots.size()) return ""; + return _slots[index].displayName; +} + +bool IdentityManager::syncNameFromActive(String& outName) const { + if (_activeIdx < 0 || _activeIdx >= (int)_slots.size()) return false; + outName = _slots[_activeIdx].displayName; + return true; +} + void IdentityManager::refresh() { loadSlotMeta(); } diff --git a/src/reticulum/IdentityManager.h b/src/reticulum/IdentityManager.h index 0afaaec..ecacf75 100644 --- a/src/reticulum/IdentityManager.h +++ b/src/reticulum/IdentityManager.h @@ -38,6 +38,14 @@ public: // Save display name for identity at index void setDisplayName(int index, const String& name); + // Get the display name for identity at index + String getDisplayName(int index) const; + + // Sync active identity's display name from/to config + // Call on boot: loads active slot's name into outName + // Returns true if outName was set + bool syncNameFromActive(String& outName) const; + // Refresh slot list from storage void refresh(); diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp index b346d46..62f61be 100644 --- a/src/reticulum/LXMFManager.cpp +++ b/src/reticulum/LXMFManager.cpp @@ -21,7 +21,14 @@ void LXMFManager::loop() { if (sendDirect(msg)) { Serial.printf("[LXMF] Queue drain: status=%s dest=%s\n", msg.statusStr(), msg.destHash.toHex().substr(0, 8).c_str()); + // Re-save with updated status (overwrite the QUEUED version) + msg.messageId = RNS::Bytes(); // Clear so saveMessage reuses timestamp key if (_store) { _store->saveMessage(msg); } + // Fire status callback so UI can refresh + if (_statusCb) { + std::string peerHex = msg.destHash.toHex(); + _statusCb(peerHex, msg.timestamp, msg.status); + } _outQueue.pop_front(); } } @@ -43,37 +50,47 @@ bool LXMFManager::sendMessage(const RNS::Bytes& destHash, const std::string& con msg.status = LXMFStatus::QUEUED; if ((int)_outQueue.size() >= RATDECK_MAX_OUTQUEUE) { _outQueue.pop_front(); } _outQueue.push_back(msg); + // Immediately save with QUEUED status so it appears in getMessages() right away + if (_store) { _store->saveMessage(msg); } return true; } bool LXMFManager::sendDirect(LXMFMessage& msg) { + Serial.printf("[LXMF] sendDirect: dest=%s\n", msg.destHash.toHex().substr(0, 12).c_str()); RNS::Identity recipientId = RNS::Identity::recall(msg.destHash); if (!recipientId) { msg.retries++; if (msg.retries >= 5) { - Serial.printf("[LXMF] recall failed for %s after %d retries — marking FAILED\n", + Serial.printf("[LXMF] recall FAILED for %s after %d retries — marking FAILED\n", msg.destHash.toHex().substr(0, 8).c_str(), msg.retries); msg.status = LXMFStatus::FAILED; return true; } - Serial.printf("[LXMF] recall failed for %s (retry %d/5) — keeping queued\n", + Serial.printf("[LXMF] recall FAILED for %s (retry %d/5) — identity not known yet\n", msg.destHash.toHex().substr(0, 8).c_str(), msg.retries); return false; // keep in queue, retry next loop } + Serial.printf("[LXMF] recall OK: identity=%s\n", recipientId.hexhash().c_str()); RNS::Destination outDest(recipientId, RNS::Type::Destination::OUT, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + Serial.printf("[LXMF] outDest hash: %s\n", outDest.hash().toHex().substr(0, 12).c_str()); std::vector payload = msg.packFull(_rns->identity()); - if (payload.empty()) { msg.status = LXMFStatus::FAILED; return true; } + if (payload.empty()) { Serial.println("[LXMF] packFull returned empty!"); msg.status = LXMFStatus::FAILED; return true; } RNS::Bytes payloadBytes(payload.data(), payload.size()); - if (payloadBytes.size() > RNS::Type::Reticulum::MDU) { msg.status = LXMFStatus::FAILED; return true; } + if (payloadBytes.size() > RNS::Type::Reticulum::MDU) { + Serial.printf("[LXMF] payload too large: %d > MDU\n", (int)payloadBytes.size()); + msg.status = LXMFStatus::FAILED; return true; + } msg.status = LXMFStatus::SENDING; + Serial.printf("[LXMF] sending packet: %d bytes to %s\n", (int)payloadBytes.size(), outDest.hash().toHex().substr(0, 12).c_str()); RNS::Packet packet(outDest, payloadBytes); RNS::PacketReceipt receipt = packet.send(); if (receipt) { msg.status = LXMFStatus::SENT; msg.messageId = RNS::Identity::full_hash(payloadBytes); - Serial.printf("[LXMF] Sent %d bytes\n", (int)payloadBytes.size()); + Serial.printf("[LXMF] SENT OK: %d bytes, msgId=%s\n", (int)payloadBytes.size(), msg.messageId.toHex().substr(0, 8).c_str()); } else { + Serial.println("[LXMF] send FAILED: no receipt"); msg.status = LXMFStatus::FAILED; } return true; diff --git a/src/reticulum/LXMFManager.h b/src/reticulum/LXMFManager.h index 71f097d..32bf375 100644 --- a/src/reticulum/LXMFManager.h +++ b/src/reticulum/LXMFManager.h @@ -14,6 +14,8 @@ class LXMFManager { public: using MessageCallback = std::function; + using StatusCallback = std::function; + void setStatusCallback(StatusCallback cb) { _statusCb = cb; } bool begin(ReticulumManager* rns, MessageStore* store); void loop(); @@ -35,6 +37,7 @@ private: ReticulumManager* _rns = nullptr; MessageStore* _store = nullptr; MessageCallback _onMessage; + StatusCallback _statusCb; std::deque _outQueue; void computeUnreadFromDisk(); diff --git a/src/reticulum/LXMFMessage.cpp b/src/reticulum/LXMFMessage.cpp index 7967168..0865b05 100644 --- a/src/reticulum/LXMFMessage.cpp +++ b/src/reticulum/LXMFMessage.cpp @@ -74,18 +74,20 @@ std::vector LXMFMessage::packContent(double timestamp, const std::strin buf.reserve(32 + content.size() + title.size()); buf.push_back(0x94); mpPackFloat64(buf, timestamp); + mpPackString(buf, title); // LXMF spec: [ts, title, content, fields] mpPackString(buf, content); - mpPackString(buf, title); buf.push_back(0x80); return buf; } std::vector LXMFMessage::packFull(const RNS::Identity& signingIdentity) const { std::vector packed = packContent(timestamp, content, title); - if (sourceHash.size() < 16) return {}; + if (sourceHash.size() < 16 || destHash.size() < 16) return {}; + // Sign: dest_hash || source_hash || packed_content (LXMF spec) std::vector signable; - signable.reserve(16 + packed.size()); + signable.reserve(32 + packed.size()); + signable.insert(signable.end(), destHash.data(), destHash.data() + 16); signable.insert(signable.end(), sourceHash.data(), sourceHash.data() + 16); signable.insert(signable.end(), packed.begin(), packed.end()); @@ -93,21 +95,25 @@ std::vector LXMFMessage::packFull(const RNS::Identity& signingIdentity) RNS::Bytes sig = signingIdentity.sign(signableBytes); if (sig.size() < 64) return {}; + // Wire (opportunistic SINGLE): [src_hash:16][signature:64][packed_content] + // dest_hash is implicit in the Reticulum SINGLE packet destination std::vector payload; - payload.reserve(16 + packed.size() + 64); + payload.reserve(16 + 64 + packed.size()); payload.insert(payload.end(), sourceHash.data(), sourceHash.data() + 16); - payload.insert(payload.end(), packed.begin(), packed.end()); payload.insert(payload.end(), sig.data(), sig.data() + 64); + payload.insert(payload.end(), packed.begin(), packed.end()); return payload; } bool LXMFMessage::unpackFull(const uint8_t* data, size_t len, LXMFMessage& msg) { - if (len < 93) return false; + // Wire (opportunistic SINGLE): [src_hash:16][signature:64][packed_content] + // dest_hash comes from the Reticulum packet (set by caller) + if (len < 81) return false; // 16+64+1 minimum msg.sourceHash = RNS::Bytes(data, 16); - msg.signature = RNS::Bytes(data + len - 64, 64); + msg.signature = RNS::Bytes(data + 16, 64); - const uint8_t* content = data + 16; - size_t contentLen = len - 16 - 64; + const uint8_t* content = data + 80; + size_t contentLen = len - 80; size_t pos = 0; if (pos >= contentLen) return false; @@ -117,9 +123,10 @@ bool LXMFMessage::unpackFull(const uint8_t* data, size_t len, LXMFMessage& msg) if (arrLen < 3) return false; pos++; + // LXMF spec: [timestamp, title, content, fields] if (!mpReadFloat64(content, contentLen, pos, msg.timestamp)) return false; - if (!mpReadString(content, contentLen, pos, msg.content)) return false; if (!mpReadString(content, contentLen, pos, msg.title)) return false; + if (!mpReadString(content, contentLen, pos, msg.content)) return false; if (arrLen >= 4 && pos < contentLen) { mpSkipValue(content, contentLen, pos); } RNS::Bytes fullPayload(data, len); diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index 644c02c..ecc5e28 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -64,6 +64,24 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { RNS::Utilities::OS::register_filesystem(fs); Serial.println("[RNS] Filesystem registered"); + // Restore routing tables from SD if missing on flash + if (_sd && _sd->isReady()) { + static const char* files[] = {"/destination_table", "/packet_hashlist"}; + for (const char* name : files) { + if (!LittleFS.exists(name)) { + char sdPath[64]; + snprintf(sdPath, sizeof(sdPath), "/ratputer/transport%s", name); + uint8_t buf[4096]; + size_t len = 0; + if (_sd->readFile(sdPath, buf, sizeof(buf), len) && len > 0) { + File f = LittleFS.open(name, "w"); + if (f) { f.write(buf, len); f.close(); } + Serial.printf("[RNS] Restored %s from SD (%d bytes)\n", name, (int)len); + } + } + } + } + _loraImpl = new LoRaInterface(radio, "LoRa.915"); _loraIface = _loraImpl; _loraIface.mode(RNS::Type::Interface::MODE_GATEWAY); @@ -197,7 +215,29 @@ void ReticulumManager::loop() { } } -void ReticulumManager::persistData() { RNS::Transport::persist_data(); } +void ReticulumManager::persistData() { + RNS::Transport::persist_data(); + // Backup routing tables to SD + if (_sd && _sd->isReady()) { + static const char* files[] = {"/destination_table", "/packet_hashlist"}; + for (const char* name : files) { + File f = LittleFS.open(name, "r"); + if (f && f.size() > 0) { + size_t sz = f.size(); + uint8_t* buf = (uint8_t*)malloc(sz); + if (buf) { + f.readBytes((char*)buf, sz); + char sdPath[64]; + snprintf(sdPath, sizeof(sdPath), "/ratputer/transport%s", name); + _sd->ensureDir("/ratputer/transport"); + _sd->writeSimple(sdPath, buf, sz); + free(buf); + } + } + if (f) f.close(); + } + } +} String ReticulumManager::identityHash() const { if (!_identity) return "unknown"; @@ -208,23 +248,32 @@ String ReticulumManager::identityHash() const { return String(hex.c_str()); } +String ReticulumManager::destinationHashHex() const { + if (!_destination) return "unknown"; + return String(_destination.hash().toHex().c_str()); +} + size_t ReticulumManager::pathCount() const { return _reticulum.get_path_table().size(); } size_t ReticulumManager::linkCount() const { return _reticulum.get_link_count(); } void ReticulumManager::announce(const RNS::Bytes& appData) { if (!_transportActive) return; - Serial.printf("[TX-DBG] dest_hash: %s\n", _destination.hash().toHex().c_str()); - Serial.printf("[TX-DBG] identity_hash: %s\n", _identity.hexhash().c_str()); - Serial.printf("[TX-DBG] public_key: %s\n", _identity.get_public_key().toHex().c_str()); - // Compute name_hash the same way Destination does: SHA256("lxmf.delivery").left(10) - RNS::Bytes nh = RNS::Identity::full_hash(RNS::Bytes("lxmf.delivery")).left(10); - Serial.printf("[TX-DBG] name_hash: %s\n", nh.toHex().c_str()); - // Compute hash_material = name_hash + identity_hash - RNS::Bytes hm = nh + _identity.hash(); - Serial.printf("[TX-DBG] hash_material: %s\n", hm.toHex().c_str()); - RNS::Bytes eh = RNS::Identity::full_hash(hm).left(16); - Serial.printf("[TX-DBG] recomputed: %s\n", eh.toHex().c_str()); + Serial.println("[ANNOUNCE-TX] === Starting ==="); + Serial.printf("[ANNOUNCE-TX] dest_hash: %s\n", _destination.hash().toHex().c_str()); + Serial.printf("[ANNOUNCE-TX] identity_hash: %s\n", _identity.hexhash().c_str()); + Serial.printf("[ANNOUNCE-TX] app_data size: %d bytes\n", (int)appData.size()); + if (appData.size() > 0) { + Serial.printf("[ANNOUNCE-TX] app_data hex: %s\n", appData.toHex().c_str()); + } + // Log registered interfaces + auto& ifaces = RNS::Transport::get_interfaces(); + Serial.printf("[ANNOUNCE-TX] registered interfaces: %d\n", (int)ifaces.size()); + for (const auto& [hash, iface] : ifaces) { + Serial.printf("[ANNOUNCE-TX] iface: %s OUT=%d online=%d mode=%d\n", + iface.toString().c_str(), iface.OUT(), iface.online(), (int)iface.mode()); + } + unsigned long startMs = millis(); _destination.announce(appData); _lastAnnounceTime = millis(); - Serial.println("[RNS] Announce sent"); + Serial.printf("[ANNOUNCE-TX] === Complete === (%lu ms)\n", millis() - startMs); } diff --git a/src/reticulum/ReticulumManager.h b/src/reticulum/ReticulumManager.h index 350aee1..a684694 100644 --- a/src/reticulum/ReticulumManager.h +++ b/src/reticulum/ReticulumManager.h @@ -45,6 +45,7 @@ public: const RNS::Identity& identity() const { return _identity; } String identityHash() const; + String destinationHashHex() const; bool isTransportActive() const { return _transportActive; } size_t pathCount() const; diff --git a/src/storage/FlashStore.cpp b/src/storage/FlashStore.cpp index f72bfc8..88a2138 100644 --- a/src/storage/FlashStore.cpp +++ b/src/storage/FlashStore.cpp @@ -33,6 +33,15 @@ void FlashStore::end() { bool FlashStore::ensureDir(const char* path) { if (!_ready) return false; if (LittleFS.exists(path)) return true; + // Create parent directories recursively + String pathStr = String(path); + int lastSlash = pathStr.lastIndexOf('/'); + if (lastSlash > 0) { + String parent = pathStr.substring(0, lastSlash); + if (!LittleFS.exists(parent.c_str())) { + ensureDir(parent.c_str()); + } + } return LittleFS.mkdir(path); } @@ -81,6 +90,9 @@ bool FlashStore::writeAtomic(const char* path, const uint8_t* data, size_t len) return false; } + // Clean up backup file after successful write + LittleFS.remove(bakPath.c_str()); + return true; } diff --git a/src/storage/MessageStore.cpp b/src/storage/MessageStore.cpp index 931faf6..bfe4dc3 100644 --- a/src/storage/MessageStore.cpp +++ b/src/storage/MessageStore.cpp @@ -20,6 +20,7 @@ bool MessageStore::begin(FlashStore* flash, SDStore* sd) { migrateFlashToSD(); } + migrateTruncatedDirs(); refreshConversations(); Serial.printf("[MSGSTORE] %d conversations found\n", (int)_conversations.size()); return true; @@ -65,6 +66,89 @@ void MessageStore::migrateFlashToSD() { } } +// Migrate old 16-char truncated directories to full 32-char hex names +void MessageStore::migrateTruncatedDirs() { + auto migrateInDir = [&](auto openFn, auto renameFn, auto readStringFn, const char* basePath) { + File dir = openFn(basePath); + if (!dir || !dir.isDirectory()) return; + + // Collect dirs that need renaming (can't rename while iterating) + std::vector> renames; // old path -> new path + + File entry = dir.openNextFile(); + while (entry) { + if (entry.isDirectory()) { + std::string dirName = entry.name(); + // Old dirs are exactly 16 hex chars; new ones are 32 + if (dirName.length() == 16) { + // Read first JSON file inside to get the full hash + String oldDir = String(basePath) + dirName.c_str(); + File inner = openFn(oldDir.c_str()); + if (inner && inner.isDirectory()) { + File jsonFile = inner.openNextFile(); + std::string fullHash; + while (jsonFile) { + if (!jsonFile.isDirectory() && isJsonFile(jsonFile.name())) { + String jsonPath = oldDir + "/" + jsonFile.name(); + String json = readStringFn(jsonPath.c_str()); + if (json.length() > 0) { + JsonDocument doc; + if (!deserializeJson(doc, json)) { + // Use src for incoming, dst for outgoing + bool incoming = doc["incoming"] | false; + std::string hash = incoming ? + (doc["src"] | "") : (doc["dst"] | ""); + if (hash.length() == 32) { + fullHash = hash; + } + } + } + jsonFile.close(); + break; + } + jsonFile.close(); + jsonFile = inner.openNextFile(); + } + inner.close(); + + if (!fullHash.empty() && fullHash.substr(0, 16) == dirName) { + String newDir = String(basePath) + fullHash.c_str(); + renames.push_back({oldDir, newDir}); + } + } + } + } + entry.close(); + entry = dir.openNextFile(); + } + dir.close(); + + for (auto& [oldPath, newPath] : renames) { + if (renameFn(oldPath.c_str(), newPath.c_str())) { + Serial.printf("[MSGSTORE] Migrated %s -> %s\n", oldPath.c_str(), newPath.c_str()); + } + } + }; + + // Migrate flash directories + migrateInDir( + [](const char* p) { return LittleFS.open(p); }, + [](const char* a, const char* b) { return LittleFS.rename(a, b); }, + [this](const char* p) { return _flash ? _flash->readString(p) : String(""); }, + PATH_MESSAGES + ); + + // Migrate SD directories + if (_sd && _sd->isReady()) { + migrateInDir( + [this](const char* p) { return _sd->openDir(p); }, + [](const char* a, const char* b) { return SD.rename(a, b); }, + [this](const char* p) { return _sd->readString(p); }, + SD_PATH_MESSAGES + ); + } +} + void MessageStore::refreshConversations() { _conversations.clear(); @@ -328,11 +412,11 @@ void MessageStore::markConversationRead(const std::string& peerHex) { } String MessageStore::conversationDir(const std::string& peerHex) const { - return String(PATH_MESSAGES) + peerHex.substr(0, 16).c_str(); + return String(PATH_MESSAGES) + peerHex.c_str(); } String MessageStore::sdConversationDir(const std::string& peerHex) const { - return String(SD_PATH_MESSAGES) + peerHex.substr(0, 16).c_str(); + return String(SD_PATH_MESSAGES) + peerHex.c_str(); } void MessageStore::enforceFlashLimit(const std::string& peerHex) { diff --git a/src/storage/MessageStore.h b/src/storage/MessageStore.h index cdb33f6..2a8a13c 100644 --- a/src/storage/MessageStore.h +++ b/src/storage/MessageStore.h @@ -26,6 +26,7 @@ private: void enforceFlashLimit(const std::string& peerHex); void enforceSDLimit(const std::string& peerHex); void migrateFlashToSD(); + void migrateTruncatedDirs(); FlashStore* _flash = nullptr; SDStore* _sd = nullptr; diff --git a/src/storage/SDStore.cpp b/src/storage/SDStore.cpp index 9903d04..301fd24 100644 --- a/src/storage/SDStore.cpp +++ b/src/storage/SDStore.cpp @@ -46,6 +46,15 @@ uint64_t SDStore::usedBytes() const { return _ready ? SD.usedBytes() : 0; } bool SDStore::ensureDir(const char* path) { if (!_ready) return false; if (SD.exists(path)) return true; + // Create parent directories recursively + String pathStr = String(path); + int lastSlash = pathStr.lastIndexOf('/'); + if (lastSlash > 0) { + String parent = pathStr.substring(0, lastSlash); + if (!SD.exists(parent.c_str())) { + ensureDir(parent.c_str()); + } + } return SD.mkdir(path); } @@ -156,6 +165,7 @@ bool SDStore::wipeRatputer() { wipeDir("/ratputer/contacts"); wipeDir("/ratputer/identity"); wipeDir("/ratputer/config"); + wipeDir("/ratputer/transport"); SD.rmdir("/ratputer"); Serial.println("[SD] Wipe complete, recreating dirs..."); return formatForRatputer(); @@ -187,6 +197,7 @@ bool SDStore::formatForRatputer() { ok &= ensureDir("/ratputer/messages"); ok &= ensureDir("/ratputer/contacts"); ok &= ensureDir("/ratputer/identity"); + ok &= ensureDir("/ratputer/transport"); if (ok) Serial.println("[SD] Directory structure ready"); return ok; } diff --git a/src/transport/TCPClientInterface.cpp b/src/transport/TCPClientInterface.cpp index c476279..5bc3b3e 100644 --- a/src/transport/TCPClientInterface.cpp +++ b/src/transport/TCPClientInterface.cpp @@ -67,7 +67,14 @@ void TCPClientInterface::loop() { } void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { - if (!_online || !_client.connected()) return; + if (!_online) { + Serial.printf("[TCP] TX BLOCKED (offline) %d bytes to %s:%d\n", (int)data.size(), _host.c_str(), _port); + return; + } + if (!_client.connected()) { + Serial.printf("[TCP] TX BLOCKED (disconnected) %d bytes to %s:%d\n", (int)data.size(), _host.c_str(), _port); + return; + } sendFrame(data.data(), data.size()); Serial.printf("[TCP] TX %d bytes to %s:%d\n", (int)data.size(), _host.c_str(), _port); diff --git a/src/ui/LvStatusBar.cpp b/src/ui/LvStatusBar.cpp index 7924467..3ddcb9f 100644 --- a/src/ui/LvStatusBar.cpp +++ b/src/ui/LvStatusBar.cpp @@ -17,21 +17,21 @@ void LvStatusBar::create(lv_obj_t* parent) { const lv_font_t* font = &lv_font_montserrat_12; - // Left side: LoRa BLE WiFi indicators - _lblLora = lv_label_create(_bar); - lv_obj_set_style_text_font(_lblLora, font, 0); - lv_label_set_text(_lblLora, "LoRa"); - lv_obj_align(_lblLora, LV_ALIGN_LEFT_MID, 4, 0); - - _lblBle = lv_label_create(_bar); - lv_obj_set_style_text_font(_lblBle, font, 0); - lv_label_set_text(_lblBle, "BLE"); - lv_obj_align(_lblBle, LV_ALIGN_LEFT_MID, 50, 0); - - _lblWifi = lv_label_create(_bar); - lv_obj_set_style_text_font(_lblWifi, font, 0); - lv_label_set_text(_lblWifi, "WiFi"); - lv_obj_align(_lblWifi, LV_ALIGN_LEFT_MID, 84, 0); + // Left side: Signal bars (3 bars, increasing height) + static const int barW = 4; + static const int barH[] = {6, 10, 14}; + static const int barGap = 2; + for (int i = 0; i < 3; i++) { + _bars[i] = lv_obj_create(_bar); + lv_obj_set_size(_bars[i], barW, barH[i]); + lv_obj_set_style_radius(_bars[i], 1, 0); + lv_obj_set_style_bg_opa(_bars[i], LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_bars[i], 0, 0); + lv_obj_set_style_pad_all(_bars[i], 0, 0); + int x = 4 + i * (barW + barGap); + int y = Theme::STATUS_BAR_H - barH[i] - 3; // bottom-aligned + lv_obj_set_pos(_bars[i], x, y); + } // Center: "Ratspeak" _lblBrand = lv_label_create(_bar); @@ -126,32 +126,9 @@ void LvStatusBar::showToast(const char* msg, uint32_t durationMs) { } void LvStatusBar::refreshIndicators() { - bool flashing = _announceFlashEnd > 0 && millis() < _announceFlashEnd; - - // LoRa: green=online, red=offline, cyan if TX flash - if (flashing) { - lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ACCENT), 0); - } else if (_loraOnline) { - lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::PRIMARY), 0); - } else { - lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ERROR_CLR), 0); - } - - // BLE: green=active, yellow=enabled-not-connected, red=disabled - if (_bleActive) { - lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::PRIMARY), 0); - } else if (_bleEnabled) { - lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::WARNING_CLR), 0); - } else { - lv_obj_set_style_text_color(_lblBle, lv_color_hex(Theme::ERROR_CLR), 0); - } - - // WiFi: green=connected, yellow=enabled-not-connected, red=disabled - if (_wifiActive) { - lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::PRIMARY), 0); - } else if (_wifiEnabled) { - lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::WARNING_CLR), 0); - } else { - lv_obj_set_style_text_color(_lblWifi, lv_color_hex(Theme::ERROR_CLR), 0); + bool connected = _loraOnline || _wifiActive; + uint32_t col = connected ? Theme::PRIMARY : Theme::ERROR_CLR; + for (int i = 0; i < 3; i++) { + if (_bars[i]) lv_obj_set_style_bg_color(_bars[i], lv_color_hex(col), 0); } } diff --git a/src/ui/LvStatusBar.h b/src/ui/LvStatusBar.h index 6fb4f7f..ed58d3a 100644 --- a/src/ui/LvStatusBar.h +++ b/src/ui/LvStatusBar.h @@ -25,9 +25,7 @@ private: void refreshIndicators(); lv_obj_t* _bar = nullptr; - lv_obj_t* _lblLora = nullptr; - lv_obj_t* _lblBle = nullptr; - lv_obj_t* _lblWifi = nullptr; + lv_obj_t* _bars[3] = {}; lv_obj_t* _lblBrand = nullptr; lv_obj_t* _lblBatt = nullptr; lv_obj_t* _toast = nullptr; diff --git a/src/ui/LvTabBar.h b/src/ui/LvTabBar.h index 55bff57..7e4af68 100644 --- a/src/ui/LvTabBar.h +++ b/src/ui/LvTabBar.h @@ -4,7 +4,7 @@ class LvTabBar { public: - enum Tab { TAB_HOME = 0, TAB_MSGS, TAB_NODES, TAB_SETUP, TAB_COUNT = 4 }; + enum Tab { TAB_HOME = 0, TAB_CONTACTS, TAB_MSGS, TAB_NODES, TAB_SETTINGS, TAB_COUNT = 5 }; void create(lv_obj_t* parent); @@ -28,5 +28,5 @@ private: int _unread[TAB_COUNT] = {}; TabCallback _tabCb = nullptr; - static constexpr const char* TAB_NAMES[TAB_COUNT] = {"Home", "Msgs", "Nodes", "Setup"}; + static constexpr const char* TAB_NAMES[TAB_COUNT] = {"Home", "Friends", "Msgs", "Peers", "Setup"}; }; diff --git a/src/ui/screens/LvContactsScreen.cpp b/src/ui/screens/LvContactsScreen.cpp new file mode 100644 index 0000000..b784ad7 --- /dev/null +++ b/src/ui/screens/LvContactsScreen.cpp @@ -0,0 +1,173 @@ +#include "LvContactsScreen.h" +#include "ui/Theme.h" +#include "ui/UIManager.h" +#include "reticulum/AnnounceManager.h" +#include + +void LvContactsScreen::createUI(lv_obj_t* parent) { + _screen = parent; + lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_pad_all(parent, 0, 0); + + _lblEmpty = lv_label_create(parent); + lv_obj_set_style_text_font(_lblEmpty, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(_lblEmpty, "No saved contacts"); + lv_obj_center(_lblEmpty); + + _list = lv_obj_create(parent); + lv_obj_set_size(_list, lv_pct(100), lv_pct(100)); + lv_obj_set_pos(_list, 0, 0); + lv_obj_set_flex_grow(_list, 1); + lv_obj_set_style_bg_color(_list, lv_color_hex(Theme::BG), 0); + lv_obj_set_style_bg_opa(_list, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_list, 0, 0); + lv_obj_set_style_pad_all(_list, 0, 0); + lv_obj_set_style_pad_row(_list, 0, 0); + lv_obj_set_style_radius(_list, 0, 0); + lv_obj_set_layout(_list, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN); + + _lastContactCount = -1; + rebuildList(); +} + +void LvContactsScreen::onEnter() { + _lastContactCount = -1; + _selectedIdx = 0; + rebuildList(); +} + +void LvContactsScreen::refreshUI() { + if (!_am) return; + int contacts = 0; + for (const auto& n : _am->nodes()) { if (n.saved) contacts++; } + if (contacts != _lastContactCount) { + rebuildList(); + } +} + +void LvContactsScreen::updateSelection(int oldIdx, int newIdx) { + if (oldIdx >= 0 && oldIdx < (int)_rows.size()) { + lv_obj_set_style_bg_color(_rows[oldIdx], lv_color_hex(Theme::BG), 0); + } + if (newIdx >= 0 && newIdx < (int)_rows.size()) { + lv_obj_set_style_bg_color(_rows[newIdx], lv_color_hex(Theme::SELECTION_BG), 0); + lv_obj_scroll_to_view(_rows[newIdx], LV_ANIM_OFF); + } +} + +void LvContactsScreen::rebuildList() { + if (!_am || !_list) return; + _rows.clear(); + _contactIndices.clear(); + lv_obj_clean(_list); + + const auto& nodes = _am->nodes(); + for (int i = 0; i < (int)nodes.size(); i++) { + if (nodes[i].saved) _contactIndices.push_back(i); + } + int count = (int)_contactIndices.size(); + _lastContactCount = count; + + if (count == 0) { + lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN); + return; + } + + lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); + + if (_selectedIdx >= count) _selectedIdx = count - 1; + if (_selectedIdx < 0) _selectedIdx = 0; + + const lv_font_t* font = &lv_font_montserrat_14; + + for (int i = 0; i < count; i++) { + const auto& node = nodes[_contactIndices[i]]; + bool selected = (i == _selectedIdx); + + lv_obj_t* row = lv_obj_create(_list); + lv_obj_set_size(row, Theme::CONTENT_W, 28); + lv_obj_set_style_bg_color(row, lv_color_hex( + selected ? Theme::SELECTION_BG : Theme::BG), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_radius(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // Display name only + lv_obj_t* lbl = lv_label_create(row); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(lbl, node.name.c_str()); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); + + _rows.push_back(row); + } +} + +bool LvContactsScreen::handleLongPress() { + if (!_am || _contactIndices.empty()) return false; + if (_selectedIdx < 0 || _selectedIdx >= (int)_contactIndices.size()) return false; + _confirmDelete = true; + if (_ui) _ui->lvStatusBar().showToast("Delete contact? Enter=Yes Esc=No", 5000); + return true; +} + +bool LvContactsScreen::handleKey(const KeyEvent& event) { + if (!_am || _contactIndices.empty()) return false; + + // Confirm delete mode + if (_confirmDelete) { + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedIdx >= 0 && _selectedIdx < (int)_contactIndices.size()) { + int nodeIdx = _contactIndices[_selectedIdx]; + if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) { + auto& nodes = const_cast&>(_am->nodes()); + nodes.erase(nodes.begin() + nodeIdx); + _am->saveContacts(); + if (_ui) _ui->lvStatusBar().showToast("Contact deleted", 1200); + _selectedIdx = 0; + rebuildList(); + } + } + _confirmDelete = false; + return true; + } + _confirmDelete = false; + if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); + return true; + } + + int count = (int)_contactIndices.size(); + + if (event.up) { + if (_selectedIdx > 0) { + int prev = _selectedIdx; + _selectedIdx--; + updateSelection(prev, _selectedIdx); + } + return true; + } + if (event.down) { + if (_selectedIdx < count - 1) { + int prev = _selectedIdx; + _selectedIdx++; + updateSelection(prev, _selectedIdx); + } + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedIdx >= 0 && _selectedIdx < count && _onSelect) { + int nodeIdx = _contactIndices[_selectedIdx]; + _onSelect(_am->nodes()[nodeIdx].hash.toHex()); + } + return true; + } + return false; +} diff --git a/src/ui/screens/LvContactsScreen.h b/src/ui/screens/LvContactsScreen.h new file mode 100644 index 0000000..852c45c --- /dev/null +++ b/src/ui/screens/LvContactsScreen.h @@ -0,0 +1,41 @@ +#pragma once + +#include "ui/UIManager.h" +#include +#include +#include + +class AnnounceManager; + +class LvContactsScreen : public LvScreen { +public: + using NodeSelectedCallback = std::function; + + void createUI(lv_obj_t* parent) override; + void refreshUI() override; + void onEnter() override; + bool handleKey(const KeyEvent& event) override; + + void setAnnounceManager(AnnounceManager* am) { _am = am; } + void setNodeSelectedCallback(NodeSelectedCallback cb) { _onSelect = cb; } + void setUIManager(class UIManager* ui) { _ui = ui; } + bool handleLongPress() override; + + const char* title() const override { return "Contacts"; } + +private: + void rebuildList(); + void updateSelection(int oldIdx, int newIdx); + + AnnounceManager* _am = nullptr; + class UIManager* _ui = nullptr; + NodeSelectedCallback _onSelect; + bool _confirmDelete = false; + int _lastContactCount = -1; + int _selectedIdx = 0; + std::vector _contactIndices; // Maps row -> node index in _am->nodes() + + lv_obj_t* _list = nullptr; + lv_obj_t* _lblEmpty = nullptr; + std::vector _rows; +}; diff --git a/src/ui/screens/LvHomeScreen.cpp b/src/ui/screens/LvHomeScreen.cpp index b22a3c1..a32e14c 100644 --- a/src/ui/screens/LvHomeScreen.cpp +++ b/src/ui/screens/LvHomeScreen.cpp @@ -1,10 +1,12 @@ #include "LvHomeScreen.h" #include "ui/Theme.h" #include "reticulum/ReticulumManager.h" +#include "reticulum/LXMFManager.h" +#include "reticulum/AnnounceManager.h" #include "radio/SX1262.h" #include "config/UserConfig.h" #include -#include +#include void LvHomeScreen::createUI(lv_obj_t* parent) { _screen = parent; @@ -23,29 +25,25 @@ void LvHomeScreen::createUI(lv_obj_t* parent) { return lbl; }; - _lblId = mkLabel("Identity: ..."); - _lblTransport = mkLabel("Transport: ..."); - _lblPaths = mkLabel("Paths: ..."); - _lblLora = mkLabel("Radio: ..."); - _lblHeap = mkLabel("Heap: ..."); - _lblPsram = mkLabel("PSRAM: ..."); - _lblUptime = mkLabel("Uptime: 0m"); + _lblName = mkLabel("Name: ..."); + _lblId = mkLabel("ID: ..."); + _lblStatus = mkLabel("Status: ..."); + _lblNodes = mkLabel("Online Nodes: ..."); - // Force refresh on new UI — invalidate cache + // Force refresh on new UI _lastUptime = ULONG_MAX; _lastHeap = UINT32_MAX; refreshUI(); } void LvHomeScreen::onEnter() { - // Invalidate cache so refreshUI() always updates after screen transition _lastUptime = ULONG_MAX; _lastHeap = UINT32_MAX; refreshUI(); } void LvHomeScreen::refreshUI() { - if (!_lblId) return; + if (!_lblName) return; unsigned long upMins = millis() / 60000; uint32_t heap = ESP.getFreeHeap() / 1024; @@ -53,39 +51,51 @@ void LvHomeScreen::refreshUI() { _lastUptime = upMins; _lastHeap = heap; + // Name + if (_cfg && !_cfg->settings().displayName.isEmpty()) { + lv_label_set_text_fmt(_lblName, "Name: %s", _cfg->settings().displayName.c_str()); + } else if (_rns) { + String dh = _rns->destinationHashHex(); + String fallback = "Ratspeak.org-" + dh.substring(0, 3); + lv_label_set_text_fmt(_lblName, "Name: %s", fallback.c_str()); + } else { + lv_label_set_text(_lblName, "Name: ---"); + } + + // ID (LXMF destination hash, 12 chars) if (_rns) { - lv_label_set_text_fmt(_lblId, "ID: %s", _rns->identityHash().c_str()); - lv_label_set_text_fmt(_lblTransport, "Transport: %s", - _rns->isTransportActive() ? "ACTIVE" : "OFFLINE"); - lv_label_set_text_fmt(_lblPaths, "Paths: %d Links: %d", - (int)_rns->pathCount(), (int)_rns->linkCount()); + String dh = _rns->destinationHashHex(); + if (dh.length() > 12) dh = dh.substring(0, 12); + lv_label_set_text_fmt(_lblId, "ID: %s", dh.c_str()); } else { - lv_label_set_text(_lblId, "Identity: ---"); - lv_label_set_text(_lblTransport, "Transport: OFFLINE"); - lv_label_set_text(_lblPaths, "Paths: 0 Links: 0"); + lv_label_set_text(_lblId, "ID: ---"); } - if (_radio && _radio->isRadioOnline()) { - lv_label_set_text_fmt(_lblLora, "LoRa: SF%d BW%luk %ddBm", - _radio->getSpreadingFactor(), - (unsigned long)(_radio->getSignalBandwidth() / 1000), - _radio->getTxPower()); - lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::PRIMARY), 0); + // Status + bool loraUp = _radioOnline && _radio && _radio->isRadioOnline(); + bool tcpUp = WiFi.status() == WL_CONNECTED; + if (loraUp && tcpUp) { + lv_label_set_text(_lblStatus, "Status: Online (LoRa/TCP)"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::PRIMARY), 0); + } else if (loraUp) { + lv_label_set_text(_lblStatus, "Status: Online (LoRa)"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::PRIMARY), 0); + } else if (tcpUp) { + lv_label_set_text(_lblStatus, "Status: Online (TCP)"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::PRIMARY), 0); } else { - lv_label_set_text(_lblLora, "Radio: OFFLINE"); - lv_obj_set_style_text_color(_lblLora, lv_color_hex(Theme::ERROR_CLR), 0); + lv_label_set_text(_lblStatus, "Status: Offline"); + lv_obj_set_style_text_color(_lblStatus, lv_color_hex(Theme::ERROR_CLR), 0); } - lv_label_set_text_fmt(_lblHeap, "Heap: %lukB free", - (unsigned long)(ESP.getFreeHeap() / 1024)); - lv_label_set_text_fmt(_lblPsram, "PSRAM: %lukB free", - (unsigned long)(ESP.getFreePsram() / 1024)); - - if (upMins >= 60) { - lv_label_set_text_fmt(_lblUptime, "Uptime: %luh %lum", upMins / 60, upMins % 60); + // Online Nodes (30 min window) + if (_am) { + int online = _am->nodesOnlineSince(1800000); + lv_label_set_text_fmt(_lblNodes, "Online Nodes: %d", online); } else { - lv_label_set_text_fmt(_lblUptime, "Uptime: %lum", upMins); + lv_label_set_text(_lblNodes, "Online Nodes: 0"); } + } bool LvHomeScreen::handleKey(const KeyEvent& event) { diff --git a/src/ui/screens/LvHomeScreen.h b/src/ui/screens/LvHomeScreen.h index 5b20122..410bfa7 100644 --- a/src/ui/screens/LvHomeScreen.h +++ b/src/ui/screens/LvHomeScreen.h @@ -6,6 +6,8 @@ class ReticulumManager; class SX1262; class UserConfig; +class LXMFManager; +class AnnounceManager; class LvHomeScreen : public LvScreen { public: @@ -17,6 +19,9 @@ public: void setReticulumManager(ReticulumManager* rns) { _rns = rns; } void setRadio(SX1262* radio) { _radio = radio; } void setUserConfig(UserConfig* cfg) { _cfg = cfg; } + void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; } + void setAnnounceManager(AnnounceManager* am) { _am = am; } + void setRadioOnline(bool online) { _radioOnline = online; } void setAnnounceCallback(std::function cb) { _announceCb = cb; } const char* title() const override { return "Home"; } @@ -25,15 +30,15 @@ private: ReticulumManager* _rns = nullptr; SX1262* _radio = nullptr; UserConfig* _cfg = nullptr; + LXMFManager* _lxmf = nullptr; + AnnounceManager* _am = nullptr; + bool _radioOnline = false; std::function _announceCb; unsigned long _lastUptime = 0; uint32_t _lastHeap = 0; + lv_obj_t* _lblName = nullptr; lv_obj_t* _lblId = nullptr; - lv_obj_t* _lblTransport = nullptr; - lv_obj_t* _lblPaths = nullptr; - lv_obj_t* _lblLora = nullptr; - lv_obj_t* _lblHeap = nullptr; - lv_obj_t* _lblPsram = nullptr; - lv_obj_t* _lblUptime = nullptr; + lv_obj_t* _lblStatus = nullptr; + lv_obj_t* _lblNodes = nullptr; }; diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp index e16c598..22e463c 100644 --- a/src/ui/screens/LvMessageView.cpp +++ b/src/ui/screens/LvMessageView.cpp @@ -1,6 +1,7 @@ #include "LvMessageView.h" #include "ui/Theme.h" #include "ui/LvTheme.h" +#include "ui/LvTabBar.h" #include "reticulum/LXMFManager.h" #include "reticulum/AnnounceManager.h" #include @@ -106,7 +107,16 @@ void LvMessageView::destroyUI() { } void LvMessageView::onEnter() { - if (_lxmf) _lxmf->markRead(_peerHex); + if (_lxmf) { + _lxmf->markRead(_peerHex); + // Update unread badge on Messages tab + if (_ui) _ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount()); + // Register status callback for live updates + std::string peer = _peerHex; + _lxmf->setStatusCallback([this, peer](const std::string& peerHex, double, LXMFStatus) { + if (peerHex == peer) _lastRefreshMs = 0; // Force rebuild on next refreshUI + }); + } _lastMsgCount = -1; _lastRefreshMs = 0; _inputText.clear(); @@ -123,6 +133,7 @@ void LvMessageView::onEnter() { } void LvMessageView::onExit() { + if (_lxmf) _lxmf->setStatusCallback(nullptr); _inputText.clear(); _cachedMsgs.clear(); } @@ -131,10 +142,17 @@ void LvMessageView::refreshUI() { if (!_lxmf) return; unsigned long now = millis(); if (now - _lastRefreshMs >= REFRESH_INTERVAL_MS) { - int oldCount = (int)_cachedMsgs.size(); - _cachedMsgs = _lxmf->getMessages(_peerHex); + auto newMsgs = _lxmf->getMessages(_peerHex); _lastRefreshMs = now; - if ((int)_cachedMsgs.size() != oldCount) { + // Rebuild if count changed or any status changed + bool changed = (newMsgs.size() != _cachedMsgs.size()); + if (!changed) { + for (size_t i = 0; i < newMsgs.size(); i++) { + if (newMsgs[i].status != _cachedMsgs[i].status) { changed = true; break; } + } + } + if (changed) { + _cachedMsgs = newMsgs; _lastMsgCount = (int)_cachedMsgs.size(); rebuildMessages(); } @@ -179,11 +197,25 @@ void LvMessageView::rebuildMessages() { } lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); - // Message label with word wrap + // Message label with word wrap — status-based colors for outgoing + uint32_t textColor = Theme::ACCENT; // incoming default + if (!msg.incoming) { + switch (msg.status) { + case LXMFStatus::QUEUED: + case LXMFStatus::SENDING: + textColor = Theme::WARNING_CLR; break; + case LXMFStatus::SENT: + case LXMFStatus::DELIVERED: + textColor = Theme::PRIMARY; break; + case LXMFStatus::FAILED: + textColor = Theme::ERROR_CLR; break; + default: + textColor = Theme::PRIMARY; break; + } + } lv_obj_t* lbl = lv_label_create(box); lv_obj_set_style_text_font(lbl, font, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex( - msg.incoming ? Theme::ACCENT : Theme::PRIMARY), 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(textColor), 0); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(lbl, maxBubbleW - 14); lv_label_set_text(lbl, msg.content.c_str()); diff --git a/src/ui/screens/LvMessageView.h b/src/ui/screens/LvMessageView.h index a64e063..e8c256d 100644 --- a/src/ui/screens/LvMessageView.h +++ b/src/ui/screens/LvMessageView.h @@ -23,6 +23,7 @@ public: void setPeerHex(const std::string& hex) { _peerHex = hex; } void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; } void setAnnounceManager(AnnounceManager* am) { _am = am; } + void setUIManager(class UIManager* ui) { _ui = ui; } void setBackCallback(BackCallback cb) { _onBack = cb; } const char* title() const override { return "Chat"; } @@ -34,6 +35,7 @@ private: LXMFManager* _lxmf = nullptr; AnnounceManager* _am = nullptr; + class UIManager* _ui = nullptr; BackCallback _onBack; std::string _peerHex; std::string _inputText; diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp index c531d62..36c976f 100644 --- a/src/ui/screens/LvMessagesScreen.cpp +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -5,6 +5,8 @@ #include "reticulum/AnnounceManager.h" #include "storage/MessageStore.h" #include +#include +#include void LvMessagesScreen::createUI(lv_obj_t* parent) { _screen = parent; @@ -43,7 +45,8 @@ void LvMessagesScreen::onEnter() { void LvMessagesScreen::refreshUI() { if (!_lxmf) return; int count = (int)_lxmf->conversations().size(); - if (count != _lastConvCount) { + int unread = _lxmf->unreadCount(); + if (count != _lastConvCount || unread != _lastUnreadTotal) { rebuildList(); } } @@ -61,9 +64,13 @@ void LvMessagesScreen::updateSelection(int oldIdx, int newIdx) { void LvMessagesScreen::rebuildList() { if (!_lxmf || !_list) return; - int count = (int)_lxmf->conversations().size(); + + const auto& convs = _lxmf->conversations(); + int count = (int)convs.size(); _lastConvCount = count; + _lastUnreadTotal = _lxmf->unreadCount(); _rows.clear(); + _sortedPeers.clear(); lv_obj_clean(_list); if (count == 0) { @@ -75,18 +82,50 @@ void LvMessagesScreen::rebuildList() { lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN); + // Build conversation info for sorting by most recent + struct ConvInfo { + std::string peerHex; + double lastTs = 0; + std::string preview; + bool hasUnread = false; + }; + + std::vector sorted; + sorted.reserve(count); + + for (int i = 0; i < count; i++) { + ConvInfo ci; + ci.peerHex = convs[i]; + ci.hasUnread = _lxmf->unreadCount(ci.peerHex) > 0; + auto msgs = _lxmf->getMessages(ci.peerHex); + if (!msgs.empty()) { + const auto& last = msgs.back(); + ci.lastTs = last.timestamp; + std::string prefix = last.incoming ? "Them: " : "You: "; + std::string content = last.content; + if (content.size() > 15) content = content.substr(0, 15) + "..."; + ci.preview = prefix + content; + } + sorted.push_back(ci); + } + + std::sort(sorted.begin(), sorted.end(), [](const ConvInfo& a, const ConvInfo& b) { + return a.lastTs > b.lastTs; + }); + + for (auto& ci : sorted) _sortedPeers.push_back(ci.peerHex); + if (_selectedIdx >= count) _selectedIdx = count - 1; if (_selectedIdx < 0) _selectedIdx = 0; - const auto& convs = _lxmf->conversations(); - const lv_font_t* font = &lv_font_montserrat_14; + const lv_font_t* nameFont = &lv_font_montserrat_14; + const lv_font_t* smallFont = &lv_font_montserrat_12; - for (int i = 0; i < count; i++) { - const auto& peerHex = convs[i]; - int unread = _lxmf->unreadCount(peerHex); + for (int i = 0; i < (int)sorted.size(); i++) { + const auto& ci = sorted[i]; lv_obj_t* row = lv_obj_create(_list); - lv_obj_set_size(row, Theme::CONTENT_W, 28); + lv_obj_set_size(row, Theme::CONTENT_W, 38); lv_obj_set_style_bg_color(row, lv_color_hex( i == _selectedIdx ? Theme::SELECTION_BG : Theme::BG), 0); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); @@ -97,26 +136,52 @@ void LvMessagesScreen::rebuildList() { lv_obj_set_style_radius(row, 0, 0); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); - // Peer name — check name cache (survives reboots) - std::string displayName; - if (_am) displayName = _am->lookupName(peerHex); - if (displayName.empty()) displayName = peerHex.substr(0, 16); + // Green dot for unread + if (ci.hasUnread) { + lv_obj_t* dot = lv_obj_create(row); + lv_obj_set_size(dot, 6, 6); + lv_obj_set_style_radius(dot, 3, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(Theme::PRIMARY), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_pad_all(dot, 0, 0); + lv_obj_set_pos(dot, 5, 8); + } - lv_obj_t* lbl = lv_label_create(row); - lv_obj_set_style_text_font(lbl, font, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::PRIMARY), 0); - lv_label_set_text(lbl, displayName.c_str()); - lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + // Peer name (line 1) + std::string peerName; + if (_am) peerName = _am->lookupName(ci.peerHex); + std::string displayName = !peerName.empty() ? + peerName.substr(0, 15) : ci.peerHex.substr(0, 12); - // Unread badge - if (unread > 0) { - char badge[8]; - snprintf(badge, sizeof(badge), "(%d)", unread); - lv_obj_t* badgeLbl = lv_label_create(row); - lv_obj_set_style_text_font(badgeLbl, font, 0); - lv_obj_set_style_text_color(badgeLbl, lv_color_hex(Theme::BADGE_BG), 0); - lv_label_set_text(badgeLbl, badge); - lv_obj_align(badgeLbl, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_t* nameLbl = lv_label_create(row); + lv_obj_set_style_text_font(nameLbl, nameFont, 0); + lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(nameLbl, displayName.c_str()); + lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 14, 2); + + // Timestamp (line 1, right) + if (ci.lastTs > 1000000) { + time_t t = (time_t)ci.lastTs; + struct tm* tm = localtime(&t); + if (tm) { + char timeBuf[8]; + snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tm->tm_hour, tm->tm_min); + lv_obj_t* timeLbl = lv_label_create(row); + lv_obj_set_style_text_font(timeLbl, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(timeLbl, timeBuf); + lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -4, 4); + } + } + + // Preview (line 2) + if (!ci.preview.empty()) { + lv_obj_t* prevLbl = lv_label_create(row); + lv_obj_set_style_text_font(prevLbl, smallFont, 0); + lv_obj_set_style_text_color(prevLbl, lv_color_hex(Theme::MUTED), 0); + lv_label_set_text(prevLbl, ci.preview.c_str()); + lv_obj_align(prevLbl, LV_ALIGN_BOTTOM_LEFT, 14, -4); } _rows.push_back(row); @@ -127,22 +192,68 @@ bool LvMessagesScreen::handleLongPress() { if (!_lxmf) return false; int count = (int)_lxmf->conversations().size(); if (count == 0 || _selectedIdx >= count) return false; - _confirmDelete = true; - if (_ui) _ui->lvStatusBar().showToast("Delete chat? Enter=Yes Esc=No", 5000); + _lpState = LP_MENU; + _menuIdx = 0; + if (_ui) _ui->lvStatusBar().showToast("Up/Down: Add Friend | Delete | Cancel", 5000); return true; } bool LvMessagesScreen::handleKey(const KeyEvent& event) { if (!_lxmf) return false; - // Confirm delete mode - if (_confirmDelete) { + // Long-press menu mode + if (_lpState == LP_MENU) { + if (event.up || event.down) { + _menuIdx = (_menuIdx + (event.down ? 1 : -1) + 3) % 3; + const char* labels[] = {">> Add Friend <<", ">> Delete Chat <<", ">> Cancel <<"}; + if (_ui) _ui->lvStatusBar().showToast(labels[_menuIdx], 5000); + return true; + } if (event.enter || event.character == '\n' || event.character == '\r') { int count = (int)_lxmf->conversations().size(); - if (_selectedIdx < count) { - const auto& peerHex = _lxmf->conversations()[_selectedIdx]; - _lxmf->markRead(peerHex); // Clear unread - // Delete via MessageStore + if (_menuIdx == 0 && _selectedIdx < (int)_sortedPeers.size()) { + // Add friend + const auto& peerHex = _sortedPeers[_selectedIdx]; + if (_am) { + const DiscoveredNode* existing = _am->findNodeByHex(peerHex); + if (existing && !existing->saved) { + auto& node = const_cast(*existing); + node.saved = true; + _am->saveContacts(); + if (_ui) _ui->lvStatusBar().showToast("Added to friends!", 1200); + } else if (!existing) { + _am->addManualContact(peerHex, ""); + if (_ui) _ui->lvStatusBar().showToast("Added to friends!", 1200); + } else { + if (_ui) _ui->lvStatusBar().showToast("Already a friend", 1200); + } + } + } else if (_menuIdx == 1 && _selectedIdx < (int)_sortedPeers.size()) { + // Confirm delete + _lpState = LP_CONFIRM_DELETE; + if (_ui) _ui->lvStatusBar().showToast("Delete chat? Enter=Yes Esc=No", 5000); + return true; + } else { + // Cancel + if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); + } + _lpState = LP_NONE; + return true; + } + if (event.del || event.character == 8 || event.character == 0x1B) { + _lpState = LP_NONE; + if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); + return true; + } + return true; + } + + // Confirm delete mode + if (_lpState == LP_CONFIRM_DELETE) { + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedIdx < (int)_sortedPeers.size()) { + const auto& peerHex = _sortedPeers[_selectedIdx]; + _lxmf->markRead(peerHex); extern MessageStore messageStore; messageStore.deleteConversation(peerHex); messageStore.refreshConversations(); @@ -151,10 +262,10 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) { _lastConvCount = -1; rebuildList(); } - _confirmDelete = false; + _lpState = LP_NONE; return true; } - _confirmDelete = false; + _lpState = LP_NONE; if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800); return true; } @@ -179,8 +290,8 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) { return true; } if (event.enter || event.character == '\n' || event.character == '\r') { - if (_selectedIdx < count && _onOpen) { - _onOpen(_lxmf->conversations()[_selectedIdx]); + if (_selectedIdx < (int)_sortedPeers.size() && _onOpen) { + _onOpen(_sortedPeers[_selectedIdx]); } return true; } diff --git a/src/ui/screens/LvMessagesScreen.h b/src/ui/screens/LvMessagesScreen.h index b7ae558..b437902 100644 --- a/src/ui/screens/LvMessagesScreen.h +++ b/src/ui/screens/LvMessagesScreen.h @@ -34,8 +34,12 @@ private: class UIManager* _ui = nullptr; OpenCallback _onOpen; int _lastConvCount = -1; + int _lastUnreadTotal = 0; int _selectedIdx = 0; - bool _confirmDelete = false; + std::vector _sortedPeers; + enum LongPressState { LP_NONE, LP_MENU, LP_CONFIRM_DELETE }; + LongPressState _lpState = LP_NONE; + int _menuIdx = 0; // 0=Add Friend, 1=Delete Chat, 2=Cancel lv_obj_t* _list = nullptr; lv_obj_t* _lblEmpty = nullptr; diff --git a/src/ui/screens/LvNameInputScreen.cpp b/src/ui/screens/LvNameInputScreen.cpp index 1b63734..e2b1136 100644 --- a/src/ui/screens/LvNameInputScreen.cpp +++ b/src/ui/screens/LvNameInputScreen.cpp @@ -35,7 +35,7 @@ void LvNameInputScreen::createUI(lv_obj_t* parent) { lv_obj_align(_textarea, LV_ALIGN_TOP_MID, 0, 95); lv_textarea_set_max_length(_textarea, MAX_NAME_LEN); lv_textarea_set_one_line(_textarea, true); - lv_textarea_set_placeholder_text(_textarea, "Name"); + lv_textarea_set_placeholder_text(_textarea, "Optional"); lv_obj_add_style(_textarea, LvTheme::styleTextarea(), 0); lv_obj_set_style_text_font(_textarea, &lv_font_montserrat_14, 0); @@ -61,8 +61,8 @@ bool LvNameInputScreen::handleKey(const KeyEvent& event) { if (event.enter || event.character == '\n' || event.character == '\r') { const char* text = lv_textarea_get_text(_textarea); - if (text && strlen(text) > 0 && _doneCb) { - _doneCb(String(text)); + if (_doneCb) { + _doneCb(String(text && strlen(text) > 0 ? text : "")); } return true; } diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp index a47c2a9..7f203ac 100644 --- a/src/ui/screens/LvNodesScreen.cpp +++ b/src/ui/screens/LvNodesScreen.cpp @@ -142,17 +142,11 @@ void LvNodesScreen::rebuildList() { lv_obj_set_style_radius(row, 0, 0); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); - // Name + hash - std::string displayHash; - if (!node.identityHex.empty() && node.identityHex.size() >= 12) { - displayHash = node.identityHex.substr(0, 4) + ":" + - node.identityHex.substr(4, 4) + ":" + - node.identityHex.substr(8, 4); - } else { - displayHash = node.hash.toHex().substr(0, 8); - } + // Name (truncated to 15) + destination hash (12 chars) + std::string truncName = node.name.substr(0, 15); + std::string displayHash = node.hash.toHex().substr(0, 12); char buf[64]; - snprintf(buf, sizeof(buf), "%s [%s]", node.name.c_str(), displayHash.c_str()); + snprintf(buf, sizeof(buf), "%s [%s]", truncName.c_str(), displayHash.c_str()); lv_obj_t* lbl = lv_label_create(row); lv_obj_set_style_text_font(lbl, font, 0); @@ -199,6 +193,11 @@ void LvNodesScreen::rebuildList() { while (_selectedIdx < _totalRows && _rowToNodeIdx[_selectedIdx] == -1) _selectedIdx++; if (_selectedIdx >= _totalRows) _selectedIdx = _totalRows - 1; + // Apply highlight to corrected selection (rows were built before skip-headers loop) + if (_selectedIdx >= 0 && _selectedIdx < (int)_rows.size()) { + lv_obj_set_style_bg_color(_rows[_selectedIdx], lv_color_hex(Theme::SELECTION_BG), 0); + } + scrollToSelected(); } @@ -215,9 +214,17 @@ bool LvNodesScreen::handleLongPress() { int nodeIdx = _rowToNodeIdx[_selectedIdx]; if (nodeIdx < 0 || nodeIdx >= (int)_am->nodes().size()) return false; const auto& node = _am->nodes()[nodeIdx]; - if (!node.saved) return false; // Only contacts can be deleted - _confirmDelete = true; - if (_ui) _ui->lvStatusBar().showToast("Delete contact? Enter=Yes Esc=No", 5000); + if (node.saved) { + _confirmDelete = true; + if (_ui) _ui->lvStatusBar().showToast("Remove friend? Enter=Yes Esc=No", 5000); + } else { + // Add as friend + auto& mutableNode = const_cast(node); + mutableNode.saved = true; + _am->saveContacts(); + if (_ui) _ui->lvStatusBar().showToast("Added to friends!", 1200); + rebuildList(); + } return true; } diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 648fa0b..f90c866 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -14,6 +14,9 @@ #include #include #include +#include +#include +#include struct RadioPresetLv { const char* name; @@ -80,6 +83,9 @@ void LvSettingsScreen::buildItems() { _items.push_back({"Version", SettingType::READONLY, nullptr, nullptr, [](int) { return String(RATDECK_VERSION_STRING); }}); idx++; + _items.push_back({"LXMF Addr", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _destinationHash.length() > 0 ? _destinationHash.substring(0, 16) : String("unknown"); }}); + idx++; _items.push_back({"Identity", SettingType::READONLY, nullptr, nullptr, [this](int) { return _identityHash.substring(0, 16); }}); idx++; @@ -88,7 +94,13 @@ void LvSettingsScreen::buildItems() { nameItem.label = "Display Name"; nameItem.type = SettingType::TEXT_INPUT; nameItem.textGetter = [&s]() { return s.displayName; }; - nameItem.textSetter = [&s](const String& v) { s.displayName = v; }; + nameItem.textSetter = [this, &s](const String& v) { + s.displayName = v; + // Keep active identity slot in sync + if (_idMgr && _idMgr->activeIndex() >= 0) { + _idMgr->setDisplayName(_idMgr->activeIndex(), v); + } + }; nameItem.maxTextLen = 16; _items.push_back(nameItem); idx++; @@ -100,8 +112,16 @@ void LvSettingsScreen::buildItems() { idSwitch.getter = [this]() { return _idMgr->activeIndex(); }; idSwitch.setter = [this](int v) { if (v == _idMgr->activeIndex()) return; + // Save current display name to outgoing identity slot + if (_cfg) { + _idMgr->setDisplayName(_idMgr->activeIndex(), _cfg->settings().displayName); + } RNS::Identity newId; if (_idMgr->switchTo(v, newId)) { + // Load incoming identity's display name into config + if (_cfg) { + _cfg->settings().displayName = _idMgr->getDisplayName(v); + } if (_ui) _ui->lvStatusBar().showToast("Identity switched! Rebooting...", 2000); applyAndSave(); delay(1000); @@ -117,7 +137,8 @@ void LvSettingsScreen::buildItems() { auto& slot = _idMgr->identities()[i]; static char labelBufs[8][32]; if (!slot.displayName.isEmpty()) { - snprintf(labelBufs[i], sizeof(labelBufs[i]), "%s", slot.displayName.c_str()); + snprintf(labelBufs[i], sizeof(labelBufs[i]), "%s [%.8s]", + slot.displayName.c_str(), slot.hash.c_str()); } else { snprintf(labelBufs[i], sizeof(labelBufs[i]), "%.12s", slot.hash.c_str()); } @@ -330,6 +351,50 @@ void LvSettingsScreen::buildItems() { _categories.push_back({"Audio", audioStart, idx - audioStart, [&s]() { return s.audioEnabled ? (String(s.audioVolume) + "%") : String("OFF"); }}); + // Info (diagnostics moved from Home screen) + int infoStart = idx; + _items.push_back({"Transport", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _rns && _rns->isTransportActive() ? String("ACTIVE") : String("OFFLINE"); }}); + idx++; + _items.push_back({"Paths", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _rns ? String((int)_rns->pathCount()) : String("0"); }}); + idx++; + _items.push_back({"Links", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _rns ? String((int)_rns->linkCount()) : String("0"); }}); + idx++; + _items.push_back({"Radio", SettingType::READONLY, nullptr, nullptr, + [this](int) { + if (_radio && _radio->isRadioOnline()) { + char buf[32]; + snprintf(buf, sizeof(buf), "SF%d BW%luk %ddBm", + _radio->getSpreadingFactor(), + (unsigned long)(_radio->getSignalBandwidth() / 1000), + _radio->getTxPower()); + return String(buf); + } + return String("OFFLINE"); + }}); + idx++; + _items.push_back({"Heap", SettingType::READONLY, nullptr, nullptr, + [](int) { return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB"; }}); + idx++; + _items.push_back({"PSRAM", SettingType::READONLY, nullptr, nullptr, + [](int) { return String((unsigned long)(ESP.getFreePsram() / 1024)) + " KB"; }}); + idx++; + _items.push_back({"Uptime", SettingType::READONLY, nullptr, nullptr, + [](int) -> String { + unsigned long m = millis() / 60000; + if (m >= 60) { + char buf[16]; + snprintf(buf, sizeof(buf), "%luh %lum", m / 60, m % 60); + return String(buf); + } + return String(m) + "m"; + }}); + idx++; + _categories.push_back({"Info", infoStart, idx - infoStart, + [this]() { return _rns && _rns->isTransportActive() ? String("ACTIVE") : String("OFFLINE"); }}); + // System int sysStart = idx; _items.push_back({"Free Heap", SettingType::READONLY, nullptr, nullptr, @@ -432,6 +497,63 @@ void LvSettingsScreen::buildItems() { _items.push_back(rebootItem); idx++; } + { + SettingItem updateCheck; + updateCheck.label = "Check for Updates"; + updateCheck.type = SettingType::ACTION; + updateCheck.formatter = [](int) { return String("[Enter]"); }; + updateCheck.action = [this]() { + if (WiFi.status() != WL_CONNECTED) { + if (_ui) _ui->lvStatusBar().showToast("Connect to WiFi to check!", 2000); + return; + } + if (_ui) _ui->lvStatusBar().showToast("Checking for updates...", 3000); + + HTTPClient http; + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setTimeout(8000); + http.begin("https://api.github.com/repos/defidude/Ratdeck/releases/latest"); + http.addHeader("Accept", "application/vnd.github.v3+json"); + int httpCode = http.GET(); + + if (httpCode != 200) { + http.end(); + if (_ui) _ui->lvStatusBar().showToast("Couldn't fetch data!", 2000); + return; + } + + String payload = http.getString(); + http.end(); + + JsonDocument doc; + if (deserializeJson(doc, payload)) { + if (_ui) _ui->lvStatusBar().showToast("Couldn't fetch data!", 2000); + return; + } + + const char* tagName = doc["tag_name"] | ""; + // Strip leading 'v' if present + const char* remoteVer = tagName; + if (remoteVer[0] == 'v' || remoteVer[0] == 'V') remoteVer++; + + if (strlen(remoteVer) == 0) { + if (_ui) _ui->lvStatusBar().showToast("Couldn't fetch data!", 2000); + return; + } + + // Compare versions (simple string compare works for semver) + int cmp = strcmp(remoteVer, RATDECK_VERSION_STRING); + if (cmp > 0) { + char msg[64]; + snprintf(msg, sizeof(msg), "v%s available! Flash at ratspeak.org", remoteVer); + if (_ui) _ui->lvStatusBar().showToast(msg, 5000); + } else { + if (_ui) _ui->lvStatusBar().showToast("You're up to date!", 2000); + } + }; + _items.push_back(updateCheck); + idx++; + } _categories.push_back({"System", sysStart, idx - sysStart, [](){ return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB free"; }}); } diff --git a/src/ui/screens/LvSettingsScreen.h b/src/ui/screens/LvSettingsScreen.h index 7e0d2c7..8c251ae 100644 --- a/src/ui/screens/LvSettingsScreen.h +++ b/src/ui/screens/LvSettingsScreen.h @@ -37,6 +37,7 @@ public: void setIdentityManager(IdentityManager* idm) { _idMgr = idm; } void setUIManager(UIManager* ui) { _ui = ui; } void setIdentityHash(const String& hash) { _identityHash = hash; } + void setDestinationHash(const String& hash) { _destinationHash = hash; } void setSaveCallback(std::function cb) { _saveCallback = cb; } void setTCPChangeCallback(std::function cb) { _tcpChangeCb = cb; } @@ -75,6 +76,7 @@ private: IdentityManager* _idMgr = nullptr; UIManager* _ui = nullptr; String _identityHash; + String _destinationHash; std::function _saveCallback; std::function _tcpChangeCb;