diff --git a/examples/link/main.cpp b/examples/link/main.cpp
index 186b0e8b..60e9d424 100644
--- a/examples/link/main.cpp
+++ b/examples/link/main.cpp
@@ -1337,7 +1337,7 @@ void cleanup_handler(int signum) {
// starts up the desired program mode.
int main(int argc, char *argv[]) {
-#if defined(MEM_LOG)
+#if defined(RNS_MEM_LOG)
RNS::loglevel(RNS::LOG_MEM);
#else
// Use DEBUG to see resource handling
diff --git a/examples/link/platformio.ini b/examples/link/platformio.ini
index a29a4b46..f47c04f8 100644
--- a/examples/link/platformio.ini
+++ b/examples/link/platformio.ini
@@ -21,7 +21,7 @@ build_type = debug
build_flags =
-DRNS_USE_ALLOCATOR=1
-DRNS_USE_TLSF=1
- ;-DMEM_LOG
+ ;-DRNS_MEM_LOG
lib_deps =
ArduinoJson@^7.4.2
MsgPack@^0.4.2
diff --git a/examples/lora_announce/platformio.ini b/examples/lora_announce/platformio.ini
index 11f1f7e3..ba6e62b8 100644
--- a/examples/lora_announce/platformio.ini
+++ b/examples/lora_announce/platformio.ini
@@ -18,7 +18,7 @@ monitor_speed = 115200
upload_speed = 921600
build_type = debug
build_flags =
- ;-DMEM_LOG=1
+ ;-DRNS_MEM_LOG=1
-DRNS_USE_ALLOCATOR=1
-DRNS_USE_ALLOCATOR=1
-DRNS_USE_TLSF=1
diff --git a/examples/udp_announce/main.cpp b/examples/udp_announce/main.cpp
index 58832da4..eaee9a5d 100644
--- a/examples/udp_announce/main.cpp
+++ b/examples/udp_announce/main.cpp
@@ -267,7 +267,7 @@ void setup() {
}
#endif
-#if defined(MEM_LOG)
+#if defined(RNS_MEM_LOG)
RNS::loglevel(RNS::LOG_MEM);
#else
//RNS::loglevel(RNS::LOG_WARNING);
diff --git a/examples/udp_announce/platformio.ini b/examples/udp_announce/platformio.ini
index 3d019e99..5971f590 100644
--- a/examples/udp_announce/platformio.ini
+++ b/examples/udp_announce/platformio.ini
@@ -20,7 +20,7 @@ build_type = debug
build_flags =
-DRNS_USE_ALLOCATOR=1
-DRNS_USE_TLSF=1
- ;-DMEM_LOG
+ ;-DRNS_MEM_LOG
lib_deps =
ArduinoJson@^6.21.3
https://github.com/attermann/Crypto.git
diff --git a/platformio.ini b/platformio.ini
index 4336cbd4..9460ae12 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -57,6 +57,10 @@ lib_deps =
${env.lib_deps}
lib_compat_mode = off
extra_scripts = pre:link_bz2.py
+src_filter =
+ +<*>
+ -
+ -
[env:native20]
platform = native
diff --git a/src/BLE/BLEFragmenter.cpp b/src/BLE/BLEFragmenter.cpp
index ea761677..55b72b7d 100644
--- a/src/BLE/BLEFragmenter.cpp
+++ b/src/BLE/BLEFragmenter.cpp
@@ -45,8 +45,8 @@ std::vector BLEFragmenter::fragment(const Bytes& data, uint16_t sequence_
// Handle empty data case
if (data.size() == 0) {
- // Single empty END fragment
- fragments.push_back(createFragment(Fragment::END, sequence_base, 1, Bytes()));
+ // Single empty LONE fragment
+ fragments.push_back(createFragment(Fragment::LONE, sequence_base, 1, Bytes()));
return fragments;
}
@@ -69,8 +69,8 @@ std::vector BLEFragmenter::fragment(const Bytes& data, uint16_t sequence_
// Determine fragment type
Fragment::Type type;
if (total_fragments == 1) {
- // Single fragment - use END type
- type = Fragment::END;
+ // Single fragment - use LONE type
+ type = Fragment::LONE;
} else if (i == 0) {
// First of multiple fragments
type = Fragment::START;
@@ -133,7 +133,8 @@ bool BLEFragmenter::parseHeader(const Bytes& fragment, Fragment::Type& type,
// Byte 0: Type
uint8_t type_byte = ptr[0];
- if (type_byte != Fragment::START &&
+ if (type_byte != Fragment::LONE &&
+ type_byte != Fragment::START &&
type_byte != Fragment::CONTINUE &&
type_byte != Fragment::END) {
return false;
diff --git a/src/BLE/BLEPeerManager.cpp b/src/BLE/BLEPeerManager.cpp
index e2dcac7b..7bfeb780 100644
--- a/src/BLE/BLEPeerManager.cpp
+++ b/src/BLE/BLEPeerManager.cpp
@@ -397,10 +397,18 @@ void BLEPeerManager::setPeerState(const Bytes& identifier, PeerState state) {
}
void BLEPeerManager::setPeerHandle(const Bytes& identifier, uint16_t conn_handle) {
+ // Validate handle is in range (ESP32 NimBLE uses 0-7)
+ if (conn_handle != 0xFFFF && conn_handle >= MAX_CONN_HANDLES) {
+ WARNING("BLEPeerManager: Rejecting invalid conn_handle=" +
+ std::to_string(conn_handle) + " (max=" +
+ std::to_string(MAX_CONN_HANDLES - 1) + ")");
+ return;
+ }
+
PeerInfo* peer = findPeer(identifier);
if (peer) {
// Remove old handle mapping if exists
- if (peer->conn_handle != 0xFFFF) {
+ if (peer->conn_handle != 0xFFFF && peer->conn_handle < MAX_CONN_HANDLES) {
clearHandleToPeer(peer->conn_handle);
}
peer->conn_handle = conn_handle;
@@ -586,6 +594,20 @@ void BLEPeerManager::cleanupStalePeers(double max_age) {
}
}
}
+
+ // Zombie detection: connected peers with no recent activity
+ for (size_t i = 0; i < PEERS_POOL_SIZE; i++) {
+ if (!_peers_by_identity_pool[i].in_use) continue;
+
+ PeerInfo& peer = _peers_by_identity_pool[i].peer;
+ if (peer.isConnected() && peer.last_activity > 0) {
+ double idle = now - peer.last_activity;
+ if (idle > Timing::ZOMBIE_TIMEOUT) {
+ WARNING("BLEPeerManager: Zombie peer detected, marking for disconnect");
+ peer.state = PeerState::DISCONNECTING;
+ }
+ }
+ }
}
//=============================================================================
@@ -686,7 +708,13 @@ void BLEPeerManager::promoteToIdentityKeyed(const Bytes& mac_address, const Byte
// Update handle mapping to point to new location
if (identity_slot->peer.conn_handle != 0xFFFF) {
- setHandleToPeer(identity_slot->peer.conn_handle, &identity_slot->peer);
+ if (identity_slot->peer.conn_handle < MAX_CONN_HANDLES) {
+ setHandleToPeer(identity_slot->peer.conn_handle, &identity_slot->peer);
+ } else {
+ WARNING("BLEPeerManager: Promoted peer has invalid conn_handle=" +
+ std::to_string(identity_slot->peer.conn_handle) + ", clearing");
+ identity_slot->peer.conn_handle = 0xFFFF;
+ }
}
// Add MAC-to-identity mapping
diff --git a/src/BLE/BLEPeerManager.h b/src/BLE/BLEPeerManager.h
index d0b28673..6534c4a7 100644
--- a/src/BLE/BLEPeerManager.h
+++ b/src/BLE/BLEPeerManager.h
@@ -54,6 +54,10 @@ struct PeerInfo {
uint8_t consecutive_failures = 0;
double blacklisted_until = 0.0;
+ // Keepalive failure tracking
+ uint8_t consecutive_keepalive_failures = 0;
+ static constexpr uint8_t MAX_KEEPALIVE_FAILURES = 3;
+
// BLE connection handle (platform-specific)
uint16_t conn_handle = 0xFFFF;
diff --git a/src/BLE/BLEPlatform.h b/src/BLE/BLEPlatform.h
index 97459999..db03b6f9 100644
--- a/src/BLE/BLEPlatform.h
+++ b/src/BLE/BLEPlatform.h
@@ -190,6 +190,15 @@ class IBLEPlatform {
*/
virtual bool write(uint16_t conn_handle, const Bytes& data, bool response = true) = 0;
+ /**
+ * @brief Write to a specific characteristic by handle
+ */
+ virtual bool writeCharacteristic(uint16_t conn_handle, uint16_t char_handle,
+ const Bytes& data, bool response = true) {
+ (void)char_handle;
+ return write(conn_handle, data, response);
+ }
+
/**
* @brief Read from a characteristic
*
diff --git a/src/BLE/BLEReassembler.cpp b/src/BLE/BLEReassembler.cpp
index 2b2148a3..15efdcf5 100644
--- a/src/BLE/BLEReassembler.cpp
+++ b/src/BLE/BLEReassembler.cpp
@@ -95,6 +95,16 @@ bool BLEReassembler::processFragment(const Bytes& peer_identity, const Bytes& fr
double now = Utilities::OS::time();
+ // Handle LONE fragment - single complete message, no reassembly needed
+ if (type == Fragment::LONE) {
+ Bytes payload = BLEFragmenter::extractPayload(fragment);
+ TRACE("BLEReassembler: LONE fragment, delivering immediately");
+ if (_reassembly_callback) {
+ _reassembly_callback(peer_identity, payload);
+ }
+ return true;
+ }
+
// Handle START fragment - begins a new reassembly
if (type == Fragment::START) {
// Clear any existing incomplete reassembly for this peer
diff --git a/src/BLE/BLETypes.h b/src/BLE/BLETypes.h
index ecfad9fc..3e8336da 100644
--- a/src/BLE/BLETypes.h
+++ b/src/BLE/BLETypes.h
@@ -52,10 +52,10 @@ namespace UUID {
//=============================================================================
namespace MTU {
+ static constexpr uint16_t ATT_OVERHEAD = 3; // ATT protocol header overhead
static constexpr uint16_t REQUESTED = 517; // Request maximum MTU (BLE 5.0)
static constexpr uint16_t MINIMUM = 23; // BLE 4.0 minimum MTU
- static constexpr uint16_t INITIAL = 185; // Conservative default (BLE 4.2)
- static constexpr uint16_t ATT_OVERHEAD = 3; // ATT protocol header overhead
+ static constexpr uint16_t INITIAL = 185 - ATT_OVERHEAD; // Conservative default (BLE 4.2), minus ATT overhead
}
//=============================================================================
@@ -66,11 +66,13 @@ namespace Timing {
static constexpr double KEEPALIVE_INTERVAL = 15.0; // Seconds between keepalives
static constexpr double REASSEMBLY_TIMEOUT = 30.0; // Seconds to complete reassembly
static constexpr double CONNECTION_TIMEOUT = 30.0; // Seconds to establish connection
- static constexpr double HANDSHAKE_TIMEOUT = 10.0; // Seconds for identity exchange
+ static constexpr double HANDSHAKE_TIMEOUT = 30.0; // Seconds for identity exchange (match Columba)
static constexpr double SCAN_INTERVAL = 5.0; // Seconds between scans
static constexpr double PEER_TIMEOUT = 30.0; // Seconds before peer removal
static constexpr double POST_MTU_DELAY = 0.15; // Seconds after MTU negotiation
static constexpr double BLACKLIST_BASE_BACKOFF = 60.0; // Base backoff seconds
+ static constexpr double ZOMBIE_TIMEOUT = 45.0; // Seconds with no activity before force-disconnect
+ static constexpr double ADVERTISING_REFRESH_INTERVAL = 60.0; // Seconds between advertising refreshes
}
//=============================================================================
@@ -111,9 +113,10 @@ namespace Fragment {
static constexpr size_t HEADER_SIZE = 5;
enum Type : uint8_t {
+ LONE = 0x00, // Single fragment (complete message)
START = 0x01, // First fragment of multi-fragment message
CONTINUE = 0x02, // Middle fragment
- END = 0x03 // Last fragment (or single fragment)
+ END = 0x03 // Last fragment
};
}
diff --git a/src/Bytes.h b/src/Bytes.h
index 1ed0fa2a..dde7b51d 100644
--- a/src/Bytes.h
+++ b/src/Bytes.h
@@ -97,14 +97,12 @@ MEM("Creating from data-move...");
MEMF("Bytes object created from data-move \"%s\", this: %lu, data: %lu", toString().c_str(), this, _data.get());
}
// Construct from std::vector with standard allocator (for MsgPack interop)
- // Only needed on Arduino where Data uses PSRAMAllocator (different from std::allocator)
-#ifdef ARDUINO
+ // Needed because Data uses PSRAMAllocator (different from std::allocator)
Bytes(const std::vector& stdvec) {
MEM("Creating from std::vector...");
assign(stdvec.data(), stdvec.size());
MEMF("Bytes object created from std::vector \"%s\", this: %lu, data: %lu", toString().c_str(), this, _data.get());
}
-#endif
Bytes(const uint8_t* chunk, size_t size) {
assign(chunk, size);
MEMF("Bytes object created from chunk \"%s\", this: %lu, data: %lu", toString().c_str(), this, _data.get());
diff --git a/src/BytesPool.h b/src/BytesPool.h
index b05767d3..4ff7be6f 100644
--- a/src/BytesPool.h
+++ b/src/BytesPool.h
@@ -14,10 +14,13 @@
* of destroying it.
*
* The pool has four tiers sized for Reticulum packet processing:
- * - 64 bytes (512 slots): hashes (16-32 bytes), small fields - highest traffic
- * - 256 bytes (24 slots): keys, small announces
- * - 512 bytes (16 slots): standard packets (MTU=500 + margin)
- * - 1024 bytes (16 slots): resource advertisements, large packets
+ * - 64 bytes (1024 slots): hashes (16-32 bytes), small fields - highest traffic
+ * - 256 bytes (16 slots): keys, small announces
+ * - 512 bytes (12 slots): standard packets (MTU=500 + margin)
+ * - 1024 bytes (12 slots): resource advertisements, large packets
+ *
+ * All storage arrays are dynamically allocated in PSRAM on ESP32 to avoid
+ * consuming internal RAM (BSS). Only the pointers (~32 bytes) stay in BSS.
*
* Thread-safe via FreeRTOS spinlock (ESP32) or std::mutex (native).
*
@@ -44,6 +47,7 @@
#if defined(ESP_PLATFORM) || defined(ARDUINO)
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
+#include
#define BYTESPOOL_USE_SPINLOCK 1
#else
// Native build - use std::mutex instead
@@ -61,13 +65,15 @@ namespace BytesPoolConfig {
static constexpr size_t TIER_MEDIUM = 512; // Standard packets
static constexpr size_t TIER_LARGE = 1024; // Large packets, resource ads
- // Slot counts per tier - tuned from runtime observation (2026-01-24)
- // NOTE: Each slot uses ~16 bytes internal RAM for vector metadata + stack pointer
- // 512 slots = ~8KB internal RAM overhead (conservative for busy networks)
- static constexpr size_t TINY_SLOTS = 512; // High traffic tier (balanced for memory)
- static constexpr size_t SMALL_SLOTS = 8; // Low traffic (peak 1)
- static constexpr size_t MEDIUM_SLOTS = 8; // Rare (packets, keep headroom)
- static constexpr size_t LARGE_SLOTS = 8; // Rare (resource ads, keep headroom)
+ // Slot counts per tier — tuned 2026-02-19
+ // Storage arrays now live in PSRAM, so internal RAM cost is only pointers (~32B).
+ // Tiny tier for transient packet processing only. Known destinations now use
+ // fixed buffers (zero pool slots). 1024 provides ample headroom for packet
+ // hashes, Transport tables, and burst announce processing.
+ static constexpr size_t TINY_SLOTS = 1024; // Transient packet processing (hashes, keys, fields)
+ static constexpr size_t SMALL_SLOTS = 16; // Keys, small announces
+ static constexpr size_t MEDIUM_SLOTS = 12; // Standard packets
+ static constexpr size_t LARGE_SLOTS = 12; // Resource ads, large packets
// Tier identifiers for deleter
enum Tier : uint8_t {
@@ -93,12 +99,12 @@ using PooledData = std::vector>;
* - Repeated capacity reservation allocations
* - shared_ptr control block allocations (via make_shared replacement)
*
- * Memory footprint (tuned 2026-01-24):
- * - Tiny: 512 slots x 64 bytes = 32KB backing + ~8KB metadata (internal RAM)
- * - Small: 8 slots x 256 bytes = 2KB backing + ~128B metadata
- * - Medium: 8 slots x 512 bytes = 4KB backing + ~128B metadata
- * - Large: 8 slots x 1024 bytes = 8KB backing + ~128B metadata
- * - Total: ~46KB PSRAM backing + ~8.5KB internal RAM metadata
+ * Memory footprint (tuned 2026-02-19, all storage in PSRAM):
+ * - Tiny: 1024 slots x 64 bytes = 64KB backing + ~16KB metadata (PSRAM)
+ * - Small: 16 slots x 256 bytes = 4KB backing + ~256B metadata (PSRAM)
+ * - Medium: 12 slots x 512 bytes = 6KB backing + ~192B metadata (PSRAM)
+ * - Large: 12 slots x 1024 bytes = 12KB backing + ~192B metadata (PSRAM)
+ * - Total: ~103KB PSRAM, ~32 bytes internal RAM (pointers only)
*/
class BytesPool {
public:
@@ -272,8 +278,7 @@ class BytesPool {
#if BYTESPOOL_USE_SPINLOCK
portMUX_INITIALIZE(&_mux);
#endif
- // Pre-allocate all pool entries
- // This is done at construction (startup) to front-load allocations
+ allocateStorage();
initializeTier(_tiny_storage, _tiny_stack, _tiny_count,
BytesPoolConfig::TIER_TINY, BytesPoolConfig::TINY_SLOTS);
initializeTier(_small_storage, _small_stack, _small_count,
@@ -288,11 +293,54 @@ class BytesPool {
BytesPool(const BytesPool&) = delete;
BytesPool& operator=(const BytesPool&) = delete;
+ // Allocate storage and stack arrays in PSRAM (ESP32) or heap (native)
+ void allocateStorage() {
+#if BYTESPOOL_USE_SPINLOCK
+ // ESP32: allocate in PSRAM to avoid consuming internal RAM (BSS)
+ #define POOL_ALLOC(ptr, type, count) do { \
+ ptr = static_cast(heap_caps_aligned_alloc( \
+ alignof(type), (count) * sizeof(type), \
+ MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); \
+ if (!ptr) { \
+ ptr = static_cast(heap_caps_aligned_alloc( \
+ alignof(type), (count) * sizeof(type), \
+ MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); \
+ if (ptr) WARNING("BytesPool: " #ptr " fell back to internal RAM"); \
+ } \
+ } while(0)
+
+ POOL_ALLOC(_tiny_storage, PooledData, BytesPoolConfig::TINY_SLOTS);
+ POOL_ALLOC(_tiny_stack, PooledData*, BytesPoolConfig::TINY_SLOTS);
+ POOL_ALLOC(_small_storage, PooledData, BytesPoolConfig::SMALL_SLOTS);
+ POOL_ALLOC(_small_stack, PooledData*, BytesPoolConfig::SMALL_SLOTS);
+ POOL_ALLOC(_medium_storage, PooledData, BytesPoolConfig::MEDIUM_SLOTS);
+ POOL_ALLOC(_medium_stack, PooledData*, BytesPoolConfig::MEDIUM_SLOTS);
+ POOL_ALLOC(_large_storage, PooledData, BytesPoolConfig::LARGE_SLOTS);
+ POOL_ALLOC(_large_stack, PooledData*, BytesPoolConfig::LARGE_SLOTS);
+ #undef POOL_ALLOC
+#else
+ // Native: use new[]
+ _tiny_storage = new PooledData[BytesPoolConfig::TINY_SLOTS];
+ _tiny_stack = new PooledData*[BytesPoolConfig::TINY_SLOTS];
+ _small_storage = new PooledData[BytesPoolConfig::SMALL_SLOTS];
+ _small_stack = new PooledData*[BytesPoolConfig::SMALL_SLOTS];
+ _medium_storage = new PooledData[BytesPoolConfig::MEDIUM_SLOTS];
+ _medium_stack = new PooledData*[BytesPoolConfig::MEDIUM_SLOTS];
+ _large_storage = new PooledData[BytesPoolConfig::LARGE_SLOTS];
+ _large_stack = new PooledData*[BytesPoolConfig::LARGE_SLOTS];
+#endif
+ }
+
// Initialize a tier with pre-allocated vectors
void initializeTier(PooledData* storage, PooledData** stack, size_t& count,
size_t capacity, size_t slots) {
+ if (!storage || !stack) {
+ ERROR("BytesPool: allocation failed for tier, pool will be undersized");
+ count = 0;
+ return;
+ }
for (size_t i = 0; i < slots; i++) {
- // Placement new to construct in storage array
+ // Placement new to construct in allocated storage
new (&storage[i]) PooledData();
storage[i].reserve(capacity);
stack[i] = &storage[i];
@@ -300,17 +348,17 @@ class BytesPool {
count = slots;
}
- // Storage for pooled vectors (fixed arrays avoid dynamic allocation)
- PooledData _tiny_storage[BytesPoolConfig::TINY_SLOTS];
- PooledData _small_storage[BytesPoolConfig::SMALL_SLOTS];
- PooledData _medium_storage[BytesPoolConfig::MEDIUM_SLOTS];
- PooledData _large_storage[BytesPoolConfig::LARGE_SLOTS];
-
- // Stacks of available vectors (indices into storage arrays)
- PooledData* _tiny_stack[BytesPoolConfig::TINY_SLOTS];
- PooledData* _small_stack[BytesPoolConfig::SMALL_SLOTS];
- PooledData* _medium_stack[BytesPoolConfig::MEDIUM_SLOTS];
- PooledData* _large_stack[BytesPoolConfig::LARGE_SLOTS];
+ // Storage for pooled vectors — dynamically allocated (PSRAM on ESP32)
+ PooledData* _tiny_storage = nullptr;
+ PooledData* _small_storage = nullptr;
+ PooledData* _medium_storage = nullptr;
+ PooledData* _large_storage = nullptr;
+
+ // Stacks of available vectors (pointers into storage arrays)
+ PooledData** _tiny_stack = nullptr;
+ PooledData** _small_stack = nullptr;
+ PooledData** _medium_stack = nullptr;
+ PooledData** _large_stack = nullptr;
// Stack counts (how many available in each tier)
size_t _tiny_count = 0;
diff --git a/src/Identity.cpp b/src/Identity.cpp
index e44896cc..ebfa9cf0 100644
--- a/src/Identity.cpp
+++ b/src/Identity.cpp
@@ -27,6 +27,9 @@ using namespace RNS::Utilities;
// Pool for known destinations - allocated in PSRAM to free ~29KB internal RAM
/*static*/ Identity::KnownDestinationSlot* Identity::_known_destinations_pool = nullptr;
/*static*/ bool Identity::_saving_known_destinations = false;
+/*static*/ void (*Identity::_persist_yield_callback)() = nullptr;
+/*static*/ bool Identity::_known_destinations_dirty = false;
+/*static*/ double Identity::_known_destinations_dirty_since = 0;
/*static*/ uint16_t Identity::_known_destinations_maxsize = 2048; // Matches KNOWN_DESTINATIONS_SIZE
// Initialize known destinations pool in PSRAM
@@ -316,16 +319,19 @@ Can be used to load previously created and saved identities into Reticulum.
slot->set_hash(destination_hash);
slot->entry = IdentityEntry(OS::time(), packet_hash, public_key, app_data);
should_save = true;
- } else if (app_data && app_data.size() > 0 && slot->entry._app_data != app_data) {
+ } else if (app_data && app_data.size() > 0 && !slot->entry.app_data_equals(app_data)) {
// Update existing with new app_data
- slot->entry._app_data = app_data;
+ slot->entry.set_app_data(app_data);
slot->entry._timestamp = OS::time();
should_save = true;
}
- // Persist to storage if changed
+ // Mark dirty — actual save deferred to periodic persist_data() call
if (should_save) {
- save_known_destinations();
+ if (!_known_destinations_dirty) {
+ _known_destinations_dirty_since = OS::time();
+ }
+ _known_destinations_dirty = true;
}
}
}
@@ -344,7 +350,7 @@ Recall identity for a destination hash.
const IdentityEntry& identity_data = slot->entry;
Identity identity(false);
identity.load_public_key(identity_data.public_key_bytes());
- identity.app_data(identity_data._app_data);
+ identity.app_data(identity_data.app_data_bytes());
return identity;
}
else {
@@ -374,7 +380,7 @@ Recall last heard app_data for a destination hash.
if (slot != nullptr) {
TRACE("Identity::recall_app_data: Found identity entry for destination " + destination_hash.toHex());
const IdentityEntry& identity_data = slot->entry;
- return identity_data._app_data;
+ return identity_data.app_data_bytes();
}
else {
TRACE("Identity::recall_app_data: Unable to find identity entry for destination " + destination_hash.toHex());
@@ -408,13 +414,20 @@ Recall last heard app_data for a destination hash.
double save_start = OS::time();
- size_t dest_count = known_destinations_count();
- DEBUG("Saving " + std::to_string(dest_count) + " known destinations to storage (binary)...");
+ // Only save persistent destinations (contacts with message history).
+ // Network announces stay in RAM but don't need to survive reboots.
+ size_t persist_count = persistent_destinations_count();
+ size_t total_count = known_destinations_count();
+ INFO("Saving " + std::to_string(persist_count) + " persistent destinations (" +
+ std::to_string(total_count) + " total in pool) to storage...");
+
+ // Atomic save: write to temp file first, then rename.
+ // If a crash occurs mid-write, the original file is intact.
+ const char* temp_path = "/known_dst.tmp";
- // Open file for writing using OS filesystem abstraction
- FileStream file = OS::open_file(storage_path, FileStream::MODE_WRITE);
+ FileStream file = OS::open_file(temp_path, FileStream::MODE_WRITE);
if (!file) {
- ERROR("Failed to open known destinations file for writing");
+ ERROR("Failed to open temp file for writing known destinations");
_saving_known_destinations = false;
return false;
}
@@ -422,15 +435,17 @@ Recall last heard app_data for a destination hash.
// Write header: magic (4) + version (1) + count (2) = 7 bytes
const uint8_t magic[4] = {'K', 'D', 'S', 'T'};
const uint8_t version = 1;
- uint16_t count = static_cast(dest_count);
+ uint16_t count = static_cast(persist_count);
file.write(magic, 4);
file.write(&version, 1);
file.write((const uint8_t*)&count, sizeof(uint16_t));
- // Write each entry directly from fixed arrays - no heap allocation!
+ // Write each persistent entry directly from fixed arrays - no heap allocation!
+ size_t entries_written = 0;
for (size_t i = 0; i < KNOWN_DESTINATIONS_SIZE; ++i) {
if (!_known_destinations_pool[i].in_use) continue;
+ if (!_known_destinations_pool[i].persist) continue; // Skip non-persistent
const KnownDestinationSlot& slot = _known_destinations_pool[i];
// destination_hash: 16 bytes
@@ -442,15 +457,28 @@ Recall last heard app_data for a destination hash.
// public_key: 64 bytes
file.write(slot.entry._public_key, PUBLIC_KEY_SIZE);
// app_data_len: 2 bytes + app_data: variable
- uint16_t app_data_len = static_cast(slot.entry._app_data.size());
+ uint16_t app_data_len = static_cast(slot.entry._app_data_len);
file.write((const uint8_t*)&app_data_len, sizeof(uint16_t));
if (app_data_len > 0) {
- file.write(slot.entry._app_data.data(), app_data_len);
+ file.write(slot.entry._app_data, app_data_len);
+ }
+
+ // Yield periodically to feed watchdog during slow flash I/O
+ if (_persist_yield_callback && (++entries_written % 5 == 0)) {
+ _persist_yield_callback();
}
}
file.close();
+ // Atomic rename: delete old file, rename temp to final path
+ OS::remove_file(storage_path);
+ if (!OS::rename_file(temp_path, storage_path)) {
+ WARNING("Failed to rename temp file to known destinations, trying direct copy");
+ // Fallback: temp file exists with valid data, next boot will find it
+ // or we can try again next persist cycle
+ }
+
std::string time_str;
double save_time = OS::time() - save_start;
if (save_time < 1) {
@@ -460,7 +488,7 @@ Recall last heard app_data for a destination hash.
time_str = std::to_string(OS::round(save_time, 1)) + " s";
}
- DEBUG("Saved " + std::to_string(dest_count) + " known destinations in " + time_str);
+ DEBUG("Saved " + std::to_string(persist_count) + " known destinations in " + time_str);
success = true;
}
@@ -481,6 +509,13 @@ Recall last heard app_data for a destination hash.
// Binary format - much more memory efficient than JSON
const char* storage_path = "/known_dst.bin";
+ const char* temp_path = "/known_dst.tmp";
+
+ // If main file doesn't exist but temp does, a crash interrupted the rename
+ if (!OS::file_exists(storage_path) && OS::file_exists(temp_path)) {
+ INFO("Recovering known destinations from temp file (crash during save)");
+ OS::rename_file(temp_path, storage_path);
+ }
if (!OS::file_exists(storage_path)) {
DEBUG("No known destinations file found, starting fresh");
@@ -502,8 +537,9 @@ Recall last heard app_data for a destination hash.
if (file.readBytes((char*)magic, 4) != 4 ||
magic[0] != 'K' || magic[1] != 'D' || magic[2] != 'S' || magic[3] != 'T') {
- WARNING("Invalid known destinations file magic");
+ WARNING("Invalid known destinations file magic, deleting corrupt file");
file.close();
+ OS::remove_file(storage_path);
return;
}
@@ -519,7 +555,7 @@ Recall last heard app_data for a destination hash.
return;
}
- DEBUG("Loading " + std::to_string(count) + " known destinations from storage (binary)...");
+ INFO("Loading " + std::to_string(count) + " known destinations from storage (binary)...");
size_t loaded_count = 0;
@@ -566,6 +602,7 @@ Recall last heard app_data for a destination hash.
break;
}
slot->in_use = true;
+ slot->persist = true; // Loaded from disk = previously a contact
slot->set_hash(dest_hash);
slot->entry = IdentityEntry(timestamp, packet_hash, public_key, app_data);
loaded_count++;
@@ -574,7 +611,15 @@ Recall last heard app_data for a destination hash.
file.close();
- DEBUG("Loaded " + std::to_string(loaded_count) + " known destinations from storage");
+ INFO("Loaded " + std::to_string(loaded_count) + " known destinations from storage");
+ // Count entries with app_data for debug
+ size_t with_app_data = 0;
+ for (size_t i = 0; i < KNOWN_DESTINATIONS_SIZE; ++i) {
+ if (_known_destinations_pool[i].in_use && _known_destinations_pool[i].entry._app_data_len > 0) {
+ with_app_data++;
+ }
+ }
+ INFO(" " + std::to_string(with_app_data) + " entries have app_data (display names)");
} catch (std::exception& e) {
ERRORF("Error loading known destinations from disk: %s", e.what());
@@ -616,6 +661,12 @@ Recall last heard app_data for a destination hash.
/*static*/ bool Identity::validate_announce(const Packet& packet) {
try {
if (packet.packet_type() == Type::Packet::ANNOUNCE) {
+ size_t min_announce_size = KEYSIZE/8 + NAME_HASH_LENGTH/8 + RANDOM_HASH_LENGTH/8 + SIGLENGTH/8;
+ if (packet.data().size() < min_announce_size) {
+ WARNING("Identity::validate_announce: packet too small (" + std::to_string(packet.data().size()) + " < " + std::to_string(min_announce_size) + "), dropping");
+ return false;
+ }
+
Bytes destination_hash = packet.destination_hash();
//TRACE("Identity::validate_announce: destination_hash: " + packet.destination_hash().toHex());
Bytes public_key = packet.data().left(KEYSIZE/8);
@@ -754,14 +805,62 @@ Recall last heard app_data for a destination hash.
/*static*/ void Identity::persist_data() {
if (!Transport::reticulum() || !Transport::reticulum().is_connected_to_shared_instance()) {
- save_known_destinations();
+ if (_known_destinations_dirty) {
+ INFO("Identity: Persisting " + std::to_string(known_destinations_count()) + " known destinations (dirty flag set)");
+ if (save_known_destinations()) {
+ _known_destinations_dirty = false;
+ _known_destinations_dirty_since = 0;
+ } else {
+ ERROR("Identity: Failed to save known destinations!");
+ }
+ }
+ }
+}
+
+/*static*/ bool Identity::should_persist_data() {
+ // Persist if dirty for more than 60 seconds.
+ // Writing 100+ destinations to SPIFFS takes 20-50s with GC, so frequent
+ // persists cause severe fragmentation and main-loop stalls. 60s gives the
+ // flash controller time to recover between writes. Data survives reboots
+ // because recoverBLEStack() and exit_handler() force an immediate persist.
+ if (_known_destinations_dirty && _known_destinations_dirty_since > 0) {
+ double age = OS::time() - _known_destinations_dirty_since;
+ if (age >= 60.0) {
+ persist_data();
+ return true;
+ }
}
+ return false;
}
/*static*/ void Identity::exit_handler() {
persist_data();
}
+/*static*/ void Identity::mark_persistent(const Bytes& destination_hash) {
+ if (!_known_destinations_pool) return;
+ KnownDestinationSlot* slot = find_known_destination_slot(destination_hash);
+ if (slot && !slot->persist) {
+ slot->persist = true;
+ _known_destinations_dirty = true;
+ if (_known_destinations_dirty_since == 0) {
+ _known_destinations_dirty_since = OS::time();
+ }
+ DEBUG("Identity: Marked " + destination_hash.toHex().substr(0, 8) + " as persistent contact");
+ }
+}
+
+/*static*/ size_t Identity::persistent_destinations_count() {
+ if (!_known_destinations_pool) return 0;
+ size_t count = 0;
+ for (size_t i = 0; i < KNOWN_DESTINATIONS_SIZE; ++i) {
+ if (_known_destinations_pool[i].in_use && _known_destinations_pool[i].persist) {
+ count++;
+ }
+ }
+ return count;
+}
+
/*
Encrypts information for the identity.
diff --git a/src/Identity.h b/src/Identity.h
index 843bb358..5194451e 100644
--- a/src/Identity.h
+++ b/src/Identity.h
@@ -26,18 +26,21 @@ namespace RNS {
static constexpr size_t DEST_HASH_SIZE = 16; // RNS truncated hash
static constexpr size_t PACKET_HASH_SIZE = 32; // SHA256 full hash
static constexpr size_t PUBLIC_KEY_SIZE = 64; // Ed25519 + X25519 public keys
+ static constexpr size_t APP_DATA_MAX_SIZE = 128; // Max app_data (display names are 5-30 bytes)
class IdentityEntry {
public:
- IdentityEntry() : _timestamp(0) {
+ IdentityEntry() : _timestamp(0), _app_data_len(0) {
memset(_packet_hash, 0, PACKET_HASH_SIZE);
memset(_public_key, 0, PUBLIC_KEY_SIZE);
+ memset(_app_data, 0, APP_DATA_MAX_SIZE);
}
IdentityEntry(double timestamp, const Bytes& packet_hash, const Bytes& public_key, const Bytes& app_data)
- : _timestamp(timestamp), _app_data(app_data)
+ : _timestamp(timestamp), _app_data_len(0)
{
set_packet_hash(packet_hash);
set_public_key(public_key);
+ set_app_data(app_data);
}
// Helper methods for fixed array access
@@ -54,19 +57,40 @@ namespace RNS {
if (len < PUBLIC_KEY_SIZE) memset(_public_key + len, 0, PUBLIC_KEY_SIZE - len);
}
+ // app_data access — fixed buffer, zero BytesPool allocations
+ Bytes app_data_bytes() const {
+ if (_app_data_len == 0) return {Bytes::NONE};
+ return Bytes(_app_data, _app_data_len);
+ }
+ void set_app_data(const Bytes& b) {
+ if (!b || b.size() == 0) {
+ _app_data_len = 0;
+ return;
+ }
+ _app_data_len = static_cast(std::min(b.size(), APP_DATA_MAX_SIZE));
+ memcpy(_app_data, b.data(), _app_data_len);
+ }
+ bool app_data_equals(const Bytes& b) const {
+ if ((!b || b.size() == 0) && _app_data_len == 0) return true;
+ if (!b || b.size() != _app_data_len) return false;
+ return memcmp(_app_data, b.data(), _app_data_len) == 0;
+ }
+
public:
double _timestamp = 0;
uint8_t _packet_hash[PACKET_HASH_SIZE]; // Fixed array - no heap alloc
- uint8_t _public_key[PUBLIC_KEY_SIZE]; // Fixed array - no heap alloc
- Bytes _app_data; // Keep as Bytes - variable size, typically small or empty
+ uint8_t _public_key[PUBLIC_KEY_SIZE]; // Fixed array - no heap alloc
+ uint8_t _app_data[APP_DATA_MAX_SIZE]; // Fixed buffer - no BytesPool slot consumed
+ uint8_t _app_data_len = 0;
};
// Fixed pool for known destinations (replaces std::map)
- // Memory: 2048 slots × ~160 bytes = ~320KB in PSRAM
- // Pool allocated in PSRAM. Zero-allocation culling allows larger pools.
+ // Memory: 2048 slots × ~250 bytes = ~500KB in PSRAM (includes 128-byte app_data buffer)
+ // Pool allocated in PSRAM. Zero BytesPool allocations per destination.
static constexpr size_t KNOWN_DESTINATIONS_SIZE = 2048;
struct KnownDestinationSlot {
bool in_use = false;
+ bool persist = false; // Only save to flash if true (contact/conversation peer)
uint8_t destination_hash[DEST_HASH_SIZE]; // Fixed array - no heap alloc
IdentityEntry entry;
@@ -83,6 +107,7 @@ namespace RNS {
}
void clear() {
in_use = false;
+ persist = false;
memset(destination_hash, 0, DEST_HASH_SIZE);
entry = IdentityEntry();
}
@@ -98,6 +123,8 @@ namespace RNS {
static size_t known_destinations_count();
//static std::map _known_destinations;
static bool _saving_known_destinations;
+ static bool _known_destinations_dirty;
+ static double _known_destinations_dirty_since; // When dirty flag was first set
// CBA
static uint16_t _known_destinations_maxsize;
@@ -255,8 +282,22 @@ namespace RNS {
static bool validate_announce(const Packet& packet);
static void persist_data();
+ static bool should_persist_data(); // Persist if dirty for >5s
static void exit_handler();
+ // Yield callback — called periodically during long persistence operations
+ // (e.g., writing 50+ known destinations to flash). Platform code should
+ // set this to feed the watchdog timer and/or yield the CPU.
+ static void (*_persist_yield_callback)();
+ static void set_persist_yield_callback(void (*cb)()) { _persist_yield_callback = cb; }
+
+ // Mark a known destination for persistence. Only persistent destinations
+ // are written to flash — the rest stay in RAM for routing but don't
+ // survive reboots. Call this when a message is sent to or received from
+ // a destination (i.e., it's a real contact, not just a network announce).
+ static void mark_persistent(const Bytes& destination_hash);
+ static size_t persistent_destinations_count();
+
// getters/setters
inline const Bytes& encryptionPrivateKey() const { assert(_object); return _object->_prv_bytes; }
inline const Bytes& signingPrivateKey() const { assert(_object); return _object->_sig_prv_bytes; }
diff --git a/src/Instrumentation/MemoryMonitor.cpp b/src/Instrumentation/MemoryMonitor.cpp
index bf031680..5bd665dc 100644
--- a/src/Instrumentation/MemoryMonitor.cpp
+++ b/src/Instrumentation/MemoryMonitor.cpp
@@ -38,6 +38,7 @@ static size_t _task_count = 0;
// Static member initialization
TimerHandle_t MemoryMonitor::_timer = nullptr;
bool MemoryMonitor::_verbose = false;
+volatile bool MemoryMonitor::_pending = false;
// Static buffer for log formatting (avoid stack allocation in callbacks)
static char _log_buffer[256];
@@ -152,8 +153,9 @@ void MemoryMonitor::logNow() {
}
-void MemoryMonitor::timerCallback(TimerHandle_t timer) {
- (void)timer; // Unused parameter
+void MemoryMonitor::poll() {
+ if (!_pending) return;
+ _pending = false;
logHeapStats();
if (_task_count > 0) {
@@ -162,6 +164,14 @@ void MemoryMonitor::timerCallback(TimerHandle_t timer) {
}
+void MemoryMonitor::timerCallback(TimerHandle_t timer) {
+ (void)timer;
+ // Only set flag — heavy logging is done in poll() on the main loop stack
+ // to avoid overflowing the small FreeRTOS timer task stack (3120 bytes)
+ _pending = true;
+}
+
+
void MemoryMonitor::logHeapStats() {
// Internal RAM statistics (critical for stability)
size_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
diff --git a/src/Instrumentation/MemoryMonitor.h b/src/Instrumentation/MemoryMonitor.h
index 44578ea3..ed1918ce 100644
--- a/src/Instrumentation/MemoryMonitor.h
+++ b/src/Instrumentation/MemoryMonitor.h
@@ -91,11 +91,21 @@ class MemoryMonitor {
*/
static void logNow();
+ /**
+ * Poll for pending log output (call from main loop)
+ *
+ * The timer callback only sets a flag; this method does the actual
+ * logging on the caller's stack (main loop) to avoid overflowing
+ * the small FreeRTOS timer task stack.
+ */
+ static void poll();
+
private:
/**
* FreeRTOS timer callback
*
* Called by the timer daemon task at each interval.
+ * Only sets _pending flag — actual work is done in poll().
*/
static void timerCallback(TimerHandle_t timer);
@@ -112,6 +122,7 @@ class MemoryMonitor {
// Static members
static TimerHandle_t _timer;
static bool _verbose;
+ static volatile bool _pending;
};
}} // namespace RNS::Instrumentation
@@ -121,6 +132,7 @@ class MemoryMonitor {
#define MEMORY_MONITOR_REGISTER_TASK(handle, name) RNS::Instrumentation::MemoryMonitor::registerTask(handle, name)
#define MEMORY_MONITOR_UNREGISTER_TASK(handle) RNS::Instrumentation::MemoryMonitor::unregisterTask(handle)
#define MEMORY_MONITOR_LOG_NOW() RNS::Instrumentation::MemoryMonitor::logNow()
+#define MEMORY_MONITOR_POLL() RNS::Instrumentation::MemoryMonitor::poll()
#define MEMORY_MONITOR_STOP() RNS::Instrumentation::MemoryMonitor::stop()
#else // MEMORY_INSTRUMENTATION_ENABLED not defined
@@ -130,6 +142,7 @@ class MemoryMonitor {
#define MEMORY_MONITOR_REGISTER_TASK(handle, name) ((void)0)
#define MEMORY_MONITOR_UNREGISTER_TASK(handle) ((void)0)
#define MEMORY_MONITOR_LOG_NOW() ((void)0)
+#define MEMORY_MONITOR_POLL() ((void)0)
#define MEMORY_MONITOR_STOP() ((void)0)
#endif // MEMORY_INSTRUMENTATION_ENABLED
diff --git a/src/LXMF/LXMRouter.cpp b/src/LXMF/LXMRouter.cpp
index 2b4ab8a5..e9255676 100644
--- a/src/LXMF/LXMRouter.cpp
+++ b/src/LXMF/LXMRouter.cpp
@@ -1,5 +1,4 @@
#include "LXMRouter.h"
-#include "PropagationNodeManager.h"
#include "../Log.h"
#include "../Utilities/OS.h"
#include "../Packet.h"
@@ -8,6 +7,12 @@
#include
+#ifdef ARDUINO
+#include
+#include
+#include
+#endif
+
using namespace LXMF;
using namespace RNS;
@@ -56,8 +61,9 @@ static RouterRegistrySlot* find_empty_router_registry_slot() {
return nullptr;
}
-// Outbound resources fixed pool (zero heap fragmentation)
-// Fixed arrays eliminate ~0.8KB Bytes metadata overhead (16 slots × 2 Bytes × 24 bytes)
+// Outbound DIRECT delivery resources fixed pool (zero heap fragmentation)
+// Tracks resource transfers for DIRECT delivery via links to recipients.
+// Separate from PropResourceSlot (in header) which tracks PROPAGATED delivery resources.
static constexpr size_t OUTBOUND_RESOURCES_SIZE = 16;
static constexpr size_t OUTBOUND_HASH_SIZE = 32; // SHA256 hash size
struct OutboundResourceSlot {
@@ -106,6 +112,101 @@ static OutboundResourceSlot* find_empty_outbound_resource_slot() {
return nullptr;
}
+namespace {
+const char* method_name(LXMF::Type::Message::Method method) {
+ switch (method) {
+ case LXMF::Type::Message::OPPORTUNISTIC: return "OPPORTUNISTIC";
+ case LXMF::Type::Message::DIRECT: return "DIRECT";
+ case LXMF::Type::Message::PROPAGATED: return "PROPAGATED";
+ case LXMF::Type::Message::PAPER: return "PAPER";
+ default: return "UNKNOWN";
+ }
+}
+
+const char* state_name(LXMF::Type::Message::State state) {
+ switch (state) {
+ case LXMF::Type::Message::GENERATING: return "GENERATING";
+ case LXMF::Type::Message::OUTBOUND: return "OUTBOUND";
+ case LXMF::Type::Message::SENDING: return "SENDING";
+ case LXMF::Type::Message::SENT: return "SENT";
+ case LXMF::Type::Message::DELIVERED: return "DELIVERED";
+ case LXMF::Type::Message::REJECTED: return "REJECTED";
+ case LXMF::Type::Message::CANCELLED: return "CANCELLED";
+ case LXMF::Type::Message::FAILED: return "FAILED";
+ default: return "UNKNOWN";
+ }
+}
+
+void log_state_transition(LXMessage& message, LXMF::Type::Message::State new_state, const std::string& reason) {
+ LXMF::Type::Message::State old_state = message.state();
+ if (old_state != new_state) {
+ std::string msg = "LXMF state " + std::string(state_name(old_state)) +
+ " -> " + state_name(new_state) +
+ " for " + message.hash().toHex().substr(0, 16) + "...";
+ if (!reason.empty()) {
+ msg += " (" + reason + ")";
+ }
+ if (new_state == LXMF::Type::Message::FAILED) {
+ WARNING(msg);
+ } else {
+ INFO(msg);
+ }
+ } else if (!reason.empty()) {
+ DEBUG("LXMF state " + std::string(state_name(new_state)) + " unchanged (" + reason + ")");
+ }
+ message.state(new_state);
+}
+
+#ifdef ARDUINO
+struct PropagationStampJob {
+ volatile bool queued = false;
+ volatile bool running = false;
+ volatile bool completed = false;
+ volatile bool failed = false;
+ uint8_t target_cost = 0;
+ LXMF::LXMRouter* router = nullptr;
+ LXMF::LXMessage* message = nullptr;
+ uint8_t message_hash[32] = {0};
+};
+
+static PropagationStampJob _propagation_stamp_job;
+static TaskHandle_t _propagation_stamp_worker = nullptr;
+
+static bool stamp_job_matches_hash(const Bytes& hash) {
+ return hash.size() == sizeof(_propagation_stamp_job.message_hash) &&
+ memcmp(_propagation_stamp_job.message_hash, hash.data(), sizeof(_propagation_stamp_job.message_hash)) == 0;
+}
+
+static void propagation_stamp_worker_task(void* param) {
+ (void)param;
+ esp_task_wdt_add(NULL);
+
+ for (;;) {
+ if (_propagation_stamp_job.queued && !_propagation_stamp_job.running) {
+ _propagation_stamp_job.running = true;
+ _propagation_stamp_job.completed = false;
+ _propagation_stamp_job.failed = false;
+
+ LXMF::LXMessage* message = _propagation_stamp_job.message;
+ uint8_t target_cost = _propagation_stamp_job.target_cost;
+ Bytes stamp;
+
+ if (message) {
+ stamp = message->generate_propagation_stamp(target_cost);
+ }
+
+ _propagation_stamp_job.failed = (message == nullptr || stamp.size() == 0);
+ _propagation_stamp_job.completed = !_propagation_stamp_job.failed;
+ _propagation_stamp_job.queued = false;
+ _propagation_stamp_job.running = false;
+ }
+
+ vTaskDelay(pdMS_TO_TICKS(2));
+ }
+}
+#endif
+} // namespace
+
// Static pending proofs pool definition
LXMRouter::PendingProofSlot LXMRouter::_pending_proofs_pool[LXMRouter::PENDING_PROOFS_SIZE];
@@ -207,6 +308,16 @@ static void static_packet_callback(const Bytes& data, const Packet& packet) {
}
}
+// Static packet callback for link (uses link destination hash for router lookup)
+static void static_link_packet_callback(const Bytes& data, const Packet& packet) {
+ // For link packets, destination_hash() is the link_id, not the delivery destination.
+ // Look up the router via the link's destination hash instead.
+ RouterRegistrySlot* slot = find_router_registry_slot(packet.link().destination().hash());
+ if (slot) {
+ slot->router->on_packet(data, packet);
+ }
+}
+
// Static link callbacks
static void static_link_established_callback(Link& link) {
// Find router that owns this link destination
@@ -285,52 +396,24 @@ static void static_outbound_resource_concluded(const Resource& resource) {
// Static proof callback - called when delivery proof is received
void LXMRouter::static_proof_callback(const PacketReceipt& receipt) {
- DEBUG(">>> PROOF CALLBACK ENTRY");
-#ifdef ARDUINO
- Serial.flush();
-#endif
-
- // Get packet hash from receipt
- DEBUG(">>> Getting packet hash from receipt");
-#ifdef ARDUINO
- Serial.flush();
-#endif
Bytes packet_hash = receipt.hash();
char buf[128];
- DEBUG(">>> Looking up pending proof slot");
-#ifdef ARDUINO
- Serial.flush();
-#endif
-
// Look up message hash for this packet
PendingProofSlot* slot = find_pending_proof_slot(packet_hash);
if (slot) {
- DEBUG(">>> Found slot, getting message hash");
-#ifdef ARDUINO
- Serial.flush();
-#endif
Bytes message_hash = slot->message_hash_bytes();
snprintf(buf, sizeof(buf), "Delivery proof received for message %.16s...", message_hash.toHex().c_str());
INFO(buf);
-#ifdef ARDUINO
- Serial.flush();
-#endif
// Track notified routers to avoid duplicates (max ROUTER_REGISTRY_SIZE)
LXMRouter* notified_routers[ROUTER_REGISTRY_SIZE];
size_t notified_count = 0;
- DEBUG(">>> Iterating router registry");
-#ifdef ARDUINO
- Serial.flush();
-#endif
-
// Find the router that sent this message and call its delivered callback
for (size_t i = 0; i < ROUTER_REGISTRY_SIZE; i++) {
if (_router_registry_pool[i].in_use) {
LXMRouter* router = _router_registry_pool[i].router;
- // Check if already notified
bool already_notified = false;
for (size_t j = 0; j < notified_count; j++) {
if (notified_routers[j] == router) {
@@ -339,48 +422,22 @@ void LXMRouter::static_proof_callback(const PacketReceipt& receipt) {
}
}
if (!already_notified && router && router->_delivered_callback) {
- DEBUGF(">>> Calling delivered callback for router %zu", i);
-#ifdef ARDUINO
- Serial.flush();
-#endif
notified_routers[notified_count++] = router;
// Create a minimal message with just the hash for the callback
- // The callback can look up full message from storage if needed
Bytes empty_hash;
LXMessage msg(empty_hash, empty_hash);
msg.hash(message_hash);
msg.state(Type::Message::DELIVERED);
- DEBUG(">>> About to invoke callback");
-#ifdef ARDUINO
- Serial.flush();
-#endif
router->_delivered_callback(msg);
- DEBUG(">>> Callback returned");
-#ifdef ARDUINO
- Serial.flush();
-#endif
}
}
}
- // Remove from pending proofs
- DEBUG(">>> Clearing slot");
-#ifdef ARDUINO
- Serial.flush();
-#endif
slot->clear();
- DEBUG(">>> Slot cleared");
-#ifdef ARDUINO
- Serial.flush();
-#endif
} else {
snprintf(buf, sizeof(buf), "Received proof for unknown packet: %.16s...", packet_hash.toHex().c_str());
DEBUG(buf);
}
- DEBUG(">>> PROOF CALLBACK EXIT");
-#ifdef ARDUINO
- Serial.flush();
-#endif
}
// Constructor
@@ -469,6 +526,16 @@ LXMessage* LXMRouter::pending_outbound_front() {
return &_pending_outbound_pool[_pending_outbound_tail];
}
+LXMessage* LXMRouter::pending_outbound_find(const Bytes& hash) {
+ for (size_t i = 0; i < _pending_outbound_count; i++) {
+ size_t idx = (_pending_outbound_tail + i) % PENDING_OUTBOUND_SIZE;
+ if (_pending_outbound_pool[idx].hash() == hash) {
+ return &_pending_outbound_pool[idx];
+ }
+ }
+ return nullptr;
+}
+
bool LXMRouter::pending_outbound_pop(LXMessage& msg) {
if (_pending_outbound_count == 0) return false;
msg = _pending_outbound_pool[_pending_outbound_tail];
@@ -529,6 +596,223 @@ bool LXMRouter::failed_outbound_pop(LXMessage& msg) {
return true;
}
+LXMRouter::OutboundContextSlot* LXMRouter::find_outbound_context_slot(const Bytes& hash) {
+ for (size_t i = 0; i < OUTBOUND_CONTEXTS_SIZE; i++) {
+ if (_outbound_contexts_pool[i].in_use && _outbound_contexts_pool[i].message_hash_equals(hash)) {
+ return &_outbound_contexts_pool[i];
+ }
+ }
+ return nullptr;
+}
+
+LXMRouter::OutboundContextSlot* LXMRouter::find_empty_outbound_context_slot() {
+ for (size_t i = 0; i < OUTBOUND_CONTEXTS_SIZE; i++) {
+ if (!_outbound_contexts_pool[i].in_use) {
+ return &_outbound_contexts_pool[i];
+ }
+ }
+ return nullptr;
+}
+
+LXMRouter::OutboundContextSlot* LXMRouter::get_or_create_outbound_context(const Bytes& hash) {
+ OutboundContextSlot* slot = find_outbound_context_slot(hash);
+ if (slot) {
+ return slot;
+ }
+
+ slot = find_empty_outbound_context_slot();
+ if (!slot) {
+ return nullptr;
+ }
+
+ slot->in_use = true;
+ slot->set_message_hash(hash);
+ return slot;
+}
+
+void LXMRouter::clear_outbound_context(const Bytes& hash) {
+ OutboundContextSlot* slot = find_outbound_context_slot(hash);
+ if (slot) {
+ slot->clear();
+ }
+
+#ifdef ARDUINO
+ if ((_propagation_stamp_job.queued || _propagation_stamp_job.running) && stamp_job_matches_hash(hash)) {
+ if (!_propagation_stamp_job.running) {
+ _propagation_stamp_job.queued = false;
+ _propagation_stamp_job.completed = false;
+ _propagation_stamp_job.failed = false;
+ _propagation_stamp_job.message = nullptr;
+ _propagation_stamp_job.router = nullptr;
+ memset(_propagation_stamp_job.message_hash, 0, sizeof(_propagation_stamp_job.message_hash));
+ }
+ }
+#endif
+}
+
+void LXMRouter::schedule_outbound_retry(LXMessage& message, double delay, OutboundStage stage, const std::string& reason) {
+ OutboundContextSlot* slot = get_or_create_outbound_context(message.hash());
+ if (slot) {
+ slot->next_attempt_time = Utilities::OS::time() + delay;
+ slot->stage = stage;
+ }
+ if (message.state() != Type::Message::OUTBOUND) {
+ log_state_transition(message, Type::Message::OUTBOUND, reason);
+ } else {
+ INFO(reason);
+ }
+}
+
+void LXMRouter::update_pending_message_state(const Bytes& message_hash, Type::Message::State state, const std::string& reason) {
+ LXMessage* message = pending_outbound_find(message_hash);
+ if (!message) {
+ return;
+ }
+
+ log_state_transition(*message, state, reason);
+ if (state == Type::Message::DELIVERED || state == Type::Message::SENT || state == Type::Message::FAILED) {
+ OutboundContextSlot* slot = find_outbound_context_slot(message_hash);
+ if (slot) {
+ slot->next_attempt_time = 0.0;
+ slot->stage = OutboundStage::NONE;
+ slot->propagation_stamp_pending = false;
+ slot->propagation_stamp_failed = false;
+ }
+ }
+}
+
+void LXMRouter::notify_sent_for_message_hash(const Bytes& message_hash, Type::Message::Method method) {
+ if (_sent_callback) {
+ Bytes empty_hash;
+ LXMessage msg(empty_hash, empty_hash);
+ msg.hash(message_hash);
+ msg.state(Type::Message::SENT);
+ msg.set_method(method);
+ _sent_callback(msg);
+ }
+}
+
+void LXMRouter::notify_delivered_for_message_hash(const Bytes& message_hash) {
+ if (_delivered_callback) {
+ Bytes empty_hash;
+ LXMessage msg(empty_hash, empty_hash);
+ msg.hash(message_hash);
+ msg.state(Type::Message::DELIVERED);
+ _delivered_callback(msg);
+ }
+}
+
+void LXMRouter::notify_failed_for_message_hash(const Bytes& message_hash) {
+ if (_failed_callback) {
+ Bytes empty_hash;
+ LXMessage msg(empty_hash, empty_hash);
+ msg.hash(message_hash);
+ msg.state(Type::Message::FAILED);
+ _failed_callback(msg);
+ }
+}
+
+bool LXMRouter::path_supports_opportunistic(const Bytes& destination_hash, bool avoid_auto_interface_path) const {
+ if (!Transport::has_path(destination_hash)) {
+ return false;
+ }
+
+ Interface next_hop_interface = Transport::next_hop_interface(destination_hash);
+ if (!next_hop_interface) {
+ return true;
+ }
+
+ std::string if_name = next_hop_interface.toString();
+ if (avoid_auto_interface_path && if_name.find("AutoInterface[") != std::string::npos) {
+ DEBUG(" Next hop is AutoInterface; suppressing opportunistic routing for this destination");
+ return false;
+ }
+
+ return true;
+}
+
+bool LXMRouter::ensure_stamp_worker_started() {
+#ifdef ARDUINO
+ if (_propagation_stamp_worker != nullptr) {
+ return true;
+ }
+
+ BaseType_t result = xTaskCreatePinnedToCore(
+ propagation_stamp_worker_task,
+ "lxmf_stamp",
+ 8192,
+ nullptr,
+ 1,
+ &_propagation_stamp_worker,
+ 1
+ );
+ if (result != pdPASS) {
+ ERROR("Failed to start propagation stamp worker task");
+ _propagation_stamp_worker = nullptr;
+ return false;
+ }
+ return true;
+#else
+ return true;
+#endif
+}
+
+bool LXMRouter::queue_propagation_stamp_job(LXMessage& message) {
+ OutboundContextSlot* slot = get_or_create_outbound_context(message.hash());
+ if (!slot) {
+ ERROR("No outbound context slot available for propagation stamp job");
+ return false;
+ }
+
+#ifdef ARDUINO
+ if (!ensure_stamp_worker_started()) {
+ return false;
+ }
+
+ if (_propagation_stamp_job.running || _propagation_stamp_job.queued) {
+ return stamp_job_matches_hash(message.hash());
+ }
+
+ slot->propagation_stamp_pending = true;
+ slot->propagation_stamp_failed = false;
+ slot->stage = OutboundStage::WAITING_PROP_STAMP;
+
+ _propagation_stamp_job.target_cost = _outbound_propagation_stamp_cost;
+ _propagation_stamp_job.router = this;
+ _propagation_stamp_job.message = &message;
+ memcpy(_propagation_stamp_job.message_hash, message.hash().data(), sizeof(_propagation_stamp_job.message_hash));
+ _propagation_stamp_job.failed = false;
+ _propagation_stamp_job.completed = false;
+ _propagation_stamp_job.queued = true;
+
+ INFO(" Queued propagation stamp generation on background worker");
+ return true;
+#else
+ Bytes stamp = message.generate_propagation_stamp(_outbound_propagation_stamp_cost);
+ slot->propagation_stamp_pending = false;
+ slot->propagation_stamp_failed = (stamp.size() == 0);
+ return !slot->propagation_stamp_failed;
+#endif
+}
+
+void LXMRouter::nudge_propagation_sync(const std::string& reason) {
+ if (_outbound_propagation_node.size() == 0) {
+ return;
+ }
+
+ double now = Utilities::OS::time();
+ if (now - _last_propagation_sync_nudge < PROPAGATION_SYNC_NUDGE_INTERVAL) {
+ return;
+ }
+ if (!(_sync_state == PR_IDLE || _sync_state == PR_COMPLETE || _sync_state == PR_FAILED)) {
+ return;
+ }
+
+ _last_propagation_sync_nudge = now;
+ INFO("Nudging propagation sync: " + reason);
+ request_messages_from_propagation_node();
+}
+
// ============== End Circular Buffer Helpers ==============
// Register callbacks
@@ -564,16 +848,16 @@ void LXMRouter::handle_outbound(LXMessage& message) {
// Pack the message
message.pack();
- // Check if message fits in a single packet - use OPPORTUNISTIC if so
- // OPPORTUNISTIC is simpler (no link needed) and works when identity is known
- if (message.packed_size() <= Type::Constants::ENCRYPTED_PACKET_MDU) {
- INFO(" Message fits in single packet, will use OPPORTUNISTIC delivery");
+ // Check if message fits in a single LoRa packet - use OPPORTUNISTIC if so
+ // Use LORA_ENCRYPTED_PACKET_MDU (159) to ensure packet fits within LoRa wire MTU (255)
+ if (message.packed_size() <= Type::Constants::LORA_ENCRYPTED_PACKET_MDU) {
+ INFO(" Message fits in single LoRa packet, will use OPPORTUNISTIC delivery");
} else {
- INFO(" Message too large for single packet, will use DIRECT (link) delivery");
+ INFO(" Message too large for single LoRa packet, will use DIRECT (link) delivery");
}
// Set state to outbound
- message.state(Type::Message::OUTBOUND);
+ log_state_transition(message, Type::Message::OUTBOUND, "queued for outbound routing");
// Add to pending queue
pending_outbound_push(message);
@@ -588,63 +872,207 @@ void LXMRouter::process_outbound() {
return;
}
- // Check backoff timer - don't process if we're in retry delay
double now = Utilities::OS::time();
- if (now < _next_outbound_process_time) {
- return; // Wait until retry delay expires
- }
-
- // Process one message per call to avoid blocking
LXMessage* message_ptr = pending_outbound_front();
if (!message_ptr) return;
LXMessage& message = *message_ptr;
+ OutboundContextSlot* context = get_or_create_outbound_context(message.hash());
char buf[128];
+ if (message.state() == Type::Message::DELIVERED) {
+ INFO("Delivery confirmed, removing message from outbound queue");
+ clear_outbound_context(message.hash());
+ LXMessage dummy;
+ pending_outbound_pop(dummy);
+ return;
+ }
+
+ if (message.method() == Type::Message::PROPAGATED && message.state() == Type::Message::SENT) {
+ INFO("Propagation transfer completed, removing message from outbound queue");
+ clear_outbound_context(message.hash());
+ LXMessage dummy;
+ pending_outbound_pop(dummy);
+ return;
+ }
+
+ if (context && now < context->next_attempt_time) {
+ return;
+ }
+
+ if (context && context->stage == OutboundStage::WAITING_PROP_STAMP && context->propagation_stamp_pending) {
+ return;
+ }
+
snprintf(buf, sizeof(buf), "Processing outbound message to %s", message.destination_hash().toHex().c_str());
DEBUG(buf);
try {
+ auto fail_message = [&](const std::string& reason) {
+ log_state_transition(message, Type::Message::FAILED, reason);
+ clear_outbound_context(message.hash());
+ if (_failed_callback) {
+ _failed_callback(message);
+ }
+ failed_outbound_push(message);
+ LXMessage dummy;
+ pending_outbound_pop(dummy);
+ };
+
+ auto retry_later = [&](double delay, OutboundStage stage, const std::string& reason) {
+ schedule_outbound_retry(message, delay, stage, reason);
+ };
+
+ auto attempt_propagated = [&](const std::string& reason) -> bool {
+ if (_outbound_propagation_node.size() == 0) {
+ fail_message("Propagation fallback requested but no propagation node is selected");
+ return true;
+ }
+
+ INFO(" Falling back to PROPAGATED delivery: " + reason);
+ if (context && !context->using_propagated_retry_budget) {
+ context->using_propagated_retry_budget = true;
+ context->propagation_attempts = 0;
+ INFO(" Resetting retry budget for PROPAGATED delivery");
+ }
+ message.set_method(Type::Message::PROPAGATED);
+
+ send_propagated(message);
+ return true;
+ };
+
+ auto using_propagated_budget = [&]() -> bool {
+ return context && context->using_propagated_retry_budget && message.method() == Type::Message::PROPAGATED;
+ };
+
+ auto current_attempts = [&]() -> int {
+ return using_propagated_budget() ? context->propagation_attempts : message.delivery_attempts();
+ };
+
+ auto current_attempt_limit = [&]() -> int {
+ return using_propagated_budget() ? MAX_PROPAGATION_DELIVERY_ATTEMPTS : MAX_DELIVERY_ATTEMPTS;
+ };
+
+ auto increment_attempts = [&]() {
+ if (using_propagated_budget()) {
+ context->propagation_attempts++;
+ } else {
+ message.increment_delivery_attempts();
+ }
+ };
+
+ if (message.state() == Type::Message::SENDING) {
+ if (context && context->stage == OutboundStage::DIRECT_TRANSFER) {
+ if (context->next_attempt_time > 0.0 && now >= context->next_attempt_time) {
+ schedule_outbound_retry(message, OUTBOUND_RETRY_DELAY, OutboundStage::WAITING_DIRECT_LINK,
+ "Direct delivery timed out waiting for proof/resource completion");
+ }
+ return;
+ }
+ if (context && context->stage == OutboundStage::PROP_TRANSFER) {
+ if (context->next_attempt_time > 0.0 && now >= context->next_attempt_time) {
+ schedule_outbound_retry(message, OUTBOUND_RETRY_DELAY, OutboundStage::WAITING_PROP_LINK,
+ "Propagation transfer timed out waiting for node confirmation");
+ }
+ return;
+ }
+ if (context && context->stage == OutboundStage::WAITING_PROP_STAMP) {
+ DEBUG(" Waiting for propagation stamp worker");
+ return;
+ }
+ }
+
+ // Check max delivery attempts
+ if (current_attempts() >= current_attempt_limit()) {
+ fail_message("Max delivery attempts reached");
+ return;
+ }
+ increment_attempts();
+
// If propagation-only mode is enabled, send via propagation node
if (_propagation_only) {
DEBUG(" Using PROPAGATED delivery (propagation-only mode)");
message.set_method(Type::Message::PROPAGATED);
- if (send_propagated(message)) {
- INFO("Message sent via PROPAGATED delivery");
- if (_sent_callback) {
- _sent_callback(message);
- }
- LXMessage dummy;
- pending_outbound_pop(dummy);
- } else {
- // Propagation not ready yet - wait and retry
- DEBUG(" Propagation delivery not ready, will retry...");
- _next_outbound_process_time = now + OUTBOUND_RETRY_DELAY;
+ if (_outbound_propagation_node.size() == 0) {
+ fail_message("Propagation-only mode is enabled but no propagation node is configured");
+ return;
}
+ send_propagated(message);
return;
}
- // Determine delivery method based on message size
- bool use_opportunistic = (message.packed_size() <= Type::Constants::ENCRYPTED_PACKET_MDU);
+ bool has_dest_identity = Identity::recall(message.destination_hash()) ? true : false;
+ bool has_dest_path = Transport::has_path(message.destination_hash());
+ if (context && message.delivery_attempts() == 1 && !has_dest_path && _fallback_to_propagation) {
+ context->avoid_auto_interface_path = true;
+ }
+ bool has_routable_path = has_dest_path;
+ if (context && context->avoid_auto_interface_path) {
+ has_routable_path = path_supports_opportunistic(message.destination_hash(), true);
+ }
+ bool has_opportunistic_path = path_supports_opportunistic(
+ message.destination_hash(),
+ context ? context->avoid_auto_interface_path : false
+ );
+ bool has_prop_node = _outbound_propagation_node.size() > 0;
- if (use_opportunistic) {
- // OPPORTUNISTIC delivery - send as single encrypted packet
- DEBUG(" Using OPPORTUNISTIC delivery (single packet)");
+ // Determine delivery method based on message size, but only allow
+ // OPPORTUNISTIC when a concrete path exists to the destination.
+ bool opportunistic_size_ok;
+ if (message.method() == Type::Message::OPPORTUNISTIC) {
+ opportunistic_size_ok = (message.packed_size() <= Type::Constants::ENCRYPTED_PACKET_MDU);
+ } else {
+ opportunistic_size_ok = (message.packed_size() <= Type::Constants::LORA_ENCRYPTED_PACKET_MDU);
+ }
- // Check if we have a path to the destination
- if (!Transport::has_path(message.destination_hash())) {
- // Request path from network
- INFO(" No path to destination, requesting...");
- Transport::request_path(message.destination_hash());
- _next_outbound_process_time = now + PATH_REQUEST_WAIT;
- return;
+ bool opportunistic_allowed = opportunistic_size_ok && has_opportunistic_path;
+ Type::Message::Method route_method = message.method() == Type::Message::PROPAGATED ?
+ Type::Message::PROPAGATED :
+ (opportunistic_allowed ? Type::Message::OPPORTUNISTIC : Type::Message::DIRECT);
+ if (message.method() != Type::Message::PROPAGATED) {
+ message.set_method(route_method);
+ }
+
+ snprintf(buf, sizeof(buf),
+ " attempt=%d/%d method=%s packed=%zu identity=%s path=%s opp_path=%s prop=%s fallback=%s",
+ current_attempts(),
+ current_attempt_limit(),
+ method_name(route_method),
+ message.packed_size(),
+ has_dest_identity ? "yes" : "no",
+ has_dest_path ? "yes" : "no",
+ has_opportunistic_path ? "yes" : "no",
+ has_prop_node ? "yes" : "no",
+ _fallback_to_propagation ? "on" : "off");
+ INFO(buf);
+
+ if (message.method() == Type::Message::PROPAGATED) {
+ attempt_propagated("message already marked for propagated delivery");
+ return;
+ }
+
+ if (opportunistic_size_ok && !has_opportunistic_path) {
+ if (has_dest_path) {
+ INFO(" Opportunistic delivery skipped because only a non-opportunistic path is available");
+ } else {
+ INFO(" Pathless OPPORTUNISTIC delivery skipped; waiting for path or fallback");
}
+ }
- // Try to recall the destination identity
+ if (message.method() == Type::Message::OPPORTUNISTIC) {
+ // OPPORTUNISTIC delivery - send as single encrypted packet
+ DEBUG(" Using OPPORTUNISTIC delivery (single packet)");
+
+ // Try to recall the destination identity (needed to encrypt the packet)
Identity dest_identity = Identity::recall(message.destination_hash());
if (!dest_identity) {
- // Path exists but identity not cached yet - wait for announce
- INFO(" Path exists but identity not known, waiting for announce...");
- _next_outbound_process_time = now + OUTBOUND_RETRY_DELAY;
+ // Identity not known - request path which may trigger an announce
+ INFO(" Destination identity not known, requesting path...");
+ Transport::request_path(message.destination_hash());
+ if (_fallback_to_propagation && current_attempts() >= PROPAGATION_FALLBACK_ATTEMPTS) {
+ attempt_propagated("destination identity remained unknown after opportunistic retries");
+ return;
+ }
+ retry_later(PATH_REQUEST_WAIT, OutboundStage::WAITING_PATH, " Waiting for destination identity/path after request");
return;
}
@@ -661,27 +1089,31 @@ void LXMRouter::process_outbound() {
LXMessage dummy;
pending_outbound_pop(dummy);
} else {
- ERROR("Failed to send OPPORTUNISTIC message");
- message.state(Type::Message::FAILED);
-
- if (_failed_callback) {
- _failed_callback(message);
+ if (_fallback_to_propagation) {
+ attempt_propagated("opportunistic send failed");
+ } else {
+ fail_message("Failed to send OPPORTUNISTIC message");
}
-
- failed_outbound_push(message);
- LXMessage dummy;
- pending_outbound_pop(dummy);
}
} else {
// DIRECT delivery - need a link for large messages
DEBUG(" Using DIRECT delivery (via link)");
// Check if we have a path to the destination
- if (!Transport::has_path(message.destination_hash())) {
+ if (!has_routable_path) {
// Request path from network
INFO(" No path to destination, requesting...");
Transport::request_path(message.destination_hash());
- _next_outbound_process_time = now + PATH_REQUEST_WAIT;
+ if (opportunistic_size_ok) {
+ INFO(" Small message is waiting for path-aware routing before send");
+ }
+ if (_fallback_to_propagation && current_attempts() >= PROPAGATION_FALLBACK_ATTEMPTS) {
+ attempt_propagated(opportunistic_size_ok ?
+ "no path became available for path-aware opportunistic delivery" :
+ "no direct path became available");
+ return;
+ }
+ retry_later(PATH_REQUEST_WAIT, OutboundStage::WAITING_PATH, " Waiting for direct path after path request");
return;
}
@@ -689,19 +1121,21 @@ void LXMRouter::process_outbound() {
Link link = get_link_for_destination(message.destination_hash());
if (!link) {
- WARNING("Failed to establish link for message delivery");
- // Set backoff timer to avoid tight loop
- _next_outbound_process_time = now + OUTBOUND_RETRY_DELAY;
- snprintf(buf, sizeof(buf), " Will retry in %d seconds", (int)OUTBOUND_RETRY_DELAY);
- INFO(buf);
+ if (_fallback_to_propagation && current_attempts() >= PROPAGATION_FALLBACK_ATTEMPTS) {
+ attempt_propagated("direct link could not be established");
+ return;
+ }
+ retry_later(OUTBOUND_RETRY_DELAY, OutboundStage::WAITING_DIRECT_LINK, " Direct link unavailable, will retry");
return;
}
// Check link status
if (link.status() != RNS::Type::Link::ACTIVE) {
- DEBUG("Link not yet active, waiting...");
- // Set shorter backoff for pending links
- _next_outbound_process_time = now + 1.0; // Check again in 1 second
+ if (_fallback_to_propagation && current_attempts() >= PROPAGATION_FALLBACK_ATTEMPTS) {
+ attempt_propagated("direct link did not become active");
+ return;
+ }
+ retry_later(1.0, OutboundStage::WAITING_DIRECT_LINK, " Direct link pending activation");
return;
}
@@ -709,41 +1143,27 @@ void LXMRouter::process_outbound() {
if (send_via_link(message, link)) {
INFO("Message sent successfully via link");
- // Call sent callback if registered
- if (_sent_callback) {
- _sent_callback(message);
+ if (context) {
+ context->stage = OutboundStage::DIRECT_TRANSFER;
+ context->next_attempt_time = now + (OUTBOUND_RETRY_DELAY * 2.0);
}
-
- // Remove from pending queue
- LXMessage dummy;
- pending_outbound_pop(dummy);
+ return;
} else {
- ERROR("Failed to send message via link");
- message.state(Type::Message::FAILED);
-
- // Call failed callback
- if (_failed_callback) {
- _failed_callback(message);
+ if (_fallback_to_propagation) {
+ attempt_propagated("direct send failed");
+ } else {
+ fail_message("Failed to send message via link");
}
-
- // Move to failed queue
- failed_outbound_push(message);
- LXMessage dummy;
- pending_outbound_pop(dummy);
}
}
} catch (const std::exception& e) {
snprintf(buf, sizeof(buf), "Exception processing outbound message: %s", e.what());
ERROR(buf);
- message.state(Type::Message::FAILED);
-
- // Call failed callback
+ log_state_transition(message, Type::Message::FAILED, "exception while processing outbound");
if (_failed_callback) {
_failed_callback(message);
}
-
- // Move to failed queue
failed_outbound_push(message);
LXMessage dummy;
pending_outbound_pop(dummy);
@@ -949,8 +1369,8 @@ void LXMRouter::on_packet(const Bytes& data, const Packet& packet) {
snprintf(buf, sizeof(buf), " Unverified reason: %u", (uint8_t)message.unverified_reason());
DEBUG(buf);
- // For Phase 1 MVP, we'll still accept messages with unknown source
- // (signature will be validated later if source identity is learned)
+ // Accept messages with unknown source — signature will be validated
+ // later if the source identity is learned via announce
if (message.unverified_reason() != Type::Message::SOURCE_UNKNOWN) {
WARNING(" Rejecting message with invalid signature");
return;
@@ -1104,7 +1524,7 @@ bool LXMRouter::send_via_link(LXMessage& message, Link& link) {
return false;
}
- message.state(Type::Message::SENDING);
+ log_state_transition(message, Type::Message::SENDING, "sending via direct link");
if (message.representation() == Type::Message::PACKET) {
// Send as single packet over link
@@ -1112,10 +1532,20 @@ bool LXMRouter::send_via_link(LXMessage& message, Link& link) {
INFO(buf);
Packet packet(link, message.packed());
- packet.send();
+ PacketReceipt receipt = packet.send();
+ if (receipt) {
+ receipt.set_delivery_callback(static_proof_callback);
+ PendingProofSlot* slot = find_empty_pending_proof_slot();
+ if (slot) {
+ slot->in_use = true;
+ slot->set_packet_hash(receipt.hash());
+ slot->set_message_hash(message.hash());
+ snprintf(buf, sizeof(buf), " Registered direct proof callback for packet %.16s...", receipt.hash().toHex().c_str());
+ DEBUG(buf);
+ }
+ }
- message.state(Type::Message::SENT);
- INFO("Message sent successfully as packet");
+ INFO("Message sent successfully as packet, waiting for proof");
return true;
} else if (message.representation() == Type::Message::RESOURCE) {
@@ -1141,8 +1571,7 @@ bool LXMRouter::send_via_link(LXMessage& message, Link& link) {
}
}
- message.state(Type::Message::SENT);
- INFO("Message resource transfer initiated");
+ INFO("Message resource transfer initiated, waiting for completion");
return true;
} else {
@@ -1210,7 +1639,7 @@ bool LXMRouter::send_opportunistic(LXMessage& message, const Identity& dest_iden
}
}
- message.state(Type::Message::SENT);
+ log_state_transition(message, Type::Message::SENT, "opportunistic packet transmitted");
INFO(" OPPORTUNISTIC packet sent");
return true;
@@ -1244,8 +1673,12 @@ void LXMRouter::handle_direct_proof(const Bytes& message_hash) {
break;
}
}
- if (!already_notified && router && router->_delivered_callback) {
+ if (!already_notified && router) {
notified_routers[notified_count++] = router;
+ router->update_pending_message_state(message_hash, Type::Message::DELIVERED, "direct delivery proof received");
+ if (!router->_delivered_callback) {
+ continue;
+ }
Bytes empty_hash;
LXMessage msg(empty_hash, empty_hash);
msg.hash(message_hash);
@@ -1286,9 +1719,11 @@ void LXMRouter::on_incoming_link_established(Link& link) {
snprintf(buf, sizeof(buf), " Link ID: %s", link.link_id().toHex().c_str());
DEBUG(buf);
- // Set up resource concluded callback to receive LXMF messages over this link
+ // Set up packet callback for single-packet LXMF messages (CONTEXT_NONE)
+ link.set_packet_callback(static_link_packet_callback);
+ // Set up resource concluded callback for multi-packet LXMF messages
link.set_resource_concluded_callback(static_resource_concluded_callback);
- DEBUG(" Resource callback registered for incoming LXMF messages");
+ DEBUG(" Packet and resource callbacks registered for incoming LXMF messages");
}
// Resource concluded callback (LXMF message received via DIRECT delivery)
@@ -1368,13 +1803,11 @@ void LXMRouter::on_resource_concluded(const RNS::Resource& resource) {
// ============== Propagation Node Support ==============
-void LXMRouter::set_propagation_node_manager(PropagationNodeManager* manager) {
- _propagation_manager = manager;
- INFO("Propagation node manager set");
-}
-
void LXMRouter::set_outbound_propagation_node(const Bytes& node_hash) {
if (node_hash.size() == 0) {
+ if (_outbound_propagation_node.size() == 0) {
+ return;
+ }
_outbound_propagation_node = {};
_outbound_propagation_link = Link(RNS::Type::NONE);
INFO("Cleared outbound propagation node");
@@ -1382,6 +1815,9 @@ void LXMRouter::set_outbound_propagation_node(const Bytes& node_hash) {
}
// Check if changing to a different node
+ if (_outbound_propagation_node == node_hash) {
+ return;
+ }
if (_outbound_propagation_node != node_hash) {
// Tear down existing link if any
if (_outbound_propagation_link && _outbound_propagation_link.status() != RNS::Type::Link::CLOSED) {
@@ -1391,9 +1827,11 @@ void LXMRouter::set_outbound_propagation_node(const Bytes& node_hash) {
}
_outbound_propagation_node = node_hash;
- char buf[64];
- snprintf(buf, sizeof(buf), "Set outbound propagation node to %.16s...", node_hash.toHex().c_str());
+ char buf[96];
+ snprintf(buf, sizeof(buf), "Set outbound propagation node (%d bytes): %s",
+ (int)node_hash.size(), node_hash.toHex().c_str());
INFO(buf);
+ nudge_propagation_sync("propagation node changed");
}
void LXMRouter::register_sync_complete_callback(SyncCompleteCallback callback) {
@@ -1421,53 +1859,63 @@ void LXMRouter::static_propagation_resource_concluded(const Resource& resource)
if (resource.status() == RNS::Type::Resource::COMPLETE) {
snprintf(buf, sizeof(buf), "PROPAGATED delivery to node confirmed for message %.16s...", message_hash.toHex().c_str());
INFO(buf);
-
- // For PROPAGATED, "delivered" means delivered to propagation node, not final recipient
- // We mark it as SENT (not DELIVERED) to indicate it's on the propagation network
- LXMRouter* notified_routers[ROUTER_REGISTRY_SIZE];
- size_t notified_count = 0;
-
- for (size_t i = 0; i < ROUTER_REGISTRY_SIZE; i++) {
- if (_router_registry_pool[i].in_use) {
- LXMRouter* router = _router_registry_pool[i].router;
- bool already_notified = false;
- for (size_t j = 0; j < notified_count; j++) {
- if (notified_routers[j] == router) {
- already_notified = true;
- break;
- }
- }
- if (!already_notified && router && router->_sent_callback) {
- notified_routers[notified_count++] = router;
- Bytes empty_hash;
- LXMessage msg(empty_hash, empty_hash);
- msg.hash(message_hash);
- msg.state(Type::Message::SENT);
- router->_sent_callback(msg);
- }
- }
- }
} else {
snprintf(buf, sizeof(buf), "PROPAGATED resource transfer failed with status %d", (int)resource.status());
WARNING(buf);
}
slot->clear();
+
+ LXMRouter* notified_routers[ROUTER_REGISTRY_SIZE];
+ size_t notified_count = 0;
+
+ for (size_t i = 0; i < ROUTER_REGISTRY_SIZE; i++) {
+ if (!_router_registry_pool[i].in_use) {
+ continue;
+ }
+
+ LXMRouter* router = _router_registry_pool[i].router;
+ bool already_notified = false;
+ for (size_t j = 0; j < notified_count; j++) {
+ if (notified_routers[j] == router) {
+ already_notified = true;
+ break;
+ }
+ }
+ if (already_notified || !router) {
+ continue;
+ }
+ notified_routers[notified_count++] = router;
+
+ if (resource.status() == RNS::Type::Resource::COMPLETE) {
+ router->update_pending_message_state(message_hash, Type::Message::SENT, "propagation node accepted message");
+ router->notify_sent_for_message_hash(message_hash, Type::Message::PROPAGATED);
+ router->nudge_propagation_sync("propagated transfer completed");
+ } else {
+ LXMessage* pending = router->pending_outbound_find(message_hash);
+ if (pending) {
+ router->schedule_outbound_retry(
+ *pending,
+ OUTBOUND_RETRY_DELAY,
+ OutboundStage::WAITING_PROP_LINK,
+ "Propagation resource failed; scheduling retry"
+ );
+ }
+ }
+ }
}
bool LXMRouter::send_propagated(LXMessage& message) {
INFO("Sending LXMF message via PROPAGATED delivery");
char buf[128];
+ OutboundContextSlot* context = get_or_create_outbound_context(message.hash());
+ if (!context) {
+ ERROR("No outbound context slot available for propagated delivery");
+ return false;
+ }
// Get propagation node
Bytes prop_node = _outbound_propagation_node;
- if (prop_node.size() == 0 && _propagation_manager) {
- DEBUG(" Looking for propagation node via manager...");
- auto nodes = _propagation_manager->get_nodes();
- snprintf(buf, sizeof(buf), " Manager has %zu nodes", nodes.size());
- DEBUG(buf);
- prop_node = _propagation_manager->get_effective_node();
- }
if (prop_node.size() == 0) {
WARNING("No propagation node available for PROPAGATED delivery");
@@ -1485,6 +1933,8 @@ bool LXMRouter::send_propagated(LXMessage& message) {
if (!Transport::has_path(prop_node)) {
INFO(" No path to propagation node, requesting...");
Transport::request_path(prop_node);
+ schedule_outbound_retry(message, PATH_REQUEST_WAIT, OutboundStage::WAITING_PATH,
+ " Waiting for propagation-node path after request");
return false; // Will retry next cycle
}
@@ -1492,6 +1942,8 @@ bool LXMRouter::send_propagated(LXMessage& message) {
Identity node_identity = Identity::recall(prop_node);
if (!node_identity) {
INFO(" Propagation node identity not known, waiting for announce...");
+ schedule_outbound_retry(message, PATH_REQUEST_WAIT, OutboundStage::WAITING_PROP_LINK,
+ " Waiting for propagation-node identity");
return false;
}
@@ -1507,28 +1959,81 @@ bool LXMRouter::send_propagated(LXMessage& message) {
// Create link with established callback
_outbound_propagation_link = Link(prop_dest);
INFO(" Establishing link to propagation node...");
+ schedule_outbound_retry(message, 1.0, OutboundStage::WAITING_PROP_LINK,
+ " Waiting for propagation link activation");
return false; // Will retry when link established
}
// Check if link is active
if (_outbound_propagation_link.status() != RNS::Type::Link::ACTIVE) {
DEBUG(" Propagation link not yet active, waiting...");
+ schedule_outbound_retry(message, 1.0, OutboundStage::WAITING_PROP_LINK,
+ " Propagation link pending activation");
return false; // Will retry
}
- // Generate propagation stamp if required by node
- if (_propagation_manager) {
- auto node_info = _propagation_manager->get_node(prop_node);
- if (node_info && node_info.stamp_cost > 0) {
- snprintf(buf, sizeof(buf), " Generating propagation stamp (cost=%u)...", node_info.stamp_cost);
+ // Generate propagation stamp asynchronously if required by node.
+ if (_outbound_propagation_stamp_cost > 0 && message.propagation_stamp().size() == 0) {
+#ifdef ARDUINO
+ if (stamp_job_matches_hash(message.hash())) {
+ if (_propagation_stamp_job.completed) {
+ context->propagation_stamp_pending = false;
+ context->propagation_stamp_failed = false;
+ context->stage = OutboundStage::NONE;
+ _propagation_stamp_job.completed = false;
+ _propagation_stamp_job.message = nullptr;
+ _propagation_stamp_job.router = nullptr;
+ memset(_propagation_stamp_job.message_hash, 0, sizeof(_propagation_stamp_job.message_hash));
+ INFO(" Propagation stamp job completed");
+ } else if (_propagation_stamp_job.failed) {
+ context->propagation_stamp_pending = false;
+ context->propagation_stamp_failed = true;
+ _propagation_stamp_job.failed = false;
+ _propagation_stamp_job.message = nullptr;
+ _propagation_stamp_job.router = nullptr;
+ memset(_propagation_stamp_job.message_hash, 0, sizeof(_propagation_stamp_job.message_hash));
+ WARNING(" Propagation stamp generation failed");
+ return false;
+ } else {
+ context->propagation_stamp_pending = true;
+ context->stage = OutboundStage::WAITING_PROP_STAMP;
+ DEBUG(" Propagation stamp still generating in background");
+ return false;
+ }
+ }
+#endif
+
+ if (!context->propagation_stamp_pending) {
+ snprintf(buf, sizeof(buf), " Queueing propagation stamp generation (cost=%u)...", _outbound_propagation_stamp_cost);
DEBUG(buf);
- Bytes stamp = message.generate_propagation_stamp(node_info.stamp_cost);
- if (stamp.size() == 0) {
- WARNING(" Failed to generate propagation stamp, sending anyway");
+ if (!queue_propagation_stamp_job(message)) {
+ WARNING(" Could not queue propagation stamp job yet");
+ schedule_outbound_retry(message, 1.0, OutboundStage::WAITING_PROP_STAMP,
+ " Waiting for propagation stamp worker availability");
+ return false;
}
}
+
+ context->propagation_stamp_pending = true;
+ context->stage = OutboundStage::WAITING_PROP_STAMP;
+ return false;
}
+ if (context->propagation_stamp_failed) {
+ WARNING(" Propagation stamp generation previously failed");
+ context->propagation_stamp_failed = false;
+ message.set_propagation_stamp({});
+ return false;
+ }
+
+ if (message.state() == Type::Message::SENDING && context->stage == OutboundStage::PROP_TRANSFER) {
+ DEBUG(" Propagation resource already in progress");
+ return false;
+ }
+
+ context->propagation_stamp_pending = false;
+ context->propagation_stamp_failed = false;
+
// Pack message for propagation
Bytes prop_packed = message.pack_propagated();
if (!prop_packed || prop_packed.size() == 0) {
@@ -1556,11 +2061,53 @@ bool LXMRouter::send_propagated(LXMessage& message) {
}
}
- message.state(Type::Message::SENDING);
+ log_state_transition(message, Type::Message::SENDING, "propagated resource transfer initiated");
+ context->stage = OutboundStage::PROP_TRANSFER;
+ context->next_attempt_time = Utilities::OS::time() + (OUTBOUND_RETRY_DELAY * 2.0);
INFO(" PROPAGATED resource transfer initiated");
return true;
}
+// Static router pointer for sync callbacks (raw function pointers required by RequestReceipt)
+static LXMRouter* _active_sync_router = nullptr;
+
+// Static callback wrappers for sync protocol
+static void static_list_response_cb(const RequestReceipt& receipt) {
+ if (_active_sync_router) {
+ _active_sync_router->on_message_list_response(receipt.get_response());
+ } else {
+ WARNING("list_response_cb: no active sync router!");
+ }
+}
+
+static void static_list_failed_cb(const RequestReceipt& receipt) {
+ WARNING("Propagation node list request failed");
+ if (_active_sync_router) {
+ _active_sync_router->on_sync_failed();
+ }
+}
+
+static void static_get_response_cb(const RequestReceipt& receipt) {
+ if (_active_sync_router) {
+ _active_sync_router->on_message_get_response(receipt.get_response());
+ } else {
+ WARNING("get_response_cb: no active sync router!");
+ }
+}
+
+static void static_get_failed_cb(const RequestReceipt& receipt) {
+ WARNING("Propagation node get request failed");
+ if (_active_sync_router) {
+ _active_sync_router->on_sync_failed();
+ }
+}
+
+void LXMRouter::on_sync_failed() {
+ _sync_state = PR_FAILED;
+ _sync_progress = 0.0f;
+ _active_sync_router = nullptr;
+}
+
void LXMRouter::request_messages_from_propagation_node() {
if (_sync_state != PR_IDLE && _sync_state != PR_COMPLETE && _sync_state != PR_FAILED) {
char buf[64];
@@ -1571,9 +2118,6 @@ void LXMRouter::request_messages_from_propagation_node() {
// Get propagation node
Bytes prop_node = _outbound_propagation_node;
- if (!prop_node && _propagation_manager) {
- prop_node = _propagation_manager->get_effective_node();
- }
if (!prop_node) {
WARNING("No propagation node available for sync");
@@ -1586,57 +2130,309 @@ void LXMRouter::request_messages_from_propagation_node() {
INFO(buf);
_sync_progress = 0.0f;
- // Check if link exists and is active
+ // Request path if we don't have one
+ if (!Transport::has_path(prop_node)) {
+ INFO(" No path to propagation node, requesting...");
+ Transport::request_path(prop_node);
+ }
+
+ _sync_state = PR_PATH_REQUESTED; // Enter state machine; process_sync() advances it
+ _sync_start_time = Utilities::OS::time();
+ process_sync();
+}
+
+void LXMRouter::process_sync() {
+ if (_sync_state == PR_IDLE || _sync_state == PR_COMPLETE || _sync_state == PR_FAILED) {
+ return; // Nothing to advance
+ }
+
+ // Global timeout: 60s for entire sync operation
+ double elapsed = Utilities::OS::time() - _sync_start_time;
+ if (elapsed > 60.0) {
+ WARNING(" Propagation sync timed out");
+ on_sync_failed();
+ return;
+ }
+
+ Bytes prop_node = _outbound_propagation_node;
+ if (!prop_node) {
+ _sync_state = PR_FAILED;
+ return;
+ }
+
+ // For states that depend on an active link, check link health
+ if (_sync_state == PR_REQUEST_SENT || _sync_state == PR_RECEIVING ||
+ _sync_state == PR_LINK_ESTABLISHED) {
+ if (!_outbound_propagation_link ||
+ _outbound_propagation_link.status() == RNS::Type::Link::CLOSED) {
+ WARNING(" Propagation link lost during sync");
+ on_sync_failed();
+ return;
+ }
+ // Log status every ~10s
+ int elapsed_int = (int)elapsed;
+ if (elapsed_int > 0 && elapsed_int % 10 == 0) {
+ static int last_logged = -1;
+ if (elapsed_int != last_logged) {
+ last_logged = elapsed_int;
+ char buf[96];
+ snprintf(buf, sizeof(buf), " Sync waiting: state=%d, link_status=%d, pending_reqs=%zu, elapsed=%ds",
+ (int)_sync_state, (int)_outbound_propagation_link.status(),
+ _outbound_propagation_link.pending_requests_count(), elapsed_int);
+ INFO(buf);
+ }
+ }
+ return; // Waiting for response callbacks
+ }
+
+ // Check if link is already active (fast path for PR_PATH_REQUESTED / PR_LINK_ESTABLISHING)
if (_outbound_propagation_link && _outbound_propagation_link.status() == RNS::Type::Link::ACTIVE) {
_sync_state = PR_LINK_ESTABLISHED;
- // TODO: Implement link.identify() and link.request() for full sync protocol
- // For now, we log that sync would happen here
- INFO(" Link active - sync protocol not yet implemented");
- INFO(" (Requires Link.identify() and Link.request() support)");
+ _active_sync_router = this;
+ // Identify ourselves to the propagation node
+ _outbound_propagation_link.identify(_identity);
+
+ // Build initial request: [nil, nil] — request message list
+ MsgPack::Packer packer;
+ packer.packArraySize(2);
+ packer.packNil();
+ packer.packNil();
+ Bytes request_data(packer.data(), packer.size());
+
+ // Request message list via "/get" path
+ Bytes path((uint8_t*)"/get", 4);
+ RequestReceipt receipt = _outbound_propagation_link.request(
+ path, request_data,
+ static_list_response_cb,
+ static_list_failed_cb
+ );
+
+ if (!receipt) {
+ WARNING(" Sync request failed - request not sent");
+ on_sync_failed();
+ return;
+ }
+
+ _sync_state = PR_REQUEST_SENT;
+ _sync_progress = 0.1f;
+ INFO(" Sync request sent to propagation node");
+ return;
+ }
+
+ // PR_PATH_REQUESTED: wait for path, then advance to link establishing
+ if (_sync_state == PR_PATH_REQUESTED) {
+ if (!Transport::has_path(prop_node)) {
+ return; // Still waiting for path
+ }
+
+ Identity node_identity = Identity::recall(prop_node);
+ if (!node_identity) {
+ INFO(" Propagation node identity not known");
+ _sync_state = PR_FAILED;
+ return;
+ }
+
+ Destination prop_dest(
+ node_identity,
+ RNS::Type::Destination::OUT,
+ RNS::Type::Destination::SINGLE,
+ "lxmf",
+ "propagation"
+ );
+
+ _outbound_propagation_link = Link(prop_dest);
+ _sync_state = PR_LINK_ESTABLISHING;
+ INFO(" Path arrived, establishing link for sync...");
+ return;
+ }
+
+ // PR_LINK_ESTABLISHING: link not yet active, check for failure
+ if (_sync_state == PR_LINK_ESTABLISHING) {
+ if (_outbound_propagation_link.status() == RNS::Type::Link::CLOSED) {
+ WARNING(" Propagation link closed before establishing");
+ _sync_state = PR_FAILED;
+ }
+ // Otherwise still waiting for link to become ACTIVE
+ }
+}
+
+void LXMRouter::on_message_list_response(const Bytes& response) {
+ char buf[128];
+ INFO("Received message list from propagation node");
+
+ if (!response || response.size() == 0) {
+ INFO(" Empty response — no messages available");
_sync_state = PR_COMPLETE;
_sync_progress = 1.0f;
+ _active_sync_router = nullptr;
if (_sync_complete_callback) {
_sync_complete_callback(0);
}
- } else {
- // Need to establish link first
- if (!Transport::has_path(prop_node)) {
- INFO(" No path to propagation node, requesting...");
- Transport::request_path(prop_node);
- _sync_state = PR_PATH_REQUESTED;
- } else {
- Identity node_identity = Identity::recall(prop_node);
- if (!node_identity) {
- INFO(" Propagation node identity not known");
- _sync_state = PR_FAILED;
- return;
+ return;
+ }
+
+ try {
+ // Parse response: array of transient_id bytes
+ MsgPack::Unpacker unpacker;
+ unpacker.feed(response.data(), response.size());
+
+ MsgPack::arr_size_t arr_size;
+ unpacker.deserialize(arr_size);
+
+ snprintf(buf, sizeof(buf), " Propagation node has %u messages", (unsigned)arr_size.size());
+ INFO(buf);
+
+ // Filter out already-seen transient IDs
+ // Build "wants" list
+ size_t wants_count = 0;
+
+ std::vector available_ids;
+ for (size_t i = 0; i < arr_size.size(); i++) {
+ MsgPack::bin_t id_bin;
+ unpacker.deserialize(id_bin);
+ Bytes transient_id(id_bin);
+ if (!transient_ids_contains(transient_id)) {
+ available_ids.push_back(transient_id);
+ wants_count++;
}
+ }
+
+ snprintf(buf, sizeof(buf), " Want %zu new messages (filtered %u already seen)",
+ wants_count, (unsigned)(arr_size.size() - wants_count));
+ INFO(buf);
- Destination prop_dest(
- node_identity,
- RNS::Type::Destination::OUT,
- RNS::Type::Destination::SINGLE,
- "lxmf",
- "propagation"
- );
+ if (wants_count == 0) {
+ INFO(" No new messages to download");
+ _sync_state = PR_COMPLETE;
+ _sync_progress = 1.0f;
+ _active_sync_router = nullptr;
+ if (_sync_complete_callback) {
+ _sync_complete_callback(0);
+ }
+ return;
+ }
- _outbound_propagation_link = Link(prop_dest);
- _sync_state = PR_LINK_ESTABLISHING;
- INFO(" Establishing link for sync...");
+ // Build request: [wants, [], 0]
+ // wants = array of transient IDs we want
+ // [] = empty "have" list (we're not a peer)
+ // 0 = message limit (0 = no limit)
+ MsgPack::Packer req_packer;
+ req_packer.packArraySize(3);
+
+ // wants array
+ req_packer.packArraySize(wants_count);
+ for (const auto& id : available_ids) {
+ req_packer.packBinary(id.data(), id.size());
}
- }
-}
-void LXMRouter::on_message_list_response(const Bytes& response) {
- // TODO: Implement when Link.request() is available
- DEBUG("on_message_list_response: Not yet implemented");
+ // empty haves array
+ req_packer.packArraySize(0);
+
+ // no limit (must be nil, not 0 — Python server treats 0 as "0 KB limit")
+ req_packer.packNil();
+
+ Bytes request_data(req_packer.data(), req_packer.size());
+
+ // Request the messages
+ Bytes path((uint8_t*)"/get", 4);
+ _outbound_propagation_link.request(
+ path, request_data,
+ static_get_response_cb,
+ static_get_failed_cb
+ );
+
+ _sync_state = PR_RECEIVING;
+ _sync_progress = 0.3f;
+
+ } catch (const std::exception& e) {
+ snprintf(buf, sizeof(buf), "Failed to parse message list: %s", e.what());
+ ERROR(buf);
+ _sync_state = PR_FAILED;
+ _active_sync_router = nullptr;
+ }
}
void LXMRouter::on_message_get_response(const Bytes& response) {
- // TODO: Implement when Link.request() is available
- DEBUG("on_message_get_response: Not yet implemented");
+ char buf[128];
+ INFO("Received messages from propagation node");
+
+ if (!response || response.size() == 0) {
+ INFO(" Empty response");
+ _sync_state = PR_COMPLETE;
+ _sync_progress = 1.0f;
+ _active_sync_router = nullptr;
+ if (_sync_complete_callback) {
+ _sync_complete_callback(0);
+ }
+ return;
+ }
+
+ size_t messages_received = 0;
+
+ try {
+ // Parse response: array of lxmf_data bytes
+ MsgPack::Unpacker unpacker;
+ unpacker.feed(response.data(), response.size());
+
+ MsgPack::arr_size_t arr_size;
+ unpacker.deserialize(arr_size);
+
+ snprintf(buf, sizeof(buf), " Processing %u messages from propagation node", (unsigned)arr_size.size());
+ INFO(buf);
+
+ // Collect received transient IDs for ack
+ std::vector received_ids;
+
+ for (size_t i = 0; i < arr_size.size(); i++) {
+ MsgPack::bin_t data_bin;
+ unpacker.deserialize(data_bin);
+ Bytes lxmf_data(data_bin);
+
+ // Process the message
+ process_propagated_lxmf(lxmf_data);
+ messages_received++;
+
+ // Track transient ID
+ Bytes transient_id = Identity::full_hash(lxmf_data);
+ received_ids.push_back(transient_id);
+
+ _sync_progress = 0.3f + 0.6f * ((float)(i + 1) / (float)arr_size.size());
+ }
+
+ // Send ack: [nil, haves] — acknowledge received messages
+ if (!received_ids.empty() && _outbound_propagation_link &&
+ _outbound_propagation_link.status() == RNS::Type::Link::ACTIVE) {
+ MsgPack::Packer ack_packer;
+ ack_packer.packArraySize(2);
+ ack_packer.packNil();
+
+ ack_packer.packArraySize(received_ids.size());
+ for (const auto& id : received_ids) {
+ ack_packer.packBinary(id.data(), id.size());
+ }
+
+ Bytes ack_data(ack_packer.data(), ack_packer.size());
+ Bytes path((uint8_t*)"/get", 4);
+ _outbound_propagation_link.request(path, ack_data);
+ }
+
+ } catch (const std::exception& e) {
+ snprintf(buf, sizeof(buf), "Failed to process messages from propagation node: %s", e.what());
+ ERROR(buf);
+ }
+
+ snprintf(buf, sizeof(buf), "Sync complete: received %zu messages", messages_received);
+ INFO(buf);
+
+ _sync_state = PR_COMPLETE;
+ _sync_progress = 1.0f;
+ _active_sync_router = nullptr;
+
+ if (_sync_complete_callback) {
+ _sync_complete_callback(messages_received);
+ }
}
void LXMRouter::process_propagated_lxmf(const Bytes& lxmf_data) {
diff --git a/src/LXMF/LXMRouter.h b/src/LXMF/LXMRouter.h
index f7134787..c4ae5c69 100644
--- a/src/LXMF/LXMRouter.h
+++ b/src/LXMF/LXMRouter.h
@@ -15,14 +15,11 @@
namespace LXMF {
- // Forward declarations
- class PropagationNodeManager;
-
/**
* @brief LXMF Router - Message delivery orchestration
*
* Manages message queues, link establishment, and delivery for LXMF messages.
- * Supports DIRECT delivery method (via established links) for Phase 1 MVP.
+ * Supports DIRECT, OPPORTUNISTIC, and PROPAGATED delivery methods.
*
* Usage:
* LXMRouter router(identity, "/path/to/storage");
@@ -255,11 +252,19 @@ namespace LXMF {
// ============== Propagation Node Support ==============
/**
- * @brief Set the propagation node manager
+ * @brief Set the stamp cost required by the outbound propagation node
+ *
+ * When set to a non-zero value, stamps will be generated before sending
+ * messages through the propagation node.
*
- * @param manager Pointer to PropagationNodeManager (not owned)
+ * @param cost Required stamp cost (0 = no stamp needed)
+ */
+ void set_outbound_propagation_stamp_cost(uint8_t cost) { _outbound_propagation_stamp_cost = cost; }
+
+ /**
+ * @brief Get the stamp cost for the outbound propagation node
*/
- void set_propagation_node_manager(PropagationNodeManager* manager);
+ uint8_t outbound_propagation_stamp_cost() const { return _outbound_propagation_stamp_cost; }
/**
* @brief Set the outbound propagation node
@@ -317,6 +322,14 @@ namespace LXMF {
*/
void request_messages_from_propagation_node();
+ /**
+ * @brief Advance propagation sync state machine
+ *
+ * Call periodically (e.g., in main loop) to advance sync after
+ * path arrival or link establishment.
+ */
+ void process_sync();
+
/**
* @brief Get the current sync state
*
@@ -469,16 +482,26 @@ namespace LXMF {
*/
bool send_propagated(LXMessage& message);
+ public:
/**
* @brief Handle message list response from propagation node
+ * NOTE: Public for static callback access, not intended for direct use.
*/
void on_message_list_response(const RNS::Bytes& response);
/**
* @brief Handle message get response from propagation node
+ * NOTE: Public for static callback access, not intended for direct use.
*/
void on_message_get_response(const RNS::Bytes& response);
+ /**
+ * @brief Handle sync failure
+ * NOTE: Public for static callback access, not intended for direct use.
+ */
+ void on_sync_failed();
+
+ private:
/**
* @brief Process received propagated LXMF data
*/
@@ -489,16 +512,77 @@ namespace LXMF {
*/
static void static_propagation_resource_concluded(const RNS::Resource& resource);
+ enum class OutboundStage : uint8_t {
+ NONE = 0,
+ WAITING_PATH,
+ WAITING_DIRECT_LINK,
+ DIRECT_TRANSFER,
+ WAITING_PROP_LINK,
+ WAITING_PROP_STAMP,
+ PROP_TRANSFER
+ };
+
// Circular buffer helpers for message queues
bool pending_outbound_push(const LXMessage& msg);
bool pending_outbound_pop(LXMessage& msg);
LXMessage* pending_outbound_front();
+ LXMessage* pending_outbound_find(const RNS::Bytes& hash);
bool pending_inbound_push(const LXMessage& msg);
bool pending_inbound_pop(LXMessage& msg);
LXMessage* pending_inbound_front();
bool failed_outbound_push(const LXMessage& msg);
bool failed_outbound_pop(LXMessage& msg);
+ struct OutboundContextSlot {
+ bool in_use = false;
+ static constexpr size_t MESSAGE_HASH_SIZE = 32;
+ uint8_t message_hash[MESSAGE_HASH_SIZE];
+ double next_attempt_time = 0.0;
+ OutboundStage stage = OutboundStage::NONE;
+ bool propagation_stamp_pending = false;
+ bool propagation_stamp_failed = false;
+ bool avoid_auto_interface_path = false;
+ bool using_propagated_retry_budget = false;
+ uint8_t propagation_attempts = 0;
+ void set_message_hash(const RNS::Bytes& b) {
+ size_t len = std::min(b.size(), MESSAGE_HASH_SIZE);
+ memcpy(message_hash, b.data(), len);
+ if (len < MESSAGE_HASH_SIZE) memset(message_hash + len, 0, MESSAGE_HASH_SIZE - len);
+ }
+ bool message_hash_equals(const RNS::Bytes& b) const {
+ if (b.size() != MESSAGE_HASH_SIZE) return false;
+ return memcmp(message_hash, b.data(), MESSAGE_HASH_SIZE) == 0;
+ }
+ void clear() {
+ in_use = false;
+ memset(message_hash, 0, MESSAGE_HASH_SIZE);
+ next_attempt_time = 0.0;
+ stage = OutboundStage::NONE;
+ propagation_stamp_pending = false;
+ propagation_stamp_failed = false;
+ avoid_auto_interface_path = false;
+ using_propagated_retry_budget = false;
+ propagation_attempts = 0;
+ }
+ };
+
+ OutboundContextSlot* find_outbound_context_slot(const RNS::Bytes& hash);
+ OutboundContextSlot* find_empty_outbound_context_slot();
+ OutboundContextSlot* get_or_create_outbound_context(const RNS::Bytes& hash);
+ void clear_outbound_context(const RNS::Bytes& hash);
+ void schedule_outbound_retry(LXMessage& message, double delay, OutboundStage stage, const std::string& reason);
+ void update_pending_message_state(const RNS::Bytes& message_hash, Type::Message::State state, const std::string& reason);
+ void notify_sent_for_message_hash(
+ const RNS::Bytes& message_hash,
+ Type::Message::Method method = Type::Message::DIRECT
+ );
+ void notify_delivered_for_message_hash(const RNS::Bytes& message_hash);
+ void notify_failed_for_message_hash(const RNS::Bytes& message_hash);
+ bool path_supports_opportunistic(const RNS::Bytes& destination_hash, bool avoid_auto_interface_path = false) const;
+ bool ensure_stamp_worker_started();
+ bool queue_propagation_stamp_job(LXMessage& message);
+ void nudge_propagation_sync(const std::string& reason);
+
private:
// Core components
RNS::Identity _identity; // Local identity
@@ -524,6 +608,9 @@ namespace LXMF {
size_t _failed_outbound_tail = 0;
size_t _failed_outbound_count = 0;
+ static constexpr size_t OUTBOUND_CONTEXTS_SIZE = PENDING_OUTBOUND_SIZE;
+ OutboundContextSlot _outbound_contexts_pool[OUTBOUND_CONTEXTS_SIZE];
+
// Link management for DIRECT delivery - fixed pool (zero heap fragmentation)
static constexpr size_t DIRECT_LINKS_SIZE = 8;
static constexpr size_t DEST_HASH_SIZE = 16; // Truncated hash size
@@ -614,12 +701,17 @@ namespace LXMF {
// Retry backoff
double _next_outbound_process_time = 0.0; // Next time to process outbound queue
- static constexpr double OUTBOUND_RETRY_DELAY = 5.0; // Seconds between retries
- static constexpr double PATH_REQUEST_WAIT = 3.0; // Seconds to wait after path request
+ static constexpr double OUTBOUND_RETRY_DELAY = 10.0; // Seconds between retries (Python: DELIVERY_RETRY_WAIT = 10)
+ static constexpr double PATH_REQUEST_WAIT = 15.0; // Seconds to wait after path request (Python: 7s, but LoRa needs more RX window)
+ static constexpr int MAX_DELIVERY_ATTEMPTS = 5; // Max attempts before failing (Python: 5)
+ static constexpr int MAX_PROPAGATION_DELIVERY_ATTEMPTS = 8;
+ static constexpr int MAX_PATHLESS_TRIES = 1; // Attempts before requesting path (Python: 1)
+ static constexpr int PROPAGATION_FALLBACK_ATTEMPTS = 3; // Direct/opportunistic retries before fallback/fail
+ static constexpr double PROPAGATION_SYNC_NUDGE_INTERVAL = 15.0;
// Propagation node support
- PropagationNodeManager* _propagation_manager = nullptr;
RNS::Bytes _outbound_propagation_node;
+ uint8_t _outbound_propagation_stamp_cost = 0;
RNS::Link _outbound_propagation_link{RNS::Type::NONE};
bool _fallback_to_propagation = true;
bool _propagation_only = false;
@@ -627,6 +719,8 @@ namespace LXMF {
// Propagation sync state
PropagationSyncState _sync_state = PR_IDLE;
float _sync_progress = 0.0f;
+ double _sync_start_time = 0.0;
+ double _last_propagation_sync_nudge = 0.0;
SyncCompleteCallback _sync_complete_callback;
// Locally delivered transient IDs circular buffer (zero heap fragmentation)
diff --git a/src/LXMF/LXMessage.cpp b/src/LXMF/LXMessage.cpp
index 176cdc29..e0d2e7be 100644
--- a/src/LXMF/LXMessage.cpp
+++ b/src/LXMF/LXMessage.cpp
@@ -125,14 +125,12 @@ const Bytes& LXMessage::pack() {
_timestamp = Utilities::OS::time();
}
- // 2. Create payload array: [timestamp, title, content, fields, stamp?] - matches Python LXMF exactly
- // Python: msgpack.packb([self.timestamp, self.title, self.content, self.fields])
- // If stamp is present, it's appended as 5th element
+ // 2. Pack 4-element payload (without stamp) for hash/signature computation.
+ // Per Python LXMF: hash and signature are ALWAYS computed over the 4-element
+ // payload [timestamp, title, content, fields], even when a stamp is present.
+ // The stamp is only appended as a 5th element in the wire format.
MsgPack::Packer packer;
-
- // Pack as array with 4 or 5 elements (5 if stamp present)
- bool has_stamp = (_stamp.size() == LXStamper::STAMP_SIZE);
- packer.packArraySize(has_stamp ? 5 : 4);
+ packer.packArraySize(4);
// Element 0: timestamp (float64)
packer.pack(_timestamp);
@@ -144,27 +142,23 @@ const Bytes& LXMessage::pack() {
packer.packBinary(_content.data(), _content.size());
// Element 3: fields (map) - iterate over fixed array
+ // Keys are packed as integers to match Python LXMF (not BIN)
packer.packMapSize(_fields_count);
for (size_t i = 0; i < MAX_FIELDS; ++i) {
if (_fields_pool[i].in_use) {
- packer.packBinary(_fields_pool[i].key.data(), _fields_pool[i].key.size());
+ packer.pack(_fields_pool[i].key.data()[0]);
packer.packBinary(_fields_pool[i].value.data(), _fields_pool[i].value.size());
}
}
- // Element 4 (optional): stamp - 32 bytes
- if (has_stamp) {
- packer.packBinary(_stamp.data(), _stamp.size());
- DEBUG(" Stamp included in payload (" + std::to_string(_stamp.size()) + " bytes)");
- }
-
- Bytes packed_payload(packer.data(), packer.size());
+ Bytes payload_without_stamp(packer.data(), packer.size());
- // 3. Calculate hash: SHA256(dest_hash + source_hash + packed_payload)
+ // 3. Calculate hash: SHA256(dest_hash + source_hash + payload_without_stamp)
+ // Hash is always over the 4-element payload (matching Python LXMF)
Bytes hashed_part;
hashed_part << _destination_hash;
hashed_part << _source_hash;
- hashed_part << packed_payload;
+ hashed_part << payload_without_stamp;
_hash = Identity::full_hash(hashed_part);
@@ -185,21 +179,44 @@ const Bytes& LXMessage::pack() {
throw std::runtime_error("Cannot sign message without source destination");
}
- // 6. Pack final message: dest_hash + source_hash + signature + packed_payload
+ // 6. Build wire payload — append stamp as 5th element if present
+ bool has_stamp = (_stamp.size() == LXStamper::STAMP_SIZE);
+ Bytes wire_payload;
+ if (has_stamp) {
+ MsgPack::Packer wire_packer;
+ wire_packer.packArraySize(5);
+ wire_packer.pack(_timestamp);
+ wire_packer.packBinary(_title.data(), _title.size());
+ wire_packer.packBinary(_content.data(), _content.size());
+ wire_packer.packMapSize(_fields_count);
+ for (size_t i = 0; i < MAX_FIELDS; ++i) {
+ if (_fields_pool[i].in_use) {
+ wire_packer.pack(_fields_pool[i].key.data()[0]);
+ wire_packer.packBinary(_fields_pool[i].value.data(), _fields_pool[i].value.size());
+ }
+ }
+ wire_packer.packBinary(_stamp.data(), _stamp.size());
+ wire_payload = Bytes(wire_packer.data(), wire_packer.size());
+ DEBUG(" Stamp included in wire payload (" + std::to_string(_stamp.size()) + " bytes)");
+ } else {
+ wire_payload = payload_without_stamp;
+ }
+
+ // 7. Pack final message: dest_hash + source_hash + signature + wire_payload
_packed.clear();
_packed << _destination_hash;
_packed << _source_hash;
_packed << _signature;
- _packed << packed_payload;
+ _packed << wire_payload;
_packed_valid = true;
- // 7. Determine delivery method and representation
- size_t content_size = packed_payload.size() - Type::Constants::TIMESTAMP_SIZE - Type::Constants::STRUCT_OVERHEAD;
+ // 8. Determine delivery method and representation
+ size_t content_size = wire_payload.size() - Type::Constants::TIMESTAMP_SIZE - Type::Constants::STRUCT_OVERHEAD;
- // For Phase 1 MVP, we only support DIRECT delivery
if (_desired_method == Type::Message::DIRECT) {
- if (content_size <= Type::Constants::LINK_PACKET_MAX_CONTENT) {
+ // Use LoRa-constrained limit (63 bytes content) to ensure link packets fit within LoRa wire MTU
+ if (content_size <= Type::Constants::LORA_LINK_PACKET_MAX_CONTENT) {
_method = Type::Message::DIRECT;
_representation = Type::Message::PACKET;
INFO(" Message will be sent as single packet (" + std::to_string(_packed.size()) + " bytes)");
@@ -208,8 +225,27 @@ const Bytes& LXMessage::pack() {
_representation = Type::Message::RESOURCE;
INFO(" Message will be sent as resource (" + std::to_string(_packed.size()) + " bytes)");
}
+ } else if (_desired_method == Type::Message::PROPAGATED) {
+ // PROPAGATED: always use resource transfer to propagation node
+ _method = Type::Message::PROPAGATED;
+ _representation = Type::Message::RESOURCE;
+ INFO(" Message will be sent via propagation (" + std::to_string(_packed.size()) + " bytes)");
+ } else if (_desired_method == Type::Message::OPPORTUNISTIC) {
+ // OPPORTUNISTIC: single encrypted packet, no link required
+ // Use general ENCRYPTED_PACKET_MDU (not LoRa-specific) since caller
+ // explicitly requested OPPORTUNISTIC — matches Python LXMF behavior
+ if (_packed.size() <= Type::Constants::ENCRYPTED_PACKET_MDU) {
+ _method = Type::Message::OPPORTUNISTIC;
+ _representation = Type::Message::PACKET;
+ INFO(" Message will be sent opportunistically (" + std::to_string(_packed.size()) + " bytes)");
+ } else {
+ // Too large for single packet, fall back to DIRECT
+ _method = Type::Message::DIRECT;
+ _representation = Type::Message::RESOURCE;
+ INFO(" Message too large for OPPORTUNISTIC, using DIRECT resource (" + std::to_string(_packed.size()) + " bytes)");
+ }
} else {
- WARNING("Only DIRECT delivery method is supported in Phase 1 MVP");
+ // Default fallback
_method = Type::Message::DIRECT;
_representation = Type::Message::PACKET;
}
@@ -218,7 +254,7 @@ const Bytes& LXMessage::pack() {
INFO("Message packed successfully (" + std::to_string(_packed.size()) + " bytes total)");
DEBUG(" Overhead: " + std::to_string(Type::Constants::LXMF_OVERHEAD) + " bytes");
- DEBUG(" Payload: " + std::to_string(packed_payload.size()) + " bytes");
+ DEBUG(" Payload: " + std::to_string(wire_payload.size()) + " bytes");
return _packed;
}
@@ -310,15 +346,16 @@ LXMessage LXMessage::unpack_from_bytes(const Bytes& lxmf_bytes, Type::Message::M
DEBUG(" Msgpack map size: " + std::to_string(map_size.size()));
// Unpack each field (key-value pairs) into temporary storage
+ // Python LXMF packs field keys as integers, values as BIN
for (size_t i = 0; i < map_size.size() && temp_fields_count < MAX_FIELDS; ++i) {
- MsgPack::bin_t key_bin;
+ uint8_t key_int;
MsgPack::bin_t value_bin;
- unpacker.deserialize(key_bin);
+ unpacker.deserialize(key_int);
unpacker.deserialize(value_bin);
temp_fields[temp_fields_count].in_use = true;
- temp_fields[temp_fields_count].key = Bytes(key_bin);
+ temp_fields[temp_fields_count].key = Bytes(&key_int, 1);
temp_fields[temp_fields_count].value = Bytes(value_bin);
++temp_fields_count;
}
@@ -367,10 +404,29 @@ LXMessage LXMessage::unpack_from_bytes(const Bytes& lxmf_bytes, Type::Message::M
}
// 4. Calculate hash for verification
+ // Per Python LXMF: hash is computed over 4-element payload (without stamp).
+ // If stamp was present, re-pack without it.
+ Bytes payload_for_hash;
+ if (stamp.size() == LXStamper::STAMP_SIZE) {
+ MsgPack::Packer repacker;
+ repacker.packArraySize(4);
+ repacker.pack(timestamp);
+ repacker.packBinary(title.data(), title.size());
+ repacker.packBinary(content.data(), content.size());
+ repacker.packMapSize(temp_fields_count);
+ for (size_t i = 0; i < temp_fields_count; ++i) {
+ repacker.pack(temp_fields[i].key.data()[0]);
+ repacker.packBinary(temp_fields[i].value.data(), temp_fields[i].value.size());
+ }
+ payload_for_hash = Bytes(repacker.data(), repacker.size());
+ } else {
+ payload_for_hash = packed_payload;
+ }
+
Bytes hashed_part;
hashed_part << destination_hash;
hashed_part << source_hash;
- hashed_part << packed_payload;
+ hashed_part << payload_for_hash;
message._hash = Identity::full_hash(hashed_part);
@@ -438,21 +494,22 @@ bool LXMessage::validate_signature() {
}
}
- // Reconstruct signed part
+ // Reconstruct signed part — must match pack() exactly
Bytes hashed_part;
hashed_part << _destination_hash;
hashed_part << _source_hash;
- // Need to repack payload for hashed_part
+ // Repack 4-element payload for hash/sig (without stamp, matching Python LXMF)
MsgPack::Packer packer;
- packer.serialize(_timestamp);
- packer.serialize(_title);
- packer.serialize(_content);
- packer.serialize((uint32_t)_fields_count);
+ packer.packArraySize(4);
+ packer.pack(_timestamp);
+ packer.packBinary(_title.data(), _title.size());
+ packer.packBinary(_content.data(), _content.size());
+ packer.packMapSize(_fields_count);
for (size_t i = 0; i < MAX_FIELDS; ++i) {
if (_fields_pool[i].in_use) {
- packer.serialize(_fields_pool[i].key);
- packer.serialize(_fields_pool[i].value);
+ packer.pack(_fields_pool[i].key.data()[0]);
+ packer.packBinary(_fields_pool[i].value.data(), _fields_pool[i].value.size());
}
}
Bytes packed_payload(packer.data(), packer.size());
diff --git a/src/LXMF/LXMessage.h b/src/LXMF/LXMessage.h
index 2b81ee20..d3942b67 100644
--- a/src/LXMF/LXMessage.h
+++ b/src/LXMF/LXMessage.h
@@ -219,6 +219,9 @@ namespace LXMF {
*/
inline void state(Type::Message::State state) { _state = state; }
+ inline int delivery_attempts() const { return _delivery_attempts; }
+ inline void increment_delivery_attempts() { _delivery_attempts++; }
+
/**
* @brief Get message hash (ID)
*/
@@ -386,6 +389,7 @@ namespace LXMF {
// Message state
Type::Message::State _state = Type::Message::GENERATING;
+ int _delivery_attempts = 0;
// Signature validation
bool _signature_validated = false;
diff --git a/src/LXMF/MessageStore.cpp b/src/LXMF/MessageStore.cpp
index 5f66ce38..f980fbad 100644
--- a/src/LXMF/MessageStore.cpp
+++ b/src/LXMF/MessageStore.cpp
@@ -4,11 +4,42 @@
#include
#include
-#include
+#include
+#include
+#include
+
+#ifdef ARDUINO
+#include
+#endif
using namespace LXMF;
using namespace RNS;
+namespace {
+
+static std::string make_message_preview(const Bytes& content) {
+ size_t preview_len = std::min(content.size(), 60);
+ std::string preview;
+ preview.assign(reinterpret_cast(content.data()), preview_len);
+ if (content.size() > preview_len) {
+ preview += "...";
+ }
+ return preview;
+}
+
+static bool write_json_document(const std::string& path, JsonDocument& doc) {
+ std::string json_str;
+ serializeJson(doc, json_str);
+ Bytes data(reinterpret_cast(json_str.data()), json_str.size());
+ return Utilities::OS::write_file(path.c_str(), data) == data.size();
+}
+
+static bool write_bytes_file(const std::string& path, const Bytes& data) {
+ return Utilities::OS::write_file(path.c_str(), data) == data.size();
+}
+
+} // namespace
+
// ConversationInfo helper methods
bool MessageStore::ConversationInfo::add_message_hash(const Bytes& hash) {
// Check if already exists
@@ -62,6 +93,7 @@ void MessageStore::ConversationInfo::clear() {
last_activity = 0.0;
unread_count = 0;
memset(last_message_hash, 0, MESSAGE_HASH_SIZE);
+ memset(last_message_preview, 0, sizeof(last_message_preview));
}
// ConversationSlot helper method
@@ -78,14 +110,38 @@ MessageStore::MessageStore(const std::string& base_path) :
{
INFO("Initializing MessageStore at: " + _base_path);
- // Initialize pool
+ // Allocate conversation pool off internal heap when PSRAM is available.
+ const size_t pool_bytes = sizeof(ConversationSlot) * MAX_CONVERSATIONS;
+ void* pool_mem = nullptr;
+ bool pool_from_psram = false;
+#ifdef ARDUINO
+ pool_mem = heap_caps_malloc(pool_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+ if (!pool_mem) {
+ WARNING("MessageStore: PSRAM pool allocation failed, falling back to internal heap");
+ pool_mem = std::malloc(pool_bytes);
+ } else {
+ pool_from_psram = true;
+ }
+#else
+ pool_mem = std::malloc(pool_bytes);
+#endif
+
+ if (!pool_mem) {
+ ERROR("Failed to allocate conversation pool");
+ return;
+ }
+
+ _conversations_pool = std::unique_ptr(
+ static_cast(pool_mem),
+ ConversationPoolDeleter(pool_from_psram)
+ );
for (size_t i = 0; i < MAX_CONVERSATIONS; ++i) {
_conversations_pool[i].clear();
}
if (initialize_storage()) {
- load_index();
_initialized = true;
+ load_index();
INFO("MessageStore initialized with " + std::to_string(count_conversations()) + " conversations");
} else {
ERROR("Failed to initialize MessageStore");
@@ -96,6 +152,7 @@ MessageStore::~MessageStore() {
if (_initialized) {
save_index();
}
+ _conversations_pool.reset();
TRACE("MessageStore destroyed");
}
@@ -113,6 +170,8 @@ bool MessageStore::initialize_storage() {
// Load conversation index from disk
void MessageStore::load_index() {
std::string index_path = "/conv.json"; // Short path for SPIFFS
+ bool index_needs_save = false;
+ std::vector> preview_backfill;
if (!Utilities::OS::file_exists(index_path.c_str())) {
DEBUG("No existing conversation index found");
@@ -178,9 +237,42 @@ void MessageStore::load_index() {
slot.info.set_last_message_hash(last_msg_bytes);
}
+ if (conv["last_message_preview"].is()) {
+ slot.info.set_last_message_preview(conv["last_message_preview"].as());
+ } else {
+ Bytes last_msg_hash = slot.info.last_message_hash_bytes();
+ if (last_msg_hash) {
+ preview_backfill.push_back(std::make_pair(slot_index, last_msg_hash));
+ }
+ }
+
++slot_index;
}
+ for (const auto& entry : preview_backfill) {
+ if (entry.first >= MAX_CONVERSATIONS) {
+ continue;
+ }
+
+ ConversationSlot& slot = _conversations_pool[entry.first];
+ if (!slot.in_use) {
+ continue;
+ }
+
+ MessageMetadata meta = load_message_metadata(entry.second);
+ if (meta.valid && !meta.content.empty()) {
+ slot.info.set_last_message_preview(make_message_preview(Bytes(
+ reinterpret_cast(meta.content.data()),
+ meta.content.size()
+ )));
+ index_needs_save = true;
+ }
+ }
+
+ if (index_needs_save) {
+ save_index();
+ }
+
DEBUG("Loaded " + std::to_string(count_conversations()) + " conversations from index");
} catch (const std::exception& e) {
@@ -216,6 +308,10 @@ bool MessageStore::save_index() {
conv["last_message_hash"] = last_msg.toHex();
}
+ if (info.last_message_preview_cstr()[0] != '\0') {
+ conv["last_message_preview"] = info.last_message_preview_cstr();
+ }
+
// Serialize message hashes
JsonArray messages = conv["messages"].to();
for (size_t j = 0; j < info.message_count; ++j) {
@@ -225,8 +321,8 @@ bool MessageStore::save_index() {
// Serialize to string then write via OS abstraction (SPIFFS compatible)
std::string json_str;
- serializeJsonPretty(_json_doc, json_str);
- Bytes data((const uint8_t*)json_str.data(), json_str.size());
+ serializeJson(_json_doc, json_str);
+ Bytes data(reinterpret_cast(json_str.data()), json_str.size());
if (Utilities::OS::write_file(index_path.c_str(), data) != data.size()) {
ERROR("Failed to write index file: " + index_path);
@@ -252,36 +348,42 @@ bool MessageStore::save_message(const LXMessage& message) {
INFO("Saving message: " + message.hash().toHex());
try {
- // Use reusable document to reduce heap fragmentation
- _json_doc.clear();
+ const Bytes message_hash = message.hash();
+ const std::string metadata_path = get_message_metadata_path(message_hash);
+ const std::string payload_path = get_message_payload_path(message_hash);
+ const std::string legacy_path = get_legacy_message_path(message_hash);
+ const std::string preview = make_message_preview(message.content());
- _json_doc["hash"] = message.hash().toHex();
+ // Build compact metadata document only. Packed payload lives in a separate file.
+ _json_doc.clear();
+ _json_doc["hash"] = message_hash.toHex();
_json_doc["destination_hash"] = message.destination_hash().toHex();
_json_doc["source_hash"] = message.source_hash().toHex();
_json_doc["incoming"] = message.incoming();
_json_doc["timestamp"] = message.timestamp();
_json_doc["state"] = static_cast(message.state());
+ _json_doc["propagated"] = false;
+ _json_doc["content"] = std::string(reinterpret_cast(message.content().data()), message.content().size());
- // Store content as UTF-8 for fast loading (no msgpack unpacking needed)
- std::string content_str((const char*)message.content().data(), message.content().size());
- _json_doc["content"] = content_str;
-
- // Store the entire packed message to preserve hash/signature
- // This ensures exact reconstruction on load
- _json_doc["packed"] = message.packed().toHex();
-
- // Write message file via OS abstraction (SPIFFS compatible)
- std::string message_path = get_message_path(message.hash());
- std::string json_str;
- serializeJsonPretty(_json_doc, json_str);
- Bytes data((const uint8_t*)json_str.data(), json_str.size());
+ if (!write_json_document(metadata_path, _json_doc)) {
+ ERROR("Failed to write message metadata: " + metadata_path);
+ return false;
+ }
- if (Utilities::OS::write_file(message_path.c_str(), data) != data.size()) {
- ERROR("Failed to write message file: " + message_path);
+ const Bytes& packed = message.packed();
+ if (!write_bytes_file(payload_path, packed)) {
+ ERROR("Failed to write message payload: " + payload_path);
+ Utilities::OS::remove_file(metadata_path.c_str());
return false;
}
- DEBUG(" Message file saved: " + message_path);
+ // Remove any legacy monolithic file so the split layout is canonical.
+ if (Utilities::OS::file_exists(legacy_path.c_str())) {
+ Utilities::OS::remove_file(legacy_path.c_str());
+ }
+
+ DEBUG(" Message metadata saved: " + metadata_path);
+ DEBUG(" Message payload saved: " + payload_path);
// Update conversation index
// Determine peer hash (the other party in the conversation)
@@ -305,7 +407,8 @@ bool MessageStore::save_message(const LXMessage& message) {
WARNING("Message pool full for conversation: " + peer_hash.toHex());
} else {
conv.last_activity = message.timestamp();
- conv.set_last_message_hash(message.hash());
+ conv.set_last_message_hash(message_hash);
+ conv.set_last_message_preview(preview);
// Increment unread count for incoming messages
if (message.incoming()) {
@@ -335,43 +438,86 @@ LXMessage MessageStore::load_message(const Bytes& message_hash) {
return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
}
- std::string message_path = get_message_path(message_hash);
-
- if (!Utilities::OS::file_exists(message_path.c_str())) {
- WARNING("Message file not found: " + message_path);
- return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
- }
+ const std::string metadata_path = get_message_metadata_path(message_hash);
+ const std::string payload_path = get_message_payload_path(message_hash);
+ const std::string legacy_path = get_legacy_message_path(message_hash);
try {
- // Read JSON file via OS abstraction (SPIFFS compatible)
- Bytes data;
- if (Utilities::OS::read_file(message_path.c_str(), data) == 0) {
- ERROR("Failed to read message file: " + message_path);
- return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
- }
+ Bytes packed;
+ bool incoming = true;
- // Use reusable document to reduce heap fragmentation
- _json_doc.clear();
- DeserializationError error = deserializeJson(_json_doc, data.data(), data.size());
+ if (Utilities::OS::file_exists(payload_path.c_str())) {
+ if (Utilities::OS::read_file(payload_path.c_str(), packed) == 0) {
+ ERROR("Failed to read message payload: " + payload_path);
+ return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
+ }
- if (error) {
- ERROR("Failed to parse message file: " + std::string(error.c_str()));
- return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
- }
+ if (Utilities::OS::file_exists(metadata_path.c_str())) {
+ Bytes metadata_bytes;
+ if (Utilities::OS::read_file(metadata_path.c_str(), metadata_bytes) > 0) {
+ _json_doc.clear();
+ DeserializationError error = deserializeJson(_json_doc, metadata_bytes.data(), metadata_bytes.size());
+ if (!error && !_json_doc["incoming"].isNull()) {
+ incoming = _json_doc["incoming"].as();
+ }
+ }
+ }
+ } else {
+ std::string source_path = legacy_path;
+ if (!Utilities::OS::file_exists(source_path.c_str())) {
+ source_path = metadata_path;
+ }
+ if (!Utilities::OS::file_exists(source_path.c_str())) {
+ WARNING("Message file not found: " + source_path);
+ return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
+ }
- // Unpack the message from stored packed bytes
- // This preserves the exact hash and signature
- Bytes packed;
- packed.assignHex(_json_doc["packed"].as());
+ Bytes data;
+ if (Utilities::OS::read_file(source_path.c_str(), data) == 0) {
+ ERROR("Failed to read message file: " + source_path);
+ return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
+ }
- // Skip signature validation - messages from storage were already validated when received
- LXMessage message = LXMessage::unpack_from_bytes(packed, LXMF::Type::Message::DIRECT, true);
+ _json_doc.clear();
+ DeserializationError error = deserializeJson(_json_doc, data.data(), data.size());
+ if (error) {
+ ERROR("Failed to parse message file: " + std::string(error.c_str()));
+ return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
+ }
+
+ if (!_json_doc["incoming"].isNull()) {
+ incoming = _json_doc["incoming"].as();
+ }
- // Restore incoming flag from storage (unpack_from_bytes defaults to true)
- if (!_json_doc["incoming"].isNull()) {
- message.incoming(_json_doc["incoming"].as());
+ const char* packed_hex = _json_doc["packed"].as();
+ if (!packed_hex || packed_hex[0] == '\0') {
+ ERROR("Message file missing packed payload: " + source_path);
+ return LXMessage(Bytes(), Bytes(), Bytes(), Bytes());
+ }
+
+ packed.assignHex(packed_hex);
+
+ if (!Utilities::OS::file_exists(metadata_path.c_str())) {
+ JsonDocument metadata_doc;
+ metadata_doc["hash"] = _json_doc["hash"] | message_hash.toHex();
+ metadata_doc["destination_hash"] = _json_doc["destination_hash"] | "";
+ metadata_doc["source_hash"] = _json_doc["source_hash"] | "";
+ metadata_doc["incoming"] = incoming;
+ metadata_doc["timestamp"] = _json_doc["timestamp"] | 0.0;
+ metadata_doc["state"] = _json_doc["state"] | 0;
+ metadata_doc["propagated"] = _json_doc["propagated"] | false;
+ if (_json_doc["content"].is()) {
+ metadata_doc["content"] = _json_doc["content"].as();
+ }
+ if (write_json_document(metadata_path, metadata_doc)) {
+ write_bytes_file(payload_path, packed);
+ Utilities::OS::remove_file(legacy_path.c_str());
+ }
+ }
}
+ LXMessage message = LXMessage::unpack_from_bytes(packed, LXMF::Type::Message::DIRECT, true);
+ message.incoming(incoming);
DEBUG("Loaded message: " + message_hash.toHex());
return message;
@@ -390,35 +536,38 @@ MessageStore::MessageMetadata MessageStore::load_message_metadata(const Bytes& m
return meta;
}
- std::string message_path = get_message_path(message_hash);
-
- if (!Utilities::OS::file_exists(message_path.c_str())) {
- return meta;
- }
+ const std::string metadata_path = get_message_metadata_path(message_hash);
+ const std::string legacy_path = get_legacy_message_path(message_hash);
try {
+ std::string source_path;
+ if (Utilities::OS::file_exists(metadata_path.c_str())) {
+ source_path = metadata_path;
+ } else if (Utilities::OS::file_exists(legacy_path.c_str())) {
+ source_path = legacy_path;
+ } else {
+ return meta;
+ }
+
Bytes data;
- if (Utilities::OS::read_file(message_path.c_str(), data) == 0) {
+ if (Utilities::OS::read_file(source_path.c_str(), data) == 0) {
return meta;
}
- // Use reusable document to reduce heap fragmentation
_json_doc.clear();
DeserializationError error = deserializeJson(_json_doc, data.data(), data.size());
-
if (error) {
return meta;
}
meta.hash = message_hash;
-
- // Read pre-extracted fields (no msgpack unpacking needed)
if (_json_doc["content"].is()) {
meta.content = _json_doc["content"].as();
}
meta.timestamp = _json_doc["timestamp"] | 0.0;
meta.incoming = _json_doc["incoming"] | true;
meta.state = _json_doc["state"] | 0;
+ meta.propagated = _json_doc["propagated"] | false;
meta.valid = true;
return meta;
@@ -428,29 +577,35 @@ MessageStore::MessageMetadata MessageStore::load_message_metadata(const Bytes& m
}
}
-// Update message state in storage
-bool MessageStore::update_message_state(const Bytes& message_hash, Type::Message::State state) {
+bool MessageStore::update_message_metadata(
+ const Bytes& message_hash,
+ const Type::Message::State* state,
+ const bool* propagated
+) {
if (!_initialized) {
ERROR("MessageStore not initialized");
return false;
}
- std::string message_path = get_message_path(message_hash);
+ const std::string metadata_path = get_message_metadata_path(message_hash);
+ const std::string payload_path = get_message_payload_path(message_hash);
+ const std::string legacy_path = get_legacy_message_path(message_hash);
- if (!Utilities::OS::file_exists(message_path.c_str())) {
- WARNING("Message file not found: " + message_path);
+ const bool has_metadata = Utilities::OS::file_exists(metadata_path.c_str());
+ const bool has_legacy = Utilities::OS::file_exists(legacy_path.c_str());
+ if (!has_metadata && !has_legacy) {
+ WARNING("Message file not found: " + metadata_path);
return false;
}
try {
- // Read existing JSON
+ const std::string source_path = has_metadata ? metadata_path : legacy_path;
Bytes data;
- if (Utilities::OS::read_file(message_path.c_str(), data) == 0) {
- ERROR("Failed to read message file: " + message_path);
+ if (Utilities::OS::read_file(source_path.c_str(), data) == 0) {
+ ERROR("Failed to read message file: " + source_path);
return false;
}
- // Use reusable document to reduce heap fragmentation
_json_doc.clear();
DeserializationError error = deserializeJson(_json_doc, data.data(), data.size());
if (error) {
@@ -458,26 +613,83 @@ bool MessageStore::update_message_state(const Bytes& message_hash, Type::Message
return false;
}
- // Update state
- _json_doc["state"] = static_cast(state);
+ bool update_migrated_layout = !has_metadata && has_legacy;
+ if (state) {
+ _json_doc["state"] = static_cast(*state);
+ }
- // Write back
- std::string json_str;
- serializeJson(_json_doc, json_str);
- if (!Utilities::OS::write_file(message_path.c_str(), Bytes((uint8_t*)json_str.c_str(), json_str.length()))) {
- ERROR("Failed to write message file: " + message_path);
+ if (propagated) {
+ _json_doc["propagated"] = *propagated;
+ }
+
+ if (update_migrated_layout) {
+ const char* packed_hex = _json_doc["packed"].as();
+ if (packed_hex && packed_hex[0] != '\0') {
+ Bytes packed;
+ packed.assignHex(packed_hex);
+
+ JsonDocument metadata_doc;
+ metadata_doc["hash"] = _json_doc["hash"] | message_hash.toHex();
+ metadata_doc["destination_hash"] = _json_doc["destination_hash"] | "";
+ metadata_doc["source_hash"] = _json_doc["source_hash"] | "";
+ metadata_doc["incoming"] = _json_doc["incoming"] | true;
+ metadata_doc["timestamp"] = _json_doc["timestamp"] | 0.0;
+ metadata_doc["state"] = _json_doc["state"] | 0;
+ metadata_doc["propagated"] = _json_doc["propagated"] | false;
+ if (_json_doc["content"].is()) {
+ metadata_doc["content"] = _json_doc["content"].as();
+ }
+
+ if (!write_json_document(metadata_path, metadata_doc)) {
+ ERROR("Failed to write message metadata: " + metadata_path);
+ return false;
+ }
+
+ if (!write_bytes_file(payload_path, packed)) {
+ ERROR("Failed to write message payload: " + payload_path);
+ return false;
+ }
+
+ Utilities::OS::remove_file(legacy_path.c_str());
+ } else {
+ ERROR("Legacy message missing packed payload: " + legacy_path);
+ return false;
+ }
+ } else if (!write_json_document(metadata_path, _json_doc)) {
+ ERROR("Failed to write message metadata: " + metadata_path);
return false;
}
- INFO("Message state updated to " + std::to_string(static_cast(state)));
+ if (state && propagated) {
+ INFO("Message delivery metadata updated: state=" +
+ std::to_string(static_cast(*state)) +
+ ", propagated=" + std::string(*propagated ? "true" : "false"));
+ } else if (state) {
+ INFO("Message state updated to " + std::to_string(static_cast(*state)));
+ } else if (propagated) {
+ INFO("Message propagated flag updated to " + std::string(*propagated ? "true" : "false"));
+ }
return true;
} catch (const std::exception& e) {
- ERROR("Exception updating message state: " + std::string(e.what()));
+ ERROR("Exception updating message metadata: " + std::string(e.what()));
return false;
}
}
+// Update message state in storage
+bool MessageStore::update_message_state(const Bytes& message_hash, Type::Message::State state) {
+ return update_message_metadata(message_hash, &state, nullptr);
+}
+
+bool MessageStore::update_message_delivery_status(
+ const Bytes& message_hash,
+ Type::Message::State state,
+ bool propagated
+) {
+ return update_message_metadata(message_hash, &state, &propagated);
+}
+
// Delete message from storage
bool MessageStore::delete_message(const Bytes& message_hash) {
if (!_initialized) {
@@ -487,13 +699,23 @@ bool MessageStore::delete_message(const Bytes& message_hash) {
INFO("Deleting message: " + message_hash.toHex());
- // Remove message file
- std::string message_path = get_message_path(message_hash);
- if (Utilities::OS::file_exists(message_path.c_str())) {
- if (!Utilities::OS::remove_file(message_path.c_str())) {
- ERROR("Failed to delete message file: " + message_path);
- return false;
- }
+ // Remove message files (split metadata/payload plus legacy monolith)
+ const std::string metadata_path = get_message_metadata_path(message_hash);
+ const std::string payload_path = get_message_payload_path(message_hash);
+ const std::string legacy_path = get_legacy_message_path(message_hash);
+
+ if (Utilities::OS::file_exists(metadata_path.c_str()) &&
+ !Utilities::OS::remove_file(metadata_path.c_str())) {
+ ERROR("Failed to delete message metadata: " + metadata_path);
+ return false;
+ }
+ if (Utilities::OS::file_exists(payload_path.c_str()) &&
+ !Utilities::OS::remove_file(payload_path.c_str())) {
+ ERROR("Failed to delete message payload: " + payload_path);
+ return false;
+ }
+ if (Utilities::OS::file_exists(legacy_path.c_str())) {
+ Utilities::OS::remove_file(legacy_path.c_str());
}
// Update conversation index - remove from all conversations
@@ -548,12 +770,41 @@ std::vector MessageStore::get_conversations() {
}
// Get conversation info
-MessageStore::ConversationInfo MessageStore::get_conversation_info(const Bytes& peer_hash) {
+const MessageStore::ConversationInfo* MessageStore::get_conversation_info(const Bytes& peer_hash) const {
const ConversationSlot* slot = find_conversation(peer_hash);
if (slot) {
- return slot->info;
+ return &slot->info;
}
- return ConversationInfo();
+ return nullptr;
+}
+
+std::vector MessageStore::get_conversation_summaries() const {
+ std::vector summaries;
+ summaries.reserve(count_conversations());
+
+ for (size_t i = 0; i < MAX_CONVERSATIONS; ++i) {
+ const ConversationSlot& slot = _conversations_pool[i];
+ if (!slot.in_use) {
+ continue;
+ }
+
+ ConversationSummary summary;
+ summary.peer_hash = slot.peer_hash_bytes();
+ summary.last_activity = slot.info.last_activity;
+ summary.unread_count = static_cast(slot.info.unread_count);
+ summary.last_message_preview = slot.info.last_message_preview_cstr();
+ summaries.push_back(summary);
+ }
+
+ std::sort(
+ summaries.begin(),
+ summaries.end(),
+ [](const ConversationSummary& a, const ConversationSummary& b) {
+ return a.last_activity > b.last_activity;
+ }
+ );
+
+ return summaries;
}
// Get messages for conversation
@@ -592,9 +843,18 @@ bool MessageStore::delete_conversation(const Bytes& peer_hash) {
// Delete all message files
for (size_t i = 0; i < slot->info.message_count; ++i) {
- std::string message_path = get_message_path(slot->info.message_hash_bytes(i));
- if (Utilities::OS::file_exists(message_path.c_str())) {
- Utilities::OS::remove_file(message_path.c_str());
+ const Bytes hash = slot->info.message_hash_bytes(i);
+ const std::string metadata_path = get_message_metadata_path(hash);
+ const std::string payload_path = get_message_payload_path(hash);
+ const std::string legacy_path = get_legacy_message_path(hash);
+ if (Utilities::OS::file_exists(metadata_path.c_str())) {
+ Utilities::OS::remove_file(metadata_path.c_str());
+ }
+ if (Utilities::OS::file_exists(payload_path.c_str())) {
+ Utilities::OS::remove_file(payload_path.c_str());
+ }
+ if (Utilities::OS::file_exists(legacy_path.c_str())) {
+ Utilities::OS::remove_file(legacy_path.c_str());
}
}
@@ -644,9 +904,18 @@ bool MessageStore::clear_all() {
continue;
}
for (size_t j = 0; j < slot.info.message_count; ++j) {
- std::string message_path = get_message_path(slot.info.message_hash_bytes(j));
- if (Utilities::OS::file_exists(message_path.c_str())) {
- Utilities::OS::remove_file(message_path.c_str());
+ const Bytes hash = slot.info.message_hash_bytes(j);
+ const std::string metadata_path = get_message_metadata_path(hash);
+ const std::string payload_path = get_message_payload_path(hash);
+ const std::string legacy_path = get_legacy_message_path(hash);
+ if (Utilities::OS::file_exists(metadata_path.c_str())) {
+ Utilities::OS::remove_file(metadata_path.c_str());
+ }
+ if (Utilities::OS::file_exists(payload_path.c_str())) {
+ Utilities::OS::remove_file(payload_path.c_str());
+ }
+ if (Utilities::OS::file_exists(legacy_path.c_str())) {
+ Utilities::OS::remove_file(legacy_path.c_str());
}
}
slot.clear();
@@ -663,6 +932,18 @@ bool MessageStore::clear_all() {
// Use short path for SPIFFS compatibility (32 char filename limit)
// Format: /m/.j (12 chars of hash = 6 bytes = plenty unique for local store)
std::string MessageStore::get_message_path(const Bytes& message_hash) const {
+ return get_legacy_message_path(message_hash);
+}
+
+std::string MessageStore::get_message_metadata_path(const Bytes& message_hash) const {
+ return "/m/" + message_hash.toHex().substr(0, 12) + ".m";
+}
+
+std::string MessageStore::get_message_payload_path(const Bytes& message_hash) const {
+ return "/m/" + message_hash.toHex().substr(0, 12) + ".p";
+}
+
+std::string MessageStore::get_legacy_message_path(const Bytes& message_hash) const {
return "/m/" + message_hash.toHex().substr(0, 12) + ".j";
}
diff --git a/src/LXMF/MessageStore.h b/src/LXMF/MessageStore.h
index d0f7bf34..4c901b73 100644
--- a/src/LXMF/MessageStore.h
+++ b/src/LXMF/MessageStore.h
@@ -4,8 +4,16 @@
#include "../Bytes.h"
#include
+#include
+#include
+#include
#include
#include
+#include
+
+#ifdef ARDUINO
+#include
+#endif
namespace LXMF {
@@ -14,6 +22,7 @@ namespace LXMF {
static constexpr size_t MAX_MESSAGES_PER_CONVERSATION = 256;
static constexpr size_t MESSAGE_HASH_SIZE = 32; // SHA256 hash
static constexpr size_t PEER_HASH_SIZE = 16; // Truncated hash
+ static constexpr size_t MESSAGE_PREVIEW_SIZE = 64;
/**
* @brief Message persistence and conversation management for LXMF
@@ -24,7 +33,9 @@ namespace LXMF {
* Storage structure:
* /
* conversations.json - Conversation index
- * messages/.json - Individual message files
+ * messages/.m - Message metadata (content, state, timestamps)
+ * messages/.p - Packed LXMF payload bytes
+ * messages/.j - Legacy monolithic file (read/migrate only)
* conversations// - Per-conversation metadata
*
* Usage:
@@ -50,6 +61,7 @@ namespace LXMF {
double last_activity = 0.0; // Timestamp of most recent message
size_t unread_count = 0; // Number of unread messages
uint8_t last_message_hash[MESSAGE_HASH_SIZE];
+ char last_message_preview[MESSAGE_PREVIEW_SIZE];
// Helper methods for accessing fixed arrays as Bytes
RNS::Bytes peer_hash_bytes() const { return RNS::Bytes(peer_hash, PEER_HASH_SIZE); }
@@ -58,6 +70,7 @@ namespace LXMF {
return RNS::Bytes(message_hashes[idx], MESSAGE_HASH_SIZE);
}
RNS::Bytes last_message_hash_bytes() const { return RNS::Bytes(last_message_hash, MESSAGE_HASH_SIZE); }
+ const char* last_message_preview_cstr() const { return last_message_preview; }
void set_peer_hash(const RNS::Bytes& b) {
size_t len = std::min(b.size(), PEER_HASH_SIZE);
@@ -69,6 +82,9 @@ namespace LXMF {
memcpy(last_message_hash, b.data(), len);
if (len < MESSAGE_HASH_SIZE) memset(last_message_hash + len, 0, MESSAGE_HASH_SIZE - len);
}
+ void set_last_message_preview(const std::string& preview) {
+ snprintf(last_message_preview, sizeof(last_message_preview), "%s", preview.c_str());
+ }
bool peer_hash_equals(const RNS::Bytes& b) const {
if (b.size() != PEER_HASH_SIZE) return false;
return memcmp(peer_hash, b.data(), PEER_HASH_SIZE) == 0;
@@ -101,6 +117,30 @@ namespace LXMF {
void clear();
};
+ struct ConversationSlot;
+
+ struct ConversationPoolDeleter {
+ bool psram_allocated = false;
+
+ ConversationPoolDeleter() noexcept = default;
+ explicit ConversationPoolDeleter(bool from_psram) noexcept : psram_allocated(from_psram) {}
+
+ void operator()(ConversationSlot* ptr) const noexcept {
+ if (!ptr) {
+ return;
+ }
+#ifdef ARDUINO
+ if (psram_allocated) {
+ heap_caps_free(ptr);
+ } else {
+ std::free(ptr);
+ }
+#else
+ std::free(ptr);
+#endif
+ }
+ };
+
/**
* @brief Fixed-size slot for conversation storage
*/
@@ -139,9 +179,17 @@ namespace LXMF {
double timestamp;
bool incoming;
int state; // Type::Message::State as int
+ bool propagated = false;
bool valid; // True if loaded successfully
};
+ struct ConversationSummary {
+ RNS::Bytes peer_hash;
+ double last_activity = 0.0;
+ uint16_t unread_count = 0;
+ std::string last_message_preview;
+ };
+
public:
/**
* @brief Construct MessageStore
@@ -175,8 +223,9 @@ namespace LXMF {
/**
* @brief Load only message metadata (fast path for chat list)
*
- * Reads content/timestamp/state directly from JSON without msgpack unpacking.
- * Much faster than load_message() for displaying message lists.
+ * Reads content/timestamp/state directly from compact metadata storage
+ * without unpacking the LXMF payload. Much faster than load_message()
+ * for displaying message lists.
*
* @param message_hash Hash of the message to load
* @return MessageMetadata struct (check .valid field)
@@ -194,6 +243,22 @@ namespace LXMF {
*/
bool update_message_state(const RNS::Bytes& message_hash, Type::Message::State state);
+ /**
+ * @brief Update outgoing delivery status in storage
+ *
+ * Updates the state and propagated flag together in a single file rewrite.
+ *
+ * @param message_hash Hash of the message to update
+ * @param state New state value
+ * @param propagated True if accepted by propagation node
+ * @return True if updated successfully
+ */
+ bool update_message_delivery_status(
+ const RNS::Bytes& message_hash,
+ Type::Message::State state,
+ bool propagated
+ );
+
/**
* @brief Delete a message from storage
*
@@ -219,7 +284,15 @@ namespace LXMF {
* @param peer_hash Hash of the peer
* @return ConversationInfo (or empty if not found)
*/
- ConversationInfo get_conversation_info(const RNS::Bytes& peer_hash);
+ const ConversationInfo* get_conversation_info(const RNS::Bytes& peer_hash) const;
+
+ /**
+ * @brief Get lightweight summaries for all conversations
+ *
+ * Returns conversations sorted by last activity (most recent first) using
+ * only the in-memory index data needed for list rendering.
+ */
+ std::vector get_conversation_summaries() const;
/**
* @brief Get all message hashes for a conversation
@@ -312,6 +385,25 @@ namespace LXMF {
*/
std::string get_message_path(const RNS::Bytes& message_hash) const;
+ /**
+ * @brief Get compact metadata path for a message
+ *
+ * New-format metadata is stored separately from packed LXMF payload.
+ */
+ std::string get_message_metadata_path(const RNS::Bytes& message_hash) const;
+
+ /**
+ * @brief Get packed payload path for a message
+ */
+ std::string get_message_payload_path(const RNS::Bytes& message_hash) const;
+
+ /**
+ * @brief Get legacy single-file message path
+ *
+ * Used only for backward-compatible reads and migration.
+ */
+ std::string get_legacy_message_path(const RNS::Bytes& message_hash) const;
+
/**
* @brief Get filesystem path for conversation directory
*
@@ -356,9 +448,21 @@ namespace LXMF {
*/
size_t count_conversations() const;
+ /**
+ * @brief Shared metadata update helper for message JSON files
+ *
+ * Updates one or both of the persisted state/propagated fields in a single
+ * read/modify/write cycle.
+ */
+ bool update_message_metadata(
+ const RNS::Bytes& message_hash,
+ const Type::Message::State* state,
+ const bool* propagated
+ );
+
private:
std::string _base_path;
- ConversationSlot _conversations_pool[MAX_CONVERSATIONS];
+ std::unique_ptr _conversations_pool;
bool _initialized;
// Reusable JSON document to reduce heap fragmentation
diff --git a/src/LXMF/PropagationNodeManager.cpp b/src/LXMF/PropagationNodeManager.cpp
index ee14265c..4dce5524 100644
--- a/src/LXMF/PropagationNodeManager.cpp
+++ b/src/LXMF/PropagationNodeManager.cpp
@@ -19,6 +19,7 @@ void PropagationNodeManager::received_announce(
const Identity& announced_identity,
const Bytes& app_data
) {
+ (void)announced_identity;
std::string hash_str = destination_hash.toHex().substr(0, 16);
TRACE("PropagationNodeManager::received_announce from " + hash_str + "...");
@@ -35,6 +36,8 @@ void PropagationNodeManager::received_announce(
info.node_hash = destination_hash;
info.last_seen = Utilities::OS::time();
+ Bytes effective_before = get_effective_node();
+ PropagationNodeInfo effective_before_info = get_node(effective_before);
// Get hop count from Transport
info.hops = Transport::hops_to(destination_hash);
@@ -48,8 +51,15 @@ void PropagationNodeManager::received_announce(
if (!slot) {
slot = find_empty_node_slot();
if (!slot) {
- WARNING("PropagationNodeManager: Pool full, cannot add node " + hash_str);
- return;
+ PropagationNodeSlot* worst_slot = find_worst_node_slot();
+ if (!worst_slot || !is_better_candidate(info, worst_slot->info)) {
+ WARNING("PropagationNodeManager: Pool full, dropping lower-priority node " + hash_str);
+ return;
+ }
+
+ INFO("PropagationNodeManager: Evicting node " +
+ worst_slot->node_hash.toHex().substr(0, 16) + "... for better candidate " + hash_str);
+ slot = worst_slot;
}
}
@@ -68,8 +78,21 @@ void PropagationNodeManager::received_announce(
"... reports propagation disabled");
}
+ update_effective_node(false);
+
// Notify listeners
- if (_update_callback) {
+ Bytes effective_after = get_effective_node();
+ PropagationNodeInfo effective_after_info = get_node(effective_after);
+ bool effective_changed = (effective_after != effective_before);
+ bool effective_info_changed =
+ (effective_after == effective_before) &&
+ (
+ effective_after_info.stamp_cost != effective_before_info.stamp_cost ||
+ effective_after_info.enabled != effective_before_info.enabled ||
+ effective_after_info.hops != effective_before_info.hops
+ );
+
+ if (_update_callback && (effective_changed || effective_info_changed)) {
_update_callback();
}
}
@@ -132,24 +155,21 @@ PropagationNodeInfo PropagationNodeManager::parse_announce_data(const Bytes& app
unpacker.deserialize(key);
if (key == PN_META_NAME) {
- // Name is a binary/string
+ // Python packs name as bytes (bin type via str.encode("utf-8"))
MsgPack::bin_t name_bin;
unpacker.deserialize(name_bin);
- info.name = std::string(name_bin.begin(), name_bin.end());
- } else {
- // Skip other metadata fields by reading and discarding
- // Try to read as binary first (most common), fall back to int
- try {
- MsgPack::bin_t skip_bin;
- unpacker.deserialize(skip_bin);
- } catch (...) {
- try {
- int64_t skip_int;
- unpacker.deserialize(skip_int);
- } catch (...) {
- // Give up - might be complex type
- }
+ if (!name_bin.empty()) {
+ info.name = std::string(name_bin.begin(), name_bin.end());
}
+ // On type mismatch (e.g. str type from non-standard node),
+ // type_error() silently advances curr_index past the element.
+ // Name stays empty and will use default fallback below.
+ } else {
+ // Skip unknown metadata value: deserialize as int64_t.
+ // On type mismatch, type_error() increments curr_index by 1,
+ // which correctly skips to the next element regardless of type.
+ int64_t skip_val;
+ unpacker.deserialize(skip_val);
}
}
@@ -202,9 +222,15 @@ bool PropagationNodeManager::has_node(const Bytes& hash) const {
}
void PropagationNodeManager::set_selected_node(const Bytes& hash) {
+ Bytes effective_before = get_effective_node();
+
if (hash.size() == 0) {
- _selected_node = {};
+ _manual_selected_node = {};
+ update_effective_node(true);
INFO("PropagationNodeManager: Cleared manual node selection");
+ if (_update_callback && get_effective_node() != effective_before) {
+ _update_callback();
+ }
return;
}
@@ -213,10 +239,14 @@ void PropagationNodeManager::set_selected_node(const Bytes& hash) {
return;
}
- _selected_node = hash;
+ _manual_selected_node = hash;
+ update_effective_node(true);
PropagationNodeInfo node = get_node(hash);
INFO("PropagationNodeManager: Selected node '" + node.name + "' (" +
hash.toHex().substr(0, 16) + "...)");
+ if (_update_callback && get_effective_node() != effective_before) {
+ _update_callback();
+ }
}
Bytes PropagationNodeManager::get_best_node() const {
@@ -230,8 +260,7 @@ Bytes PropagationNodeManager::get_best_node() const {
}
const PropagationNodeInfo& node = _nodes_pool[i].info;
- // Skip disabled nodes
- if (!node.enabled) {
+ if (!is_reachable_candidate(node)) {
continue;
}
@@ -241,6 +270,8 @@ Bytes PropagationNodeManager::get_best_node() const {
is_better = true;
} else if (node.hops == best.hops && node.last_seen > best.last_seen) {
is_better = true;
+ } else if (node.hops == best.hops && node.last_seen == best.last_seen && node.stamp_cost < best.stamp_cost) {
+ is_better = true;
}
if (is_better) {
@@ -257,21 +288,27 @@ Bytes PropagationNodeManager::get_best_node() const {
}
Bytes PropagationNodeManager::get_effective_node() const {
- if (_selected_node.size() > 0) {
- // Verify selected node is still valid
- const PropagationNodeSlot* slot = find_node_slot(_selected_node);
- if (slot && slot->info.enabled) {
- return _selected_node;
+ if (_manual_selected_node.size() > 0) {
+ const PropagationNodeSlot* slot = find_node_slot(_manual_selected_node);
+ if (slot) {
+ return _manual_selected_node;
+ }
+ }
+
+ if (_effective_node.size() > 0) {
+ const PropagationNodeSlot* slot = find_node_slot(_effective_node);
+ if (slot) {
+ return _effective_node;
}
}
- // Fall back to auto-selection
return get_best_node();
}
void PropagationNodeManager::clean_stale_nodes() {
double now = Utilities::OS::time();
bool removed_any = false;
+ Bytes effective_before = get_effective_node();
for (size_t i = 0; i < MAX_PROPAGATION_NODES; ++i) {
if (_nodes_pool[i].in_use &&
@@ -283,7 +320,11 @@ void PropagationNodeManager::clean_stale_nodes() {
}
}
- if (removed_any && _update_callback) {
+ if (removed_any) {
+ update_effective_node(true);
+ }
+
+ if (removed_any && _update_callback && get_effective_node() != effective_before) {
_update_callback();
}
}
@@ -315,6 +356,26 @@ PropagationNodeSlot* PropagationNodeManager::find_empty_node_slot() {
return nullptr;
}
+PropagationNodeSlot* PropagationNodeManager::find_worst_node_slot() {
+ PropagationNodeSlot* worst = nullptr;
+
+ for (size_t i = 0; i < MAX_PROPAGATION_NODES; ++i) {
+ if (!_nodes_pool[i].in_use) {
+ continue;
+ }
+ if (!worst) {
+ worst = &_nodes_pool[i];
+ continue;
+ }
+
+ if (is_better_candidate(worst->info, _nodes_pool[i].info)) {
+ worst = &_nodes_pool[i];
+ }
+ }
+
+ return worst;
+}
+
size_t PropagationNodeManager::nodes_count() const {
size_t count = 0;
for (size_t i = 0; i < MAX_PROPAGATION_NODES; ++i) {
@@ -324,3 +385,97 @@ size_t PropagationNodeManager::nodes_count() const {
}
return count;
}
+
+bool PropagationNodeManager::is_better_candidate(const PropagationNodeInfo& candidate, const PropagationNodeInfo& current) const {
+ if (!candidate.enabled && current.enabled) {
+ return false;
+ }
+ if (candidate.enabled && !current.enabled) {
+ return true;
+ }
+ if (candidate.hops != current.hops) {
+ return candidate.hops < current.hops;
+ }
+ if (candidate.last_seen != current.last_seen) {
+ return candidate.last_seen > current.last_seen;
+ }
+ return candidate.stamp_cost < current.stamp_cost;
+}
+
+bool PropagationNodeManager::is_reachable_candidate(const PropagationNodeInfo& candidate) const {
+ if (!candidate || !candidate.enabled) {
+ return false;
+ }
+
+ if (!Transport::has_path(candidate.node_hash)) {
+ return false;
+ }
+
+ Identity identity = Identity::recall(candidate.node_hash);
+ return static_cast(identity);
+}
+
+bool PropagationNodeManager::should_switch_effective_node(
+ const PropagationNodeInfo& current,
+ const PropagationNodeInfo& candidate,
+ bool force_reselect
+) const {
+ if (!candidate || !is_reachable_candidate(candidate)) {
+ return false;
+ }
+ if (!current) {
+ return true;
+ }
+ if (!is_reachable_candidate(current)) {
+ return true;
+ }
+ if (force_reselect) {
+ return is_better_candidate(candidate, current);
+ }
+
+ double now = Utilities::OS::time();
+ double current_age = now - current.last_seen;
+ if (current_age > EFFECTIVE_NODE_STICKY_WINDOW) {
+ return is_better_candidate(candidate, current);
+ }
+
+ if (candidate.hops + EFFECTIVE_NODE_SWITCH_HOP_MARGIN <= current.hops) {
+ return true;
+ }
+
+ return false;
+}
+
+void PropagationNodeManager::update_effective_node(bool force_reselect) {
+ if (_manual_selected_node.size() > 0) {
+ const PropagationNodeSlot* manual_slot = find_node_slot(_manual_selected_node);
+ if (manual_slot) {
+ _effective_node = _manual_selected_node;
+ return;
+ }
+ }
+
+ if (_effective_node.size() > 0) {
+ const PropagationNodeSlot* current_slot = find_node_slot(_effective_node);
+ if (current_slot) {
+ Bytes best_hash = get_best_node();
+ if (best_hash.size() == 0) {
+ return;
+ }
+
+ const PropagationNodeSlot* best_slot = find_node_slot(best_hash);
+ if (!best_slot) {
+ return;
+ }
+
+ if (!should_switch_effective_node(current_slot->info, best_slot->info, force_reselect)) {
+ return;
+ }
+ }
+ }
+
+ Bytes best_hash = get_best_node();
+ if (best_hash.size() > 0) {
+ _effective_node = best_hash;
+ }
+}
diff --git a/src/LXMF/PropagationNodeManager.h b/src/LXMF/PropagationNodeManager.h
index 71278334..5c92c504 100644
--- a/src/LXMF/PropagationNodeManager.h
+++ b/src/LXMF/PropagationNodeManager.h
@@ -83,6 +83,8 @@ namespace LXMF {
// Stale node timeout (1 hour)
static constexpr double NODE_STALE_TIMEOUT = 3600.0;
+ static constexpr double EFFECTIVE_NODE_STICKY_WINDOW = 120.0;
+ static constexpr uint8_t EFFECTIVE_NODE_SWITCH_HOP_MARGIN = 2;
public:
/**
@@ -146,7 +148,7 @@ namespace LXMF {
*
* @return Destination hash of selected node (or empty if none)
*/
- RNS::Bytes get_selected_node() const { return _selected_node; }
+ RNS::Bytes get_selected_node() const { return _manual_selected_node; }
/**
* @brief Auto-select the best available propagation node
@@ -216,6 +218,7 @@ namespace LXMF {
* @return Pointer to empty slot, nullptr if pool is full
*/
PropagationNodeSlot* find_empty_node_slot();
+ PropagationNodeSlot* find_worst_node_slot();
/**
* @brief Get the number of nodes currently in use
@@ -223,10 +226,15 @@ namespace LXMF {
* @return Number of active nodes in the pool
*/
size_t nodes_count() const;
+ bool is_better_candidate(const PropagationNodeInfo& candidate, const PropagationNodeInfo& current) const;
+ bool is_reachable_candidate(const PropagationNodeInfo& candidate) const;
+ bool should_switch_effective_node(const PropagationNodeInfo& current, const PropagationNodeInfo& candidate, bool force_reselect) const;
+ void update_effective_node(bool force_reselect = false);
private:
PropagationNodeSlot _nodes_pool[MAX_PROPAGATION_NODES];
- RNS::Bytes _selected_node;
+ RNS::Bytes _manual_selected_node;
+ RNS::Bytes _effective_node;
NodeUpdateCallback _update_callback;
};
diff --git a/src/LXMF/Type.h b/src/LXMF/Type.h
index 3792c0c0..22345dd8 100644
--- a/src/LXMF/Type.h
+++ b/src/LXMF/Type.h
@@ -43,7 +43,7 @@ namespace LXMF {
*/
enum Method : uint8_t {
OPPORTUNISTIC = 0x01, ///< Single packet, fire-and-forget
- DIRECT = 0x02, ///< Via established link (Phase 1 MVP)
+ DIRECT = 0x02, ///< Via established link
PROPAGATED = 0x03, ///< Store-and-forward via propagation nodes
PAPER = 0x05 ///< QR code / paper-based transfer
};
@@ -96,6 +96,13 @@ namespace LXMF {
// With an MTU of 500, encrypted packet MDU is 391 bytes
static const uint16_t ENCRYPTED_PACKET_MDU = RNS::Type::Packet::ENCRYPTED_MDU + TIMESTAMP_SIZE; // 391 bytes
+ // LoRa-constrained ENCRYPTED_PACKET_MDU (for devices with LoRa interfaces)
+ // LoRa wire MTU=255, SINGLE header=19, IFAC=1 → max encrypted payload=235
+ // Subtract ephemeral key(32) + IV(16) + HMAC(32) = 155 bytes for ciphertext
+ // Max AES blocks: floor(155/16)=9 → max padded plaintext=144 → max plaintext=143
+ // Add back destination hash(16) stripped in OPPORTUNISTIC: max packed_size=159
+ static const uint16_t LORA_ENCRYPTED_PACKET_MDU = 159;
+
/**
* @brief Max content in single encrypted packet: 295 bytes
*
@@ -111,14 +118,27 @@ namespace LXMF {
*/
static const uint16_t LINK_PACKET_MDU = RNS::Type::Link::MDU;
+ // LoRa-constrained LINK_PACKET_MDU (for devices with LoRa interfaces)
+ // LoRa wire MTU=255, SX1262 adds 1 IFAC byte → max at transmit=254
+ // Link header=19 bytes (2 flags + 1 context + 16 link_id)
+ // Max Token output = 254 - 19 = 235 bytes
+ // Token = IV(16) + ciphertext + HMAC(32), max ciphertext = 235 - 48 = 187
+ // Max AES blocks: floor(187/16)=11 → max padded ciphertext=176
+ // Max plaintext: 175 (PKCS7: 175 mod 16 = 15, pad 1 → 176)
+ static const uint16_t LORA_LINK_PACKET_MDU = 175;
+
/**
- * @brief Max content in single link packet: 319 bytes (Phase 1 MVP limit)
+ * @brief Max content in single link packet: 319 bytes
*
* Calculation: LINK_PACKET_MDU - LXMF_OVERHEAD
* Messages larger than 319 bytes will use Resource transfer.
*/
static const uint16_t LINK_PACKET_MAX_CONTENT = LINK_PACKET_MDU - LXMF_OVERHEAD;
+ // LoRa-constrained: max content in single link packet = 175 - 112 = 63 bytes
+ // Messages with content > 63 bytes sent over links will use Resource transfer
+ static const uint16_t LORA_LINK_PACKET_MAX_CONTENT = LORA_LINK_PACKET_MDU - LXMF_OVERHEAD;
+
// Plain (unencrypted) packet MDU
static const uint16_t PLAIN_PACKET_MDU = RNS::Type::Packet::PLAIN_MDU;
diff --git a/src/Link.cpp b/src/Link.cpp
index 88e9b41b..f45ff5ea 100644
--- a/src/Link.cpp
+++ b/src/Link.cpp
@@ -18,6 +18,63 @@
#define MSGPACK_DEBUGLOG_ENABLE 0
#include
+// Helper: parse msgpack [request_id, response_data] without type constraints.
+// The request_id is always BIN (hash bytes). The response_data can be ANY
+// msgpack type (array, int, bin, etc.) — we return its raw bytes for the
+// caller to parse. This is needed for Python interop where response_data
+// is a native msgpack array, not BIN-wrapped.
+// Returns byte offset past request_id (>0 on success, 0 on failure).
+static size_t parse_response_array(const RNS::Bytes& packed, RNS::Bytes& request_id_out, RNS::Bytes& response_data_out) {
+ const uint8_t* p = packed.data();
+ size_t total = packed.size();
+ if (total < 3) return 0; // Minimum: fixarray(2) + 1 byte + 1 byte
+
+ size_t pos = 0;
+
+ // 1. Parse array header — expect fixarray of 2 (0x92) or array16/array32
+ uint8_t header = p[pos++];
+ size_t arr_size = 0;
+ if ((header & 0xf0) == 0x90) {
+ arr_size = header & 0x0f; // fixarray
+ } else if (header == 0xdc && pos + 2 <= total) {
+ arr_size = ((size_t)p[pos] << 8) | p[pos+1]; // array16
+ pos += 2;
+ } else if (header == 0xdd && pos + 4 <= total) {
+ arr_size = ((size_t)p[pos] << 24) | ((size_t)p[pos+1] << 16) | ((size_t)p[pos+2] << 8) | p[pos+3]; // array32
+ pos += 4;
+ } else {
+ return 0; // Not an array
+ }
+ if (arr_size < 2) return 0;
+
+ // 2. Parse element [0]: request_id (BIN type — hash bytes)
+ if (pos >= total) return 0;
+ uint8_t bin_header = p[pos++];
+ size_t bin_len = 0;
+ if (bin_header == 0xc4 && pos + 1 <= total) { // bin8
+ bin_len = p[pos++];
+ } else if (bin_header == 0xc5 && pos + 2 <= total) { // bin16
+ bin_len = ((size_t)p[pos] << 8) | p[pos+1];
+ pos += 2;
+ } else if (bin_header == 0xc6 && pos + 4 <= total) { // bin32
+ bin_len = ((size_t)p[pos] << 24) | ((size_t)p[pos+1] << 16) | ((size_t)p[pos+2] << 8) | p[pos+3];
+ pos += 4;
+ } else {
+ return 0; // request_id not BIN type
+ }
+ if (pos + bin_len > total) return 0;
+ request_id_out = RNS::Bytes(p + pos, bin_len);
+ pos += bin_len;
+
+ // 3. Remaining bytes are the raw msgpack of element [1] (response_data)
+ if (pos >= total) {
+ response_data_out = RNS::Bytes();
+ } else {
+ response_data_out = RNS::Bytes(p + pos, total - pos);
+ }
+ return pos;
+}
+
#include
#include
@@ -192,6 +249,23 @@ Link::Link(const Destination& destination /*= {Type::NONE}*/, Callbacks::establi
Link link({Type::NONE}, nullptr, nullptr, owner, data.left(ECPUBSIZE/2), data.mid(ECPUBSIZE/2, ECPUBSIZE/2));
INFO(">>> Link::validate_request step 2: setting link_id");
link.set_link_id(packet);
+
+ if (data.size() == ECPUBSIZE + LINK_MTU_SIZE) {
+ DEBUG("Link request includes MTU signalling");
+ try {
+ uint16_t mtu = mtu_from_lr_packet(packet);
+ link.mtu((mtu != 0) ? mtu : Type::Reticulum::MTU);
+ }
+ catch (std::exception& e) {
+ ERRORF("An error occurred while validating link request %s", link.link_id().toHex().c_str());
+ link.mtu(Type::Reticulum::MTU);
+ }
+ }
+
+ link.mode(mode_from_lr_packet(packet));
+ DEBUGF("Incoming link request with mode %d", link.get_mode());
+ link.update_mdu();
+
link.destination(packet.destination());
link.establishment_timeout(ESTABLISHMENT_TIMEOUT_PER_HOP * std::max((uint8_t)1, packet.hops()) + KEEPALIVE);
link.establishment_cost(link.establishment_cost() + packet.raw().size());
@@ -220,7 +294,7 @@ Link::Link(const Destination& destination /*= {Type::NONE}*/, Callbacks::establi
}
}
else {
- DEBUG("Invalid link request payload size, dropping request");
+ DEBUGF("Invalid link request payload size (%zu), dropping request", data.size());
return {Type::NONE};
}
}
@@ -280,12 +354,20 @@ void Link::prove() {
INFO(">>> prove(): entry");
DEBUGF("Link %s requesting proof", link_id().toHex().c_str());
INFO(">>> prove(): preparing signed_data");
- Bytes signed_data =_object->_link_id + _object->_pub_bytes + _object->_sig_pub_bytes;
+ // Cap link MTU at interface HW_MTU for constrained interfaces (e.g. LoRa 255 bytes)
+ if (_object->_attached_interface &&
+ (_object->_attached_interface.AUTOCONFIGURE_MTU() || _object->_attached_interface.FIXED_MTU()) &&
+ _object->_attached_interface.HW_MTU() < _object->_mtu) {
+ DEBUGF("Capping link MTU from %d to interface HW_MTU %d", _object->_mtu, _object->_attached_interface.HW_MTU());
+ _object->_mtu = _object->_attached_interface.HW_MTU();
+ }
+ Bytes signalling_bytes = Link::signalling_bytes(_object->_mtu, _object->_mode);
+ Bytes signed_data =_object->_link_id + _object->_pub_bytes + _object->_sig_pub_bytes + signalling_bytes;
INFO(">>> prove(): calling identity.sign()");
const Bytes signature(_object->_owner.identity().sign(signed_data));
INFO(">>> prove(): signature complete, building proof packet");
- Bytes proof_data = signature + _object->_pub_bytes;
+ Bytes proof_data = signature + _object->_pub_bytes + signalling_bytes;
// CBA LINK
// CBA TODO: Determine which approach is better, passing liunk to packet or passing _link_destination
INFO(">>> prove(): creating Packet");
@@ -346,7 +428,7 @@ void Link::validate_proof(const Packet& packet) {
handshake();
_object->_establishment_cost += packet.raw().size();
- Bytes signed_data = _object->_link_id + _object->_peer_pub_bytes + _object->_peer_sig_pub_bytes;
+ Bytes signed_data = _object->_link_id + _object->_peer_pub_bytes + _object->_peer_sig_pub_bytes + signalling_bytes;
const Bytes signature(packet_data.left(Type::Identity::SIGLENGTH/8));
TRACEF("Link %s validating identity", link_id().toHex().c_str());
@@ -359,27 +441,35 @@ void Link::validate_proof(const Packet& packet) {
_object->__remote_identity = _object->_destination.identity();
if (confirmed_mtu) _object->_mtu = confirmed_mtu;
else _object->_mtu = RNS::Type::Reticulum::MTU;
+ // Cap link MTU at interface HW_MTU for constrained interfaces (e.g. LoRa 255 bytes)
+ if (_object->_attached_interface &&
+ (_object->_attached_interface.AUTOCONFIGURE_MTU() || _object->_attached_interface.FIXED_MTU()) &&
+ _object->_attached_interface.HW_MTU() < _object->_mtu) {
+ DEBUGF("Capping link MTU from %d to interface HW_MTU %d", _object->_mtu, _object->_attached_interface.HW_MTU());
+ _object->_mtu = _object->_attached_interface.HW_MTU();
+ }
update_mdu();
_object->_status = Type::Link::ACTIVE;
_object->_activated_at = OS::time();
_object->_last_proof = _object->_activated_at;
+ // Save a local copy before activate_link, which removes the
+ // link from the pending pool and invalidates *this.
+ Link self(*this);
Transport::activate_link(*this);
- std::string link_str = toString();
- std::string dest_str = _object->_destination.toString();
- VERBOSEF("Link %s established with %s, RTT is %f s", link_str.c_str(), dest_str.c_str(), OS::round(_object->_rtt, 3));
-
- //p if _object->_rtt != None and _object->_establishment_cost != None and _object->_rtt > 0 and _object->_establishment_cost > 0:
- if (_object->_rtt != 0.0 && _object->_establishment_cost != 0 && _object->_rtt > 0 and _object->_establishment_cost > 0) {
- _object->_establishment_rate = _object->_establishment_cost / _object->_rtt;
+ // After activate_link, *this and _object are invalid - use self.
+ VERBOSEF("Link %s established with %s, RTT is %f s",
+ self.toString().c_str(), self.destination().toString().c_str(),
+ OS::round(self._object->_rtt, 3));
+
+ if (self._object->_rtt != 0.0 && self._object->_establishment_cost != 0 && self._object->_rtt > 0 && self._object->_establishment_cost > 0) {
+ self._object->_establishment_rate = self._object->_establishment_cost / self._object->_rtt;
}
- //p rtt_data = umsgpack.packb(self.rtt)
MsgPack::Packer packer;
- packer.serialize(_object->_rtt);
+ packer.serialize(self._object->_rtt);
Bytes rtt_data(packer.data(), packer.size());
TRACEF("***** RTT data size: %d", rtt_data.size());
- //p rtt_packet = RNS.Packet(self, rtt_data, context=RNS.Packet.LRRTT)
- Packet rtt_packet(*this, rtt_data, Type::Packet::DATA, Type::Packet::LRRTT);
+ Packet rtt_packet(self, rtt_data, Type::Packet::DATA, Type::Packet::LRRTT);
TRACEF("***** RTT packet data: %s", rtt_packet.data().toHex().c_str());
rtt_packet.pack();
Packet test_packet(RNS::Destination(RNS::Type::NONE), rtt_packet.raw());
@@ -387,17 +477,13 @@ test_packet.unpack();
TRACEF("***** RTT test packet destination hash: %s", test_packet.destination_hash().toHex().c_str());
TRACEF("***** RTT test packet data size: %d", test_packet.data().size());
TRACEF("***** RTT test packet data: %s", test_packet.data().toHex().c_str());
-Bytes plaintext = decrypt(test_packet.data());
+Bytes plaintext = self.decrypt(test_packet.data());
TRACEF("***** RTT test packet plaintext: %s", plaintext.toHex().c_str());
rtt_packet.send();
- had_outbound();
-
- if (_object->_callbacks._established != nullptr) {
- VERBOSEF("Link %s is established", link_id().toHex().c_str());
- //p thread = threading.Thread(target=_object->_callbacks.link_established, args=(self,))
- //p thread.daemon = True
- //p thread.start()
- _object->_callbacks._established(*this);
+ self.had_outbound();
+
+ if (self._object->_callbacks._established != nullptr) {
+ self._object->_callbacks._established(self);
}
}
else {
@@ -456,9 +542,41 @@ const RNS::RequestReceipt Link::request(const Bytes& path, const Bytes& data /*=
//p unpacked_request = [OS::time(), request_path_hash, data]
//p packed_request = umsgpack.packb(unpacked_request)
- MsgPack::Packer packer;
- packer.to_array(OS::time(), request_path_hash, data);
- Bytes packed_request(packer.data(), packer.size());
+ // NOTE: data is pre-serialized msgpack. We must embed it as raw bytes
+ // (not BIN-wrapped) so Python peers see nested objects, not binary blobs.
+ // Using to_array() would call Bytes::to_msgpack() which packs as BIN type,
+ // causing Python's umsgpack.unpackb() to return bytes instead of the
+ // original structure. Build the packed request manually instead.
+
+ // Pack timestamp as float64
+ MsgPack::Packer ts_packer;
+ ts_packer.pack(OS::time());
+ // Pack path_hash as BIN (correct: both Python and C++ expect bytes)
+ MsgPack::Packer ph_packer;
+ request_path_hash.to_msgpack(ph_packer);
+
+ // Build [timestamp, path_hash, data] with data as raw embedded msgpack
+ size_t total = 1 + ts_packer.size() + ph_packer.size(); // 1 for fixarray header
+ if (data && data.size() > 0) {
+ total += data.size();
+ } else {
+ total += 1; // nil byte
+ }
+
+ Bytes packed_request;
+ uint8_t* p = packed_request.writable(total);
+ size_t pos = 0;
+ p[pos++] = 0x93; // fixarray of 3
+ memcpy(p + pos, ts_packer.data(), ts_packer.size());
+ pos += ts_packer.size();
+ memcpy(p + pos, ph_packer.data(), ph_packer.size());
+ pos += ph_packer.size();
+ if (data && data.size() > 0) {
+ memcpy(p + pos, data.data(), data.size());
+ pos += data.size();
+ } else {
+ p[pos++] = 0xc0; // nil
+ }
if (timeout == 0.0) {
timeout = _object->_rtt * _object->_traffic_timeout_factor + Type::Resource::RESPONSE_MAX_GRACE_TIME * 1.125;
@@ -983,18 +1101,16 @@ void Link::request_resource_concluded(const Resource& resource) {
void Link::response_resource_concluded(const Resource& resource) {
assert(_object);
if (resource.status() == Type::Resource::COMPLETE) {
- //p packed_response = resource.data.read()
Bytes packed_response = resource.data();
- //p unpacked_response = umsgpack.unpackb(packed_response)
- //p request_id = unpacked_response[0]
- //p response_data = unpacked_response[1]
- MsgPack::Unpacker unpacker;
- unpacker.feed(packed_response.data(), packed_response.size());
- MsgPack::bin_t request_id;
- MsgPack::bin_t response_data;
- unpacker.from_array(request_id, response_data);
-
- handle_response(request_id, response_data, resource.total_size(), resource.size());
+ // Parse [request_id, response_data] — response_data may be any msgpack type
+ Bytes request_id;
+ Bytes response_data;
+ size_t offset = parse_response_array(packed_response, request_id, response_data);
+ if (offset > 0) {
+ handle_response(request_id, response_data, resource.total_size(), resource.size());
+ } else {
+ ERROR("response_resource_concluded: Failed to parse [request_id, response_data]");
+ }
}
else {
DEBUGF("Incoming response resource failed with status: %d", resource.status());
@@ -1049,6 +1165,7 @@ void Link::receive(const Packet& packet) {
switch (packet.context()) {
case Type::Packet::CONTEXT_NONE:
{
+ DEBUGF("Link::receive CTX_NONE: data_sz=%zu first64=%s", packet.data().size(), packet.data().left(64).toHex().c_str());
const Bytes plaintext = decrypt(packet.data());
if (plaintext) {
if (_object->_callbacks._packet) {
@@ -1087,7 +1204,7 @@ void Link::receive(const Packet& packet) {
{
const Bytes plaintext = decrypt(packet.data());
if (plaintext) {
- if (_object->_initiator && plaintext.size() == Type::Identity::KEYSIZE/8 + Type::Identity::SIGLENGTH/8) {
+ if (!_object->_initiator && plaintext.size() == Type::Identity::KEYSIZE/8 + Type::Identity::SIGLENGTH/8) {
const Bytes public_key = plaintext.left(Type::Identity::KEYSIZE/8);
const Bytes signed_data = _object->_link_id + public_key;
const Bytes signature = plaintext.mid(Type::Identity::KEYSIZE/8, Type::Identity::SIGLENGTH/8);
@@ -1143,16 +1260,17 @@ void Link::receive(const Packet& packet) {
//p unpacked_response = umsgpack.unpackb(packed_response)
//p request_id = unpacked_response[0]
//p response_data = unpacked_response[1]
- //p transfer_size = len(umsgpack.packb(response_data))-2
- MsgPack::Unpacker unpacker;
- unpacker.feed(packed_response.data(), packed_response.size());
- MsgPack::bin_t request_id;
- MsgPack::bin_t response_data;
- unpacker.from_array(request_id, response_data);
- MsgPack::Packer packer;
- packer.serialize(response_data);
- size_t transfer_size = packer.size() - 2;
- handle_response(Bytes(request_id.data(), request_id.size()), Bytes(response_data.data(), response_data.size()), transfer_size, transfer_size);
+ //p transfer_size = len(umsgpack.packb(response_data))-2
+ // NOTE: response_data may be any msgpack type (array, int, etc.)
+ // from Python peers, not just BIN. Extract request_id then take
+ // remaining raw bytes as response data for caller to parse.
+ Bytes request_id;
+ Bytes response_data;
+ size_t offset = parse_response_array(packed_response, request_id, response_data);
+ if (offset > 0) {
+ size_t transfer_size = response_data.size();
+ handle_response(request_id, response_data, transfer_size, transfer_size);
+ }
}
}
catch (std::exception& e) {
@@ -1165,6 +1283,7 @@ void Link::receive(const Packet& packet) {
if (!_object->_initiator) {
rtt_packet(packet);
}
+ break;
}
case Type::Packet::LINKCLOSE:
{
@@ -1265,8 +1384,10 @@ void Link::receive(const Packet& packet) {
*/
case Type::Packet::RESOURCE_ADV:
{
+ DEBUGF("Link::receive RESOURCE_ADV: data_sz=%zu first64=%s", packet.data().size(), packet.data().left(64).toHex().c_str());
const Bytes plaintext = decrypt(packet.data());
if (plaintext) {
+ DEBUGF("Link::receive RESOURCE_ADV: decrypt OK pt_sz=%zu", plaintext.size());
// Store plaintext in packet for Resource::accept to use
const_cast(packet).plaintext(plaintext);
@@ -1478,7 +1599,7 @@ const Bytes Link::decrypt(const Bytes& ciphertext) {
return _object->_token->decrypt(ciphertext);
}
catch (std::exception& e) {
- ERRORF("Decryption failed on link %s. The contained exception was: %s", toString().c_str(), e.what());
+ ERRORF("Decryption FAILED on link %s: %s (ct_sz=%zu ct_first32=%s)", toString().c_str(), e.what(), ciphertext.size(), ciphertext.left(32).toHex().c_str());
return {Bytes::NONE};
}
}
@@ -1593,7 +1714,7 @@ void Link::cancel_incoming_resource(const Resource& resource) {
bool Link::ready_for_new_resource() {
assert(_object);
- return (_object->_outgoing_resources_count > 0);
+ return (_object->_outgoing_resources_count == 0);
}
SegmentAccumulator& Link::segment_accumulator() {
@@ -1632,9 +1753,17 @@ void Link::handle_resource_concluded(const Resource& resource) {
// Check if resource completed successfully
if (resource_copy.status() != Type::Resource::COMPLETE) {
- // Failed resource - clean up and notify application
resource_concluded(resource_copy);
DEBUGF("Link::handle_resource_concluded: Resource failed with status %d", resource_copy.status());
+ // Check if this failed resource was a response to a pending request
+ if (resource_copy.request_id() && _object->_pending_requests_count > 0) {
+ for (size_t i = 0; i < _object->_pending_requests_count; i++) {
+ if (_object->_pending_requests[i].request_id() == resource_copy.request_id()) {
+ response_resource_concluded(resource_copy);
+ return;
+ }
+ }
+ }
if (_object->_callbacks._resource_concluded) {
_object->_callbacks._resource_concluded(resource_copy);
}
@@ -1664,6 +1793,37 @@ void Link::handle_resource_concluded(const Resource& resource) {
DEBUG("Link::handle_resource_concluded: segment_completed returned false, falling through");
}
+ // Check if this resource is a response to a pending Link::request().
+ // First try matching by resource's request_id field (from advertisement).
+ // If that's empty (Python servers often omit it), try parsing the resource
+ // data as [request_id, response_data] and matching the embedded request_id.
+ if (_object->_pending_requests_count > 0) {
+ Bytes match_id = resource_copy.request_id();
+
+ // If no request_id in resource metadata, try extracting from data
+ if (!match_id && resource_copy.data().size() > 3) {
+ Bytes extracted_id;
+ Bytes unused_data;
+ if (parse_response_array(resource_copy.data(), extracted_id, unused_data) > 0) {
+ match_id = extracted_id;
+ DEBUGF("Link::handle_resource_concluded: Extracted request_id from data: %s",
+ match_id.toHex().c_str());
+ }
+ }
+
+ if (match_id) {
+ for (size_t i = 0; i < _object->_pending_requests_count; i++) {
+ if (_object->_pending_requests[i].request_id() == match_id) {
+ DEBUGF("Link::handle_resource_concluded: Resource is response to pending request %s",
+ match_id.toHex().c_str());
+ resource_concluded(resource_copy);
+ response_resource_concluded(resource_copy);
+ return;
+ }
+ }
+ }
+ }
+
// Single-segment resource or non-segmented - clean up and notify application
resource_concluded(resource_copy);
if (_object->_callbacks._resource_concluded) {
@@ -1691,6 +1851,11 @@ const Destination& Link::destination() const {
return _object->_destination;
}
+const Interface& Link::attached_interface() const {
+ assert(_object);
+ return _object->_attached_interface;
+}
+
// CBA LINK
/*
const Destination& Link::link_destination() const {
@@ -1946,6 +2111,16 @@ void Link::status(Type::Link::status status) {
_object->_status = status;
}
+void Link::mtu(uint16_t mtu) {
+ assert(_object);
+ _object->_mtu = mtu;
+}
+
+void Link::mode(RNS::Type::Link::link_mode mode) {
+ assert(_object);
+ _object->_mode = mode;
+}
+
//RequestReceipt::RequestReceipt(const Link& link, const PacketReceipt& packet_receipt /*= {Type::NONE}*/, const Resource& resource /*= {Type::NONE}*/, RequestReceipt::Callbacks::response response_callback /*= nullptr*/, RequestReceipt::Callbacks::failed failed_callback /*= nullptr*/, RequestReceipt::Callbacks::progress progress_callback /*= nullptr*/, double timeout /*= 0.0*/, int request_size /*= 0*/) :
RequestReceipt::RequestReceipt(const Link& link, const PacketReceipt& packet_receipt, const Resource& resource, RequestReceipt::Callbacks::response response_callback /*= nullptr*/, RequestReceipt::Callbacks::failed failed_callback /*= nullptr*/, RequestReceipt::Callbacks::progress progress_callback /*= nullptr*/, double timeout /*= 0.0*/, int request_size /*= 0*/) :
diff --git a/src/Link.h b/src/Link.h
index 98d8aac4..85ed9516 100644
--- a/src/Link.h
+++ b/src/Link.h
@@ -302,6 +302,8 @@ namespace RNS {
void increment_tx();
void increment_txbytes(uint16_t bytes);
void status(Type::Link::status status);
+ void mtu(uint16_t mtu);
+ void mode(RNS::Type::Link::link_mode mode);
protected:
std::shared_ptr _object;
diff --git a/src/Log.cpp b/src/Log.cpp
index bad13115..0eecd7b1 100644
--- a/src/Log.cpp
+++ b/src/Log.cpp
@@ -85,12 +85,14 @@ void RNS::doLog(LogLevel level, const char* msg) {
return;
}
#ifdef ARDUINO
- Serial.print(getTimeString());
- Serial.print(" [");
- Serial.print(getLevelName(level));
- Serial.print("] ");
- Serial.println(msg);
- Serial.flush();
+ if (Serial) {
+ Serial.print(getTimeString());
+ Serial.print(" [");
+ Serial.print(getLevelName(level));
+ Serial.print("] ");
+ Serial.println(msg);
+ Serial.flush();
+ }
#else
printf("%s [%s] %s\n", getTimeString(), getLevelName(level), msg);
fflush(stdout);
@@ -102,7 +104,9 @@ void HEAD(const char* msg, LogLevel level) {
return;
}
#ifdef ARDUINO
- Serial.println("");
+ if (Serial) {
+ Serial.println("");
+ }
#else
printf("\n");
#endif
diff --git a/src/Log.h b/src/Log.h
index 9026996c..5caacbbb 100644
--- a/src/Log.h
+++ b/src/Log.h
@@ -29,7 +29,7 @@
#define DEBUGF(msg, ...) (RNS::debugf(msg, __VA_ARGS__))
#define TRACE(msg) (RNS::trace(msg))
#define TRACEF(msg, ...) (RNS::tracef(msg, __VA_ARGS__))
- #if defined(MEM_LOG)
+ #if defined(RNS_MEM_LOG)
#define MEM(msg) (RNS::mem(msg))
#define MEMF(msg, ...) (RNS::memf(msg, __VA_ARGS__))
#else
diff --git a/src/Packet.cpp b/src/Packet.cpp
index a93aa688..e68eea24 100644
--- a/src/Packet.cpp
+++ b/src/Packet.cpp
@@ -38,7 +38,7 @@ size_t PacketReceipt::_pool_fallback_count = 0;
// MEM-H2: Packet::Object allocation now uses pool to prevent heap fragmentation
// Pool provides 24 slots, falls back to heap on exhaustion
-Packet::Packet(const Destination& destination, const Interface& attached_interface, const Bytes& data, types packet_type /*= DATA*/, context_types context /*= CONTEXT_NONE*/, Type::Transport::types transport_type /*= Type::Transport::BROADCAST*/, header_types header_type /*= HEADER_1*/, const Bytes& transport_id /*= {Bytes::NONE}*/, bool create_receipt /*= true*/, context_flag context_flag /*= FLAG_UNSET*/)
+Packet::Packet(const Destination& destination, const Interface& attached_interface, const Bytes& data, types packet_type /*= DATA*/, context_types context /*= CONTEXT_NONE*/, Type::Transport::types transport_type /*= Type::Transport::BROADCAST*/, header_types header_type /*= HEADER_1*/, const Bytes& transport_id /*= {Bytes::NONE}*/, bool create_receipt /*= true*/, Type::Packet::context_flag context_flag /*= FLAG_UNSET*/)
{
// Try pool first, fall back to heap on exhaustion
Object* obj = objectPool().allocate(destination, attached_interface);
@@ -104,7 +104,7 @@ Packet::Packet(const Destination& destination, const Interface& attached_interfa
}
// CBA LINK
-Packet::Packet(const Link& link, const Bytes& data, Type::Packet::types packet_type /*= Type::Packet::DATA*/, Type::Packet::context_types context /*= Type::Packet::CONTEXT_NONE*/, context_flag context_flag /*= FLAG_UNSET*/) :
+Packet::Packet(const Link& link, const Bytes& data, Type::Packet::types packet_type /*= Type::Packet::DATA*/, Type::Packet::context_types context /*= Type::Packet::CONTEXT_NONE*/, Type::Packet::context_flag context_flag /*= FLAG_UNSET*/) :
//_object(new Object(link))
//Packet(link.destination(), data, packet_type, context, Type::Transport::BROADCAST, Type::Packet::HEADER_1, {Bytes::NONE}, true, context_flag)
// CBA Must use a destination that targets the Link itself instead of the original destination used to create the link
@@ -128,14 +128,14 @@ uint8_t Packet::get_packed_flags() {
uint8_t packed_flags = 0;
if (_object->_context == LRPROOF) {
TRACE("***** Packing with LINK type");
- packed_flags = (_object->_header_type << 6) | (_object->_transport_type << 4) | (Type::Destination::LINK << 2) | _object->_packet_type;
+ packed_flags = (_object->_header_type << 6) | (_object->_context_flag << 5) | (_object->_transport_type << 4) | (Type::Destination::LINK << 2) | _object->_packet_type;
}
else {
if (!_object->_destination) {
throw std::logic_error("Packet destination is required");
}
TRACEF("***** Packing with %d type", _object->_destination.type());
- packed_flags = (_object->_header_type << 6) | (_object->_transport_type << 4) | (_object->_destination.type() << 2) | _object->_packet_type;
+ packed_flags = (_object->_header_type << 6) | (_object->_context_flag << 5) | (_object->_transport_type << 4) | (_object->_destination.type() << 2) | _object->_packet_type;
}
return packed_flags;
}
@@ -143,7 +143,8 @@ TRACEF("***** Packing with %d type", _object->_destination.type());
void Packet::unpack_flags(uint8_t flags) {
assert(_object);
_object->_header_type = static_cast((flags & 0b01000000) >> 6);
- _object->_transport_type = static_cast((flags & 0b00110000) >> 4);
+ _object->_context_flag = static_cast((flags & 0b00100000) >> 5);
+ _object->_transport_type = static_cast((flags & 0b00010000) >> 4);
_object->_destination_type = static_cast((flags & 0b00001100) >> 2);
_object->_packet_type = static_cast(flags & 0b00000011);
}
diff --git a/src/Packet.h b/src/Packet.h
index 32aab55b..f2a818e7 100644
--- a/src/Packet.h
+++ b/src/Packet.h
@@ -283,6 +283,7 @@ namespace RNS {
inline const Interface& attached_interface() const { assert(_object); return _object->_attached_interface; }
inline const Interface& receiving_interface() const { assert(_object); return _object->_receiving_interface; }
inline Type::Packet::header_types header_type() const { assert(_object); return _object->_header_type; }
+ inline Type::Packet::context_flag context_flag() const { assert(_object); return static_cast(_object->_context_flag); }
inline Type::Transport::types transport_type() const { assert(_object); return _object->_transport_type; }
inline Type::Destination::types destination_type() const { assert(_object); return _object->_destination_type; }
inline Type::Packet::types packet_type() const { assert(_object); return _object->_packet_type; }
diff --git a/src/Resource.cpp b/src/Resource.cpp
index 28789b9d..f542f706 100644
--- a/src/Resource.cpp
+++ b/src/Resource.cpp
@@ -11,9 +11,6 @@
#include
#include
-#include
-#include
-#include
#if defined(ESP_PLATFORM) || defined(ARDUINO)
#include
@@ -539,16 +536,16 @@ void Resource::cancel() {
Packet cancel_packet(_object->_link, cancel_data,
Type::Packet::DATA, Type::Packet::RESOURCE_ICL);
cancel_packet.send();
- _object->_link.cancel_outgoing_resource(*this);
} else {
// Receiver cancelling - send RESOURCE_RCL (Receiver Cancel)
Packet cancel_packet(_object->_link, cancel_data,
Type::Packet::DATA, Type::Packet::RESOURCE_RCL);
cancel_packet.send();
- _object->_link.cancel_incoming_resource(*this);
}
- // Fire concluded callback with FAILED status
+ // Fire concluded callback - this removes the resource from the link's
+ // tracking array via handle_resource_concluded, invalidating 'this'.
+ // Do NOT access _object after this point.
if (_object->_callbacks._concluded != nullptr) {
_object->_callbacks._concluded(*this);
}
@@ -572,26 +569,35 @@ void Resource::check_timeout() {
}
_object->_watchdog_lock = true;
+ // Timeout handlers return true if the resource was terminated (concluded
+ // callback fired). When terminated, the concluded callback removes this
+ // resource from the link's tracking array via handle_resource_concluded(),
+ // which compacts the array and invalidates 'this'. We must NOT access
+ // _object after a terminal return.
+ bool terminated = false;
+
switch (_object->_status) {
case Type::Resource::ADVERTISED:
- timeout_advertised();
+ terminated = timeout_advertised();
break;
case Type::Resource::TRANSFERRING:
- timeout_transferring();
+ terminated = timeout_transferring();
break;
case Type::Resource::AWAITING_PROOF:
- timeout_awaiting_proof();
+ terminated = timeout_awaiting_proof();
break;
default:
break;
}
- _object->_watchdog_lock = false;
+ if (!terminated) {
+ _object->_watchdog_lock = false;
+ }
}
-void Resource::timeout_advertised() {
+bool Resource::timeout_advertised() {
// Only sender can be in ADVERTISED state
- if (!_object->_initiator) return;
+ if (!_object->_initiator) return false;
double now = OS::time();
@@ -616,11 +622,14 @@ void Resource::timeout_advertised() {
WARNING("Resource::timeout_advertised: Failing resource - internal RAM critically low ("
+ std::to_string(free_internal) + " bytes free)");
_object->_status = Type::Resource::FAILED;
+ // Reset watchdog before callback (callback may invalidate 'this')
+ _object->_watchdog_lock = false;
if (_object->_callbacks._concluded != nullptr) {
_object->_callbacks._concluded(*this);
}
- _object->_link.cancel_outgoing_resource(*this);
- return;
+ // Do NOT access _object after callback - handle_resource_concluded
+ // already removed this resource from link tracking
+ return true;
}
#endif
_object->_retries_left--;
@@ -631,17 +640,19 @@ void Resource::timeout_advertised() {
DEBUG("Resource::timeout_advertised: Advertisement timed out, max retries exceeded");
_object->_status = Type::Resource::FAILED;
+ // Reset watchdog before callback (callback may invalidate 'this')
+ _object->_watchdog_lock = false;
if (_object->_callbacks._concluded != nullptr) {
_object->_callbacks._concluded(*this);
}
-
- // Unregister from link
- _object->_link.cancel_outgoing_resource(*this);
+ // Do NOT access _object after callback
+ return true;
}
}
+ return false;
}
-void Resource::timeout_transferring() {
+bool Resource::timeout_transferring() {
double now = OS::time();
// Get RTT estimate
@@ -663,11 +674,13 @@ void Resource::timeout_transferring() {
DEBUG("Resource::timeout_transferring: Sender timeout waiting for requests");
_object->_status = Type::Resource::FAILED;
+ // Reset watchdog before callback (callback may invalidate 'this')
+ _object->_watchdog_lock = false;
if (_object->_callbacks._concluded != nullptr) {
_object->_callbacks._concluded(*this);
}
-
- _object->_link.cancel_outgoing_resource(*this);
+ // Do NOT access _object after callback
+ return true;
}
} else {
// Receiver: waiting for parts from sender
@@ -703,11 +716,12 @@ void Resource::timeout_transferring() {
WARNING("Resource::timeout_transferring: Failing resource - internal RAM critically low ("
+ std::to_string(free_internal) + " bytes free)");
_object->_status = Type::Resource::FAILED;
+ _object->_watchdog_lock = false;
if (_object->_callbacks._concluded != nullptr) {
_object->_callbacks._concluded(*this);
}
- _object->_link.cancel_incoming_resource(*this);
- return;
+ // Do NOT access _object after callback
+ return true;
}
#endif
_object->_retries_left--;
@@ -728,19 +742,22 @@ void Resource::timeout_transferring() {
DEBUG("Resource::timeout_transferring: Transfer timed out, max retries exceeded");
_object->_status = Type::Resource::FAILED;
+ // Reset watchdog before callback (callback may invalidate 'this')
+ _object->_watchdog_lock = false;
if (_object->_callbacks._concluded != nullptr) {
_object->_callbacks._concluded(*this);
}
-
- _object->_link.cancel_incoming_resource(*this);
+ // Do NOT access _object after callback
+ return true;
}
}
}
+ return false;
}
-void Resource::timeout_awaiting_proof() {
+bool Resource::timeout_awaiting_proof() {
// Only sender awaits proof
- if (!_object->_initiator) return;
+ if (!_object->_initiator) return false;
double now = OS::time();
@@ -759,12 +776,15 @@ void Resource::timeout_awaiting_proof() {
DEBUG("Resource::timeout_awaiting_proof: Proof timed out");
_object->_status = Type::Resource::FAILED;
+ // Reset watchdog before callback (callback may invalidate 'this')
+ _object->_watchdog_lock = false;
if (_object->_callbacks._concluded != nullptr) {
_object->_callbacks._concluded(*this);
}
-
- _object->_link.cancel_outgoing_resource(*this);
+ // Do NOT access _object after callback
+ return true;
}
+ return false;
}
void Resource::prepare_next_segment() {
@@ -1638,6 +1658,10 @@ void Resource::receive_part(const Packet& packet) {
if (_object->_received_count >= _object->_total_parts) {
DEBUG("Resource::receive_part: All parts received, assembling");
assemble();
+ // IMPORTANT: assemble()'s concluded callback removes this resource from
+ // the link's _incoming_resources array, which invalidates 'this'.
+ // We must return immediately - do NOT access _object after this point.
+ return;
} else if (_object->_outstanding_parts == 0) {
// Dynamic window scaling: measure RTT and adjust window for fast links
if (_object->_req_sent > 0) {
@@ -1699,47 +1723,6 @@ void Resource::assemble() {
TRACE("Resource::assemble: Starting assembly");
- // DEBUG: Save individual parts before assembly
- {
- std::string parts_dir = "/tmp/cpp_stage0_parts";
- // Create directory using system call
- system("mkdir -p /tmp/cpp_stage0_parts");
-
- for (size_t i = 0; i < _object->_parts.size(); i++) {
- // Format part number with leading zeros (e.g., part_0000.bin)
- std::ostringstream filename;
- filename << parts_dir << "/part_" << std::setfill('0') << std::setw(4) << i << ".bin";
-
- std::string filename_str = filename.str();
- std::ofstream f(filename_str, std::ios::binary);
- if (f) {
- const Bytes& part = _object->_parts[i];
- f.write(reinterpret_cast(part.data()), part.size());
- f.close();
- DEBUGF("Resource::assemble: Saved part %zu (%zu bytes) to %s",
- i, part.size(), filename_str.c_str());
- }
- }
-
- // Save parts metadata
- std::ofstream meta("/tmp/cpp_parts_metadata.json", std::ios::out);
- if (meta) {
- meta << "{\n";
- meta << " \"total_parts\": " << _object->_parts.size() << ",\n";
- meta << " \"parts\": [\n";
- for (size_t i = 0; i < _object->_parts.size(); i++) {
- meta << " {\"index\": " << i << ", \"size\": " << _object->_parts[i].size();
- meta << ", \"hash\": \"" << _object->_hashmap[i].toHex() << "\"}";
- if (i < _object->_parts.size() - 1) meta << ",";
- meta << "\n";
- }
- meta << " ]\n";
- meta << "}\n";
- meta.close();
- DEBUG("Resource::assemble: Saved parts metadata to /tmp/cpp_parts_metadata.json");
- }
- }
-
// Concatenate all parts (Token-encrypted chunks)
Bytes assembled_data;
for (size_t i = 0; i < _object->_parts.size(); i++) {
@@ -1748,16 +1731,6 @@ void Resource::assemble() {
DEBUGF("Resource::assemble: Assembled %zu bytes from %zu parts", assembled_data.size(), _object->_parts.size());
- // DEBUG: Save encrypted data before decryption
- {
- std::ofstream f("/tmp/cpp_stage1_encrypted.bin", std::ios::binary);
- if (f) {
- f.write(reinterpret_cast(assembled_data.data()), assembled_data.size());
- f.close();
- DEBUGF("Resource::assemble: Saved %zu encrypted bytes to /tmp/cpp_stage1_encrypted.bin", assembled_data.size());
- }
- }
-
// Decrypt if needed (Resource uses Token encryption via link.encrypt())
if (_object->_encrypted) {
Bytes decrypted = _object->_link.decrypt(assembled_data);
@@ -1769,16 +1742,6 @@ void Resource::assemble() {
}
assembled_data = decrypted;
DEBUGF("Resource::assemble: Decrypted to %zu bytes", assembled_data.size());
-
- // DEBUG: Save decrypted data (with random_hash)
- {
- std::ofstream f("/tmp/cpp_stage2_decrypted.bin", std::ios::binary);
- if (f) {
- f.write(reinterpret_cast(assembled_data.data()), assembled_data.size());
- f.close();
- DEBUGF("Resource::assemble: Saved %zu decrypted bytes to /tmp/cpp_stage2_decrypted.bin", assembled_data.size());
- }
- }
}
// Strip off the random_hash prefix (4 bytes)
@@ -1788,26 +1751,9 @@ void Resource::assemble() {
_object->_assembly_lock = false;
return;
}
- Bytes random_hash_prefix = assembled_data.left(Type::Resource::RANDOM_HASH_SIZE);
- std::string random_hash_prefix_hex = random_hash_prefix.toHex();
- DEBUGF("Resource::assemble: random_hash prefix = %s", random_hash_prefix_hex.c_str());
assembled_data = assembled_data.mid(Type::Resource::RANDOM_HASH_SIZE);
DEBUGF("Resource::assemble: After stripping random_hash: %zu bytes", assembled_data.size());
- // DEBUG: Save data after stripping random_hash (before decompression)
- {
- std::ofstream f("/tmp/cpp_stage3_stripped.bin", std::ios::binary);
- if (f) {
- f.write(reinterpret_cast(assembled_data.data()), assembled_data.size());
- f.close();
- DEBUGF("Resource::assemble: Saved %zu stripped bytes to /tmp/cpp_stage3_stripped.bin", assembled_data.size());
- std::string first_50_hex = assembled_data.left(50).toHex();
- std::string last_20_hex = assembled_data.right(20).toHex();
- DEBUGF("Resource::assemble: First 50 bytes: %s", first_50_hex.c_str());
- DEBUGF("Resource::assemble: Last 20 bytes: %s", last_20_hex.c_str());
- }
- }
-
// Decompress if needed
if (_object->_compressed) {
Bytes decompressed = Cryptography::bz2_decompress(assembled_data);
@@ -1819,20 +1765,6 @@ void Resource::assemble() {
}
assembled_data = decompressed;
DEBUGF("Resource::assemble: Decompressed to %zu bytes", assembled_data.size());
-
- // DEBUG: Save decompressed data
- {
- std::ofstream f("/tmp/cpp_stage4_decompressed.bin", std::ios::binary);
- if (f) {
- f.write(reinterpret_cast(assembled_data.data()), assembled_data.size());
- f.close();
- DEBUGF("Resource::assemble: Saved %zu decompressed bytes to /tmp/cpp_stage4_decompressed.bin",
- assembled_data.size());
- std::string decompressed_50_hex = assembled_data.left(50).toHex();
- DEBUGF("Resource::assemble: Decompressed first 50 bytes: %s",
- decompressed_50_hex.c_str());
- }
- }
}
// Verify hash
@@ -1855,35 +1787,6 @@ void Resource::assemble() {
DEBUGF("Resource::assemble: Assembly complete, data_size=%zu, expected_total_size=%zu",
_object->_data.size(), _object->_total_size);
- // DEBUG: Save final verified data
- {
- std::ofstream f("/tmp/cpp_stage5_final.bin", std::ios::binary);
- if (f) {
- f.write(reinterpret_cast(assembled_data.data()), assembled_data.size());
- f.close();
- DEBUGF("Resource::assemble: Saved %zu final verified bytes to /tmp/cpp_stage5_final.bin",
- assembled_data.size());
- }
-
- // Save comprehensive metadata
- std::ofstream meta("/tmp/cpp_final_metadata.json", std::ios::out);
- if (meta) {
- meta << "{\n";
- meta << " \"resource_hash\": \"" << _object->_hash.toHex() << "\",\n";
- meta << " \"random_hash\": \"" << _object->_random_hash.toHex() << "\",\n";
- meta << " \"total_size\": " << _object->_total_size << ",\n";
- meta << " \"transfer_size\": " << _object->_size << ",\n";
- meta << " \"total_parts\": " << _object->_total_parts << ",\n";
- meta << " \"compressed\": " << (_object->_compressed ? "true" : "false") << ",\n";
- meta << " \"encrypted\": " << (_object->_encrypted ? "true" : "false") << ",\n";
- meta << " \"final_data_size\": " << _object->_data.size() << ",\n";
- meta << " \"hash_verification\": \"PASSED\"\n";
- meta << "}\n";
- meta.close();
- DEBUG("Resource::assemble: Saved final metadata to /tmp/cpp_final_metadata.json");
- }
- }
-
// Validate data size matches advertised total_size
if (_object->_data.size() != _object->_total_size) {
ERRORF("Resource::assemble: SIZE MISMATCH! received %zu bytes, expected %zu bytes",
@@ -1893,12 +1796,15 @@ void Resource::assemble() {
// Send proof to sender
prove();
- // Call concluded callback
+ // Release assembly lock BEFORE concluded callback, because the callback
+ // may remove this resource from the link's tracking (dropping our shared_ptr
+ // ref count), invalidating _object. Assembly is complete at this point anyway.
+ _object->_assembly_lock = false;
+
+ // Call concluded callback (may destroy this resource's _object via shared_ptr)
if (_object->_callbacks._concluded != nullptr) {
_object->_callbacks._concluded(*this);
}
-
- _object->_assembly_lock = false;
}
// Send proof that resource was received
diff --git a/src/Resource.h b/src/Resource.h
index 21ab31c2..c1613311 100644
--- a/src/Resource.h
+++ b/src/Resource.h
@@ -89,9 +89,9 @@ namespace RNS {
void check_timeout();
private:
- void timeout_advertised();
- void timeout_transferring();
- void timeout_awaiting_proof();
+ bool timeout_advertised();
+ bool timeout_transferring();
+ bool timeout_awaiting_proof();
public:
// Multi-segment sending
diff --git a/src/Reticulum.cpp b/src/Reticulum.cpp
index c7464a97..548a5ca5 100644
--- a/src/Reticulum.cpp
+++ b/src/Reticulum.cpp
@@ -80,6 +80,9 @@ Reticulum::Reticulum() : _object(new Object()) {
//RNG.addNoiseSource(noise);
#endif
+ // Initialize Transport pools in PSRAM before any interface registration
+ Transport::init_pools();
+
//z RNS.vendor.platformutils.platform_checks()
/*p TODO
diff --git a/src/Transport.cpp b/src/Transport.cpp
index b4ae78df..6bfeaf73 100644
--- a/src/Transport.cpp
+++ b/src/Transport.cpp
@@ -13,6 +13,11 @@
#include
#include
#include
+#include // For placement new
+
+#ifdef ARDUINO
+#include
+#endif
using namespace RNS;
using namespace RNS::Type::Transport;
@@ -26,71 +31,71 @@ using namespace RNS::Utilities;
#elif defined(INTERFACES_MAP)
/*static*/ std::map Transport::_interfaces;
#elif defined(INTERFACES_POOL)
-/*static*/ Transport::InterfaceSlot Transport::_interfaces_pool[Transport::INTERFACES_POOL_SIZE];
+/*static*/ Transport::InterfaceSlot* Transport::_interfaces_pool = nullptr;
#endif
#if defined(DESTINATIONS_SET)
/*static*/ std::set Transport::_destinations;
#elif defined(DESTINATIONS_MAP)
/*static*/ std::map Transport::_destinations;
#elif defined(DESTINATIONS_POOL)
-/*static*/ Transport::DestinationSlot Transport::_destinations_pool[Transport::DESTINATIONS_POOL_SIZE];
+/*static*/ Transport::DestinationSlot* Transport::_destinations_pool = nullptr;
#endif
///*static*/ std::set Transport::_pending_links; // Replaced by fixed array
///*static*/ std::set Transport::_active_links; // Replaced by fixed array
-/*static*/ Link Transport::_pending_links_pool[Transport::PENDING_LINKS_SIZE];
+/*static*/ Link* Transport::_pending_links_pool = nullptr;
/*static*/ size_t Transport::_pending_links_count = 0;
-/*static*/ Link Transport::_active_links_pool[Transport::ACTIVE_LINKS_SIZE];
+/*static*/ Link* Transport::_active_links_pool = nullptr;
/*static*/ size_t Transport::_active_links_count = 0;
///*static*/ std::set Transport::_packet_hashlist; // Replaced by circular buffer
-/*static*/ Bytes Transport::_packet_hashlist_buffer[Transport::PACKET_HASHLIST_SIZE];
+/*static*/ Bytes* Transport::_packet_hashlist_buffer = nullptr;
/*static*/ size_t Transport::_packet_hashlist_head = 0;
/*static*/ size_t Transport::_packet_hashlist_count = 0;
///*static*/ std::set Transport::_discovery_pr_tags; // Replaced by circular buffer
-/*static*/ Bytes Transport::_discovery_pr_tags_buffer[Transport::DISCOVERY_PR_TAGS_SIZE];
+/*static*/ Bytes* Transport::_discovery_pr_tags_buffer = nullptr;
/*static*/ size_t Transport::_discovery_pr_tags_head = 0;
/*static*/ size_t Transport::_discovery_pr_tags_count = 0;
///*static*/ std::list Transport::_receipts; // Replaced by fixed array
-/*static*/ PacketReceipt Transport::_receipts_pool[Transport::RECEIPTS_SIZE];
+/*static*/ PacketReceipt* Transport::_receipts_pool = nullptr;
/*static*/ size_t Transport::_receipts_count = 0;
// Announce table pool (replaces std::map)
-/*static*/ Transport::AnnounceTableSlot Transport::_announce_table_pool[Transport::ANNOUNCE_TABLE_SIZE];
+/*static*/ Transport::AnnounceTableSlot* Transport::_announce_table_pool = nullptr;
// Destination table pool (replaces std::map)
-/*static*/ Transport::DestinationTableSlot Transport::_destination_table_pool[Transport::DESTINATION_TABLE_SIZE];
+/*static*/ Transport::DestinationTableSlot* Transport::_destination_table_pool = nullptr;
///*static*/ std::map Transport::_reverse_table; // Replaced by fixed pool
-/*static*/ Transport::ReverseTableSlot Transport::_reverse_table_pool[Transport::REVERSE_TABLE_SIZE];
+/*static*/ Transport::ReverseTableSlot* Transport::_reverse_table_pool = nullptr;
///*static*/ std::map Transport::_link_table; // Replaced by fixed pool
-/*static*/ Transport::LinkTableSlot Transport::_link_table_pool[Transport::LINK_TABLE_SIZE];
+/*static*/ Transport::LinkTableSlot* Transport::_link_table_pool = nullptr;
///*static*/ std::map Transport::_held_announces; // Replaced by pool
-/*static*/ Transport::HeldAnnounceSlot Transport::_held_announces_pool[Transport::HELD_ANNOUNCES_SIZE];
+/*static*/ Transport::HeldAnnounceSlot* Transport::_held_announces_pool = nullptr;
///*static*/ std::set Transport::_announce_handlers; // Replaced by fixed array
-/*static*/ HAnnounceHandler Transport::_announce_handlers_pool[Transport::ANNOUNCE_HANDLERS_SIZE];
+/*static*/ HAnnounceHandler* Transport::_announce_handlers_pool = nullptr;
/*static*/ size_t Transport::_announce_handlers_count = 0;
///*static*/ std::map Transport::_tunnels; // Replaced by fixed pool
-/*static*/ Transport::TunnelSlot Transport::_tunnels_pool[Transport::TUNNELS_SIZE];
+/*static*/ Transport::TunnelSlot* Transport::_tunnels_pool = nullptr;
///*static*/ std::map Transport::_announce_rate_table; // Replaced by fixed pool
-/*static*/ Transport::RateTableSlot Transport::_announce_rate_table_pool[Transport::ANNOUNCE_RATE_TABLE_SIZE];
+/*static*/ Transport::RateTableSlot* Transport::_announce_rate_table_pool = nullptr;
///*static*/ std::map Transport::_path_requests; // Replaced by fixed pool
-/*static*/ Transport::PathRequestSlot Transport::_path_requests_pool[Transport::PATH_REQUESTS_SIZE];
+/*static*/ Transport::PathRequestSlot* Transport::_path_requests_pool = nullptr;
///*static*/ std::map Transport::_discovery_path_requests; // Replaced by pool
-/*static*/ Transport::DiscoveryPathRequestSlot Transport::_discovery_path_requests_pool[Transport::DISCOVERY_PATH_REQUESTS_SIZE];
+/*static*/ Transport::DiscoveryPathRequestSlot* Transport::_discovery_path_requests_pool = nullptr;
///*static*/ std::set Transport::_discovery_pr_tags; // Replaced by circular buffer
///*static*/ std::set Transport::_control_destinations; // Replaced by fixed array
-/*static*/ Destination Transport::_control_destinations_pool[Transport::CONTROL_DESTINATIONS_SIZE];
+/*static*/ Destination* Transport::_control_destinations_pool = nullptr;
/*static*/ size_t Transport::_control_destinations_count = 0;
///*static*/ std::set Transport::_control_hashes; // Replaced by fixed array
-/*static*/ Bytes Transport::_control_hashes_pool[Transport::CONTROL_HASHES_SIZE];
+/*static*/ Bytes* Transport::_control_hashes_pool = nullptr;
/*static*/ size_t Transport::_control_hashes_count = 0;
///*static*/ std::set Transport::_local_client_interfaces;
///*static*/ std::set, std::less> Transport::_local_client_interfaces; // Replaced by fixed array
-/*static*/ const Interface* Transport::_local_client_interfaces_pool[Transport::LOCAL_CLIENT_INTERFACES_SIZE];
+/*static*/ const Interface** Transport::_local_client_interfaces_pool = nullptr;
/*static*/ size_t Transport::_local_client_interfaces_count = 0;
///*static*/ std::map Transport::_pending_local_path_requests; // Replaced by pool
-/*static*/ Transport::PendingLocalPathRequestSlot Transport::_pending_local_path_requests_pool[Transport::PENDING_LOCAL_PATH_REQUESTS_SIZE];
+/*static*/ Transport::PendingLocalPathRequestSlot* Transport::_pending_local_path_requests_pool = nullptr;
// CBA - _packet_table not currently used, commented out to reduce STL container usage
///*static*/ std::map Transport::_packet_table;
@@ -154,6 +159,89 @@ using namespace RNS::Utilities;
/*static*/ size_t Transport::_last_memory = 0;
/*static*/ size_t Transport::_last_flash = 0;
+// Allocate all Transport pools in PSRAM (frees ~15-25KB internal RAM)
+// Follows same pattern as Identity::init_known_destinations_pool()
+/*static*/ bool Transport::init_pools() {
+ if (_announce_table_pool != nullptr) {
+ return true; // Already initialized
+ }
+
+ size_t total_bytes = 0;
+
+#ifdef ARDUINO
+ // Helper: allocate in PSRAM with fallback to internal RAM, then placement-new each element
+ #define ALLOC_POOL(pool, type, count, name) do { \
+ size_t sz = (count) * sizeof(type); \
+ pool = (type*)heap_caps_aligned_alloc(8, sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); \
+ if (!pool) { \
+ pool = (type*)heap_caps_aligned_alloc(8, sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); \
+ if (pool) { WARNING("Transport: " name " allocated in internal RAM (PSRAM unavailable)"); } \
+ } \
+ if (!pool) { \
+ ERROR("Transport: Failed to allocate " name " (" + std::to_string(sz) + " bytes)"); \
+ return false; \
+ } \
+ for (size_t _i = 0; _i < (count); _i++) { new (&pool[_i]) type(); } \
+ total_bytes += sz; \
+ } while(0)
+#else
+ // Non-Arduino: use regular new[]
+ #define ALLOC_POOL(pool, type, count, name) do { \
+ pool = new type[count]; \
+ if (!pool) { return false; } \
+ total_bytes += (count) * sizeof(type); \
+ } while(0)
+#endif
+
+ ALLOC_POOL(_announce_table_pool, AnnounceTableSlot, ANNOUNCE_TABLE_SIZE, "announce_table");
+ ALLOC_POOL(_destination_table_pool, DestinationTableSlot, DESTINATION_TABLE_SIZE, "destination_table");
+ ALLOC_POOL(_reverse_table_pool, ReverseTableSlot, REVERSE_TABLE_SIZE, "reverse_table");
+ ALLOC_POOL(_link_table_pool, LinkTableSlot, LINK_TABLE_SIZE, "link_table");
+ ALLOC_POOL(_held_announces_pool, HeldAnnounceSlot, HELD_ANNOUNCES_SIZE, "held_announces");
+ ALLOC_POOL(_tunnels_pool, TunnelSlot, TUNNELS_SIZE, "tunnels");
+ ALLOC_POOL(_announce_rate_table_pool, RateTableSlot, ANNOUNCE_RATE_TABLE_SIZE, "announce_rate_table");
+ ALLOC_POOL(_path_requests_pool, PathRequestSlot, PATH_REQUESTS_SIZE, "path_requests");
+ ALLOC_POOL(_receipts_pool, PacketReceipt, RECEIPTS_SIZE, "receipts");
+ ALLOC_POOL(_packet_hashlist_buffer, Bytes, PACKET_HASHLIST_SIZE, "packet_hashlist");
+ ALLOC_POOL(_discovery_pr_tags_buffer, Bytes, DISCOVERY_PR_TAGS_SIZE, "discovery_pr_tags");
+ ALLOC_POOL(_pending_links_pool, Link, PENDING_LINKS_SIZE, "pending_links");
+ ALLOC_POOL(_active_links_pool, Link, ACTIVE_LINKS_SIZE, "active_links");
+ ALLOC_POOL(_control_hashes_pool, Bytes, CONTROL_HASHES_SIZE, "control_hashes");
+ ALLOC_POOL(_control_destinations_pool, Destination, CONTROL_DESTINATIONS_SIZE, "control_destinations");
+ ALLOC_POOL(_announce_handlers_pool, HAnnounceHandler, ANNOUNCE_HANDLERS_SIZE, "announce_handlers");
+
+ // local_client_interfaces is an array of pointers, not objects
+ {
+ size_t sz = LOCAL_CLIENT_INTERFACES_SIZE * sizeof(const Interface*);
+#ifdef ARDUINO
+ _local_client_interfaces_pool = (const Interface**)heap_caps_aligned_alloc(8, sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+ if (!_local_client_interfaces_pool) {
+ _local_client_interfaces_pool = (const Interface**)heap_caps_aligned_alloc(8, sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
+ }
+#else
+ _local_client_interfaces_pool = new const Interface*[LOCAL_CLIENT_INTERFACES_SIZE];
+#endif
+ if (!_local_client_interfaces_pool) {
+ ERROR("Transport: Failed to allocate local_client_interfaces");
+ return false;
+ }
+ for (size_t i = 0; i < LOCAL_CLIENT_INTERFACES_SIZE; i++) {
+ _local_client_interfaces_pool[i] = nullptr;
+ }
+ total_bytes += sz;
+ }
+
+ ALLOC_POOL(_interfaces_pool, InterfaceSlot, INTERFACES_POOL_SIZE, "interfaces");
+ ALLOC_POOL(_destinations_pool, DestinationSlot, DESTINATIONS_POOL_SIZE, "destinations");
+ ALLOC_POOL(_discovery_path_requests_pool, DiscoveryPathRequestSlot, DISCOVERY_PATH_REQUESTS_SIZE, "discovery_path_requests");
+ ALLOC_POOL(_pending_local_path_requests_pool, PendingLocalPathRequestSlot, PENDING_LOCAL_PATH_REQUESTS_SIZE, "pending_local_path_requests");
+
+ #undef ALLOC_POOL
+
+ INFO("Transport pools initialized (" + std::to_string(total_bytes) + " bytes total)");
+ return true;
+}
+
/*static*/ bool Transport::packet_hashlist_contains(const Bytes& hash) {
for (size_t i = 0; i < _packet_hashlist_count; i++) {
size_t idx = (_packet_hashlist_head + PACKET_HASHLIST_SIZE - _packet_hashlist_count + i) % PACKET_HASHLIST_SIZE;
@@ -1114,7 +1202,12 @@ using namespace RNS::Utilities;
AnnounceEntry& announce_entry = slot.entry;
//TRACE("[0] announce entry data size: " + std::to_string(announce_entry._packet.data().size()));
//p announce_entry = Transport.announce_table[destination_hash]
- if (announce_entry._retries > Type::Transport::PATHFINDER_R) {
+ if (announce_entry._retries > 0 && announce_entry._retries >= Type::Transport::LOCAL_REBROADCASTS_MAX) {
+ TRACE("Completed announce processing for " + destination_hash.toHex() + ", local rebroadcast limit reached");
+ slot.clear();
+ break;
+ }
+ else if (announce_entry._retries > Type::Transport::PATHFINDER_R) {
TRACE("Completed announce processing for " + destination_hash.toHex() + ", retry limit reached");
// CBA OK to modify collection here since we're immediately exiting iteration
slot.clear();
@@ -1154,7 +1247,9 @@ using namespace RNS::Utilities;
announce_context,
Type::Transport::TRANSPORT,
Type::Packet::HEADER_2,
- Transport::_identity.hash()
+ Transport::_identity.hash(),
+ true,
+ announce_entry._packet.context_flag()
);
new_packet.hops(announce_entry._hops);
@@ -1678,10 +1773,11 @@ using namespace RNS::Utilities;
TRACE("Transport::outbound: Pscket destination is link-closed, not transmitting");
should_transmit = false;
}
- // CBA Bug? Destination has no member attached_interface
- //z if (interface != packet.destination().attached_interface()) {
- //z should_transmit = false;
- //z }
+ // Route link packets only through the link's attached interface
+ if (packet.destination_link().attached_interface() && interface != packet.destination_link().attached_interface()) {
+ TRACE("Transport::outbound: Link packet not for this interface, skipping");
+ should_transmit = false;
+ }
}
if (packet.attached_interface() && interface != packet.attached_interface()) {
@@ -2028,7 +2124,7 @@ using namespace RNS::Utilities;
}
/*static*/ void Transport::inbound(const Bytes& raw, const Interface& interface /*= {Type::NONE}*/) {
- TRACE("Transport::inbound()");
+ TRACEF("Transport::inbound: received %zu bytes", raw.size());
++_packets_received;
// CBA
if (_callbacks._receive_packet) {
@@ -2809,7 +2905,9 @@ using namespace RNS::Utilities;
announce_context,
Type::Transport::TRANSPORT,
Type::Packet::HEADER_2,
- _identity.hash()
+ _identity.hash(),
+ true,
+ packet.context_flag()
);
new_announce.hops(packet.hops());
@@ -2829,7 +2927,9 @@ using namespace RNS::Utilities;
announce_context,
Type::Transport::TRANSPORT,
Type::Packet::HEADER_2,
- _identity.hash()
+ _identity.hash(),
+ true,
+ packet.context_flag()
);
new_announce.hops(packet.hops());
@@ -2863,7 +2963,9 @@ using namespace RNS::Utilities;
Type::Packet::PATH_RESPONSE,
Type::Transport::TRANSPORT,
Type::Packet::HEADER_2,
- _identity.hash()
+ _identity.hash(),
+ true,
+ packet.context_flag()
);
new_announce.hops(packet.hops());
diff --git a/src/Transport.h b/src/Transport.h
index 64379078..9729c9de 100644
--- a/src/Transport.h
+++ b/src/Transport.h
@@ -391,62 +391,62 @@ namespace RNS {
// Fixed-size pool structures for zero heap fragmentation
// Overflow behavior: find_empty_*_slot() returns nullptr when pool full
// Callers must check for nullptr and handle gracefully (drop/log/cull)
- static constexpr size_t ANNOUNCE_TABLE_SIZE = 8; // Reduced for testing
+ static constexpr size_t ANNOUNCE_TABLE_SIZE = 32;
struct AnnounceTableSlot {
bool in_use = false;
Bytes destination_hash;
AnnounceEntry entry;
void clear() { in_use = false; destination_hash.clear(); entry = AnnounceEntry(); }
};
- static AnnounceTableSlot _announce_table_pool[ANNOUNCE_TABLE_SIZE];
+ static AnnounceTableSlot* _announce_table_pool;
static AnnounceTableSlot* find_announce_table_slot(const Bytes& hash);
static AnnounceTableSlot* find_empty_announce_table_slot();
static size_t announce_table_count();
- static constexpr size_t DESTINATION_TABLE_SIZE = 16; // Reduced for testing
+ static constexpr size_t DESTINATION_TABLE_SIZE = 64;
struct DestinationTableSlot {
bool in_use = false;
Bytes destination_hash;
DestinationEntry entry;
void clear() { in_use = false; destination_hash.clear(); entry = DestinationEntry(); }
};
- static DestinationTableSlot _destination_table_pool[DESTINATION_TABLE_SIZE];
+ static DestinationTableSlot* _destination_table_pool;
static DestinationTableSlot* find_destination_table_slot(const Bytes& hash);
static DestinationTableSlot* find_empty_destination_table_slot();
static size_t destination_table_count();
- static constexpr size_t REVERSE_TABLE_SIZE = 8; // Reduced for testing
+ static constexpr size_t REVERSE_TABLE_SIZE = 16;
struct ReverseTableSlot {
bool in_use = false;
Bytes packet_hash;
ReverseEntry entry;
void clear() { in_use = false; packet_hash.clear(); entry = ReverseEntry(); }
};
- static ReverseTableSlot _reverse_table_pool[REVERSE_TABLE_SIZE];
+ static ReverseTableSlot* _reverse_table_pool;
static ReverseTableSlot* find_reverse_table_slot(const Bytes& hash);
static ReverseTableSlot* find_empty_reverse_table_slot();
static size_t reverse_table_count();
- static constexpr size_t LINK_TABLE_SIZE = 8; // Reduced for testing
+ static constexpr size_t LINK_TABLE_SIZE = 16;
struct LinkTableSlot {
bool in_use = false;
Bytes link_id;
LinkEntry entry;
void clear() { in_use = false; link_id.clear(); entry = LinkEntry(); }
};
- static LinkTableSlot _link_table_pool[LINK_TABLE_SIZE];
+ static LinkTableSlot* _link_table_pool;
static LinkTableSlot* find_link_table_slot(const Bytes& id);
static LinkTableSlot* find_empty_link_table_slot();
static size_t link_table_count();
- static constexpr size_t HELD_ANNOUNCES_SIZE = 8; // Reduced for testing
+ static constexpr size_t HELD_ANNOUNCES_SIZE = 16;
struct HeldAnnounceSlot {
bool in_use = false;
Bytes destination_hash;
AnnounceEntry entry;
void clear() { in_use = false; destination_hash.clear(); entry = AnnounceEntry(); }
};
- static HeldAnnounceSlot _held_announces_pool[HELD_ANNOUNCES_SIZE];
+ static HeldAnnounceSlot* _held_announces_pool;
static HeldAnnounceSlot* find_held_announce_slot(const Bytes& hash);
static HeldAnnounceSlot* find_empty_held_announce_slot();
static size_t held_announces_count();
@@ -458,46 +458,46 @@ namespace RNS {
TunnelEntry entry;
void clear() { in_use = false; tunnel_id.clear(); entry.clear(); }
};
- static TunnelSlot _tunnels_pool[TUNNELS_SIZE];
+ static TunnelSlot* _tunnels_pool;
static TunnelSlot* find_tunnel_slot(const Bytes& id);
static TunnelSlot* find_empty_tunnel_slot();
static size_t tunnels_count();
- static constexpr size_t ANNOUNCE_RATE_TABLE_SIZE = 8; // Reduced for testing
+ static constexpr size_t ANNOUNCE_RATE_TABLE_SIZE = 16;
struct RateTableSlot {
bool in_use = false;
Bytes destination_hash;
RateEntry entry;
void clear() { in_use = false; destination_hash.clear(); entry = RateEntry(); }
};
- static RateTableSlot _announce_rate_table_pool[ANNOUNCE_RATE_TABLE_SIZE];
+ static RateTableSlot* _announce_rate_table_pool;
static RateTableSlot* find_rate_table_slot(const Bytes& hash);
static RateTableSlot* find_empty_rate_table_slot();
static size_t announce_rate_table_count();
- static constexpr size_t PATH_REQUESTS_SIZE = 8; // Reduced for testing
+ static constexpr size_t PATH_REQUESTS_SIZE = 32;
struct PathRequestSlot {
bool in_use = false;
Bytes destination_hash;
double timestamp = 0;
void clear() { in_use = false; destination_hash.clear(); timestamp = 0; }
};
- static PathRequestSlot _path_requests_pool[PATH_REQUESTS_SIZE];
+ static PathRequestSlot* _path_requests_pool;
static PathRequestSlot* find_path_request_slot(const Bytes& hash);
static PathRequestSlot* find_empty_path_request_slot();
static size_t path_requests_count();
// Receipts fixed array
- static constexpr size_t RECEIPTS_SIZE = 8; // Reduced for testing
- static PacketReceipt _receipts_pool[RECEIPTS_SIZE];
+ static constexpr size_t RECEIPTS_SIZE = 16;
+ static PacketReceipt* _receipts_pool;
static size_t _receipts_count;
static bool receipts_add(const PacketReceipt& receipt);
static bool receipts_remove(const PacketReceipt& receipt);
static inline size_t receipts_count() { return _receipts_count; }
// Packet hashlist circular buffer (replaces std::set)
- static constexpr size_t PACKET_HASHLIST_SIZE = 64; // Reduced for testing
- static Bytes _packet_hashlist_buffer[PACKET_HASHLIST_SIZE];
+ static constexpr size_t PACKET_HASHLIST_SIZE = 128;
+ static Bytes* _packet_hashlist_buffer;
static size_t _packet_hashlist_head;
static size_t _packet_hashlist_count;
static bool packet_hashlist_contains(const Bytes& hash);
@@ -507,15 +507,15 @@ namespace RNS {
// Discovery PR tags circular buffer (replaces std::set)
static constexpr size_t DISCOVERY_PR_TAGS_SIZE = 32;
- static Bytes _discovery_pr_tags_buffer[DISCOVERY_PR_TAGS_SIZE];
+ static Bytes* _discovery_pr_tags_buffer;
static size_t _discovery_pr_tags_head;
static size_t _discovery_pr_tags_count;
static bool discovery_pr_tags_contains(const Bytes& tag);
static void discovery_pr_tags_add(const Bytes& tag);
// Pending links fixed array (replaces std::set)
- static constexpr size_t PENDING_LINKS_SIZE = 4; // Reduced for testing
- static Link _pending_links_pool[PENDING_LINKS_SIZE];
+ static constexpr size_t PENDING_LINKS_SIZE = 8;
+ static Link* _pending_links_pool;
static size_t _pending_links_count;
static bool pending_links_contains(const Link& link);
static bool pending_links_add(const Link& link);
@@ -524,7 +524,7 @@ namespace RNS {
// Active links fixed array (replaces std::set)
static constexpr size_t ACTIVE_LINKS_SIZE = 4; // Reduced for testing
- static Link _active_links_pool[ACTIVE_LINKS_SIZE];
+ static Link* _active_links_pool;
static size_t _active_links_count;
static bool active_links_contains(const Link& link);
static bool active_links_add(const Link& link);
@@ -533,7 +533,7 @@ namespace RNS {
// Control hashes fixed array (replaces std::set)
static constexpr size_t CONTROL_HASHES_SIZE = 8;
- static Bytes _control_hashes_pool[CONTROL_HASHES_SIZE];
+ static Bytes* _control_hashes_pool;
static size_t _control_hashes_count;
static bool control_hashes_contains(const Bytes& hash);
static bool control_hashes_add(const Bytes& hash);
@@ -541,14 +541,14 @@ namespace RNS {
// Control destinations fixed array (replaces std::set)
static constexpr size_t CONTROL_DESTINATIONS_SIZE = 8;
- static Destination _control_destinations_pool[CONTROL_DESTINATIONS_SIZE];
+ static Destination* _control_destinations_pool;
static size_t _control_destinations_count;
static bool control_destinations_add(const Destination& dest);
static size_t control_destinations_size();
// Announce handlers fixed array (replaces std::set)
static constexpr size_t ANNOUNCE_HANDLERS_SIZE = 8;
- static HAnnounceHandler _announce_handlers_pool[ANNOUNCE_HANDLERS_SIZE];
+ static HAnnounceHandler* _announce_handlers_pool;
static size_t _announce_handlers_count;
static bool announce_handlers_add(HAnnounceHandler handler);
static bool announce_handlers_remove(HAnnounceHandler handler);
@@ -556,7 +556,7 @@ namespace RNS {
// Local client interfaces fixed array (replaces std::set>)
static constexpr size_t LOCAL_CLIENT_INTERFACES_SIZE = 8;
- static const Interface* _local_client_interfaces_pool[LOCAL_CLIENT_INTERFACES_SIZE];
+ static const Interface** _local_client_interfaces_pool;
static size_t _local_client_interfaces_count;
static bool local_client_interfaces_contains(const Interface& iface);
static bool local_client_interfaces_add(const Interface& iface);
@@ -571,7 +571,7 @@ namespace RNS {
void clear() { in_use = false; hash.clear(); interface_ptr = nullptr; }
};
static constexpr size_t INTERFACES_POOL_SIZE = 8;
- static InterfaceSlot _interfaces_pool[INTERFACES_POOL_SIZE];
+ static InterfaceSlot* _interfaces_pool;
static InterfaceSlot* find_interface_slot(const Bytes& hash);
static InterfaceSlot* find_empty_interface_slot();
static size_t interfaces_count();
@@ -585,7 +585,7 @@ namespace RNS {
void clear() { in_use = false; hash.clear(); destination = Destination(); }
};
static constexpr size_t DESTINATIONS_POOL_SIZE = 32;
- static DestinationSlot _destinations_pool[DESTINATIONS_POOL_SIZE];
+ static DestinationSlot* _destinations_pool;
static DestinationSlot* find_destination_slot(const Bytes& hash);
static DestinationSlot* find_empty_destination_slot();
static size_t destinations_count();
@@ -600,7 +600,7 @@ namespace RNS {
void clear() { in_use = false; destination_hash.clear(); timeout = 0; requesting_interface = Interface(Type::NONE); }
};
static constexpr size_t DISCOVERY_PATH_REQUESTS_SIZE = 32;
- static DiscoveryPathRequestSlot _discovery_path_requests_pool[DISCOVERY_PATH_REQUESTS_SIZE];
+ static DiscoveryPathRequestSlot* _discovery_path_requests_pool;
static DiscoveryPathRequestSlot* find_discovery_path_request_slot(const Bytes& hash);
static DiscoveryPathRequestSlot* find_empty_discovery_path_request_slot();
static size_t discovery_path_requests_count();
@@ -613,12 +613,13 @@ namespace RNS {
void clear() { in_use = false; destination_hash.clear(); attached_interface = Interface(Type::NONE); }
};
static constexpr size_t PENDING_LOCAL_PATH_REQUESTS_SIZE = 32;
- static PendingLocalPathRequestSlot _pending_local_path_requests_pool[PENDING_LOCAL_PATH_REQUESTS_SIZE];
+ static PendingLocalPathRequestSlot* _pending_local_path_requests_pool;
static PendingLocalPathRequestSlot* find_pending_local_path_request_slot(const Bytes& hash);
static PendingLocalPathRequestSlot* find_empty_pending_local_path_request_slot();
static size_t pending_local_path_requests_count();
public:
+ static bool init_pools(); // Call early in startup, allocates pools in PSRAM
static void start(const Reticulum& reticulum_instance);
static void loop();
static void jobs();
diff --git a/src/UI/LXMF/ConversationListScreen.cpp b/src/UI/LXMF/ConversationListScreen.cpp
index 2f34bd05..43fa9d40 100644
--- a/src/UI/LXMF/ConversationListScreen.cpp
+++ b/src/UI/LXMF/ConversationListScreen.cpp
@@ -196,8 +196,8 @@ void ConversationListScreen::refresh() {
_conversation_containers.clear();
_peer_hash_pool.clear();
- // Load conversations from store
- std::vector peer_hashes = _message_store->get_conversations();
+ // Load conversations from store
+ std::vector peer_hashes = _message_store->get_conversations();
// Reserve capacity to avoid reallocations during population
_peer_hash_pool.reserve(peer_hashes.size());
@@ -210,20 +210,13 @@ void ConversationListScreen::refresh() {
INFO(log_buf);
}
- for (const auto& peer_hash : peer_hashes) {
- std::vector messages = _message_store->get_messages_for_conversation(peer_hash);
+ for (const auto& peer_hash : peer_hashes) {
+ ::LXMF::MessageStore::ConversationInfo info = _message_store->get_conversation_info(peer_hash);
+ Bytes last_msg_hash = info.last_message_hash_bytes();
- if (messages.empty()) {
- continue;
- }
-
- // Load last message for preview
- Bytes last_msg_hash = messages.back();
- ::LXMF::LXMessage last_msg = _message_store->load_message(last_msg_hash);
-
- // Create conversation item
- ConversationItem item;
- item.peer_hash = peer_hash;
+ // Create conversation item
+ ConversationItem item;
+ item.peer_hash = peer_hash;
// Try to get display name from app_data, fall back to hash
Bytes app_data = Identity::recall_app_data(peer_hash);
@@ -234,24 +227,35 @@ void ConversationListScreen::refresh() {
} else {
item.peer_name = truncate_hash(peer_hash);
}
- } else {
- item.peer_name = truncate_hash(peer_hash);
- }
-
- // Get message content for preview
- String content((const char*)last_msg.content().data(), last_msg.content().size());
- item.last_message = content.substring(0, 30); // Truncate to 30 chars
- if (content.length() > 30) {
- item.last_message += "...";
- }
-
- item.timestamp = (uint32_t)last_msg.timestamp();
- item.timestamp_str = format_timestamp(item.timestamp);
- item.unread_count = 0; // TODO: Track unread count
-
- _conversations.push_back(item);
- create_conversation_item(item);
- }
+ } else {
+ item.peer_name = truncate_hash(peer_hash);
+ }
+
+ // Prefer preview cached in the index; only fall back to metadata if needed.
+ if (info.last_message_preview_cstr()[0] != '\0') {
+ item.last_message = info.last_message_preview_cstr();
+ } else if (last_msg_hash) {
+ ::LXMF::MessageStore::MessageMetadata meta = _message_store->load_message_metadata(last_msg_hash);
+ if (meta.valid) {
+ String content(meta.content.c_str());
+ item.last_message = content.substring(0, 30);
+ if (content.length() > 30) {
+ item.last_message += "...";
+ }
+ } else {
+ item.last_message = "";
+ }
+ } else {
+ item.last_message = "";
+ }
+
+ item.timestamp = (uint32_t)info.last_activity;
+ item.timestamp_str = format_timestamp(item.timestamp);
+ item.unread_count = info.unread_count;
+
+ _conversations.push_back(item);
+ create_conversation_item(item);
+ }
}
void ConversationListScreen::create_conversation_item(const ConversationItem& item) {
@@ -320,15 +324,49 @@ void ConversationListScreen::create_conversation_item(const ConversationItem& it
}
void ConversationListScreen::update_unread_count(const Bytes& peer_hash, uint16_t unread_count) {
- LVGL_LOCK();
- // Find conversation and update
- for (auto& conv : _conversations) {
- if (conv.peer_hash == peer_hash) {
- conv.unread_count = unread_count;
- refresh(); // Redraw list
- break;
- }
- }
+ LVGL_LOCK();
+ for (size_t i = 0; i < _conversations.size(); ++i) {
+ auto& conv = _conversations[i];
+ if (conv.peer_hash != peer_hash) {
+ continue;
+ }
+
+ conv.unread_count = unread_count;
+ if (i < _conversation_containers.size()) {
+ lv_obj_t* container = _conversation_containers[i];
+ if (container) {
+ uint32_t child_count = lv_obj_get_child_cnt(container);
+ lv_obj_t* badge = (child_count > 3) ? lv_obj_get_child(container, child_count - 1) : nullptr;
+
+ if (unread_count == 0) {
+ if (badge) {
+ lv_obj_del(badge);
+ }
+ } else {
+ if (badge && lv_obj_get_child_cnt(badge) > 0) {
+ lv_obj_t* label_count = lv_obj_get_child(badge, 0);
+ if (label_count) {
+ lv_label_set_text_fmt(label_count, "%d", unread_count);
+ }
+ } else {
+ lv_obj_t* new_badge = lv_obj_create(container);
+ lv_obj_set_size(new_badge, 20, 20);
+ lv_obj_align(new_badge, LV_ALIGN_BOTTOM_RIGHT, -6, -4);
+ lv_obj_set_style_bg_color(new_badge, Theme::error(), 0);
+ lv_obj_set_style_radius(new_badge, LV_RADIUS_CIRCLE, 0);
+ lv_obj_set_style_border_width(new_badge, 0, 0);
+ lv_obj_set_style_pad_all(new_badge, 0, 0);
+
+ lv_obj_t* label_count = lv_label_create(new_badge);
+ lv_label_set_text_fmt(label_count, "%d", unread_count);
+ lv_obj_center(label_count);
+ lv_obj_set_style_text_color(label_count, lv_color_white(), 0);
+ }
+ }
+ }
+ }
+ break;
+ }
}
void ConversationListScreen::set_conversation_selected_callback(ConversationSelectedCallback callback) {
diff --git a/src/UI/LXMF/UIManager.cpp b/src/UI/LXMF/UIManager.cpp
index 32af37c8..56fd3134 100644
--- a/src/UI/LXMF/UIManager.cpp
+++ b/src/UI/LXMF/UIManager.cpp
@@ -216,12 +216,6 @@ bool UIManager::init() {
void UIManager::update() {
LVGL_LOCK();
- // Process outbound LXMF messages
- _router.process_outbound();
-
- // Process inbound LXMF messages
- _router.process_inbound();
-
// Update status indicators (WiFi/battery) on conversation list
static uint32_t last_status_update = 0;
uint32_t now = millis();
@@ -587,9 +581,10 @@ void UIManager::on_message_received(::LXMF::LXMessage& message) {
}
}
- // Update conversation list unread count
- // TODO: Track unread counts
- _conversation_list_screen->refresh();
+ // Update conversation list only while it is visible.
+ if (_current_screen == SCREEN_CONVERSATION_LIST) {
+ _conversation_list_screen->refresh();
+ }
INFO(" Message processed");
}
diff --git a/test/test_lxmf/python/conftest.py b/test/test_lxmf/python/conftest.py
new file mode 100644
index 00000000..5e4c6379
--- /dev/null
+++ b/test/test_lxmf/python/conftest.py
@@ -0,0 +1,49 @@
+"""
+Shared fixtures for LXMF interop tests (C++ <-> Python).
+
+Loads test vectors from:
+ /tmp/lxmf_cpp_vectors.json - generated by C++ testCppGenerateVectors()
+ /tmp/lxmf_test_vectors_full.json - generated by generate_vectors.py
+"""
+
+import json
+import pytest
+import RNS
+
+
+@pytest.fixture(scope="session")
+def cpp_vectors():
+ """Load C++ generated test vectors."""
+ path = "/tmp/lxmf_cpp_vectors.json"
+ try:
+ with open(path) as f:
+ return json.load(f)
+ except FileNotFoundError:
+ pytest.skip(f"C++ vectors not found at {path}. Run: pio test -e native17 -f test_lxmf")
+
+
+@pytest.fixture(scope="session")
+def python_vectors():
+ """Load Python generated test vectors."""
+ path = "/tmp/lxmf_test_vectors_full.json"
+ try:
+ with open(path) as f:
+ return json.load(f)
+ except FileNotFoundError:
+ pytest.skip(f"Python vectors not found at {path}. Run: python3 generate_vectors.py")
+
+
+def get_vector_by_name(vectors, name):
+ """Find a vector by name in a vector list."""
+ for v in vectors:
+ if v["name"] == name:
+ return v
+ pytest.fail(f"Vector '{name}' not found in vectors")
+
+
+def identity_from_pub_hex(pub_hex):
+ """Reconstruct an RNS Identity from a hex-encoded public key."""
+ pub_bytes = bytes.fromhex(pub_hex)
+ identity = RNS.Identity(create_keys=False)
+ identity.load_public_key(pub_bytes)
+ return identity
diff --git a/test/test_lxmf/python/generate_vectors.py b/test/test_lxmf/python/generate_vectors.py
new file mode 100644
index 00000000..87c66150
--- /dev/null
+++ b/test/test_lxmf/python/generate_vectors.py
@@ -0,0 +1,276 @@
+#!/usr/bin/env python3
+"""
+Generate LXMF test vectors for C++ interoperability testing.
+
+Produces:
+ /tmp/lxmf_test_vector.json - single basic vector (consumed by testPythonInterop)
+ /tmp/lxmf_test_vectors_full.json - all test vectors (consumed by expanded interop tests)
+
+Usage:
+ python3 generate_vectors.py
+"""
+
+import json
+import sys
+import time
+
+try:
+ import RNS
+ import LXMF
+except ImportError:
+ print("Error: RNS and LXMF required. Run: pip install rns lxmf", file=sys.stderr)
+ sys.exit(1)
+
+
+def make_identity():
+ """Create a fresh RNS Identity."""
+ return RNS.Identity()
+
+
+def make_destinations(source_id, dest_id):
+ """Create source and destination objects (OUT direction to avoid Transport init)."""
+ source = RNS.Destination(source_id, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
+ dest = RNS.Destination(dest_id, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
+ return source, dest
+
+
+def identity_to_dict(identity):
+ """Extract identity key material as hex strings."""
+ return {
+ "prv": identity.get_private_key().hex(),
+ "pub": identity.get_public_key().hex(),
+ }
+
+
+def message_to_vector(msg, source_id, dest_id, name, extra=None):
+ """Convert a packed LXMessage to a test vector dict."""
+ vector = {
+ "name": name,
+ "source_identity_prv": source_id.get_private_key().hex(),
+ "source_identity_pub": source_id.get_public_key().hex(),
+ "dest_identity_pub": dest_id.get_public_key().hex(),
+ "source_hash": msg.source_hash.hex(),
+ "dest_hash": msg.destination_hash.hex(),
+ "packed": msg.packed.hex(),
+ "content": msg.content.decode("utf-8", errors="replace") if msg.content else "",
+ "title": msg.title.decode("utf-8", errors="replace") if msg.title else "",
+ "message_hash": msg.hash.hex(),
+ "timestamp": msg.timestamp,
+ "has_stamp": msg.stamp is not None and len(msg.stamp) > 0,
+ "stamp": msg.stamp.hex() if msg.stamp and len(msg.stamp) > 0 else "",
+ }
+
+ # Encode fields — Python LXMF uses integer keys
+ if msg.fields:
+ fields_dict = {}
+ for k, v in msg.fields.items():
+ key_hex = k.to_bytes(1, "big").hex() if isinstance(k, int) else k.hex()
+ val_hex = v.hex() if isinstance(v, (bytes, bytearray)) else str(v).encode().hex()
+ fields_dict[key_hex] = val_hex
+ vector["fields"] = fields_dict
+ else:
+ vector["fields"] = {}
+
+ if extra:
+ vector.update(extra)
+
+ return vector
+
+
+def generate_basic():
+ """Basic message — simple title + content, DIRECT method."""
+ source_id = make_identity()
+ dest_id = make_identity()
+ source, dest = make_destinations(source_id, dest_id)
+
+ msg = LXMF.LXMessage(
+ dest, source,
+ "Hello from Python LXMF!",
+ "Test Message",
+ desired_method=LXMF.LXMessage.DIRECT,
+ )
+ msg.pack()
+
+ return message_to_vector(msg, source_id, dest_id, "basic")
+
+
+def generate_empty():
+ """Empty content and title."""
+ source_id = make_identity()
+ dest_id = make_identity()
+ source, dest = make_destinations(source_id, dest_id)
+
+ msg = LXMF.LXMessage(
+ dest, source,
+ "",
+ "",
+ desired_method=LXMF.LXMessage.DIRECT,
+ )
+ msg.pack()
+
+ return message_to_vector(msg, source_id, dest_id, "empty")
+
+
+def generate_with_fields():
+ """Message with integer-keyed fields (standard LXMF format)."""
+ source_id = make_identity()
+ dest_id = make_identity()
+ source, dest = make_destinations(source_id, dest_id)
+
+ fields = {
+ 0x01: b"field_value_one",
+ 0x02: b"field_value_two",
+ 0x03: b"field_value_three",
+ }
+
+ msg = LXMF.LXMessage(
+ dest, source,
+ "Content with fields",
+ "Fields Test",
+ fields=fields,
+ desired_method=LXMF.LXMessage.DIRECT,
+ )
+ msg.pack()
+
+ return message_to_vector(msg, source_id, dest_id, "with_fields")
+
+
+def generate_large():
+ """Large content (>319 bytes, would be RESOURCE representation)."""
+ source_id = make_identity()
+ dest_id = make_identity()
+ source, dest = make_destinations(source_id, dest_id)
+
+ # 500 bytes of content
+ content = "A" * 500
+ msg = LXMF.LXMessage(
+ dest, source,
+ content,
+ "Large Message",
+ desired_method=LXMF.LXMessage.DIRECT,
+ )
+ msg.pack()
+
+ return message_to_vector(msg, source_id, dest_id, "large")
+
+
+def generate_unicode():
+ """Unicode content and title."""
+ source_id = make_identity()
+ dest_id = make_identity()
+ source, dest = make_destinations(source_id, dest_id)
+
+ msg = LXMF.LXMessage(
+ dest, source,
+ "Hello \u4e16\u754c! \U0001f680 Caf\u00e9",
+ "\u2603 Unicode Title \U0001f4ac",
+ desired_method=LXMF.LXMessage.DIRECT,
+ )
+ msg.pack()
+
+ return message_to_vector(msg, source_id, dest_id, "unicode")
+
+
+def generate_with_stamp():
+ """Message with a stamp (proof-of-work).
+
+ LXMF stamps are appended as the 5th element of the payload array.
+ Hash and signature are computed over the 4-element payload (WITHOUT stamp),
+ then the stamp is appended for the wire format.
+ """
+ from RNS.vendor import umsgpack
+
+ source_id = make_identity()
+ dest_id = make_identity()
+ source, dest = make_destinations(source_id, dest_id)
+
+ msg = LXMF.LXMessage(
+ dest, source,
+ "Stamped message",
+ "Stamp Test",
+ desired_method=LXMF.LXMessage.DIRECT,
+ stamp_cost=8, # Low cost for fast test generation
+ )
+ msg.pack()
+
+ # Generate stamp (PoW against the message hash, which is over 4-element payload)
+ stamp = msg.get_stamp(timeout=30)
+
+ # Hash and signature from pack() are already correct (over 4-element payload).
+ # Just append stamp as 5th element to the wire payload.
+ dest_hash = msg.packed[:16]
+ src_hash = msg.packed[16:32]
+ original_sig = msg.packed[32:96]
+ payload_bytes = msg.packed[96:]
+
+ # Append stamp as 5th element
+ payload_arr = umsgpack.unpackb(payload_bytes)
+ payload_arr.append(stamp)
+ new_payload_bytes = umsgpack.packb(payload_arr)
+
+ # Assemble with original signature (computed over 4-element payload)
+ new_packed = dest_hash + src_hash + original_sig + new_payload_bytes
+
+ vector = {
+ "name": "with_stamp",
+ "source_identity_prv": source_id.get_private_key().hex(),
+ "source_identity_pub": source_id.get_public_key().hex(),
+ "dest_identity_pub": dest_id.get_public_key().hex(),
+ "source_hash": src_hash.hex(),
+ "dest_hash": dest_hash.hex(),
+ "packed": new_packed.hex(),
+ "content": "Stamped message",
+ "title": "Stamp Test",
+ "message_hash": msg.hash.hex(), # Hash over 4-element payload
+ "timestamp": msg.timestamp,
+ "has_stamp": True,
+ "stamp": stamp.hex(),
+ "fields": {},
+ }
+
+ return vector
+
+
+def main():
+ print(f"Generating LXMF test vectors (RNS {RNS.__version__}, LXMF {LXMF.__version__})",
+ file=sys.stderr)
+
+ vectors = []
+
+ # Generate all test vectors
+ print(" Generating basic...", file=sys.stderr)
+ basic = generate_basic()
+ vectors.append(basic)
+
+ print(" Generating empty...", file=sys.stderr)
+ vectors.append(generate_empty())
+
+ print(" Generating with_fields...", file=sys.stderr)
+ vectors.append(generate_with_fields())
+
+ print(" Generating large...", file=sys.stderr)
+ vectors.append(generate_large())
+
+ print(" Generating unicode...", file=sys.stderr)
+ vectors.append(generate_unicode())
+
+ print(" Generating with_stamp (PoW, may take a moment)...", file=sys.stderr)
+ vectors.append(generate_with_stamp())
+
+ # Write single vector (backward compatible with existing testPythonInterop)
+ single_path = "/tmp/lxmf_test_vector.json"
+ with open(single_path, "w") as f:
+ json.dump(basic, f, indent=2)
+ print(f" Wrote {single_path}", file=sys.stderr)
+
+ # Write full vectors
+ full_path = "/tmp/lxmf_test_vectors_full.json"
+ with open(full_path, "w") as f:
+ json.dump(vectors, f, indent=2)
+ print(f" Wrote {full_path} ({len(vectors)} vectors)", file=sys.stderr)
+
+ print("Done.", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_lxmf/python/test_interop.py b/test/test_lxmf/python/test_interop.py
new file mode 100644
index 00000000..d41ac072
--- /dev/null
+++ b/test/test_lxmf/python/test_interop.py
@@ -0,0 +1,290 @@
+"""
+LXMF interop tests: validate C++ output in Python.
+
+Reads /tmp/lxmf_cpp_vectors.json (generated by C++ testCppGenerateVectors)
+and verifies that Python LXMF can correctly unpack and validate the messages.
+
+Also tests bidirectional roundtrip: Python → C++ → Python.
+"""
+
+import hashlib
+import json
+import pytest
+
+import RNS
+import LXMF
+from RNS.vendor import umsgpack
+
+from conftest import get_vector_by_name, identity_from_pub_hex
+
+
+def register_source_identity(vector):
+ """Register source identity so Python LXMF can validate signatures."""
+ source_identity = identity_from_pub_hex(vector["source_identity_pub"])
+ source_hash = bytes.fromhex(vector["source_hash"])
+ RNS.Identity.remember(None, source_hash, source_identity.get_public_key())
+ return source_identity
+
+
+def validate_signature_manual(packed_bytes, pub_hex):
+ """Manually validate Ed25519 signature on packed LXMF message."""
+ identity = identity_from_pub_hex(pub_hex)
+
+ dest_hash = packed_bytes[:16]
+ src_hash = packed_bytes[16:32]
+ signature = packed_bytes[32:96]
+ payload = packed_bytes[96:]
+
+ # Hash and signature are computed over the payload WITHOUT stamp
+ decoded = umsgpack.unpackb(payload)
+ if len(decoded) > 4:
+ # Strip stamp, repack
+ payload = umsgpack.packb(decoded[:4])
+
+ hashed_part = dest_hash + src_hash + payload
+ msg_hash = hashlib.sha256(hashed_part).digest()
+ signed_part = hashed_part + msg_hash
+
+ return identity.validate(signature, signed_part)
+
+
+# ==================== C++ → Python Tests ====================
+
+
+class TestCppBasicUnpack:
+ """Test that Python can unpack basic C++ messages."""
+
+ def test_content_and_title(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_basic")
+ register_source_identity(v)
+
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+
+ assert msg.content_as_string() == v["content"]
+ assert msg.title_as_string() == v["title"]
+
+ def test_empty_content(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_empty")
+ register_source_identity(v)
+
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+
+ assert msg.content_as_string() == ""
+ assert msg.title_as_string() == ""
+
+ def test_unicode_content(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_unicode")
+ register_source_identity(v)
+
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+
+ assert msg.content_as_string() == v["content"]
+ assert msg.title_as_string() == v["title"]
+
+
+class TestCppSignatureValid:
+ """Test that C++ Ed25519 signatures are valid."""
+
+ def test_basic_signature(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_basic")
+ packed = bytes.fromhex(v["packed"])
+ assert validate_signature_manual(packed, v["source_identity_pub"])
+
+ def test_empty_signature(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_empty")
+ packed = bytes.fromhex(v["packed"])
+ assert validate_signature_manual(packed, v["source_identity_pub"])
+
+ def test_fields_signature(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_with_fields")
+ packed = bytes.fromhex(v["packed"])
+ assert validate_signature_manual(packed, v["source_identity_pub"])
+
+ def test_unicode_signature(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_unicode")
+ packed = bytes.fromhex(v["packed"])
+ assert validate_signature_manual(packed, v["source_identity_pub"])
+
+
+class TestCppHashMatches:
+ """Test that Python-computed hash matches C++ reported hash."""
+
+ def _verify_hash(self, vector):
+ packed = bytes.fromhex(vector["packed"])
+ dest_hash = packed[:16]
+ src_hash = packed[16:32]
+ payload = packed[96:]
+
+ hashed_part = dest_hash + src_hash + payload
+ computed_hash = hashlib.sha256(hashed_part).digest()
+
+ assert computed_hash.hex() == vector["message_hash"]
+
+ def test_basic_hash(self, cpp_vectors):
+ self._verify_hash(get_vector_by_name(cpp_vectors, "cpp_basic"))
+
+ def test_empty_hash(self, cpp_vectors):
+ self._verify_hash(get_vector_by_name(cpp_vectors, "cpp_empty"))
+
+ def test_fields_hash(self, cpp_vectors):
+ self._verify_hash(get_vector_by_name(cpp_vectors, "cpp_with_fields"))
+
+ def test_unicode_hash(self, cpp_vectors):
+ self._verify_hash(get_vector_by_name(cpp_vectors, "cpp_unicode"))
+
+
+class TestCppFields:
+ """Test that C++ field encoding can be decoded by Python."""
+
+ def test_fields_present(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_with_fields")
+ register_source_identity(v)
+
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+
+ # C++ uses binary keys (Bytes), Python LXMF unpacks them as bytes
+ fields = msg.get_fields()
+ assert fields is not None
+ assert len(fields) == v["fields_count"]
+
+ def test_field_values(self, cpp_vectors):
+ v = get_vector_by_name(cpp_vectors, "cpp_with_fields")
+ register_source_identity(v)
+
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+ fields = msg.get_fields()
+
+ # Check each field from the vector
+ for key_hex, val_hex in v["fields"].items():
+ key_bytes = bytes.fromhex(key_hex)
+ val_bytes = bytes.fromhex(val_hex)
+ assert key_bytes in fields, f"Field key {key_hex} not found in unpacked fields"
+ assert fields[key_bytes] == val_bytes, f"Field value mismatch for key {key_hex}"
+
+
+class TestCppStamp:
+ """Test that C++ generated stamps are valid per Python LXMF."""
+
+ def test_stamp_present(self, cpp_vectors):
+ """C++ stamped message should have a 32-byte stamp after unpack."""
+ v = get_vector_by_name(cpp_vectors, "cpp_with_stamp")
+ register_source_identity(v)
+
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+
+ assert msg.stamp is not None, "Stamp missing from unpacked message"
+ assert len(msg.stamp) == 32, f"Stamp wrong size: {len(msg.stamp)}"
+
+ def test_stamp_bytes_match(self, cpp_vectors):
+ """Stamp bytes from unpack should match what C++ reported."""
+ v = get_vector_by_name(cpp_vectors, "cpp_with_stamp")
+ register_source_identity(v)
+
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+
+ expected_stamp = bytes.fromhex(v["stamp"])
+ assert msg.stamp == expected_stamp
+
+ def test_stamp_validates(self, cpp_vectors):
+ """Python LXStamper should accept the C++ generated stamp."""
+ from LXMF import LXStamper
+
+ v = get_vector_by_name(cpp_vectors, "cpp_with_stamp")
+ register_source_identity(v)
+
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+
+ stamp_cost = v["stamp_cost"]
+ assert msg.validate_stamp(stamp_cost), (
+ f"Python rejected C++ stamp (cost={stamp_cost})"
+ )
+
+ def test_stamp_signature_valid(self, cpp_vectors):
+ """Signature should be valid (computed over 4-element payload without stamp)."""
+ v = get_vector_by_name(cpp_vectors, "cpp_with_stamp")
+ packed = bytes.fromhex(v["packed"])
+ assert validate_signature_manual(packed, v["source_identity_pub"])
+
+ def test_stamp_hash_matches(self, cpp_vectors):
+ """Hash should be computed over the payload WITHOUT stamp."""
+ v = get_vector_by_name(cpp_vectors, "cpp_with_stamp")
+ packed = bytes.fromhex(v["packed"])
+
+ dest_hash = packed[:16]
+ src_hash = packed[16:32]
+ payload = packed[96:]
+
+ # Strip stamp (5th element) before computing hash
+ decoded = umsgpack.unpackb(payload)
+ if len(decoded) > 4:
+ payload = umsgpack.packb(decoded[:4])
+
+ hashed_part = dest_hash + src_hash + payload
+ computed_hash = hashlib.sha256(hashed_part).digest()
+ assert computed_hash.hex() == v["message_hash"]
+
+
+class TestBidirectionalRoundtrip:
+ """Test that Python→C++→Python roundtrip preserves data.
+
+ Python generates vectors, C++ unpacks and repacks them (testCppGenerateVectors),
+ then Python unpacks the C++ output. We verify the original Python vectors
+ match the C++ output.
+ """
+
+ def test_basic_roundtrip_hash(self, python_vectors, cpp_vectors):
+ """Verify hash computation is consistent across implementations.
+
+ Note: hash won't be identical because C++ generates new messages
+ (different keys/timestamps). Instead we verify that Python can
+ independently compute the same hash from the C++ packed bytes.
+ Hash is always over the 4-element payload (without stamp).
+ """
+ for v in cpp_vectors:
+ packed = bytes.fromhex(v["packed"])
+ dest_hash = packed[:16]
+ src_hash = packed[16:32]
+ payload = packed[96:]
+
+ # Strip stamp (5th element) if present before computing hash
+ decoded = umsgpack.unpackb(payload)
+ if len(decoded) > 4:
+ payload = umsgpack.packb(decoded[:4])
+
+ hashed_part = dest_hash + src_hash + payload
+ py_hash = hashlib.sha256(hashed_part).digest()
+
+ assert py_hash.hex() == v["message_hash"], (
+ f"Hash mismatch for {v['name']}: "
+ f"Python={py_hash.hex()}, C++={v['message_hash']}"
+ )
+
+ def test_all_cpp_vectors_unpack(self, cpp_vectors):
+ """Every C++ vector should unpack without error in Python."""
+ for v in cpp_vectors:
+ register_source_identity(v)
+ packed = bytes.fromhex(v["packed"])
+ msg = LXMF.LXMessage.unpack_from_bytes(packed)
+
+ assert msg.content_as_string() == v["content"], (
+ f"Content mismatch for {v['name']}"
+ )
+ assert msg.title_as_string() == v["title"], (
+ f"Title mismatch for {v['name']}"
+ )
+
+ def test_all_cpp_signatures_valid(self, cpp_vectors):
+ """Every C++ vector should have a valid signature."""
+ for v in cpp_vectors:
+ packed = bytes.fromhex(v["packed"])
+ assert validate_signature_manual(packed, v["source_identity_pub"]), (
+ f"Signature invalid for {v['name']}"
+ )
diff --git a/test/test_lxmf/run_interop.sh b/test/test_lxmf/run_interop.sh
new file mode 100755
index 00000000..597d5d6b
--- /dev/null
+++ b/test/test_lxmf/run_interop.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+# E2E LXMF interop test: Python <-> C++
+#
+# Runs the full bidirectional test pipeline:
+# 1. Python generates test vectors → /tmp/lxmf_test_vector*.json
+# 2. C++ unpacks Python vectors, generates its own → /tmp/lxmf_cpp_vectors.json
+# 3. Python validates C++ vectors
+#
+# Usage: bash run_interop.sh
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+echo "=== LXMF E2E Interop Tests ==="
+echo " Script dir: $SCRIPT_DIR"
+echo " Repo dir: $REPO_DIR"
+echo
+
+# Step 1: Generate Python test vectors
+echo "--- Step 1: Generate Python test vectors ---"
+python3 "$SCRIPT_DIR/python/generate_vectors.py"
+echo
+
+# Step 2: Run C++ tests (consumes Python vectors, generates C++ vectors)
+echo "--- Step 2: Run C++ tests ---"
+cd "$REPO_DIR"
+pio test -e native17 -f test_lxmf 2>&1 | grep -E "test.*\[|SUMMARY|======" || true
+echo
+
+# Step 3: Run Python validation tests
+echo "--- Step 3: Run Python validation tests ---"
+cd "$SCRIPT_DIR"
+python3 -m pytest python/test_interop.py -v
+echo
+
+echo "=== All LXMF interop tests passed ==="
diff --git a/test/test_lxmf/test_main.cpp b/test/test_lxmf/test_main.cpp
index c0174376..19f64ae1 100644
--- a/test/test_lxmf/test_main.cpp
+++ b/test/test_lxmf/test_main.cpp
@@ -7,6 +7,7 @@
#include "Bytes.h"
#include "Identity.h"
#include "Destination.h"
+#include "Transport.h"
#include "LXMF/LXMessage.h"
#include "LXMF/LXMRouter.h"
#include "LXMF/MessageStore.h"
@@ -178,77 +179,332 @@ void testLXMessageFields() {
TEST_ASSERT_TRUE(*val2 == Bytes("value2"));
}
-void testPythonInterop() {
- // Test unpacking a message created by Python LXMF
- // This validates byte-level compatibility between C++ and Python implementations
+// ==================== Python Interop Helpers ====================
- // Read the test vector generated by lxmf_simple_test.py
- std::ifstream file("/tmp/lxmf_test_vector.json");
- if (!file.is_open()) {
- TEST_IGNORE_MESSAGE("Python test vector not found at /tmp/lxmf_test_vector.json. Run: python3 test/test_interop/python/lxmf_simple_test.py");
- return;
- }
+// Load a single vector from a JSON file
+static bool loadSingleVector(const char* path, JsonDocument& doc) {
+ std::ifstream file(path);
+ if (!file.is_open()) return false;
+ std::stringstream buffer;
+ buffer << file.rdbuf();
+ file.close();
+ return !deserializeJson(doc, buffer.str());
+}
- // Parse JSON
+// Load the full vectors array, return a specific vector by name
+static bool loadVectorByName(const char* name, JsonDocument& doc) {
+ std::ifstream file("/tmp/lxmf_test_vectors_full.json");
+ if (!file.is_open()) return false;
std::stringstream buffer;
buffer << file.rdbuf();
file.close();
+ if (deserializeJson(doc, buffer.str())) return false;
+
+ // Find vector with matching name
+ JsonArray arr = doc.as();
+ for (JsonObject v : arr) {
+ if (strcmp(v["name"].as(), name) == 0) {
+ // Copy to doc root (overwrite array with single object)
+ JsonDocument single;
+ single.set(v);
+ doc = single;
+ return true;
+ }
+ }
+ return false;
+}
- JsonDocument doc;
- DeserializationError error = deserializeJson(doc, buffer.str());
- TEST_ASSERT_FALSE(error);
+// Unpack a vector and register its source identity, returning the unpacked message
+static LXMessage unpackVector(JsonDocument& doc) {
+ Bytes packed;
+ packed.assignHex(doc["packed"].as());
- // Extract test data
- const char* packed_hex = doc["packed"];
- const char* content_str = doc["content"];
- const char* title_str = doc["title"];
+ Bytes source_pub;
+ source_pub.assignHex(doc["source_identity_pub"].as());
+ Bytes source_hash;
+ source_hash.assignHex(doc["source_hash"].as());
- // Convert hex strings to Bytes
- Bytes packed;
- packed.assignHex(packed_hex);
+ // Register source identity for signature validation
+ Identity::remember(Identity::get_random_hash(), source_hash, source_pub);
- // Extract additional test data for validation
- const char* source_identity_pub_hex = doc["source_identity_pub"];
- const char* source_hash_hex = doc["source_hash"];
- const char* dest_hash_hex = doc["dest_hash"];
- const char* message_hash_hex = doc["message_hash"];
+ return LXMessage::unpack_from_bytes(packed);
+}
- Bytes source_pub;
- source_pub.assignHex(source_identity_pub_hex);
+// ==================== Python → C++ Interop Tests ====================
+
+void testPythonInterop() {
+ // Test unpacking a basic message created by Python LXMF
+
+ JsonDocument doc;
+ if (!loadSingleVector("/tmp/lxmf_test_vector.json", doc)) {
+ TEST_IGNORE_MESSAGE("Python test vector not found. Run: python3 test/test_lxmf/python/generate_vectors.py");
+ return;
+ }
+
+ const char* content_str = doc["content"];
+ const char* title_str = doc["title"];
+ Bytes expected_hash;
+ expected_hash.assignHex(doc["message_hash"].as());
Bytes source_hash;
- source_hash.assignHex(source_hash_hex);
+ source_hash.assignHex(doc["source_hash"].as());
Bytes dest_hash;
- dest_hash.assignHex(dest_hash_hex);
- Bytes expected_hash;
- expected_hash.assignHex(message_hash_hex);
+ dest_hash.assignHex(doc["dest_hash"].as());
- // Remember the source identity so signature can be validated
- Bytes packet_hash = Identity::get_random_hash();
- Identity::remember(packet_hash, source_hash, source_pub);
+ LXMessage unpacked = unpackVector(doc);
- // Unpack the Python-generated message
- LXMessage unpacked = LXMessage::unpack_from_bytes(packed);
-
- // Verify hash matches
+ // Verify hash
TEST_ASSERT_EQUAL_size_t(32, unpacked.hash().size());
TEST_ASSERT_EQUAL_INT(0, memcmp(expected_hash.data(), unpacked.hash().data(), 32));
- // Verify content matches
+ // Verify content
std::string content_cpp(reinterpret_cast(unpacked.content().data()), unpacked.content().size());
TEST_ASSERT_EQUAL_STRING(content_str, content_cpp.c_str());
- // Verify title matches
+ // Verify title
std::string title_cpp(reinterpret_cast(unpacked.title().data()), unpacked.title().size());
TEST_ASSERT_EQUAL_STRING(title_str, title_cpp.c_str());
- // Verify signature was validated
+ // Verify signature
TEST_ASSERT_TRUE(unpacked.signature_validated());
- // Verify hashes match
+ // Verify source/dest hashes
TEST_ASSERT_EQUAL_INT(0, memcmp(source_hash.data(), unpacked.source_hash().data(), 16));
TEST_ASSERT_EQUAL_INT(0, memcmp(dest_hash.data(), unpacked.destination_hash().data(), 16));
}
+void testPythonInteropEmpty() {
+ // Test unpacking a Python message with empty content and title
+
+ JsonDocument doc;
+ if (!loadVectorByName("empty", doc)) {
+ TEST_IGNORE_MESSAGE("Full vectors not found. Run: python3 test/test_lxmf/python/generate_vectors.py");
+ return;
+ }
+
+ LXMessage unpacked = unpackVector(doc);
+
+ TEST_ASSERT_EQUAL_size_t(0, unpacked.content().size());
+ TEST_ASSERT_EQUAL_size_t(0, unpacked.title().size());
+ TEST_ASSERT_TRUE(unpacked.signature_validated());
+
+ // Verify hash
+ Bytes expected_hash;
+ expected_hash.assignHex(doc["message_hash"].as());
+ TEST_ASSERT_EQUAL_INT(0, memcmp(expected_hash.data(), unpacked.hash().data(), 32));
+}
+
+void testPythonInteropLarge() {
+ // Test unpacking a large Python message (>319 bytes content)
+
+ JsonDocument doc;
+ if (!loadVectorByName("large", doc)) {
+ TEST_IGNORE_MESSAGE("Full vectors not found. Run: python3 test/test_lxmf/python/generate_vectors.py");
+ return;
+ }
+
+ LXMessage unpacked = unpackVector(doc);
+
+ // Verify content is 500 'A' characters
+ TEST_ASSERT_EQUAL_size_t(500, unpacked.content().size());
+ for (size_t i = 0; i < unpacked.content().size(); ++i) {
+ TEST_ASSERT_EQUAL_UINT8('A', unpacked.content().data()[i]);
+ }
+ TEST_ASSERT_TRUE(unpacked.signature_validated());
+}
+
+void testPythonInteropUnicode() {
+ // Test unpacking a Python message with UTF-8 content
+
+ JsonDocument doc;
+ if (!loadVectorByName("unicode", doc)) {
+ TEST_IGNORE_MESSAGE("Full vectors not found. Run: python3 test/test_lxmf/python/generate_vectors.py");
+ return;
+ }
+
+ const char* content_str = doc["content"];
+ const char* title_str = doc["title"];
+
+ LXMessage unpacked = unpackVector(doc);
+
+ std::string content_cpp(reinterpret_cast(unpacked.content().data()), unpacked.content().size());
+ TEST_ASSERT_EQUAL_STRING(content_str, content_cpp.c_str());
+
+ std::string title_cpp(reinterpret_cast(unpacked.title().data()), unpacked.title().size());
+ TEST_ASSERT_EQUAL_STRING(title_str, title_cpp.c_str());
+ TEST_ASSERT_TRUE(unpacked.signature_validated());
+}
+
+void testPythonInteropStamp() {
+ // Test unpacking a Python message with a stamp (5th element in payload array)
+
+ JsonDocument doc;
+ if (!loadVectorByName("with_stamp", doc)) {
+ TEST_IGNORE_MESSAGE("Full vectors not found. Run: python3 test/test_lxmf/python/generate_vectors.py");
+ return;
+ }
+
+ LXMessage unpacked = unpackVector(doc);
+
+ // Verify stamp is present (32 bytes)
+ TEST_ASSERT_EQUAL_size_t(32, unpacked.stamp().size());
+ TEST_ASSERT_TRUE(unpacked.signature_validated());
+
+ // Verify stamp bytes match
+ Bytes expected_stamp;
+ expected_stamp.assignHex(doc["stamp"].as());
+ TEST_ASSERT_EQUAL_INT(0, memcmp(expected_stamp.data(), unpacked.stamp().data(), 32));
+}
+
+// ==================== C++ → Python Vector Generation ====================
+
+void testCppGenerateVectors() {
+ // Generate test vectors from C++ for Python to validate.
+ // Writes /tmp/lxmf_cpp_vectors.json
+
+ JsonDocument output;
+ JsonArray vectors = output.to();
+
+ // --- Vector 1: basic message ---
+ {
+ Identity source_identity;
+ Identity dest_identity;
+ Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+ Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+
+ LXMessage msg(dest, source, Bytes("Hello from C++!"), Bytes("C++ Test"));
+ msg.pack();
+
+ JsonObject v = vectors.add();
+ v["name"] = "cpp_basic";
+ v["source_identity_pub"] = source_identity.get_public_key().toHex();
+ v["source_hash"] = source.hash().toHex();
+ v["dest_hash"] = dest.hash().toHex();
+ v["packed"] = msg.packed().toHex();
+ v["content"] = "Hello from C++!";
+ v["title"] = "C++ Test";
+ v["message_hash"] = msg.hash().toHex();
+ v["timestamp"] = msg.timestamp();
+ }
+
+ // --- Vector 2: empty content ---
+ {
+ Identity source_identity;
+ Identity dest_identity;
+ Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+ Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+
+ LXMessage msg(dest, source, Bytes(), Bytes());
+ msg.pack();
+
+ JsonObject v = vectors.add();
+ v["name"] = "cpp_empty";
+ v["source_identity_pub"] = source_identity.get_public_key().toHex();
+ v["source_hash"] = source.hash().toHex();
+ v["dest_hash"] = dest.hash().toHex();
+ v["packed"] = msg.packed().toHex();
+ v["content"] = "";
+ v["title"] = "";
+ v["message_hash"] = msg.hash().toHex();
+ v["timestamp"] = msg.timestamp();
+ }
+
+ // --- Vector 3: with fields (binary keys) ---
+ {
+ Identity source_identity;
+ Identity dest_identity;
+ Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+ Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+
+ LXMessage msg(dest, source, Bytes("Content with fields"), Bytes("Fields Test"));
+ msg.fields_set(Bytes("field1"), Bytes("value1"));
+ msg.fields_set(Bytes("field2"), Bytes("value2"));
+ msg.pack();
+
+ JsonObject v = vectors.add();
+ v["name"] = "cpp_with_fields";
+ v["source_identity_pub"] = source_identity.get_public_key().toHex();
+ v["source_hash"] = source.hash().toHex();
+ v["dest_hash"] = dest.hash().toHex();
+ v["packed"] = msg.packed().toHex();
+ v["content"] = "Content with fields";
+ v["title"] = "Fields Test";
+ v["message_hash"] = msg.hash().toHex();
+ v["timestamp"] = msg.timestamp();
+ v["fields_count"] = (int)msg.fields_count();
+
+ // Store field keys/values as hex for Python to verify
+ JsonObject fields = v["fields"].to();
+ fields[Bytes("field1").toHex()] = Bytes("value1").toHex();
+ fields[Bytes("field2").toHex()] = Bytes("value2").toHex();
+ }
+
+ // --- Vector 4: unicode content ---
+ {
+ Identity source_identity;
+ Identity dest_identity;
+ Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+ Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+
+ // UTF-8 encoded content
+ const char* utf8_content = "Hello \xe4\xb8\x96\xe7\x95\x8c! Caf\xc3\xa9";
+ const char* utf8_title = "\xe2\x98\x83 Unicode";
+
+ LXMessage msg(dest, source, Bytes(utf8_content), Bytes(utf8_title));
+ msg.pack();
+
+ JsonObject v = vectors.add();
+ v["name"] = "cpp_unicode";
+ v["source_identity_pub"] = source_identity.get_public_key().toHex();
+ v["source_hash"] = source.hash().toHex();
+ v["dest_hash"] = dest.hash().toHex();
+ v["packed"] = msg.packed().toHex();
+ v["content"] = utf8_content;
+ v["title"] = utf8_title;
+ v["message_hash"] = msg.hash().toHex();
+ v["timestamp"] = msg.timestamp();
+ }
+
+ // --- Vector 5: with stamp ---
+ {
+ Identity source_identity;
+ Identity dest_identity;
+ Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+ Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery");
+
+ LXMessage msg(dest, source, Bytes("Stamped from C++"), Bytes("Stamp Test"));
+ msg.set_stamp_cost(8);
+ msg.pack(); // First pack to get hash
+ msg.generate_stamp(); // PoW against that hash
+ const Bytes& packed = msg.pack(); // Re-pack with stamp in payload
+
+ TEST_ASSERT_EQUAL_size_t(32, msg.stamp().size());
+
+ JsonObject v = vectors.add();
+ v["name"] = "cpp_with_stamp";
+ v["source_identity_pub"] = source_identity.get_public_key().toHex();
+ v["source_hash"] = source.hash().toHex();
+ v["dest_hash"] = dest.hash().toHex();
+ v["packed"] = packed.toHex();
+ v["content"] = "Stamped from C++";
+ v["title"] = "Stamp Test";
+ v["message_hash"] = msg.hash().toHex();
+ v["timestamp"] = msg.timestamp();
+ v["stamp"] = msg.stamp().toHex();
+ v["stamp_cost"] = msg.stamp_cost();
+ }
+
+ // Write to file
+ std::ofstream outfile("/tmp/lxmf_cpp_vectors.json");
+ TEST_ASSERT_TRUE(outfile.is_open());
+ serializeJsonPretty(output, outfile);
+ outfile.close();
+
+ // Verify file was written
+ std::ifstream verify("/tmp/lxmf_cpp_vectors.json");
+ TEST_ASSERT_TRUE(verify.is_open());
+ verify.close();
+}
+
void testLXMRouterCreation() {
// Test router initialization
Identity router_identity;
@@ -475,7 +731,10 @@ void tearDown(void) {
int runUnityTests(void) {
UNITY_BEGIN();
- // Suite-level setup - register filesystem for MessageStore tests
+ // Suite-level setup - initialize pools and register filesystem
+ Identity::init_known_destinations_pool();
+ Transport::init_pools();
+
RNS::FileSystem lxmf_filesystem = new ::FileSystem();
((::FileSystem*)lxmf_filesystem.get())->init();
RNS::Utilities::OS::register_filesystem(lxmf_filesystem);
@@ -487,6 +746,11 @@ int runUnityTests(void) {
RUN_TEST(testLXMessageEmptyContent);
RUN_TEST(testLXMessageFields);
RUN_TEST(testPythonInterop);
+ RUN_TEST(testPythonInteropEmpty);
+ RUN_TEST(testPythonInteropLarge);
+ RUN_TEST(testPythonInteropUnicode);
+ RUN_TEST(testPythonInteropStamp);
+ RUN_TEST(testCppGenerateVectors);
RUN_TEST(testLXMRouterCreation);
// Skip router tests that require full RNS stack
//RUN_TEST(testLXMRouterOutbound);