Merge branch 'ratspeak:main' into ls-add-battery-bar

This commit is contained in:
DJ2LS
2026-06-27 16:35:44 +02:00
committed by GitHub
12 changed files with 124 additions and 62 deletions
+1 -1
View File
@@ -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) |
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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 ---
+4 -2
View File
@@ -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
+26
View File
@@ -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;
+3
View File
@@ -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<DiscoveredNode> _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<std::string, int> _hashIndex; // raw hash bytes → _nodes index
+57 -33
View File
@@ -4,6 +4,7 @@
#include <Transport.h>
#include <time.h>
#include <algorithm>
#include <exception>
LXMFManager* LXMFManager::_instance = nullptr;
std::map<std::string, LXMFManager::PendingProof> 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) {
+1 -1
View File
@@ -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);
+7 -1
View File
@@ -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)) {
+1
View File
@@ -56,6 +56,7 @@ public:
RNS::Destination& destination() { return _destination; }
LoRaInterface* loraInterface() { return _loraImpl; }
bool isLoRaNextHop(const RNS::Bytes& destHash) const;
private:
bool loadOrCreateIdentity();
+1 -1
View File
@@ -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);
+19 -19
View File
@@ -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);