diff --git a/README.md b/README.md index 4f12630..d38d11b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Status](https://img.shields.io/badge/status-beta-yellow.svg)](#install) [![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.0.0-success.svg)](https://github.com/ratspeak/rsDeck/releases) +[![Version](https://img.shields.io/badge/version-2.0.1-success.svg)](https://github.com/ratspeak/rsDeck/releases) [Ratspeak](https://github.com/ratspeak/Ratspeak) | [Docs](https://ratspeak.org/docs.html) | diff --git a/platformio.ini b/platformio.ini index 4241725..e029fb2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -52,7 +52,7 @@ build_unflags = -std=gnu++11 lib_deps = - https://github.com/ratspeak/microReticulum.git#3ddf3c362bdf05d1188ba2245c2ea06cb6514d40 + https://github.com/ratspeak/microReticulum.git#83b88146f4a49bb46ea6e0c22c560257c33815d7 bblanchon/ArduinoJson@^7.4.2 lovyan03/LovyanGFX@~1.1.16 lvgl/lvgl@~8.3.4 diff --git a/src/config/Config.h b/src/config/Config.h index 642fc6f..c40ca23 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -6,8 +6,8 @@ #define RSDECK_VERSION_MAJOR 2 #define RSDECK_VERSION_MINOR 0 -#define RSDECK_VERSION_PATCH 0 -#define RSDECK_VERSION_STRING "2.0.0" +#define RSDECK_VERSION_PATCH 1 +#define RSDECK_VERSION_STRING "2.0.1" // --- Feature Flags --- #define HAS_DISPLAY true @@ -68,7 +68,7 @@ #define RSDECK_MAX_MESSAGES_PER_CONV 100 #define FLASH_MSG_CACHE_LIMIT 20 #define RSDECK_MAX_OUTQUEUE 20 -#define RSDECK_LXMF_SINGLE_FRAME_MAX 254 // T-Deck-safe payload cap until resource transfers are fixed +#define RSDECK_RNODE_SINGLE_FRAME_RAW_MAX 254 // Raw Reticulum bytes per RNode LoRa RF frame #define PATH_PERSIST_INTERVAL_MS 60000 // --- Power Management --- diff --git a/src/main.cpp b/src/main.cpp index a7e9711..9554c3c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -711,7 +711,7 @@ static constexpr uint8_t LITE_TRANSPORT_ID[16] = { 'r', 's', 'l', 'i', 't', 'e', '-', 'h', 'e', 'l', 't', 'e', 'c', '-', 'v', '3' }; -static constexpr size_t RNODE_DIAG_SINGLE_MTU = 254; +static constexpr size_t RNODE_DIAG_SINGLE_MTU = RSDECK_RNODE_SINGLE_FRAME_RAW_MAX; static RNS::Bytes diagnosticLiteLinkId; static bool sendDiagnosticRawReticulum(const RNS::Bytes& raw, const char* label) { @@ -1878,7 +1878,9 @@ void loop() { handleSerialCommands(); // 1. Input polling + bool screenWasOn = powerMgr.isScreenOn(); inputManager.update(); + bool wakeOnlyInput = !screenWasOn && inputManager.hadStrongActivity(); if (inputManager.hadStrongActivity()) { powerMgr.activity(); // Keyboard/touch: wake from any state } else if (inputManager.hadActivity()) { @@ -1893,7 +1895,7 @@ void loop() { } // 3. Key event dispatch - if (inputManager.hasKeyEvent()) { + if (inputManager.hasKeyEvent() && !wakeOnlyInput) { const KeyEvent& evt = inputManager.getKeyEvent(); // Help overlay intercepts all keys when visible diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index ae88001..e6bb272 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -152,6 +152,7 @@ void AnnounceManager::received_announce( auto& node = _nodes[it->second]; if (node.lastSeen != 0 && now >= node.lastSeen && now - node.lastSeen < ANNOUNCE_MIN_INTERVAL_MS) return; + bool identityChanged = !idHex.empty() && node.identityHex != idHex; // Saved contacts own their local alias. Incoming announce app_data is // still cached below, but must not reset the user-chosen contact name. if (!name.empty() && !node.saved) node.name = name; @@ -167,8 +168,15 @@ void AnnounceManager::received_announce( if (nc == _nameCache.end() || nc->second != name) { _nameCache[destHex] = name; _nameCacheDirty = true; + saveNameCache(); + _nameCacheDirty = false; } } + if (!idHex.empty()) { + persistKnownDestinationsAfterAnnounce( + identityChanged ? "identity update" : "repeat announce", + identityChanged); + } return; } @@ -192,6 +200,8 @@ void AnnounceManager::received_announce( } } } + saveNameCache(); + _nameCacheDirty = false; } } @@ -242,6 +252,9 @@ void AnnounceManager::received_announce( if (_loraIf) { node.rssi = _loraIf->lastRxRssi(); node.snr = _loraIf->lastRxSnr(); } _hashIndex[key] = (int)_nodes.size(); _nodes.push_back(node); + if (!idHex.empty()) { + persistKnownDestinationsAfterAnnounce("new peer", true); + } } void AnnounceManager::loop() { @@ -258,6 +271,19 @@ void AnnounceManager::loop() { } } +void AnnounceManager::persistKnownDestinationsAfterAnnounce(const char* reason, bool force) { + unsigned long now = millis(); + if (!force && _lastKnownDestinationsPersist != 0 && + now - _lastKnownDestinationsPersist < KNOWN_DESTINATION_PERSIST_MIN_INTERVAL_MS) { + return; + } + + _lastKnownDestinationsPersist = now; + RNS::Identity::persist_data(); + Serial.printf("[ANNOUNCE] Known destinations persisted after %s\n", + reason ? reason : "announce"); +} + int AnnounceManager::nodesOnlineSince(unsigned long maxAgeMs) const { unsigned long now = millis(); int count = 0; diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h index e79b37e..637681b 100644 --- a/src/reticulum/AnnounceManager.h +++ b/src/reticulum/AnnounceManager.h @@ -60,6 +60,7 @@ public: private: void saveContact(const DiscoveredNode& node); void removeContact(const std::string& hexHash); + void persistKnownDestinationsAfterAnnounce(const char* reason, bool force); std::vector _nodes; SDStore* _sd = nullptr; @@ -77,7 +78,9 @@ private: static constexpr int MAX_NODES = 100; static constexpr int MAX_NAME_CACHE = 300; static constexpr unsigned long CONTACT_SAVE_INTERVAL_MS = 30000; + static constexpr unsigned long KNOWN_DESTINATION_PERSIST_MIN_INTERVAL_MS = 5000; static constexpr unsigned long ANNOUNCE_MIN_INTERVAL_MS = 200; // Rate-limit announce processing + unsigned long _lastKnownDestinationsPersist = 0; std::unordered_map _hashIndex; // raw hash bytes → _nodes index diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp index 3c5f1ee..2965c9d 100644 --- a/src/reticulum/LXMFManager.cpp +++ b/src/reticulum/LXMFManager.cpp @@ -4,6 +4,7 @@ #include #include #include +#include LXMFManager* LXMFManager::_instance = nullptr; std::map LXMFManager::_pendingProofs; @@ -71,7 +72,7 @@ void LXMFManager::loop() { // Keep unresolved peers from churning the UI loop or LoRa airtime. // The first attempt is immediate; later path/identity retries happen - // every 10s and are capped in sendDirect(). + // every 10s and are capped in attemptOutboundDelivery(). if (msg.retries > 0 && (millis() - msg.lastRetryMs) < LXMF_DISCOVERY_RETRY_INTERVAL_MS) { ++it; continue; @@ -79,7 +80,7 @@ void LXMFManager::loop() { msg.lastRetryMs = millis(); - if (sendDirect(msg)) { + if (attemptOutboundDelivery(msg)) { processed++; Serial.printf("[LXMF] Queue drain: status=%s dest=%s\n", msg.statusStr(), msg.destHash.toHex().substr(0, 8).c_str()); @@ -98,7 +99,7 @@ void LXMFManager::loop() { clearDeliveryPreference(msg); it = _outQueue.erase(it); } else { - // sendDirect returned false — message stays in queue, try next + // attemptOutboundDelivery returned false — message stays in queue, try next ++it; } } @@ -131,13 +132,6 @@ bool LXMFManager::sendMessage(const RNS::Bytes& destHash, const std::string& con Serial.println("[LXMF] Message pack failed"); return false; } - if (payload.size() > RSDECK_LXMF_SINGLE_FRAME_MAX) { - msg.status = LXMFStatus::FAILED; - if (_store) _store->saveMessage(msg); - Serial.printf("[LXMF] Message too large for T-Deck safe path (%d > %d); resource transfer disabled\n", - (int)payload.size(), RSDECK_LXMF_SINGLE_FRAME_MAX); - return true; - } if (preference == DeliveryPreference::Link) { _linkRequiredIds.insert(msg.messageId.toHex()); Serial.printf("[LXMF] Message %s queued for link delivery\n", @@ -193,8 +187,8 @@ bool LXMFManager::ensureOutboundLink(const RNS::Destination& dest, const RNS::By return false; } -bool LXMFManager::sendDirect(LXMFMessage& msg) { - Serial.printf("[LXMF] sendDirect: dest=%s link=%s pending=%s\n", +bool LXMFManager::attemptOutboundDelivery(LXMFMessage& msg) { + Serial.printf("[LXMF] attemptOutboundDelivery: dest=%s link=%s pending=%s\n", msg.destHash.toHex().substr(0, 12).c_str(), _outLink ? (_outLink.status() == RNS::Type::Link::ACTIVE ? "ACTIVE" : "INACTIVE") : "NONE", _outLinkPending ? "yes" : "no"); @@ -267,12 +261,6 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { if (payload.empty()) { Serial.println("[LXMF] packFull returned empty!"); msg.status = LXMFStatus::FAILED; return true; } const std::string msgIdHex = msg.messageId.toHex(); const bool requireLink = _linkRequiredIds.count(msgIdHex) > 0; - if (payload.size() > RSDECK_LXMF_SINGLE_FRAME_MAX) { - Serial.printf("[LXMF] Refusing resource-sized message (%d bytes); T-Deck safe cap is %d\n", - (int)payload.size(), RSDECK_LXMF_SINGLE_FRAME_MAX); - msg.status = LXMFStatus::FAILED; - return true; - } msg.status = LXMFStatus::SENDING; bool sent = false; @@ -334,24 +322,60 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { return false; } - // Use opportunistic only for packets that fit in a single LoRa frame (254 bytes). - // Larger packets require split-frame over LoRa, which is unreliable — any single - // frame loss (CRC error, collision, half-duplex timing) kills the entire transfer - // with no recovery. Link-based delivery handles retransmission at the protocol level. - if (payloadBytes.size() <= RSDECK_LXMF_SINGLE_FRAME_MAX) { - // Fits in single LoRa frame — send opportunistic - Serial.printf("[LXMF] sending opportunistic: %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) { sent = true; registerProofTracking(receipt, msg); } + // Use opportunistic only when the LXMF payload fits in a normal + // Reticulum packet and, on LoRa, the final raw packet fits in one + // RNode RF frame. The 254-byte RNode limit applies after encryption. + const bool opportunisticPayloadFits = payloadBytes.size() <= RNS::Type::Reticulum::MDU; + bool packetPacked = false; + size_t rawLen = 0; + size_t estimatedLoRaRawLen = 0; + const bool loraPath = _rns && _rns->isLoRaNextHop(msg.destHash); + const uint8_t hops = RNS::Transport::hops_to(msg.destHash); + + if (!opportunisticPayloadFits) { + Serial.printf("[LXMF] Opportunistic payload exceeds MDU: payload=%d mdu=%u\n", + (int)payloadBytes.size(), (unsigned)RNS::Type::Reticulum::MDU); } else { - // Too large for single frame — need link + resource transfer - Serial.printf("[LXMF] Message needs link delivery (%d bytes > %d single-frame), retry %d\n", - (int)payloadBytes.size(), RSDECK_LXMF_SINGLE_FRAME_MAX, msg.retries); + RNS::Packet packet(outDest, payloadBytes); + const bool transportWrapsHeader2 = loraPath && hops > 1 + && packet.header_type() == RNS::Type::Packet::HEADER_1; + + try { + packet.pack(); + packetPacked = true; + rawLen = packet.raw().size(); + estimatedLoRaRawLen = rawLen + + (transportWrapsHeader2 ? RNS::Type::Reticulum::DESTINATION_LENGTH : 0); + } catch (const std::exception& e) { + Serial.printf("[LXMF] Opportunistic pack failed (%s); trying link delivery\n", e.what()); + } + + const bool singleFrameSafe = packetPacked && (!loraPath + || estimatedLoRaRawLen <= RSDECK_RNODE_SINGLE_FRAME_RAW_MAX); + + if (singleFrameSafe) { + Serial.printf("[LXMF] sending opportunistic: payload=%d raw=%u lora_raw=%u lora=%s hops=%u to %s\n", + (int)payloadBytes.size(), (unsigned)rawLen, + (unsigned)estimatedLoRaRawLen, + loraPath ? "yes" : "no", (unsigned)hops, + outDest.hash().toHex().substr(0, 12).c_str()); + RNS::PacketReceipt receipt = packet.send(); + if (receipt) { sent = true; registerProofTracking(receipt, msg); } + } + } + + if (!sent) { + // Too large for single-frame LoRa, or too large for an opportunistic + // Reticulum packet. Use link/resource delivery so packet loss is + // recoverable above the physical RNode split-frame layer. + Serial.printf("[LXMF] Message needs link delivery: payload=%d raw=%u lora_raw=%u limit=%u lora=%s hops=%u retry=%d\n", + (int)payloadBytes.size(), (unsigned)rawLen, + (unsigned)estimatedLoRaRawLen, + (unsigned)RSDECK_RNODE_SINGLE_FRAME_RAW_MAX, + loraPath ? "yes" : "no", (unsigned)hops, msg.retries); if (msg.retries % 3 == 0 && (!_outLink || _outLinkDestHash != msg.destHash || _outLink.status() != RNS::Type::Link::ACTIVE)) { - ensureOutboundLink(outDest, msg.destHash, "resource transfer"); + ensureOutboundLink(outDest, msg.destHash, loraPath ? "single-frame LoRa overflow" : "resource transfer"); } msg.retries++; if (msg.retries >= 30) { diff --git a/src/reticulum/LXMFManager.h b/src/reticulum/LXMFManager.h index 52be37a..d4c6978 100644 --- a/src/reticulum/LXMFManager.h +++ b/src/reticulum/LXMFManager.h @@ -41,7 +41,7 @@ public: const ConversationSummary* getConversationSummary(const std::string& peerHex) const; private: - bool sendDirect(LXMFMessage& msg); + bool attemptOutboundDelivery(LXMFMessage& msg); bool ensureOutboundLink(const RNS::Destination& dest, const RNS::Bytes& destHash, const char* reason); void clearDeliveryPreference(const LXMFMessage& msg); void processIncoming(const uint8_t* data, size_t len, const RNS::Bytes& destHash); diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index 5c67b1c..12f59b2 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -210,7 +210,7 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash, bool loraEnabled) if (loraEnabled) { _loraImpl = new LoRaInterface(radio, "LoRa"); _loraIface = _loraImpl; - _loraIface.mode(RNS::Type::Interface::MODE_GATEWAY); + _loraIface.mode(RNS::Type::Interface::MODE_ROAMING); RNS::Transport::register_interface(_loraIface); if (!_loraImpl->start()) { Serial.println("[RNS] WARNING: LoRa interface failed to start"); @@ -274,6 +274,12 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash, bool loraEnabled) return true; } +bool ReticulumManager::isLoRaNextHop(const RNS::Bytes& destHash) const { + if (!_loraImpl) return false; + RNS::Interface nextHop = RNS::Transport::next_hop_interface(destHash); + return nextHop && nextHop.get() == _loraImpl; +} + bool ReticulumManager::loadOrCreateIdentity() { // Tier 1: Flash (LittleFS) if (_flash->exists(PATH_IDENTITY)) { diff --git a/src/reticulum/ReticulumManager.h b/src/reticulum/ReticulumManager.h index 780a117..9596c56 100644 --- a/src/reticulum/ReticulumManager.h +++ b/src/reticulum/ReticulumManager.h @@ -56,6 +56,7 @@ public: RNS::Destination& destination() { return _destination; } LoRaInterface* loraInterface() { return _loraImpl; } + bool isLoRaNextHop(const RNS::Bytes& destHash) const; private: bool loadOrCreateIdentity(); diff --git a/src/ui/screens/LvMessageView.cpp b/src/ui/screens/LvMessageView.cpp index 3914178..622d393 100644 --- a/src/ui/screens/LvMessageView.cpp +++ b/src/ui/screens/LvMessageView.cpp @@ -714,7 +714,7 @@ bool LvMessageView::handleLongPress() { void LvMessageView::showSendModeMenu() { if (_inputText.empty()) return; hideSendModeMenu(); - _sendMenuIdx = 1; + _sendMenuIdx = 0; _sendOverlay = lv_obj_create(lv_layer_top()); lv_obj_set_size(_sendOverlay, 244, 118); diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index f546f45..34ce3fe 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -176,9 +176,9 @@ bool LvSettingsScreen::settingNeedsReboot(const SettingItem& item) const { const auto& s = _cfg->settings(); if (labelEq(item.label, "WiFi Mode")) return s.wifiMode != _rebootSnap.wifiMode; if (labelEq(item.label, "LoRa Radio")) return loraSettingsChanged(); - if (labelEq(item.label, "Active WiFi")) return s.wifiSTASelected != _rebootSnap.wifiSTASelected; + if (labelEq(item.label, "WiFi Profile")) return s.wifiSTASelected != _rebootSnap.wifiSTASelected; if (isWiFiSSIDLabel(item.label) || isWiFiPasswordLabel(item.label)) return interfaceSettingsChanged(); - if (labelEq(item.label, "WiFi Scan") || labelEq(item.label, "Forget WiFi")) return interfaceSettingsChanged(); + if (labelEq(item.label, "Scan Networks") || labelEq(item.label, "Forget Network")) return interfaceSettingsChanged(); if (labelEq(item.label, "TCP Relay") || labelEq(item.label, "Relay Host") || labelEq(item.label, "Relay Port")) return tcpSettingsChanged(); if (labelEq(item.label, "LAN Discovery")) return s.autoIfaceEnabled != _rebootSnap.autoIfaceEnabled; @@ -191,7 +191,7 @@ bool LvSettingsScreen::categoryNeedsReboot(int catIdx) const { if (labelEq(_categories[catIdx].name, "LoRa")) { return loraSettingsChanged(); } - if (labelEq(_categories[catIdx].name, "Interfaces")) { + if (labelEq(_categories[catIdx].name, "Network")) { return interfaceSettingsChanged() || tcpSettingsChanged(); } if (labelEq(_categories[catIdx].name, "Storage & Maintenance")) { @@ -774,7 +774,7 @@ void LvSettingsScreen::buildItems() { return label; }}); - // Interfaces + // Network int netStart = idx; _items.push_back({"WiFi Mode", SettingType::ENUM_CHOICE, [&s]() { return (int)s.wifiMode; }, @@ -784,7 +784,19 @@ void LvSettingsScreen::buildItems() { }, nullptr, 0, 2, 1, {"Off", "Hotspot", "Client"}}); idx++; - _items.push_back({"Active WiFi", SettingType::INTEGER, + { + SettingItem scanItem; + scanItem.label = "Scan Networks"; + scanItem.type = SettingType::ACTION; + scanItem.formatter = [](int) { return String("[Enter]"); }; + scanItem.action = [this, &s]() { + _wifiTargetSlot = selectedWiFiSlot(s); + showWifiPicker(); + }; + _items.push_back(scanItem); + idx++; + } + _items.push_back({"WiFi Profile", SettingType::INTEGER, [&s]() { return (int)selectedWiFiSlot(s) + 1; }, [&s](int v) { s.wifiSTASelected = (uint8_t)constrain(v - 1, 0, (int)WIFI_STA_MAX_NETWORKS - 1); }, [&s](int v) { return wifiProfileValue(s, constrain(v - 1, 0, (int)WIFI_STA_MAX_NETWORKS - 1)); }, @@ -827,21 +839,9 @@ void LvSettingsScreen::buildItems() { _items.push_back(passItem); idx++; } - { - SettingItem scanItem; - scanItem.label = "WiFi Scan"; - scanItem.type = SettingType::ACTION; - scanItem.formatter = [](int) { return String("[Enter]"); }; - scanItem.action = [this, &s]() { - _wifiTargetSlot = selectedWiFiSlot(s); - showWifiPicker(); - }; - _items.push_back(scanItem); - idx++; - } { SettingItem forgetItem; - forgetItem.label = "Forget WiFi"; + forgetItem.label = "Forget Network"; forgetItem.type = SettingType::ACTION; forgetItem.formatter = [&s](int) { String ssid = selectedWiFiSSID(s); @@ -921,7 +921,7 @@ void LvSettingsScreen::buildItems() { [&s](int v) { s.autoIfaceEnabled = (v != 0); }, [](int v) { return String(onOff(v != 0)); }}); idx++; - _categories.push_back({"Interfaces", netStart, idx - netStart, + _categories.push_back({"Network", netStart, idx - netStart, [this, &s]() { if (interfaceSettingsChanged() || tcpSettingsChanged()) return String("Saved - reboot to apply"); String summary = wifiModeLabel(s.wifiMode);