From a83739c1cf8a884c02733f62788bae47e4026b89 Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:48:24 -0600 Subject: [PATCH] Fix LXMF link delivery and improve LoRa reliability Link proof signature, TCP proof routing, and split-packet handling were causing intermittent message failures with Python LXMF clients. Route >254B messages through link delivery, add proof retry for LoRa. --- src/main.cpp | 14 +- src/reticulum/LXMFManager.cpp | 15 +- src/reticulum/ReticulumManager.cpp | 15 +- src/reticulum/ReticulumManager.h | 2 + src/transport/LoRaInterface.cpp | 49 ++- src/transport/TCPClientInterface.cpp | 6 +- src/transport/TCPClientInterface.h | 1 + test_lxmf_compat.py | 513 +++++++++++++++++++++++++++ 8 files changed, 588 insertions(+), 27 deletions(-) create mode 100644 test_lxmf_compat.py diff --git a/src/main.cpp b/src/main.cpp index b393e4e..c89b669 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -133,6 +133,9 @@ constexpr unsigned long TCP_GLOBAL_BUDGET_MS = 35; // Max cumulative TCP ti bool wifiDeferredAnnounce = false; unsigned long wifiConnectedAt = 0; +// LXMF diagnostic counters (reset each heartbeat) +static uint32_t diagTcpSkipEvents = 0; + // ============================================================================= // Timezone helper — returns POSIX TZ string for current config // ============================================================================= @@ -1062,6 +1065,7 @@ void loop() { // 8. WiFi + TCP loops (with global budget) — skip only if RNS severely overloaded { bool skipTcp = (rnsDuration > 500); + if (skipTcp) diagTcpSkipEvents++; if (!skipTcp && wifiImpl) wifiImpl->loop(); if (!skipTcp) { unsigned long tcpBudgetStart = millis(); @@ -1157,10 +1161,18 @@ void loop() { { auto& ifaces = RNS::Transport::get_interfaces(); int tcpUp = 0; - for (auto* tcp : tcpClients) { if (tcp && tcp->isConnected()) tcpUp++; } + int tcpRx = 0; + for (auto* tcp : tcpClients) { + if (tcp && tcp->isConnected()) tcpUp++; + if (tcp) tcpRx += tcp->hubRxCount(); + } Serial.printf("[HEART-DIAG] ifaces=%d tcp=%d/%d wifi=%s\n", (int)ifaces.size(), tcpUp, (int)tcpClients.size(), wifiSTAConnected ? "STA" : (wifiImpl ? "AP" : "OFF")); + Serial.printf("[LXMF-DIAG] tcp_rx=%d tcp_skip=%lu ann_filt=%lu\n", + tcpRx, (unsigned long)diagTcpSkipEvents, + (unsigned long)rns.announceFilterCount()); + diagTcpSkipEvents = 0; } #if HAS_GPS if (userConfig.settings().gpsTimeEnabled) { diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp index 1c12cb0..1b4b062 100644 --- a/src/reticulum/LXMFManager.cpp +++ b/src/reticulum/LXMFManager.cpp @@ -189,17 +189,22 @@ bool LXMFManager::sendDirect(LXMFMessage& msg) { // 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) { - // Small enough for single opportunistic packet + // Use opportunistic only for packets that fit in a single LoRa frame (254 bytes). + // Larger packets require split-frame over LoRa, which is unreliable — any single + // frame loss (CRC error, collision, half-duplex timing) kills the entire transfer + // with no recovery. Link-based delivery handles retransmission at the protocol level. + static constexpr size_t SINGLE_FRAME_MAX = 254; + if (payloadBytes.size() <= SINGLE_FRAME_MAX) { + // Fits in single LoRa frame — send opportunistic Serial.printf("[LXMF] sending opportunistic: %d bytes to %s\n", (int)payloadBytes.size(), outDest.hash().toHex().substr(0, 12).c_str()); RNS::Packet packet(outDest, payloadBytes); RNS::PacketReceipt receipt = packet.send(); if (receipt) { sent = true; } } 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); + // Too large for single frame — need link + resource transfer + Serial.printf("[LXMF] Message needs link delivery (%d bytes > %d single-frame), retry %d\n", + (int)payloadBytes.size(), (int)SINGLE_FRAME_MAX, msg.retries); if (msg.retries % 3 == 0 && (!_outLink || _outLinkDestHash != msg.destHash || _outLink.status() != RNS::Type::Link::ACTIVE)) { _outLinkPendingHash = msg.destHash; diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index 3b6d1eb..46d478b 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -6,6 +6,8 @@ #include #include +uint32_t ReticulumManager::_announceFilterCount = 0; + bool LittleFSFileSystem::init() { return true; } bool LittleFSFileSystem::file_exists(const char* p) { return LittleFS.exists(p); } @@ -116,7 +118,7 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { // Adaptive rate: tighter during first 60s boot flood, then normal unsigned int maxRate = (now < 60000) ? 3 : RATDECK_MAX_ANNOUNCES_PER_SEC; - if (++count > maxRate) return false; + if (++count > maxRate) { ReticulumManager::_announceFilterCount++; return false; } // Skip re-validation of known paths (saves ~100ms Ed25519 per announce) // Allow through if better path discovered, or once per 5 min for name/ratchet updates. @@ -131,7 +133,10 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { packet.destination_hash().size()); auto it = lastRevalidate.find(key); - if (it != lastRevalidate.end() && (now - it->second) < 300000) return false; + if (it != lastRevalidate.end() && (now - it->second) < 300000) { + ReticulumManager::_announceFilterCount++; + return false; + } lastRevalidate[key] = now; if (lastRevalidate.size() > 300) lastRevalidate.clear(); @@ -288,7 +293,11 @@ void ReticulumManager::persistData() { } break; } - Serial.printf("[PERSIST] Cycle %d done (%lums)\n", _persistCycle, millis() - start); + unsigned long dur = millis() - start; + Serial.printf("[PERSIST] Cycle %d done (%lums)\n", _persistCycle, dur); + if (dur > 500) { + Serial.printf("[PERSIST] WARNING: Cycle %d blocked for %lums!\n", _persistCycle, dur); + } _persistCycle = (_persistCycle + 1) % 3; } diff --git a/src/reticulum/ReticulumManager.h b/src/reticulum/ReticulumManager.h index 02557dd..0c19cee 100644 --- a/src/reticulum/ReticulumManager.h +++ b/src/reticulum/ReticulumManager.h @@ -52,6 +52,7 @@ public: void announce(const RNS::Bytes& appData = {}); unsigned long lastAnnounceTime() const { return _lastAnnounceTime; } + static uint32_t announceFilterCount() { return _announceFilterCount; } RNS::Destination& destination() { return _destination; } LoRaInterface* loraInterface() { return _loraImpl; } @@ -71,4 +72,5 @@ private: unsigned long _lastPersist = 0; unsigned long _lastAnnounceTime = 0; uint8_t _persistCycle = 0; // Rotating: 0=Transport, 1=Identity, 2=SD backup + static uint32_t _announceFilterCount; }; diff --git a/src/transport/LoRaInterface.cpp b/src/transport/LoRaInterface.cpp index a3fb4f0..62817bc 100644 --- a/src/transport/LoRaInterface.cpp +++ b/src/transport/LoRaInterface.cpp @@ -50,10 +50,17 @@ void LoRaInterface::send_outgoing(const RNS::Bytes& data) { return; } - if (_txPending || _splitTxPending) { + // Queue TX when radio is busy OR when we're waiting for split frame 2. + // Transmitting during split RX would put the radio in TX mode, causing + // frame 2 to be lost (LoRa is half-duplex). + if (_txPending || _splitTxPending || _splitRxPending) { if ((int)_txQueue.size() < TX_QUEUE_MAX) { _txQueue.push_back(data); - Serial.printf("[LORA_IF] TX queued (%d in queue)\n", (int)_txQueue.size()); + if (_splitRxPending) { + Serial.printf("[LORA_IF] TX deferred (split RX pending, %d in queue)\n", (int)_txQueue.size()); + } else { + 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(); @@ -132,19 +139,19 @@ void LoRaInterface::loop() { if (_splitTxPending) { _splitTxPending = false; - Serial.printf("[LORA_IF] TX SPLIT frame 2: %d+1 bytes\n", - (int)_splitTxRemaining.size()); + size_t frame2Size = _splitTxRemaining.size(); + Serial.printf("[LORA_IF] TX SPLIT frame 2: %d+1 bytes\n", (int)frame2Size); _radio->beginPacket(); _radio->write(_splitTxHeader); - _radio->write(_splitTxRemaining.data(), _splitTxRemaining.size()); + _radio->write(_splitTxRemaining.data(), frame2Size); _radio->endPacket(true); _txPending = true; _splitTxRemaining = RNS::Bytes(); - // Track airtime for second frame - float airtimeMs = _radio->getAirtime(_splitTxRemaining.size() + RNODE_HEADER_L); + // Track airtime for second frame (must use saved size before clear) + float airtimeMs = _radio->getAirtime(frame2Size + RNODE_HEADER_L); _airtimeAccumMs += airtimeMs; return; } @@ -162,11 +169,17 @@ void LoRaInterface::loop() { return; } - // Split RX timeout: discard stale partial packets + // Split RX timeout: discard stale partial packets and drain deferred TX if (_splitRxPending && (millis() - _splitRxTimestamp > SPLIT_RX_TIMEOUT_MS)) { Serial.println("[LORA_IF] RX SPLIT timeout, discarding partial"); _splitRxPending = false; _splitRxBuffer = RNS::Bytes(); + // Drain any TX that was deferred during split RX hold + if (!_txQueue.empty() && !_txPending) { + RNS::Bytes next = _txQueue.front(); + _txQueue.pop_front(); + transmitNow(next); + } } // Periodic RX debug @@ -231,13 +244,19 @@ void LoRaInterface::loop() { InterfaceImpl::handle_incoming(_splitRxBuffer); _splitRxBuffer = RNS::Bytes(); - if (!_txPending) { + // Drain any TX that was deferred during split RX hold + if (!_txQueue.empty() && !_txPending) { + RNS::Bytes next = _txQueue.front(); + _txQueue.pop_front(); + transmitNow(next); + } else 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", + // Different split packet's frame 1 arrived — the previous split is lost. + // This happens when frame 2 was missed (radio was busy, collision, etc.) + Serial.printf("[LORA_IF] RX SPLIT new seq (had 0x%02X, got 0x%02X), previous frame 2 lost\n", _splitRxSeq, seq); _splitRxSeq = seq; _splitRxBuffer = RNS::Bytes(raw + RNODE_HEADER_L, payloadSize); @@ -247,11 +266,11 @@ void LoRaInterface::loop() { } } - // Non-split packet — if we were waiting for split frame 2, discard the partial + // Non-split packet while waiting for split frame 2: + // Process the non-split packet normally but KEEP the split buffer. + // Frame 2 may still arrive after this interleaving packet. 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 non-split %d bytes while awaiting split frame 2 (kept)\n", payloadSize); } Serial.printf("[LORA_IF] RX %d bytes (hdr=0x%02X, payload=%d), RSSI=%d, SNR=%.1f\n", diff --git a/src/transport/TCPClientInterface.cpp b/src/transport/TCPClientInterface.cpp index 364d153..30373a4 100644 --- a/src/transport/TCPClientInterface.cpp +++ b/src/transport/TCPClientInterface.cpp @@ -83,9 +83,9 @@ void TCPClientInterface::loop() { return; // Will reconnect on next loop iteration } - // Drain incoming frames per loop (up to 10, time-boxed) + // Drain incoming frames per loop (up to 15, time-boxed) unsigned long tcpStart = millis(); - for (int i = 0; i < 10 && _client.available() && (millis() - tcpStart < TCP_LOOP_BUDGET_MS); i++) { + for (int i = 0; i < 15 && _client.available() && (millis() - tcpStart < TCP_LOOP_BUDGET_MS); i++) { unsigned long rxStart = millis(); int len = readFrame(); if (len > 0) { @@ -151,7 +151,7 @@ void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { Serial.printf("[TCP-DIAG] *** PROOF packet being sent via TCP! ***\n"); } - if (packet_type != 0x01) { // Not ANNOUNCE + if (packet_type != 0x01 && packet_type != 0x03) { // Not ANNOUNCE, not PROOF if (header_type == 0) { // Header1 → wrap as Header2 (handles hops==1, hops==0, unknown path) uint8_t new_flags = flags | 0x50; // Set Header2 (bit 6) + Transport (bit 4) diff --git a/src/transport/TCPClientInterface.h b/src/transport/TCPClientInterface.h index 583c67f..5178dcd 100644 --- a/src/transport/TCPClientInterface.h +++ b/src/transport/TCPClientInterface.h @@ -65,4 +65,5 @@ private: public: unsigned long lastRxTime() const { return _lastRxTime; } + unsigned long hubRxCount() const { return _hubRxCount; } }; diff --git a/test_lxmf_compat.py b/test_lxmf_compat.py new file mode 100644 index 0000000..68b7abb --- /dev/null +++ b/test_lxmf_compat.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +""" +LXMF Compatibility Test Suite for Ratdeck. + +Two test layers: + 1. RAW PACKET tests — send RNS packets directly, verify via serial. Tests firmware. + 2. LXMF ROUTER tests — use Python LXMRouter, tests end-to-end client compatibility. + +Usage: + python3 test_lxmf_compat.py # discover mode (LoRa) + python3 test_lxmf_compat.py # full suite (LoRa) + python3 test_lxmf_compat.py --tcp # full suite (TCP) + python3 test_lxmf_compat.py --suite raw # raw packet tests only + python3 test_lxmf_compat.py --suite lxmf # LXMF router tests only + python3 test_lxmf_compat.py --suite listen # listen for Ratdeck msgs + python3 test_lxmf_compat.py --suite all # everything (default) +""" +import RNS +import LXMF +import time +import sys +import os +import shutil +import struct +import hashlib +import argparse + +# --- Config --- +LORA_CONFIG = { + "port": "/dev/cu.usbserial-0001", + "frequency": 915000000, + "bandwidth": 250000, + "txpower": 14, + "spreadingfactor": 7, + "codingrate": 5, + "preamble": 18, +} +TCP_HOST = "3.ratspeak.org" +TCP_PORT = 4343 +STORAGE_PATH = "/tmp/ratdeck_test_storage" +IDENTITY_PATH = "/tmp/ratdeck_test_identity" + +# --- State --- +results = {} +messages_rx = [] + + +def decode_name(app_data): + if not app_data: + return "" + try: + d = app_data.decode("utf-8") + if d.isprintable(): + return d + except: + pass + if len(app_data) >= 3 and app_data[0] == 0x91 and app_data[1] == 0xC4: + n = app_data[2] + if len(app_data) >= 3 + n: + try: + return app_data[3:3 + n].decode("utf-8") + except: + pass + return "" + + +class AnnounceHandler: + aspect_filter = "lxmf.delivery" + def received_announce(self, destination_hash, announced_identity, app_data): + name = decode_name(app_data) + if name: + print(f" [ANNOUNCE] {destination_hash.hex()} \"{name}\"", flush=True) + + +def on_rx(message): + content = message.content.decode("utf-8") if message.content else "" + sender = message.source_hash.hex()[:16] + messages_rx.append({"content": content, "sender": sender, "time": time.time()}) + print(f" [RX] from {sender}...: \"{content[:80]}\"", flush=True) + + +def init_rns(transport="lora"): + os.makedirs(STORAGE_PATH, exist_ok=True) + config_path = os.path.join(STORAGE_PATH, "config") + + if transport == "lora": + lc = LORA_CONFIG + iface_config = f""" [[RNode LoRa]] + type = RNodeInterface + enabled = true + port = {lc['port']} + frequency = {lc['frequency']} + bandwidth = {lc['bandwidth']} + txpower = {lc['txpower']} + spreadingfactor = {lc['spreadingfactor']} + codingrate = {lc['codingrate']} + preamble = {lc['preamble']} + flow_control = false""" + else: + iface_config = f""" [[TCP Hub]] + type = TCPClientInterface + enabled = true + target_host = {TCP_HOST} + target_port = {TCP_PORT} + kiss_framing = false""" + + with open(config_path, "w") as f: + f.write(f"""[reticulum] + enable_transport = false + share_instance = false + shared_instance_port = 37433 + instance_control_port = 37434 + panic_on_interface_errors = no + +[interfaces] + [[Default Interface]] + type = AutoInterface + enabled = false + +{iface_config} +""") + + r = RNS.Reticulum(configdir=STORAGE_PATH, loglevel=RNS.LOG_DEBUG) + + if os.path.exists(IDENTITY_PATH): + identity = RNS.Identity.from_file(IDENTITY_PATH) + else: + identity = RNS.Identity() + identity.to_file(IDENTITY_PATH) + + router = LXMF.LXMRouter(identity=identity, storagepath=STORAGE_PATH) + dd = router.register_delivery_identity(identity, display_name="RatdeckTest") + router.register_delivery_callback(on_rx) + RNS.Transport.register_announce_handler(AnnounceHandler()) + router.announce(dd.hash) + + print(f"[INIT] transport={transport} identity={identity.hash.hex()[:16]}...") + print(f"[INIT] LXMF dest={dd.hash.hex()}", flush=True) + return router, dd, identity + + +def wait_for_path(dest_hash, timeout=90): + print(f"[PATH] Waiting for target (up to {timeout}s)...", flush=True) + for i in range(timeout // 3): + has_id = RNS.Identity.recall(dest_hash) is not None + has_path = RNS.Transport.has_path(dest_hash) + if has_id and has_path: + hops = RNS.Transport.hops_to(dest_hash) + print(f"[PATH] Ready after {(i+1)*3}s (hops={hops})", flush=True) + return True + if i % 5 == 0: + RNS.Transport.request_path(dest_hash) + time.sleep(3) + print("[PATH] TIMEOUT", flush=True) + return False + + +# ============================================================ +# RAW PACKET HELPERS — bypass Python LXMF router entirely +# ============================================================ + +def build_lxmf_packet(identity, dest_hash, content_str): + """Build a raw LXMF opportunistic payload.""" + content_bytes = content_str.encode("utf-8") + src_hash = identity.hash[:16] + timestamp = time.time() + # MsgPack: fixarray(4) [float64, bin8(title), bin8(content), fixmap(0)] + packed = (bytes([0x94, 0xCB]) + struct.pack(">d", timestamp) + + bytes([0xC4, 0]) + # empty title + bytes([0xC4, len(content_bytes)]) + content_bytes + + bytes([0x80])) # empty fields + signable_prefix = dest_hash[:16] + src_hash + hash_input = signable_prefix + packed + msg_hash = hashlib.sha256(hash_input).digest() + signature = identity.sign(hash_input + msg_hash) + return src_hash + signature + packed + + +def send_raw_and_wait(identity, dest, dest_hash, content, label, wait_s=15): + """Send a raw LXMF packet and wait for proof. Returns (delivered, elapsed).""" + payload = build_lxmf_packet(identity, dest_hash, content) + packet = RNS.Packet(dest, payload) + start = time.time() + receipt = packet.send() + + for _ in range(wait_s * 4): + time.sleep(0.25) + if receipt.status == RNS.PacketReceipt.DELIVERED: + elapsed = time.time() - start + results[label] = "DELIVERED" + return True, elapsed + elif receipt.status == RNS.PacketReceipt.FAILED: + results[label] = "FAILED" + return False, time.time() - start + + results[label] = "TIMEOUT" + return False, time.time() - start + + +# ============================================================ +# RAW PACKET TEST SUITES — firmware verification +# ============================================================ + +def run_raw_size_tests(identity, dest, dest_hash): + """Test different content sizes via raw packets.""" + print("\n" + "=" * 60) + print("RAW PACKET: SIZE TESTS") + print("=" * 60) + + sizes = [ + ("RS1", 10, "minimal"), + ("RS2", 50, "short"), + ("RS3", 100, "typical"), + ("RS4", 150, "medium"), + ("RS5", 200, "long"), + ("RS6", 250, "near frame limit"), + ] + + passed = 0 + for label, size, desc in sizes: + content = f"{label}:" + "X" * max(0, size - len(label) - 1) + ok, elapsed = send_raw_and_wait(identity, dest, dest_hash, content, label) + icon = "OK" if ok else "FAIL" + if ok: + passed += 1 + print(f" [{icon}] {label}: {size}B ({desc}) -> {results[label]} in {elapsed:.1f}s", flush=True) + time.sleep(3) + + print(f"\n Raw size: {passed}/{len(sizes)} passed") + + +def run_raw_unicode_tests(identity, dest, dest_hash): + """Test Unicode content via raw packets.""" + print("\n" + "=" * 60) + print("RAW PACKET: UNICODE TESTS") + print("=" * 60) + + msgs = [ + ("RU1", "Hello ASCII baseline", "ASCII"), + ("RU2", "Привет из теста!", "Cyrillic"), + ("RU3", "你好世界 テスト 🌍", "CJK + emoji"), + ("RU4", "🔐📡🛰️ mesh crypto LoRa", "Multi-emoji"), + ("RU5", "Ñoño café résumé naïve über Zürich", "Latin Extended"), + ("RU6", "مرحبا العالم", "Arabic RTL"), + ] + + passed = 0 + for label, content, desc in msgs: + ok, elapsed = send_raw_and_wait(identity, dest, dest_hash, content, label) + icon = "OK" if ok else "FAIL" + if ok: + passed += 1 + print(f" [{icon}] {label}: \"{content[:35]}\" ({desc}) -> {elapsed:.1f}s", flush=True) + time.sleep(3) + + print(f"\n Raw unicode: {passed}/{len(msgs)} passed") + + +def run_raw_timing_tests(identity, dest, dest_hash): + """Test rapid sending via raw packets.""" + print("\n" + "=" * 60) + print("RAW PACKET: TIMING TESTS") + print("=" * 60) + + intervals = [ + ("RT1", 5.0, 5, "5s cadence"), + ("RT2", 2.0, 5, "2s cadence"), + ("RT3", 1.0, 5, "1s cadence"), + ("RT4", 0.5, 5, "500ms cadence"), + ("RT5", 2.0, 10, "sustained 10 msgs"), + ] + + for group_label, interval, count, desc in intervals: + print(f"\n--- {group_label}: {count} msgs, {interval}s apart ({desc}) ---") + passed = 0 + for i in range(count): + label = f"{group_label}-{i+1}" + content = f"{group_label} msg {i+1}/{count}" + ok, elapsed = send_raw_and_wait(identity, dest, dest_hash, content, label, wait_s=10) + if ok: + passed += 1 + else: + print(f" [FAIL] {label}: {results[label]} ({elapsed:.1f}s)", flush=True) + time.sleep(interval) + print(f" {group_label}: {passed}/{count} delivered", flush=True) + + +# ============================================================ +# LXMF ROUTER TEST SUITES — Python client compatibility +# ============================================================ + +def run_lxmf_link_test(router, dest, source): + """Test link-based delivery via LXMRouter.""" + print("\n" + "=" * 60) + print("LXMF ROUTER: LINK TESTS (DIRECT method)") + print("=" * 60) + + print("\n--- LK1: Single message via link (60s timeout) ---") + msg = LXMF.LXMessage(dest, source, "LK1: Link delivery test") + msg.desired_method = LXMF.LXMessage.DIRECT + def cb(m): + results["LK1"] = f"s{m.state}" + print(f" [LK1] -> s{m.state}", flush=True) + msg.register_delivery_callback(cb) + router.handle_outbound(msg) + print(" Queued. Waiting 60s...", flush=True) + for i in range(120): + time.sleep(0.5) + if msg.state >= 3: + break + if "LK1" not in results: + results["LK1"] = f"s{msg.state}" + icon = "OK" if msg.state in (3, 4, 8, 255) else "??" + print(f" [{icon}] LK1: s{msg.state}", flush=True) + + # Send 3 more via link (should reuse established link) + print("\n--- LK2: 3 messages over established link ---") + time.sleep(5) + for i in range(3): + label = f"LK2-{i+1}" + msg = LXMF.LXMessage(dest, source, f"LK2: Link msg {i+1}/3") + msg.desired_method = LXMF.LXMessage.DIRECT + def make_cb(l): + def cb(m): + results[l] = f"s{m.state}" + print(f" [{l}] -> s{m.state}", flush=True) + return cb + msg.register_delivery_callback(make_cb(label)) + router.handle_outbound(msg) + time.sleep(10) + print(" Waiting 30s for remaining deliveries...", flush=True) + time.sleep(30) + for i in range(3): + label = f"LK2-{i+1}" + s = results.get(label, "PENDING") + icon = "OK" if any(x in s for x in ("s3", "s4", "s8", "s255")) else "??" + print(f" [{icon}] {label}: {s}", flush=True) + + +def run_lxmf_opportunistic_test(router, dest, source): + """Test opportunistic delivery via LXMRouter.""" + print("\n" + "=" * 60) + print("LXMF ROUTER: OPPORTUNISTIC TESTS") + print("=" * 60) + + print("\n--- LO1: 5 messages, 5s apart ---") + for i in range(5): + label = f"LO1-{i+1}" + msg = LXMF.LXMessage(dest, source, f"LO1 opp {i+1}/5") + msg.desired_method = LXMF.LXMessage.OPPORTUNISTIC + def make_cb(l): + def cb(m): + results[l] = f"s{m.state}" + return cb + msg.register_delivery_callback(make_cb(label)) + router.handle_outbound(msg) + time.sleep(5) + print(" Waiting 15s...") + time.sleep(15) + passed = 0 + for i in range(5): + label = f"LO1-{i+1}" + s = results.get(label, "PENDING") + ok = any(x in s for x in ("s3", "s4", "s8", "s255")) + if ok: + passed += 1 + icon = "OK" if ok else "??" + print(f" [{icon}] {label}: {s}", flush=True) + print(f" LO1: {passed}/5 confirmed", flush=True) + + +def run_listen_mode(router, dest, source): + """Listen for messages FROM the Ratdeck.""" + print("\n" + "=" * 60) + print("LISTEN MODE — Waiting for messages from Ratdeck") + print("=" * 60) + print(f"\n Our LXMF address (enter on Ratdeck):") + print(f" {source.hash.hex()}") + print(f"\n Send a message FROM the Ratdeck to this address.") + print(f" Listening for 120s...\n", flush=True) + + rx_before = len(messages_rx) + for i in range(24): + time.sleep(5) + new = len(messages_rx) - rx_before + if new > 0: + print(f" [{(i+1)*5}s] Received {new} message(s)!", flush=True) + for m in messages_rx[rx_before:]: + print(f" \"{m['content'][:60]}\"", flush=True) + results["LISTEN"] = "OK" + break + else: + results["LISTEN"] = "NO_MSG" + print(" No messages received from Ratdeck", flush=True) + + +# ============================================================ +# SUMMARY +# ============================================================ + +def print_summary(): + print("\n" + "=" * 60) + print("FINAL RESULTS") + print("=" * 60) + + passed = failed = pending = 0 + for label in sorted(results.keys()): + status = results[label] + if status in ("DELIVERED", "OK") or any(x in str(status) for x in ("s3", "s4", "s8", "s255")): + icon, passed = "OK", passed + 1 + elif status in ("FAILED", "TIMEOUT") or "s6" in str(status): + icon, failed = "FAIL", failed + 1 + else: + icon, pending = "??", pending + 1 + print(f" [{icon}] {label:10s}: {status}") + + total = passed + failed + pending + print(f"\n PASSED: {passed}/{total}") + print(f" FAILED: {failed}/{total}") + print(f" PENDING: {pending}/{total}") + print(f"\n Messages received from Ratdeck: {len(messages_rx)}") + for m in messages_rx: + print(f" \"{m['content'][:60]}\"") + print() + + +# ============================================================ +# MAIN +# ============================================================ + +def discover_mode(transport): + print("=== DISCOVER MODE ===") + print(f"Transport: {transport}\n") + router, dd, identity = init_rns(transport) + seen = set() + for t in range(12): + time.sleep(5) + for dh in RNS.Identity.known_destinations: + h = dh.hex() + if h not in seen: + seen.add(h) + app_data = RNS.Identity.recall_app_data(dh) + name = decode_name(app_data) + marker = " <---" if name else "" + print(f" [{(t+1)*5:3d}s] {h} \"{name}\"{marker}", flush=True) + print(f"\n{len(seen)} destinations found.") + print(f"Run: python3 {sys.argv[0]} [--tcp]") + + +def test_mode(dest_hex, transport, suite): + dest_hash = bytes.fromhex(dest_hex.replace(":", "")) + print(f"{'='*60}") + print(f" LXMF COMPATIBILITY TEST SUITE") + print(f"{'='*60}") + print(f" Target: {dest_hex}") + print(f" Transport: {transport}") + print(f" Suite: {suite}") + print(f"{'='*60}\n") + + router, dd, identity = init_rns(transport) + + if not wait_for_path(dest_hash): + return False + + dest_id = RNS.Identity.recall(dest_hash) + dest = RNS.Destination( + dest_id, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery" + ) + print(f"[OK] Destination ready (hops={RNS.Transport.hops_to(dest_hash)})\n") + + # --- RAW PACKET TESTS (firmware verification) --- + if suite in ("raw", "all"): + run_raw_size_tests(identity, dest, dest_hash) + run_raw_unicode_tests(identity, dest, dest_hash) + run_raw_timing_tests(identity, dest, dest_hash) + + # --- LXMF ROUTER TESTS (Python client compatibility) --- + if suite in ("lxmf", "all"): + run_lxmf_opportunistic_test(router, dest, dd) + run_lxmf_link_test(router, dest, dd) + + # --- LISTEN MODE (Ratdeck → Python) --- + if suite in ("listen", "all"): + run_listen_mode(router, dest, dd) + + print_summary() + return True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="LXMF Compatibility Test Suite") + parser.add_argument("dest_hash", nargs="?", help="Target destination hash (hex)") + parser.add_argument("--tcp", action="store_true", help="Use TCP hub instead of LoRa") + parser.add_argument("--suite", default="all", + choices=["raw", "lxmf", "listen", "all"], + help="Which test suite to run") + args = parser.parse_args() + + transport = "tcp" if args.tcp else "lora" + + try: + if args.dest_hash: + test_mode(args.dest_hash, transport, args.suite) + else: + discover_mode(transport) + except KeyboardInterrupt: + print("\nInterrupted") + if results: + print_summary() + except Exception as e: + print(f"\n[ERROR] {e}") + import traceback + traceback.print_exc()