From 851da63220f9e9a7ffc87131f46c0796efe4dca4 Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:55:07 -0600 Subject: [PATCH] LoRa split-packet framing, TX queue, and LXMF delivery fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoRaInterface: Implement RNode-compatible split-packet framing so the full Reticulum MTU (500 bytes) works over LoRa. Packets >254 bytes are transparently split into two LoRa frames with matching sequence numbers and reassembled on the receiver. Also adds a 4-deep TX queue instead of dropping packets when the radio is busy — critical for link handshakes. LXMFManager: Large messages (>MDU) now queue pending link establishment and retry via resource transfer instead of failing immediately. Stale link-pending state is detected and reset. Speculative background link establishment removed to avoid LoRa collisions. AnnounceManager: Add app_data hex diagnostics on announce RX for debugging name extraction issues. main.cpp: Centralize all announce paths through announceWithName() so display name and app_data are always logged. --- platformio.ini | 1 + src/main.cpp | 25 ++-- src/reticulum/AnnounceManager.cpp | 4 + src/reticulum/LXMFManager.cpp | 70 ++++++--- src/transport/LoRaInterface.cpp | 235 +++++++++++++++++++++--------- src/transport/LoRaInterface.h | 19 +++ 6 files changed, 252 insertions(+), 102 deletions(-) diff --git a/platformio.ini b/platformio.ini index 0992355..964e1ee 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,6 +38,7 @@ build_flags = -DDISPLAY_HEIGHT=240 -DLV_CONF_INCLUDE_SIMPLE "-I${PROJECT_DIR}" + ; Task watchdog is already enabled by ESP-IDF defaults (5s timeout) build_unflags = -fno-exceptions diff --git a/src/main.cpp b/src/main.cpp index 8833c41..5cc6ca9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -142,14 +142,19 @@ RNS::Bytes encodeAnnounceName(const String& name) { } static void announceWithName() { - Serial.println("[ANNOUNCE-TX] announceWithName() entry"); RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); + Serial.printf("[ANNOUNCE-TX] name=\"%s\" appData=%d bytes\n", + userConfig.settings().displayName.c_str(), (int)appData.size()); + if (appData.size() > 0) { + Serial.printf("[ANNOUNCE-TX] hex: "); + for (size_t i = 0; i < appData.size() && i < 20; i++) Serial.printf("%02X", appData.data()[i]); + Serial.println(); + } rns.announce(appData); ui.statusBar().flashAnnounce(); ui.statusBar().showToast("Announce sent!"); ui.lvStatusBar().flashAnnounce(); ui.lvStatusBar().showToast("Announce sent!"); - Serial.println("[ANNOUNCE-TX] announceWithName() exit"); } // ============================================================================= @@ -785,11 +790,8 @@ void setup() { ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME); // Initial announce with name - RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); - rns.announce(appData); + announceWithName(); lastAutoAnnounce = millis(); - ui.statusBar().flashAnnounce(); - ui.lvStatusBar().flashAnnounce(); Serial.println("[BOOT] Initial announce sent"); }); @@ -804,8 +806,7 @@ void setup() { ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME); // Initial announce with name - RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); - rns.announce(appData); + announceWithName(); lastAutoAnnounce = millis(); Serial.println("[BOOT] Initial announce sent"); } @@ -908,10 +909,7 @@ void loop() { if (rns.loraInterface() && rns.loraInterface()->airtimeUtilization() > LoRaInterface::AIRTIME_THROTTLE) { Serial.println("[AUTO] Skipping announce: LoRa airtime > 25%"); } else { - RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); - rns.announce(appData); - ui.statusBar().flashAnnounce(); - ui.lvStatusBar().flashAnnounce(); + announceWithName(); Serial.println("[AUTO] Periodic announce"); } } @@ -963,8 +961,7 @@ void loop() { } if (anyTcpConnected) { Serial.println("[TCP] Sending announce over new TCP connection..."); - RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); - rns.announce(appData); + announceWithName(); lastAutoAnnounce = millis(); } else { Serial.println("[TCP] No TCP clients connected, skipping announce"); diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index ea18f7b..f6ccd59 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -105,6 +105,10 @@ void AnnounceManager::received_announce( { std::string name; if (app_data.size() > 0) { + Serial.printf("[ANNOUNCE-RX] app_data: %d bytes hex: ", (int)app_data.size()); + for (size_t i = 0; i < std::min((size_t)16, app_data.size()); i++) + Serial.printf("%02X", app_data.data()[i]); + Serial.println(); std::string rawName = extractMsgPackName(app_data.data(), app_data.size()); if (rawName.empty()) { bool isText = app_data.size() > 0 && app_data.size() <= 32; diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp index 0f0c4c0..b8dd4f8 100644 --- a/src/reticulum/LXMFManager.cpp +++ b/src/reticulum/LXMFManager.cpp @@ -86,7 +86,22 @@ bool LXMFManager::sendMessage(const RNS::Bytes& destHash, const std::string& con } bool LXMFManager::sendDirect(LXMFMessage& msg) { - Serial.printf("[LXMF] sendDirect: dest=%s\n", msg.destHash.toHex().substr(0, 12).c_str()); + Serial.printf("[LXMF] sendDirect: dest=%s link=%s pending=%s\n", + msg.destHash.toHex().substr(0, 12).c_str(), + _outLink ? (_outLink.status() == RNS::Type::Link::ACTIVE ? "ACTIVE" : "INACTIVE") : "NONE", + _outLinkPending ? "yes" : "no"); + + // Reset stale link-pending state: if pending but link never became ACTIVE, + // allow a new link attempt. Covers: link object destroyed (NONE), timed out, or failed. + if (_outLinkPending) { + bool linkReady = _outLink && _outLink.status() == RNS::Type::Link::ACTIVE; + if (!linkReady) { + _outLinkPending = false; + _outLink = {RNS::Type::NONE}; + Serial.println("[LXMF] Clearing stale link-pending (link not active)"); + } + } + RNS::Identity recipientId = RNS::Identity::recall(msg.destHash); if (!recipientId) { msg.retries++; @@ -160,7 +175,7 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { RNS::PacketReceipt receipt = packet.send(); if (receipt) { sent = true; } } else { - // Too large for single packet — use Resource transfer + // Too large for single packet — use Resource transfer (chunked) Serial.printf("[LXMF] sending via link resource: %d bytes to %s\n", (int)linkBytes.size(), msg.destHash.toHex().substr(0, 8).c_str()); if (_outLink.start_resource_transfer(linkBytes)) { @@ -171,38 +186,51 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { } } - // Fallback: opportunistic delivery (always available, no delay) + // Fallback: opportunistic or queue for link-based resource transfer if (!sent) { RNS::Bytes payloadBytes(payload.data(), payload.size()); - if (payloadBytes.size() > RNS::Type::Reticulum::MDU) { - Serial.printf("[LXMF] payload too large: %d > MDU\n", (int)payloadBytes.size()); - msg.status = LXMFStatus::FAILED; return true; + if (payloadBytes.size() <= RNS::Type::Reticulum::MDU) { + // Small enough for single opportunistic packet + Serial.printf("[LXMF] sending opportunistic: %d bytes to %s\n", + (int)payloadBytes.size(), outDest.hash().toHex().substr(0, 12).c_str()); + RNS::Packet packet(outDest, payloadBytes); + RNS::PacketReceipt receipt = packet.send(); + if (receipt) { sent = true; } + } else { + // Too large for opportunistic — need link + resource transfer + Serial.printf("[LXMF] Message too large for opportunistic (%d bytes > MDU), needs link (retry %d)\n", + (int)payloadBytes.size(), msg.retries); + if (msg.retries % 3 == 0 && (!_outLink || _outLinkDestHash != msg.destHash + || _outLink.status() != RNS::Type::Link::ACTIVE)) { + _outLinkPendingHash = msg.destHash; + _outLinkPending = true; + Serial.printf("[LXMF] Establishing link to %s for resource transfer\n", + msg.destHash.toHex().substr(0, 8).c_str()); + RNS::Link newLink(outDest, onOutLinkEstablished, onOutLinkClosed); + } + msg.retries++; + if (msg.retries >= 30) { + Serial.printf("[LXMF] Link for %s not established after %d retries — FAILED\n", + msg.destHash.toHex().substr(0, 8).c_str(), msg.retries); + msg.status = LXMFStatus::FAILED; + return true; + } + return false; // Keep in queue, retry when link is established } - Serial.printf("[LXMF] sending opportunistic: %d bytes to %s\n", - (int)payloadBytes.size(), outDest.hash().toHex().substr(0, 12).c_str()); - RNS::Packet packet(outDest, payloadBytes); - RNS::PacketReceipt receipt = packet.send(); - if (receipt) { sent = true; } } if (sent) { msg.status = LXMFStatus::SENT; - // messageId already computed by packFull() matching Python's LXMessage.pack() Serial.printf("[LXMF] SENT OK: msgId=%s\n", msg.messageId.toHex().substr(0, 8).c_str()); } else { Serial.println("[LXMF] send FAILED: no receipt"); msg.status = LXMFStatus::FAILED; } - // Background: establish link for future messages to this peer - if (!_outLinkPending && (!_outLink || _outLinkDestHash != msg.destHash - || _outLink.status() == RNS::Type::Link::CLOSED)) { - _outLinkPendingHash = msg.destHash; - _outLinkPending = true; - Serial.printf("[LXMF] Establishing link to %s for future messages\n", - msg.destHash.toHex().substr(0, 8).c_str()); - RNS::Link newLink(outDest, onOutLinkEstablished, onOutLinkClosed); - } + // Link establishment is now only triggered on-demand when a message + // is too large for opportunistic delivery (see large-message path above). + // Speculative background links cause collisions on LoRa when both + // devices try to establish simultaneously after exchanging short messages. return true; } diff --git a/src/transport/LoRaInterface.cpp b/src/transport/LoRaInterface.cpp index e58335f..40403e6 100644 --- a/src/transport/LoRaInterface.cpp +++ b/src/transport/LoRaInterface.cpp @@ -2,19 +2,21 @@ #include "config/BoardConfig.h" #include -// RNode on-air framing constants (from RNode_Firmware_CE Framing.h / Config.h) +// RNode on-air framing constants (from RNode_Firmware Framing.h / Config.h) // Every LoRa packet has a 1-byte header: upper nibble = random sequence, lower nibble = flags #define RNODE_HEADER_L 1 #define RNODE_FLAG_SPLIT 0x01 #define RNODE_NIBBLE_SEQ 0xF0 +#define RNODE_SINGLE_MTU (MAX_PACKET_SIZE - RNODE_HEADER_L) // 254 bytes payload per frame LoRaInterface::LoRaInterface(SX1262* radio, const char* name) : RNS::InterfaceImpl(name), _radio(radio) { _IN = true; _OUT = true; - _bitrate = 2000; // Approximate for SF8/125kHz - _HW_MTU = MAX_PACKET_SIZE - RNODE_HEADER_L; // 254 bytes payload (1 byte reserved for RNode header) + _bitrate = 2000; + // Reticulum MTU (500 bytes) — split-packet framing allows up to 2x254 = 508 bytes + _HW_MTU = RNS::Type::Reticulum::MTU; } LoRaInterface::~LoRaInterface() { @@ -29,7 +31,7 @@ bool LoRaInterface::start() { } _online = true; _radio->receive(); - Serial.println("[LORA_IF] Interface started"); + Serial.println("[LORA_IF] Interface started (split-packet enabled, MTU=500)"); return true; } @@ -41,46 +43,69 @@ void LoRaInterface::stop() { void LoRaInterface::send_outgoing(const RNS::Bytes& data) { if (!_online || !_radio) return; - if (_txPending) { - Serial.println("[LORA_IF] TX busy, dropping packet"); + // Reject packets exceeding Reticulum MTU (500 bytes) + if (data.size() > RNS::Type::Reticulum::MTU) { + Serial.printf("[LORA_IF] TX DROPPED: exceeds Reticulum MTU (%d > %d)\n", + (int)data.size(), (int)RNS::Type::Reticulum::MTU); return; } - // Reject packets that exceed the LoRa MTU (255 bytes including 1-byte header). - // Sending truncated packets corrupts encryption (HMAC failures on receiver). - if (data.size() + RNODE_HEADER_L > MAX_PACKET_SIZE) { - Serial.printf("[LORA_IF] TX DROPPED: packet too large (%d + %d = %d > %d)\n", - (int)data.size(), RNODE_HEADER_L, - (int)(data.size() + RNODE_HEADER_L), MAX_PACKET_SIZE); + if (_txPending || _splitTxPending) { + if ((int)_txQueue.size() < TX_QUEUE_MAX) { + _txQueue.push_back(data); + Serial.printf("[LORA_IF] TX queued (%d in queue)\n", (int)_txQueue.size()); + } else { + Serial.println("[LORA_IF] TX queue full, dropping oldest"); + _txQueue.pop_front(); + _txQueue.push_back(data); + } return; } - // Build RNode-compatible 1-byte header: - // Upper nibble: random sequence number (for split-packet tracking) - // Lower nibble: flags (FLAG_SPLIT=0x01 if packet won't fit in single frame) - uint8_t header = (uint8_t)(random(256)) & RNODE_NIBBLE_SEQ; // Random upper nibble, flags=0 + transmitNow(data); +} - Serial.printf("[LORA_IF] TX: sending %d bytes, radio: SF%d BW%lu CR%d preamble=%ld freq=%lu txp=%d\n", - data.size(), - _radio->getSpreadingFactor(), - (unsigned long)_radio->getSignalBandwidth(), - _radio->getCodingRate4(), - _radio->getPreambleLength(), - (unsigned long)_radio->getFrequency(), - _radio->getTxPower()); +void LoRaInterface::transmitNow(const RNS::Bytes& data) { + uint8_t header = (uint8_t)(random(256)) & RNODE_NIBBLE_SEQ; + bool needsSplit = (data.size() > RNODE_SINGLE_MTU); - _radio->beginPacket(); - _radio->write(header); // 1-byte RNode header - _radio->write(data.data(), data.size()); // Reticulum packet payload - _radio->endPacket(true); // Async: start TX and return immediately + if (needsSplit) { + header |= RNODE_FLAG_SPLIT; + // First frame: header + first 254 bytes of payload + size_t firstLen = RNODE_SINGLE_MTU; + + Serial.printf("[LORA_IF] TX SPLIT: %d bytes in 2 frames (seq=0x%02X)\n", + (int)data.size(), header & RNODE_NIBBLE_SEQ); + + _radio->beginPacket(); + _radio->write(header); + _radio->write(data.data(), firstLen); + _radio->endPacket(true); + + // Save remaining data for second frame + _splitTxPending = true; + _splitTxRemaining = RNS::Bytes(data.data() + firstLen, data.size() - firstLen); + _splitTxHeader = header; + + Serial.printf("[LORA_IF] TX SPLIT frame 1: %d+1 bytes (remaining: %d)\n", + (int)firstLen, (int)_splitTxRemaining.size()); + } else { + // Single frame: fits in one LoRa packet + _radio->beginPacket(); + _radio->write(header); + _radio->write(data.data(), data.size()); + _radio->endPacket(true); + + Serial.printf("[LORA_IF] TX %d+1 bytes (hdr=0x%02X)\n", (int)data.size(), header); + } _txPending = true; _txData = data; InterfaceImpl::handle_outgoing(data); - Serial.printf("[LORA_IF] TX %d+1 bytes queued (hdr=0x%02X)\n", data.size(), header); // Track airtime - float airtimeMs = _radio->getAirtime(data.size() + RNODE_HEADER_L); + size_t airBytes = needsSplit ? (RNODE_SINGLE_MTU + RNODE_HEADER_L) : (data.size() + RNODE_HEADER_L); + float airtimeMs = _radio->getAirtime(airBytes); unsigned long txNow = millis(); if (txNow - _airtimeWindowStart >= AIRTIME_WINDOW_MS) { _airtimeAccumMs = 0; @@ -93,7 +118,6 @@ void LoRaInterface::send_outgoing(const RNS::Bytes& data) { _airtimeWindowStart = txNow; } _airtimeAccumMs += airtimeMs; - Serial.printf("[LORA_IF] TX airtime: %.1fms (util=%.1f%%)\n", airtimeMs, airtimeUtilization() * 100); } void LoRaInterface::loop() { @@ -103,13 +127,49 @@ void LoRaInterface::loop() { if (_txPending) { if (!_radio->isTxBusy()) { _txPending = false; + + // If split TX pending, send the second frame immediately + if (_splitTxPending) { + _splitTxPending = false; + + Serial.printf("[LORA_IF] TX SPLIT frame 2: %d+1 bytes\n", + (int)_splitTxRemaining.size()); + + _radio->beginPacket(); + _radio->write(_splitTxHeader); + _radio->write(_splitTxRemaining.data(), _splitTxRemaining.size()); + _radio->endPacket(true); + + _txPending = true; + _splitTxRemaining = RNS::Bytes(); + + // Track airtime for second frame + float airtimeMs = _radio->getAirtime(_splitTxRemaining.size() + RNODE_HEADER_L); + _airtimeAccumMs += airtimeMs; + return; + } + _txData = RNS::Bytes(); - _radio->receive(); + + if (!_txQueue.empty()) { + RNS::Bytes next = _txQueue.front(); + _txQueue.pop_front(); + transmitNow(next); + } else { + _radio->receive(); + } } - return; // Don't process RX while TX is active + return; } - // Periodic RX debug: dump RSSI + chip status every 30 seconds + // Split RX timeout: discard stale partial packets + if (_splitRxPending && (millis() - _splitRxTimestamp > SPLIT_RX_TIMEOUT_MS)) { + Serial.println("[LORA_IF] RX SPLIT timeout, discarding partial"); + _splitRxPending = false; + _splitRxBuffer = RNS::Bytes(); + } + + // Periodic RX debug static unsigned long lastRxDebug = 0; if (millis() - lastRxDebug > 30000) { lastRxDebug = millis(); @@ -120,44 +180,85 @@ void LoRaInterface::loop() { rssi, status, chipMode); } - // Only check for packets when DIO1 interrupt signals one is available if (!_radio->packetAvailable) return; _radio->packetAvailable = false; int packetSize = _radio->parsePacket(); - if (packetSize > RNODE_HEADER_L) { - // parsePacket() already read the FIFO into packetBuffer() — copy from there - // (avoid calling readBytes() which would re-read the FIFO via read()) - uint8_t raw[MAX_PACKET_SIZE]; - memcpy(raw, _radio->packetBuffer(), packetSize); - - // Strip the 1-byte RNode header, pass only the Reticulum payload - uint8_t header = raw[0]; - int payloadSize = packetSize - RNODE_HEADER_L; - - Serial.printf("[LORA_IF] RX %d bytes (hdr=0x%02X, payload=%d), RSSI=%d, SNR=%.1f\n", - packetSize, header, payloadSize, - _radio->packetRssi(), _radio->packetSnr()); - - // Hex dump first 32 bytes for debugging interop - Serial.printf("[LORA_IF] RX hex: "); - for (int i = 0; i < packetSize && i < 32; i++) Serial.printf("%02X ", raw[i]); - Serial.println(); - - RNS::Bytes buf(payloadSize); - memcpy(buf.writable(payloadSize), raw + RNODE_HEADER_L, payloadSize); - InterfaceImpl::handle_incoming(buf); - - // Re-enter RX — but only if handle_incoming didn't trigger a TX. - // handle_incoming() can synchronously call send_outgoing() (for link - // proofs, path responses), which starts an async TX via endPacket(true). - // Calling receive() here would abort that TX (clears IRQ flags + enters RX). - if (!_txPending) { - _radio->receive(); + if (packetSize <= RNODE_HEADER_L) { + if (packetSize > 0) { + Serial.printf("[LORA_IF] RX runt packet (%d bytes), discarding\n", packetSize); } - } else if (packetSize > 0) { - // Packet too small (only header, no payload) — discard - Serial.printf("[LORA_IF] RX runt packet (%d bytes), discarding\n", packetSize); + _radio->receive(); + return; + } + + uint8_t raw[MAX_PACKET_SIZE]; + memcpy(raw, _radio->packetBuffer(), packetSize); + + uint8_t header = raw[0]; + int payloadSize = packetSize - RNODE_HEADER_L; + uint8_t seq = header & RNODE_NIBBLE_SEQ; + bool isSplit = (header & RNODE_FLAG_SPLIT) != 0; + + if (isSplit) { + // Split packet handling + if (!_splitRxPending) { + // First frame of a split packet + _splitRxPending = true; + _splitRxSeq = seq; + _splitRxBuffer = RNS::Bytes(raw + RNODE_HEADER_L, payloadSize); + _splitRxTimestamp = millis(); + + Serial.printf("[LORA_IF] RX SPLIT frame 1: %d bytes (seq=0x%02X), RSSI=%d, SNR=%.1f\n", + payloadSize, seq, _radio->packetRssi(), _radio->packetSnr()); + _radio->receive(); + return; + } else if (seq == _splitRxSeq) { + // Second frame matches — reassemble + Serial.printf("[LORA_IF] RX SPLIT frame 2: %d bytes (seq=0x%02X), RSSI=%d, SNR=%.1f\n", + payloadSize, seq, _radio->packetRssi(), _radio->packetSnr()); + + _splitRxBuffer.append(raw + RNODE_HEADER_L, payloadSize); + int totalSize = _splitRxBuffer.size(); + _splitRxPending = false; + + Serial.printf("[LORA_IF] RX SPLIT reassembled: %d bytes total\n", totalSize); + + InterfaceImpl::handle_incoming(_splitRxBuffer); + _splitRxBuffer = RNS::Bytes(); + + if (!_txPending) { + _radio->receive(); + } + return; + } else { + // Sequence mismatch — discard old, start new + Serial.printf("[LORA_IF] RX SPLIT seq mismatch (had 0x%02X, got 0x%02X), restarting\n", + _splitRxSeq, seq); + _splitRxSeq = seq; + _splitRxBuffer = RNS::Bytes(raw + RNODE_HEADER_L, payloadSize); + _splitRxTimestamp = millis(); + _radio->receive(); + return; + } + } + + // Non-split packet — if we were waiting for split frame 2, discard the partial + if (_splitRxPending) { + Serial.println("[LORA_IF] RX non-split while waiting for split frame 2, discarding partial"); + _splitRxPending = false; + _splitRxBuffer = RNS::Bytes(); + } + + Serial.printf("[LORA_IF] RX %d bytes (hdr=0x%02X, payload=%d), RSSI=%d, SNR=%.1f\n", + packetSize, header, payloadSize, + _radio->packetRssi(), _radio->packetSnr()); + + RNS::Bytes buf(payloadSize); + memcpy(buf.writable(payloadSize), raw + RNODE_HEADER_L, payloadSize); + InterfaceImpl::handle_incoming(buf); + + if (!_txPending) { _radio->receive(); } } diff --git a/src/transport/LoRaInterface.h b/src/transport/LoRaInterface.h index c236f74..aad2958 100644 --- a/src/transport/LoRaInterface.h +++ b/src/transport/LoRaInterface.h @@ -2,6 +2,7 @@ #include #include "radio/SX1262.h" +#include class LoRaInterface : public RNS::InterfaceImpl { public: @@ -22,10 +23,28 @@ protected: virtual void send_outgoing(const RNS::Bytes& data) override; private: + void transmitNow(const RNS::Bytes& data); + SX1262* _radio; bool _txPending = false; RNS::Bytes _txData; + // TX queue: buffer packets when radio is busy instead of dropping + static constexpr int TX_QUEUE_MAX = 4; + std::deque _txQueue; + + // Split-packet TX state: when a packet > 254 bytes, send in two LoRa frames + bool _splitTxPending = false; + RNS::Bytes _splitTxRemaining; + uint8_t _splitTxHeader = 0; + + // Split-packet RX state: reassemble two LoRa frames into one Reticulum packet + static constexpr unsigned long SPLIT_RX_TIMEOUT_MS = 5000; + bool _splitRxPending = false; + uint8_t _splitRxSeq = 0; + RNS::Bytes _splitRxBuffer; + unsigned long _splitRxTimestamp = 0; + unsigned long _airtimeWindowStart = 0; float _airtimeAccumMs = 0; static constexpr unsigned long AIRTIME_WINDOW_MS = 60000;