mirror of
https://github.com/torlando-tech/columba
synced 2026-08-12 18:07:15 -04:00
struct.pack("!H") requires 0-65535 but coarsened locations with
approxRadius > 655m produce values exceeding this, crashing the send.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8111 lines
403 KiB
Python
8111 lines
403 KiB
Python
"""
|
||
Reticulum Wrapper for Kotlin Integration
|
||
Provides a simplified interface to Reticulum/LXMF that Kotlin can call via Chaquopy.
|
||
"""
|
||
|
||
from typing import Optional, Dict, List, Callable
|
||
import json
|
||
import struct
|
||
import threading
|
||
import time
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import importlib
|
||
import importlib.util
|
||
import traceback
|
||
from logging_utils import log_debug, log_info, log_warning, log_error, log_separator
|
||
from signal_quality import extract_signal_metrics, add_signal_to_message_event
|
||
|
||
# umsgpack is available via RNS dependencies (bundled with Chaquopy on Android)
|
||
try:
|
||
import umsgpack
|
||
except ImportError:
|
||
umsgpack = None # Will be available at runtime on Android
|
||
|
||
# Note: RNS/LXMF imports are deferred until after patches are deployed
|
||
# This ensures Python loads the patched code, not the original buggy code
|
||
RETICULUM_AVAILABLE = False
|
||
RNS = None
|
||
LXMF = None
|
||
|
||
|
||
# ============================================================================
|
||
# Global Exception Handler
|
||
# ============================================================================
|
||
# Catches unhandled exceptions in any thread and logs them before the crash.
|
||
# This helps diagnose issues that would otherwise silently kill the service.
|
||
|
||
def _global_exception_handler(exc_type, exc_value, exc_traceback):
|
||
"""
|
||
Global exception handler that logs unhandled exceptions.
|
||
Installed via sys.excepthook to catch crashes before process dies.
|
||
"""
|
||
# Let KeyboardInterrupt and SystemExit pass through normally
|
||
if issubclass(exc_type, (KeyboardInterrupt, SystemExit)):
|
||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||
return
|
||
|
||
# Format and log the exception
|
||
exc_text = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
||
log_error("GLOBAL", "excepthook", f"Unhandled {exc_type.__name__}: {exc_value}")
|
||
log_error("GLOBAL", "excepthook", f"Traceback:\n{exc_text}")
|
||
|
||
# Also print to stderr as a fallback
|
||
print(f"FATAL: Unhandled {exc_type.__name__}: {exc_value}", file=sys.stderr)
|
||
print(exc_text, file=sys.stderr)
|
||
|
||
|
||
# Install global exception handler at module load time
|
||
sys.excepthook = _global_exception_handler
|
||
|
||
|
||
# ============================================================================
|
||
# LXMF Field Constants (from LXMF specification)
|
||
# ============================================================================
|
||
FIELD_TELEMETRY = 0x02 # Standard telemetry field for Sideband interoperability
|
||
FIELD_TELEMETRY_STREAM = 0x03 # Bulk telemetry stream from collector [[source_hash, timestamp, packed_telemetry, appearance], ...]
|
||
FIELD_COLUMBA_META = 0x70 # Custom field for Columba-specific metadata (cease signals, etc.)
|
||
FIELD_ICON_APPEARANCE = 0x04 # Icon appearance [name, bg_bytes(3), fg_bytes(3)] for Sideband/MeshChat interoperability
|
||
FIELD_FILE_ATTACHMENTS = 0x05 # LXMF standard field for file attachments
|
||
FIELD_COMMANDS = 0x09 # LXMF standard field for commands (telemetry requests, etc.)
|
||
FIELD_IMAGE = 0x06 # LXMF standard field for images
|
||
FIELD_AUDIO = 0x07 # LXMF standard field for audio
|
||
LEGACY_LOCATION_FIELD = 7 # Legacy field ID for backwards compatibility
|
||
|
||
# Command IDs for FIELD_COMMANDS (Sideband telemetry collector protocol)
|
||
COMMAND_TELEMETRY_REQUEST = 0x01 # Request telemetry from collector
|
||
|
||
# Sensor IDs (from Sideband sense.py)
|
||
SID_TIME = 0x01
|
||
SID_LOCATION = 0x02
|
||
|
||
|
||
# ============================================================================
|
||
# Marker Symbol Registry (MDI icon names for Sideband interoperability)
|
||
# ============================================================================
|
||
# Maps marker symbol keys to Material Design Icon names used by Sideband/MeshChat.
|
||
# When a peer's marker metadata includes a symbol, the icon_name in the
|
||
# appearance tuple is looked up here, and the background colour is derived
|
||
# from the symbol key via _color_from_symbol_key().
|
||
|
||
MARKER_SYMBOL_REGISTRY: Dict[str, str] = {
|
||
"person": "account",
|
||
"car": "car",
|
||
"bike": "bike",
|
||
"truck": "truck",
|
||
"bus": "bus",
|
||
"plane": "airplane",
|
||
"boat": "sail-boat",
|
||
"train": "train",
|
||
"helicopter":"helicopter",
|
||
"motorcycle":"motorbike",
|
||
"walking": "walk",
|
||
"running": "run",
|
||
"pet": "paw",
|
||
"dog": "dog",
|
||
"cat": "cat",
|
||
"home": "home",
|
||
"office": "office-building",
|
||
"hospital": "hospital-building",
|
||
"school": "school",
|
||
"store": "store",
|
||
"restaurant":"food-fork-drink",
|
||
"fuel": "gas-station",
|
||
"parking": "parking",
|
||
"camp": "campfire",
|
||
"mountain": "mountain",
|
||
"tree": "tree",
|
||
"water": "water",
|
||
"flag": "flag",
|
||
"star": "star",
|
||
"heart": "heart",
|
||
"warning": "alert",
|
||
"info": "information",
|
||
"pin": "map-marker",
|
||
"circle": "circle",
|
||
"square": "square",
|
||
"triangle": "triangle",
|
||
"diamond": "diamond",
|
||
"rectangle": "rectangle",
|
||
"cross": "cross",
|
||
"anchor": "anchor",
|
||
"tower": "tower-broadcast",
|
||
"antenna": "antenna",
|
||
"repeater": "access-point",
|
||
"gateway": "router-wireless",
|
||
"node": "access-point-network",
|
||
}
|
||
|
||
|
||
def _color_from_symbol_key(symbol_key: str) -> bytes:
|
||
"""
|
||
Derive a deterministic background RGB colour from a marker symbol key.
|
||
|
||
Uses a simple hash to map any symbol key string to a colour in the
|
||
mid-saturation, mid-brightness range so that white or light foreground
|
||
text remains readable.
|
||
|
||
Args:
|
||
symbol_key: The marker symbol key string (e.g. "car", "person")
|
||
|
||
Returns:
|
||
3-byte RGB value suitable for an appearance background field
|
||
"""
|
||
import hashlib
|
||
h = hashlib.sha256(symbol_key.encode("utf-8")).digest()
|
||
# Use first 3 bytes but clamp to 40-200 range for readability
|
||
r = 40 + (h[0] % 161) # 40..200
|
||
g = 40 + (h[1] % 161)
|
||
b = 40 + (h[2] % 161)
|
||
return bytes([r, g, b])
|
||
|
||
|
||
def appearance_from_marker_symbol(symbol_key: str) -> Optional[list]:
|
||
"""
|
||
Build a Sideband-compatible appearance tuple from a marker symbol key.
|
||
|
||
Returns [icon_name, bg_bytes(3), fg_bytes(3)] or None if the symbol key
|
||
is not recognised.
|
||
|
||
Args:
|
||
symbol_key: The marker symbol key (e.g. "car", "rectangle")
|
||
|
||
Returns:
|
||
Appearance list [icon_name, bg_rgb_bytes, fg_rgb_bytes] or None
|
||
"""
|
||
mdi_name = MARKER_SYMBOL_REGISTRY.get(symbol_key)
|
||
if mdi_name is None:
|
||
return None
|
||
bg = _color_from_symbol_key(symbol_key)
|
||
fg = b"\xff\xff\xff" # White foreground for readability
|
||
return [mdi_name, bg, fg]
|
||
|
||
|
||
# ============================================================================
|
||
# Telemetry Pack/Unpack Helpers (Sideband Telemeter format)
|
||
# ============================================================================
|
||
|
||
def pack_location_telemetry(lat: float, lon: float, accuracy: float, timestamp_ms: int,
|
||
altitude: float = 0.0, speed: float = 0.0, bearing: float = 0.0) -> bytes:
|
||
"""
|
||
Pack location data in Sideband Telemeter format for FIELD_TELEMETRY.
|
||
|
||
This format is compatible with Sideband's sense.py Location sensor.
|
||
|
||
Args:
|
||
lat: Latitude in decimal degrees (WGS84)
|
||
lon: Longitude in decimal degrees (WGS84)
|
||
accuracy: Horizontal accuracy in meters
|
||
timestamp_ms: Unix timestamp in milliseconds
|
||
altitude: Altitude in meters (default 0.0)
|
||
speed: Speed in km/h (default 0.0)
|
||
bearing: Bearing/heading in degrees (default 0.0)
|
||
|
||
Returns:
|
||
msgpack-packed bytes for FIELD_TELEMETRY
|
||
"""
|
||
# Lazy import - umsgpack is available after RNS is loaded on Android
|
||
global umsgpack
|
||
if umsgpack is None:
|
||
import umsgpack as _umsgpack
|
||
umsgpack = _umsgpack
|
||
|
||
timestamp_s = int(timestamp_ms / 1000)
|
||
|
||
# Pack location data exactly as Sideband's Location.pack() does (sense.py:880-897)
|
||
location_packed = [
|
||
struct.pack("!i", int(round(lat, 6) * 1e6)), # latitude in microdegrees
|
||
struct.pack("!i", int(round(lon, 6) * 1e6)), # longitude in microdegrees
|
||
struct.pack("!i", int(round(altitude, 2) * 1e2)), # altitude in centimeters
|
||
struct.pack("!I", int(round(speed, 2) * 1e2)), # speed in cm/s (unsigned)
|
||
struct.pack("!i", int(round(bearing, 2) * 1e2)), # bearing in centi-degrees
|
||
struct.pack("!H", min(int(round(accuracy, 2) * 1e2), 65535)), # accuracy in centimeters (unsigned short, clamped)
|
||
timestamp_s, # last_update timestamp
|
||
]
|
||
|
||
telemetry = {
|
||
SID_TIME: timestamp_s,
|
||
SID_LOCATION: location_packed,
|
||
}
|
||
|
||
return umsgpack.packb(telemetry)
|
||
|
||
|
||
def unpack_location_telemetry(packed_data: bytes) -> Optional[Dict]:
|
||
"""
|
||
Unpack Sideband Telemeter format from FIELD_TELEMETRY to Columba JSON format.
|
||
|
||
Args:
|
||
packed_data: msgpack-packed bytes from FIELD_TELEMETRY
|
||
|
||
Returns:
|
||
Dict with location data in Columba format, or None if unpacking fails
|
||
"""
|
||
# Lazy import - umsgpack is available after RNS is loaded on Android
|
||
global umsgpack
|
||
if umsgpack is None:
|
||
import umsgpack as _umsgpack
|
||
umsgpack = _umsgpack
|
||
|
||
try:
|
||
telemetry = umsgpack.unpackb(packed_data)
|
||
|
||
if SID_LOCATION not in telemetry:
|
||
return None
|
||
|
||
loc = telemetry[SID_LOCATION]
|
||
if len(loc) < 7:
|
||
return None
|
||
|
||
# Unpack exactly as Sideband's Location.unpack() does (sense.py:899-914)
|
||
lat = struct.unpack("!i", loc[0])[0] / 1e6
|
||
lon = struct.unpack("!i", loc[1])[0] / 1e6
|
||
altitude = struct.unpack("!i", loc[2])[0] / 1e2
|
||
speed = struct.unpack("!I", loc[3])[0] / 1e2
|
||
bearing = struct.unpack("!i", loc[4])[0] / 1e2
|
||
accuracy = struct.unpack("!H", loc[5])[0] / 1e2
|
||
last_update = loc[6]
|
||
|
||
# Log human-readable view of decoded sensors
|
||
log_debug("TelemetryHelper", "unpack_location_telemetry",
|
||
f"Decoded sensors: lat={lat:.6f} lon={lon:.6f} "
|
||
f"alt={altitude:.1f}m spd={speed:.1f}km/h "
|
||
f"bear={bearing:.1f}° acc={accuracy:.1f}m "
|
||
f"t={last_update}")
|
||
|
||
return {
|
||
"type": "location_share",
|
||
"lat": lat,
|
||
"lng": lon,
|
||
"acc": accuracy,
|
||
"ts": last_update * 1000, # Convert to milliseconds
|
||
"altitude": altitude,
|
||
"speed": speed,
|
||
"bearing": bearing,
|
||
}
|
||
except Exception as e:
|
||
log_warning("TelemetryHelper", "unpack_location_telemetry",
|
||
f"Failed to unpack telemetry: {e}")
|
||
return None
|
||
|
||
|
||
def pack_telemetry_stream(entries: List) -> bytes:
|
||
"""
|
||
Pack telemetry stream entries for FIELD_TELEMETRY_STREAM.
|
||
|
||
Args:
|
||
entries: List of [source_hash_bytes, timestamp, packed_telemetry, appearance]
|
||
|
||
Returns:
|
||
msgpack-encoded stream data
|
||
"""
|
||
try:
|
||
# The stream is just a list of entries, msgpack encoded
|
||
# Use umsgpack which is bundled with RNS
|
||
return umsgpack.packb(entries)
|
||
except Exception as e:
|
||
log_error("TelemetryHelper", "pack_telemetry_stream",
|
||
f"Failed to pack telemetry stream: {e}")
|
||
return b''
|
||
|
||
|
||
def unpack_telemetry_stream(stream_data: List) -> List[Dict]:
|
||
"""
|
||
Unpack FIELD_TELEMETRY_STREAM entries to Columba location format.
|
||
|
||
The stream format from Sideband is:
|
||
[[source_hash, timestamp, packed_telemetry, appearance], ...]
|
||
|
||
Where appearance is optional: [icon_name, bg_bytes(3), fg_bytes(3)]
|
||
|
||
Args:
|
||
stream_data: List of telemetry stream entries
|
||
|
||
Returns:
|
||
List of dicts with location data and appearance in Columba format
|
||
"""
|
||
results = []
|
||
for entry in stream_data:
|
||
try:
|
||
if len(entry) < 3:
|
||
log_warning("TelemetryHelper", "unpack_telemetry_stream",
|
||
f"Invalid stream entry, expected at least 3 elements: {len(entry)}")
|
||
continue
|
||
|
||
source_hash = entry[0]
|
||
timestamp = entry[1]
|
||
packed_telemetry = entry[2]
|
||
appearance = entry[3] if len(entry) > 3 else None
|
||
|
||
# Convert source_hash to hex string
|
||
if isinstance(source_hash, bytes):
|
||
source_hash_hex = source_hash.hex()
|
||
else:
|
||
source_hash_hex = str(source_hash)
|
||
|
||
# Unpack the telemetry data
|
||
location_event = unpack_location_telemetry(packed_telemetry)
|
||
if not location_event:
|
||
log_warning("TelemetryHelper", "unpack_telemetry_stream",
|
||
f"Failed to unpack telemetry for source {source_hash_hex[:16]}...")
|
||
continue
|
||
|
||
# Override timestamp if provided separately (validate to avoid setting 0/invalid/future)
|
||
if timestamp is not None and timestamp > 0:
|
||
# Reject timestamps more than 1 hour in the future
|
||
current_time = time.time()
|
||
if timestamp > current_time + 3600:
|
||
log_warning("TelemetryHelper", "unpack_telemetry_stream",
|
||
f"Rejecting future timestamp: {timestamp} (current: {current_time})")
|
||
else:
|
||
location_event['ts'] = timestamp * 1000 # Convert to milliseconds
|
||
|
||
# Add source hash
|
||
location_event['source_hash'] = source_hash_hex
|
||
|
||
# Parse appearance if present
|
||
# Sideband format: [icon_name, background_rgb_bytes, foreground_rgb_bytes]
|
||
if appearance and isinstance(appearance, list) and len(appearance) >= 3:
|
||
try:
|
||
icon_name = appearance[0]
|
||
bg_bytes = appearance[1]
|
||
fg_bytes = appearance[2]
|
||
|
||
# Validate icon name - alphanumeric, underscores, and hyphens only, max 50 chars
|
||
# MDI icon names use hyphens (e.g. "sail-boat", "access-point-network")
|
||
if isinstance(icon_name, str) and len(icon_name) <= 50 and icon_name.replace('_', '').replace('-', '').isalnum():
|
||
# Convert RGB bytes to hex color string
|
||
if isinstance(fg_bytes, bytes) and len(fg_bytes) >= 3:
|
||
fg_hex = "#{:02x}{:02x}{:02x}".format(fg_bytes[0], fg_bytes[1], fg_bytes[2])
|
||
else:
|
||
fg_hex = None
|
||
|
||
if isinstance(bg_bytes, bytes) and len(bg_bytes) >= 3:
|
||
bg_hex = "#{:02x}{:02x}{:02x}".format(bg_bytes[0], bg_bytes[1], bg_bytes[2])
|
||
else:
|
||
bg_hex = None
|
||
|
||
location_event['appearance'] = {
|
||
'name': icon_name,
|
||
'fg': fg_hex,
|
||
'bg': bg_hex,
|
||
}
|
||
else:
|
||
log_warning("TelemetryHelper", "unpack_telemetry_stream",
|
||
f"Invalid icon name format: {repr(icon_name)[:50]}")
|
||
except Exception as e:
|
||
log_warning("TelemetryHelper", "unpack_telemetry_stream",
|
||
f"Failed to parse appearance: {e}")
|
||
|
||
results.append(location_event)
|
||
except Exception as e:
|
||
log_warning("TelemetryHelper", "unpack_telemetry_stream",
|
||
f"Failed to process stream entry: {e}")
|
||
|
||
return results
|
||
|
||
|
||
def get_hello_message() -> str:
|
||
"""
|
||
Simple test function to verify Python integration.
|
||
Returns a greeting message with Reticulum availability status.
|
||
"""
|
||
global RETICULUM_AVAILABLE
|
||
if RETICULUM_AVAILABLE:
|
||
return "Hello from Python! Reticulum is available."
|
||
else:
|
||
return "Hello from Python! (Mock mode - Reticulum not available)"
|
||
|
||
|
||
# Global wrapper instance for AndroidBLEDriver to access KotlinBLEBridge
|
||
_global_wrapper_instance = None
|
||
|
||
|
||
class AnnounceHandler:
|
||
"""
|
||
Wrapper class for announce callbacks that implements RNS.Transport requirements.
|
||
|
||
RNS.Transport.register_announce_handler requires:
|
||
1. An object with an 'aspect_filter' attribute
|
||
2. A 'received_announce(destination_hash, announced_identity, app_data)' callable
|
||
|
||
This class wraps our internal _announce_handler method to meet these requirements.
|
||
"""
|
||
|
||
def __init__(self, aspect_filter, callback):
|
||
"""
|
||
Initialize the announce handler wrapper.
|
||
|
||
Args:
|
||
aspect_filter: The aspect to filter for (e.g., "lxmf.delivery", "nomadnetwork.node")
|
||
Use None to receive ALL announces
|
||
callback: The actual callback function to invoke when announces are received
|
||
Signature: callback(aspect, destination_hash, announced_identity, app_data, announce_packet_hash)
|
||
"""
|
||
self.aspect_filter = aspect_filter
|
||
self.callback = callback
|
||
|
||
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash=None):
|
||
"""
|
||
Called by RNS.Transport when an announce is received.
|
||
|
||
Args:
|
||
destination_hash: The destination hash that announced
|
||
announced_identity: The RNS.Identity object of the announcing peer
|
||
app_data: Application-specific data included in the announce
|
||
announce_packet_hash: Hash of the announce packet (optional, for future use)
|
||
"""
|
||
# Pass aspect to callback so it knows which aspect this announce is for
|
||
self.callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
|
||
|
||
|
||
class ReticulumWrapper:
|
||
"""Main wrapper class for Reticulum operations"""
|
||
|
||
def __init__(self, storage_path: str):
|
||
global _global_wrapper_instance
|
||
|
||
self.storage_path = storage_path
|
||
self.reticulum = None
|
||
self.router = None
|
||
self.message_callbacks = []
|
||
self.announce_callbacks = []
|
||
self.link_callbacks = []
|
||
self.destinations = {} # Track destinations by hash
|
||
self.initialized = False
|
||
self.failed_interfaces = [] # Track interfaces that failed to initialize
|
||
self.rns_thread = None
|
||
self.pending_announces = [] # Queue for announces waiting to be retrieved
|
||
self.announce_lock = threading.Lock()
|
||
self.seen_message_hashes = set() # Track which messages we've already processed
|
||
self.local_lxmf_destination = None # Our local LXMF delivery destination
|
||
self.last_announce_poll_time = 0 # Track last poll time for announce_table polling
|
||
self.seen_announce_hashes = set() # Track which announces we've already processed from announce_table
|
||
self.identities = {} # Local cache of recalled identities (identity_hash_hex -> RNS.Identity)
|
||
self.active_propagation_node = None # Currently active propagation node destination hash (bytes)
|
||
|
||
# BLE interface support (Android-specific - driver-based architecture)
|
||
self.ble_interface = None # AndroidBLEInterface instance (if enabled)
|
||
self.transport_identity_hash = None # 16-byte Transport identity hash (for BLE Protocol v2.2)
|
||
self.kotlin_ble_bridge = None # KotlinBLEBridge instance (passed from Kotlin)
|
||
|
||
# RNode interface support (Bluetooth Classic or BLE to RNode LoRa hardware)
|
||
self.rnode_interfaces = {} # Dict of ColumbaRNodeInterface instances keyed by name
|
||
self.kotlin_rnode_bridge = None # KotlinRNodeBridge instance (passed from Kotlin)
|
||
self._pending_rnode_configs = [] # List of stored RNode configs during initialization
|
||
self._rnode_init_lock = threading.Lock() # Lock to prevent concurrent RNode initialization
|
||
self._rnode_initializing = False # Flag to track if RNode initialization is in progress
|
||
|
||
# Delivery status callback support (for event-driven message status updates)
|
||
self.kotlin_delivery_status_callback = None # Callback to Kotlin for delivery status events
|
||
|
||
# Message received callback support (Phase 2.2 - event-driven message notifications)
|
||
self.kotlin_message_received_callback = None # Callback to Kotlin when LXMF message received
|
||
|
||
# Location telemetry callback support (Phase 3 - location sharing over LXMF)
|
||
self.kotlin_location_received_callback = None # Callback to Kotlin when location telemetry received
|
||
|
||
# Reaction received callback support (emoji reactions to messages)
|
||
self.kotlin_reaction_received_callback = None # Callback to Kotlin when reaction received
|
||
|
||
# General Reticulum bridge for protocol-level callbacks (announces, link events, etc.)
|
||
self.kotlin_reticulum_bridge = None # KotlinReticulumBridge instance (passed from Kotlin)
|
||
|
||
# Voice call support (LXST Telephony integration)
|
||
self.kotlin_call_bridge = None # CallBridge instance (passed from Kotlin)
|
||
self.kotlin_network_bridge = None # NetworkPacketBridge instance (passed from Kotlin)
|
||
self._call_manager = None # LXST CallManager instance (initialized when bridges are set)
|
||
|
||
# Opportunistic message timeout tracking
|
||
# When opportunistic messages are sent but recipient is offline, they get stuck in SENT state
|
||
# forever waiting for a delivery receipt. This tracking dict + timer provides a timeout
|
||
# mechanism to trigger propagation fallback for undelivered opportunistic messages.
|
||
self._opportunistic_messages = {} # {msg_hash_hex: {'message': lxmf_message, 'sent_time': timestamp}}
|
||
self._opportunistic_timeout_seconds = 30 # Timeout before falling back to propagation
|
||
self._opportunistic_check_interval = 10 # How often to check for timeouts (seconds)
|
||
self._opportunistic_timer = None # Timer thread reference
|
||
|
||
# Propagation node fallback tracking
|
||
# When the selected relay is offline and propagation fails, we request alternative relays
|
||
# from Kotlin. Messages wait in pending dict until alternative is provided or all exhausted.
|
||
self.kotlin_request_alternative_relay_callback = None # Callback to request alternative relay
|
||
self._pending_relay_fallback_messages = {} # {msg_hash_hex: lxmf_message} - waiting for alternative
|
||
self._max_relay_retries = 3 # Maximum number of alternative relays to try
|
||
|
||
# Successfully propagated message tracking (Issue #257 fix)
|
||
# LXMF may call the failure callback spuriously for propagated messages because it
|
||
# expects delivery confirmation that will never come from the relay. We track messages
|
||
# that successfully reached SENT state with PROPAGATED method, and ignore any
|
||
# subsequent failure callbacks for these messages.
|
||
self._successfully_propagated = {} # {msg_hash_hex: timestamp} - messages that reached relay
|
||
self._propagated_tracking_ttl_seconds = 86400 # 24 hours - cleanup old entries
|
||
|
||
# Pending file notifications for propagated messages with attachments
|
||
# When a message with file attachments is retried via propagation, we track it here
|
||
# and send the notification only after propagation succeeds
|
||
self._pending_file_notifications = {} # {msg_hash_hex: lxmf_message}
|
||
|
||
# Native stamp generator callback (Kotlin)
|
||
# Used to bypass Python multiprocessing issues on Android
|
||
self.kotlin_stamp_generator_callback = None
|
||
|
||
# Propagation sync state callback (for real-time sync progress updates)
|
||
# Invoked when LXMF propagation state changes (idle, receiving, complete, etc.)
|
||
self.kotlin_propagation_state_callback = None
|
||
self._last_propagation_state = None # For change detection
|
||
self._last_propagation_progress = 0.0 # For progress change detection during transfers
|
||
|
||
# Service heartbeat tracking (Sideband-inspired process monitoring)
|
||
# Python updates timestamp every second; Kotlin monitors for stale heartbeats
|
||
# If heartbeat is stale > 10 seconds, Kotlin should restart the service
|
||
self._heartbeat_timestamp = 0.0 # Updated every second when running
|
||
self._heartbeat_thread = None # Thread reference for heartbeat loop
|
||
|
||
# Service maintenance tracking (Sideband-inspired interface recovery)
|
||
# Periodically checks for failed interfaces and attempts reinit
|
||
self._maintenance_thread = None # Thread reference for maintenance loop
|
||
self._last_interface_reinit_attempt = 0.0 # Timestamp of last reinit attempt
|
||
self._interface_reinit_interval = 60.0 # Retry failed interfaces every 60 seconds
|
||
|
||
# Set global instance so AndroidBLEDriver can access it
|
||
_global_wrapper_instance = self
|
||
|
||
# Announce handlers - register multiple aspect-specific handlers
|
||
# Following MeshChat's pattern to properly distinguish announce types
|
||
self._announce_handlers = {
|
||
"lxmf.delivery": AnnounceHandler("lxmf.delivery", self._announce_handler),
|
||
"lxmf.propagation": AnnounceHandler("lxmf.propagation", self._announce_handler),
|
||
"nomadnetwork.node": AnnounceHandler("nomadnetwork.node", self._announce_handler),
|
||
"rmsp.maps": AnnounceHandler("rmsp.maps", self._announce_handler),
|
||
}
|
||
|
||
# RMSP client for map tile fetching over Reticulum
|
||
self._rmsp_client = None
|
||
|
||
# Shared instance state
|
||
self.is_shared_instance = False # True if connected to external shared RNS instance
|
||
|
||
# Telemetry collector host state
|
||
self.telemetry_collector_enabled = False # True when acting as host/collector
|
||
self.collected_telemetry = {} # {source_hash_hex: {timestamp, packed_telemetry, appearance, received_at}}
|
||
self.telemetry_retention_seconds = 86400 # 24 hours TTL
|
||
self.telemetry_allowed_requesters = set() # Empty = allow all, otherwise set of allowed identity hashes (lowercase hex)
|
||
|
||
# Don't initialize here - wait for explicit initialize() call
|
||
log_info("ReticulumWrapper", "__init__", f"Created with storage path: {storage_path}")
|
||
|
||
def set_ble_bridge(self, bridge):
|
||
"""
|
||
Set the KotlinBLEBridge instance for BLE operations.
|
||
Should be called from Kotlin before initialize().
|
||
|
||
Args:
|
||
bridge: KotlinBLEBridge instance from Kotlin
|
||
"""
|
||
self.kotlin_ble_bridge = bridge
|
||
log_info("ReticulumWrapper", "set_ble_bridge", "KotlinBLEBridge instance set")
|
||
|
||
def set_rnode_bridge(self, bridge):
|
||
"""
|
||
Set the KotlinRNodeBridge instance for RNode operations.
|
||
Should be called from Kotlin before initialize().
|
||
|
||
Args:
|
||
bridge: KotlinRNodeBridge instance from Kotlin
|
||
"""
|
||
self.kotlin_rnode_bridge = bridge
|
||
log_info("ReticulumWrapper", "set_rnode_bridge", "KotlinRNodeBridge instance set")
|
||
|
||
def set_usb_bridge(self, bridge):
|
||
"""
|
||
Set the KotlinUSBBridge instance for USB RNode operations.
|
||
Should be called from Kotlin before initialize().
|
||
|
||
Args:
|
||
bridge: KotlinUSBBridge instance from Kotlin
|
||
"""
|
||
import usb_bridge
|
||
usb_bridge.set_usb_bridge(bridge)
|
||
log_info("ReticulumWrapper", "set_usb_bridge", "KotlinUSBBridge instance set")
|
||
|
||
def set_call_bridge(self, bridge):
|
||
"""
|
||
Set the CallBridge instance for call state management.
|
||
Should be called from Kotlin before initialize_call_manager().
|
||
|
||
Args:
|
||
bridge: CallBridge instance from Kotlin
|
||
"""
|
||
self.kotlin_call_bridge = bridge
|
||
log_info("ReticulumWrapper", "set_call_bridge", "CallBridge instance set")
|
||
|
||
def set_network_bridge(self, bridge):
|
||
"""
|
||
Set the NetworkPacketBridge instance for Kotlin-Python packet transfer.
|
||
Should be called from Kotlin before initialize_call_manager().
|
||
|
||
Args:
|
||
bridge: NetworkPacketBridge instance from Kotlin
|
||
"""
|
||
self.kotlin_network_bridge = bridge
|
||
log_info("ReticulumWrapper", "set_network_bridge", "NetworkPacketBridge instance set")
|
||
|
||
def set_telemetry_collector_enabled(self, enabled: bool) -> Dict:
|
||
"""
|
||
Enable or disable telemetry collector (host) mode.
|
||
|
||
When enabled, this device will:
|
||
- Store incoming FIELD_TELEMETRY location data from peers
|
||
- Handle FIELD_COMMANDS telemetry requests
|
||
- Respond with FIELD_TELEMETRY_STREAM containing all stored entries
|
||
|
||
Args:
|
||
enabled: True to enable host mode, False to disable
|
||
|
||
Returns:
|
||
Dict with success status
|
||
"""
|
||
try:
|
||
self.telemetry_collector_enabled = enabled
|
||
if not enabled:
|
||
# Clear collected telemetry when disabling
|
||
self.collected_telemetry.clear()
|
||
log_info("ReticulumWrapper", "set_telemetry_collector_enabled",
|
||
f"Telemetry collector mode {'enabled' if enabled else 'disabled'}")
|
||
return {'success': True, 'enabled': enabled}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "set_telemetry_collector_enabled", str(e))
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
def set_telemetry_allowed_requesters(self, allowed_hashes: list) -> Dict:
|
||
"""
|
||
Set the list of identity hashes that are allowed to request telemetry when in host mode.
|
||
|
||
Only requesters whose identity hash is in the list will receive responses;
|
||
requests from others will be silently ignored. If the list is empty,
|
||
all requests will be blocked.
|
||
|
||
Args:
|
||
allowed_hashes: List of 32-character hex identity hash strings
|
||
|
||
Returns:
|
||
Dict with success status and count of configured allowed requesters
|
||
"""
|
||
try:
|
||
# Normalize to lowercase and store as a set for O(1) lookup
|
||
self.telemetry_allowed_requesters = set(h.lower() for h in allowed_hashes if h)
|
||
count = len(self.telemetry_allowed_requesters)
|
||
|
||
if count > 0:
|
||
log_info("ReticulumWrapper", "set_telemetry_allowed_requesters",
|
||
f"Configured {count} allowed requester(s)")
|
||
else:
|
||
log_info("ReticulumWrapper", "set_telemetry_allowed_requesters",
|
||
"Cleared allowed requesters list (all requesters allowed)")
|
||
|
||
return {'success': True, 'count': count}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "set_telemetry_allowed_requesters", str(e))
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
def _cleanup_expired_telemetry(self):
|
||
"""
|
||
Remove telemetry entries older than the retention period.
|
||
Called before sending telemetry stream or periodically.
|
||
"""
|
||
import time
|
||
now = time.time()
|
||
expired_keys = []
|
||
|
||
for source_hash, entry in self.collected_telemetry.items():
|
||
if now - entry.get('received_at', 0) > self.telemetry_retention_seconds:
|
||
expired_keys.append(source_hash)
|
||
|
||
for key in expired_keys:
|
||
del self.collected_telemetry[key]
|
||
|
||
if expired_keys:
|
||
log_debug("ReticulumWrapper", "_cleanup_expired_telemetry",
|
||
f"Removed {len(expired_keys)} expired entries")
|
||
|
||
def _send_telemetry_stream_response(self, requester_hash_bytes, requester_identity, timebase):
|
||
"""
|
||
Send FIELD_TELEMETRY_STREAM response to a telemetry request.
|
||
|
||
Args:
|
||
requester_hash_bytes: The destination hash bytes of the requester
|
||
requester_identity: The Identity object of the requester
|
||
timebase: The timebase from the request (entries newer than this are sent)
|
||
"""
|
||
import time
|
||
try:
|
||
# Cleanup expired entries first
|
||
self._cleanup_expired_telemetry()
|
||
|
||
# Filter entries based on timebase
|
||
entries_to_send = []
|
||
for source_hash_hex, entry in self.collected_telemetry.items():
|
||
entry_timestamp = entry.get('timestamp', 0)
|
||
received_at = entry.get('received_at', 0)
|
||
# Use received_at for filtering (when we got the data), not internal timestamp
|
||
# This handles cases where sender's clock is off or telemetry was generated earlier
|
||
# If timebase is None or 0, send all entries
|
||
if timebase is None or timebase == 0 or received_at >= timebase:
|
||
entry_appearance = entry.get('appearance', None)
|
||
|
||
# Format: [source_hash_bytes, timestamp, packed_telemetry, appearance]
|
||
entries_to_send.append([
|
||
bytes.fromhex(source_hash_hex),
|
||
entry_timestamp,
|
||
entry.get('packed_telemetry', b''),
|
||
entry_appearance
|
||
])
|
||
|
||
log_info("ReticulumWrapper", "_send_telemetry_stream_response",
|
||
f"📡 Telemetry request received, sending stream with {len(entries_to_send)} entries (timebase={timebase})")
|
||
|
||
# Send the response message
|
||
# NOTE: Don't pre-pack the stream - LXMF handles msgpack encoding of fields
|
||
destination = RNS.Destination(
|
||
requester_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
|
||
# Pass raw list - LXMF will msgpack-encode it
|
||
fields = {FIELD_TELEMETRY_STREAM: entries_to_send}
|
||
|
||
lxmf_message = LXMF.LXMessage(
|
||
destination,
|
||
self.local_lxmf_destination, # Our local LXMF destination
|
||
"", # Empty content
|
||
fields=fields,
|
||
desired_method=LXMF.LXMessage.DIRECT
|
||
)
|
||
|
||
# Use router to send (handles delivery callbacks)
|
||
self.router.handle_outbound(lxmf_message)
|
||
|
||
log_info("ReticulumWrapper", "_send_telemetry_stream_response",
|
||
f"✅ Telemetry stream response sent to {requester_hash_bytes.hex()[:16]}")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_send_telemetry_stream_response",
|
||
f"Failed to send telemetry stream: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _store_telemetry_for_collector(self, source_hash_hex, packed_telemetry, timestamp,
|
||
appearance=None):
|
||
"""
|
||
Store incoming telemetry data when acting as host/collector.
|
||
|
||
Args:
|
||
source_hash_hex: Hex string of the source's destination hash
|
||
packed_telemetry: The raw packed telemetry bytes
|
||
timestamp: Timestamp from the telemetry data
|
||
appearance: Optional appearance data (icon/color) from FIELD_ICON_APPEARANCE
|
||
"""
|
||
import time
|
||
self.collected_telemetry[source_hash_hex] = {
|
||
'timestamp': timestamp,
|
||
'packed_telemetry': packed_telemetry,
|
||
'appearance': appearance,
|
||
'received_at': time.time()
|
||
}
|
||
log_debug("ReticulumWrapper", "_store_telemetry_for_collector",
|
||
f"📦 Stored telemetry from {source_hash_hex[:16]}, total entries: {len(self.collected_telemetry)}")
|
||
|
||
def initialize_call_manager(self) -> Dict:
|
||
"""
|
||
Initialize the CallManager for voice calls.
|
||
|
||
Requires call_bridge to be set first.
|
||
Also requires Reticulum to be initialized.
|
||
|
||
Returns:
|
||
Dict with success status and optional error message
|
||
"""
|
||
try:
|
||
if not self.initialized:
|
||
return {'success': False, 'error': 'Reticulum not initialized'}
|
||
|
||
if self.kotlin_call_bridge is None:
|
||
return {'success': False, 'error': 'Call bridge not set'}
|
||
|
||
if self._call_manager is not None:
|
||
return {'success': True, 'message': 'CallManager already initialized'}
|
||
|
||
# Get the local identity for calls
|
||
identity = self.router.identity if self.router else None
|
||
if identity is None:
|
||
return {'success': False, 'error': 'No local identity available'}
|
||
|
||
# Initialize the CallManager
|
||
from lxst_modules.call_manager import initialize_call_manager
|
||
self._call_manager = initialize_call_manager(
|
||
identity=identity,
|
||
kotlin_call_bridge=self.kotlin_call_bridge,
|
||
kotlin_network_bridge=self.kotlin_network_bridge,
|
||
)
|
||
|
||
if self._call_manager is None:
|
||
return {'success': False, 'error': 'CallManager initialization failed'}
|
||
|
||
# Note: Kotlin PythonWrapperManager now handles registering the call_manager
|
||
# as both AudioPacketHandler and PythonCallManagerInterface. This avoids
|
||
# passing PyObject to :lxst module which has no Chaquopy dependency.
|
||
|
||
log_info("ReticulumWrapper", "initialize_call_manager", "CallManager initialized successfully")
|
||
return {'success': True}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "initialize_call_manager", f"Error: {e}")
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
def shutdown_call_manager(self):
|
||
"""
|
||
Shutdown the LXST CallManager and cleanup resources.
|
||
"""
|
||
try:
|
||
if self._call_manager is not None:
|
||
from lxst_modules.call_manager import shutdown_call_manager
|
||
shutdown_call_manager()
|
||
self._call_manager = None
|
||
log_info("ReticulumWrapper", "shutdown_call_manager", "CallManager shutdown complete")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "shutdown_call_manager", f"Error: {e}")
|
||
|
||
def get_call_manager(self):
|
||
"""
|
||
Get the LXST CallManager instance.
|
||
|
||
Returns:
|
||
CallManager instance or None if not initialized
|
||
"""
|
||
return self._call_manager
|
||
|
||
def get_paired_rnodes(self) -> Dict:
|
||
"""
|
||
Get list of paired Bluetooth devices that might be RNodes.
|
||
|
||
Uses the KotlinRNodeBridge to query paired devices.
|
||
Returns devices that appear to be RNodes based on naming patterns.
|
||
|
||
Returns:
|
||
Dict with:
|
||
- success: boolean
|
||
- devices: list of device name strings
|
||
- error: optional error message
|
||
"""
|
||
try:
|
||
if self.kotlin_rnode_bridge is None:
|
||
return {'success': False, 'devices': [], 'error': 'KotlinRNodeBridge not set'}
|
||
|
||
devices = self.kotlin_rnode_bridge.getPairedRNodes()
|
||
device_list = list(devices) if devices else []
|
||
|
||
log_info("ReticulumWrapper", "get_paired_rnodes", f"Found {len(device_list)} paired RNode(s)")
|
||
return {'success': True, 'devices': device_list}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_paired_rnodes", f"ERROR getting paired RNodes: {e}")
|
||
return {'success': False, 'devices': [], 'error': str(e)}
|
||
|
||
def set_reticulum_bridge(self, bridge):
|
||
"""
|
||
Set the KotlinReticulumBridge instance for general protocol callbacks.
|
||
Should be called from Kotlin before initialize().
|
||
|
||
This bridge handles non-BLE specific events like announces, link events,
|
||
and other protocol-level notifications that work across all interfaces.
|
||
|
||
Args:
|
||
bridge: KotlinReticulumBridge instance from Kotlin
|
||
"""
|
||
self.kotlin_reticulum_bridge = bridge
|
||
log_info("ReticulumWrapper", "set_reticulum_bridge", "KotlinReticulumBridge instance set")
|
||
|
||
def set_delivery_status_callback(self, callback):
|
||
"""
|
||
Set callback to be invoked when LXMF message delivery status changes.
|
||
Uses the same pattern as BLE bridge callbacks for event-driven updates.
|
||
|
||
Callback signature: callback(status_json: str)
|
||
|
||
Status JSON format:
|
||
{
|
||
"message_hash": "abc123...", # Hex string of message hash
|
||
"status": "delivered" | "failed",
|
||
"timestamp": 1234567890000 # Milliseconds since epoch
|
||
}
|
||
|
||
Args:
|
||
callback: PyObject callable from Kotlin (passed via Chaquopy)
|
||
"""
|
||
self.kotlin_delivery_status_callback = callback
|
||
log_info("ReticulumWrapper", "set_delivery_status_callback", "Delivery status callback registered")
|
||
|
||
def set_message_received_callback(self, callback):
|
||
"""
|
||
Set callback to be invoked when LXMF messages are received (Phase 2.2).
|
||
Eliminates need for message polling by providing event-driven notifications.
|
||
|
||
Callback signature: callback(message_json: str)
|
||
|
||
Message JSON format:
|
||
{
|
||
"message_hash": "abc123...", # Hex string of message hash
|
||
"source_hash": "def456...", # Hex string of source identity hash
|
||
"destination_hash": "ghi789...", # Hex string of destination hash
|
||
"timestamp": 1234567890000, # Milliseconds since epoch
|
||
"content_length": 1234 # Length of message content in bytes
|
||
}
|
||
|
||
Args:
|
||
callback: PyObject callable from Kotlin (passed via Chaquopy)
|
||
"""
|
||
self.kotlin_message_received_callback = callback
|
||
log_info("ReticulumWrapper", "set_message_received_callback",
|
||
"✅ Message received callback registered (event-driven architecture enabled)")
|
||
|
||
def set_location_received_callback(self, callback):
|
||
"""
|
||
Set callback to be invoked when location telemetry is received (Phase 3 - Location Sharing).
|
||
Location telemetry is sent via LXMF field 7 as JSON.
|
||
|
||
Callback signature: callback(location_json: str)
|
||
|
||
Location JSON format:
|
||
{
|
||
"source_hash": "abc123...", # Hex string of sender identity hash
|
||
"type": "location_share", # Always "location_share"
|
||
"lat": 37.7749, # Latitude (WGS84)
|
||
"lng": -122.4194, # Longitude (WGS84)
|
||
"acc": 10.0, # Accuracy in meters
|
||
"ts": 1234567890000, # Timestamp when captured (millis)
|
||
"expires": 1234570890000 # When sharing ends (millis), null for indefinite
|
||
}
|
||
|
||
Args:
|
||
callback: PyObject callable from Kotlin (passed via Chaquopy)
|
||
"""
|
||
self.kotlin_location_received_callback = callback
|
||
log_info("ReticulumWrapper", "set_location_received_callback",
|
||
"✅ Location received callback registered (location sharing enabled)")
|
||
|
||
def set_reaction_received_callback(self, callback):
|
||
"""
|
||
Set callback to be invoked when an emoji reaction is received.
|
||
|
||
Reactions are sent via LXMF Field 16 with the following keys:
|
||
- reaction_to: Message ID being reacted to
|
||
- emoji: The emoji reaction (e.g., "👍", "❤️", "😂")
|
||
- sender: Identity hash of the reaction sender
|
||
|
||
Callback signature: callback(reaction_json: str)
|
||
|
||
Reaction JSON format:
|
||
{
|
||
"reaction_to": "abc123...", # Message ID being reacted to
|
||
"emoji": "👍", # The emoji reaction
|
||
"sender": "def456...", # Sender identity hash (hex)
|
||
"source_hash": "ghi789...", # Source destination hash (hex)
|
||
"timestamp": 1234567890000 # Milliseconds since epoch
|
||
}
|
||
|
||
Args:
|
||
callback: PyObject callable from Kotlin (passed via Chaquopy)
|
||
"""
|
||
self.kotlin_reaction_received_callback = callback
|
||
log_info("ReticulumWrapper", "set_reaction_received_callback",
|
||
"✅ Reaction received callback registered (emoji reactions enabled)")
|
||
|
||
def set_kotlin_request_alternative_relay_callback(self, callback):
|
||
"""
|
||
Set callback to request alternative relay when propagation fails.
|
||
|
||
When a message fails to deliver via the current propagation node (relay is offline),
|
||
this callback is invoked to request Kotlin to find an alternative relay.
|
||
|
||
Callback signature: callback(request_json: str)
|
||
|
||
Request JSON format:
|
||
{
|
||
"message_hash": "abc123...", # Hex string of message hash needing retry
|
||
"exclude_relays": ["def456..."], # List of relay hashes to exclude (already tried)
|
||
"timestamp": 1234567890000 # Milliseconds since epoch
|
||
}
|
||
|
||
Args:
|
||
callback: PyObject callable from Kotlin (passed via Chaquopy)
|
||
"""
|
||
self.kotlin_request_alternative_relay_callback = callback
|
||
log_info("ReticulumWrapper", "set_kotlin_request_alternative_relay_callback",
|
||
"✅ Alternative relay callback registered (relay fallback enabled)")
|
||
|
||
def set_stamp_generator_callback(self, callback):
|
||
"""
|
||
Set native Kotlin callback for stamp generation.
|
||
|
||
This bypasses Python multiprocessing-based stamp generation which fails on Android
|
||
due to lack of sem_open support and aggressive process killing by Android.
|
||
|
||
Callback signature: callback(workblock: bytes, stamp_cost: int) -> (stamp: bytes, rounds: int)
|
||
|
||
Args:
|
||
callback: PyObject callable from Kotlin (passed via Chaquopy)
|
||
"""
|
||
self.kotlin_stamp_generator_callback = callback
|
||
|
||
# Register with LXMF's LXStamper module
|
||
try:
|
||
from LXMF import LXStamper
|
||
LXStamper.set_external_generator(callback)
|
||
log_info("ReticulumWrapper", "set_stamp_generator_callback",
|
||
"✅ Native stamp generator registered with LXMF")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "set_stamp_generator_callback",
|
||
f"Failed to register stamp generator: {e}")
|
||
|
||
def set_propagation_state_callback(self, callback):
|
||
"""
|
||
Set callback for propagation sync state changes.
|
||
|
||
This callback is invoked whenever the LXMF propagation state changes
|
||
(e.g., idle -> path_requested -> receiving -> complete).
|
||
Used by Kotlin to show real-time sync progress.
|
||
|
||
Callback signature: callback(state_json: str) -> None
|
||
state_json contains: {"state": int, "state_name": str, "progress": float, "messages_received": int}
|
||
|
||
Args:
|
||
callback: PyObject callable from Kotlin (passed via Chaquopy)
|
||
"""
|
||
self.kotlin_propagation_state_callback = callback
|
||
log_info("ReticulumWrapper", "set_propagation_state_callback",
|
||
"Propagation state callback registered")
|
||
|
||
def _clear_stale_ble_paths(self):
|
||
"""
|
||
Clear stale BLE paths from Transport.path_table on startup.
|
||
|
||
Bug workaround: Reticulum core loads path table entries from storage
|
||
with timestamp=0 (or very old timestamps), causing paths to immediately
|
||
expire. This prevents LXMF message delivery as messages wait for paths
|
||
that are constantly expiring and being recreated.
|
||
|
||
This workaround clears any BLE paths with invalid timestamps on startup,
|
||
forcing fresh path discovery via announces.
|
||
"""
|
||
try:
|
||
if not hasattr(RNS.Transport, 'path_table') or not RNS.Transport.path_table:
|
||
return
|
||
|
||
current_time = time.time()
|
||
stale_threshold = 60 # Paths older than 60 seconds are considered stale
|
||
stale_paths = []
|
||
|
||
# Scan for stale BLE paths
|
||
for dest_hash, entry in list(RNS.Transport.path_table.items()):
|
||
try:
|
||
timestamp = entry[0] # IDX_PT_TIMESTAMP
|
||
receiving_interface = entry[5] if len(entry) > 5 else None # IDX_PT_RVCD_IF
|
||
|
||
# Check if this is a BLE path
|
||
if receiving_interface and "BLE" in str(type(receiving_interface).__name__):
|
||
# Check for timestamp=0 bug or very old timestamps
|
||
if timestamp == 0:
|
||
stale_paths.append((dest_hash, timestamp, "timestamp=0 (Unix epoch bug)"))
|
||
elif (current_time - timestamp) > stale_threshold:
|
||
stale_paths.append((dest_hash, timestamp, f"age={(current_time - timestamp):.0f}s (stale from previous session)"))
|
||
except (IndexError, TypeError) as e:
|
||
# Malformed path entry
|
||
log_debug("ReticulumWrapper", "_clear_stale_ble_paths", f"Skipping malformed path table entry: {e}")
|
||
continue
|
||
|
||
# Remove stale paths
|
||
if stale_paths:
|
||
log_info("ReticulumWrapper", "_clear_stale_ble_paths", f"Bug workaround: Found {len(stale_paths)} stale BLE path(s) to clear")
|
||
for dest_hash, old_timestamp, reason in stale_paths:
|
||
RNS.Transport.path_table.pop(dest_hash)
|
||
log_debug("ReticulumWrapper", "_clear_stale_ble_paths", f"Cleared stale BLE path for {dest_hash.hex()[:16]}... - {reason}")
|
||
log_info("ReticulumWrapper", "_clear_stale_ble_paths", "Stale path cleanup complete. Fresh paths will be discovered via announces.")
|
||
else:
|
||
log_debug("ReticulumWrapper", "_clear_stale_ble_paths", "No stale BLE paths found in path table")
|
||
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "_clear_stale_ble_paths", f"Error during stale path cleanup (non-fatal): {e}")
|
||
|
||
def check_shared_instance_available(self, host: str = "127.0.0.1", port: int = 37428, timeout: float = 1.0) -> bool:
|
||
"""
|
||
Check if a shared Reticulum instance is available via TCP.
|
||
|
||
RNS shared instances listen on 127.0.0.1:37428 by default for TCP clients.
|
||
This method attempts to connect to that port to detect if another app
|
||
(e.g., Sideband) is already running a shared Reticulum instance.
|
||
|
||
This method is callable from Kotlin via the service AIDL interface.
|
||
|
||
Args:
|
||
host: Host to check (default: localhost)
|
||
port: Port to check (default: 37428, RNS shared instance default)
|
||
timeout: Connection timeout in seconds
|
||
|
||
Returns:
|
||
True if a shared instance appears to be available, False otherwise
|
||
"""
|
||
import socket
|
||
|
||
try:
|
||
log_info("ReticulumWrapper", "check_shared_instance_available",
|
||
f"Checking for shared instance at {host}:{port}...")
|
||
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
sock.settimeout(timeout)
|
||
|
||
result = sock.connect_ex((host, port))
|
||
sock.close()
|
||
|
||
if result == 0:
|
||
log_info("ReticulumWrapper", "check_shared_instance_available",
|
||
f"✓ Shared instance detected at {host}:{port}")
|
||
return True
|
||
else:
|
||
log_info("ReticulumWrapper", "check_shared_instance_available",
|
||
f"No shared instance found at {host}:{port} (error code: {result})")
|
||
return False
|
||
|
||
except socket.timeout:
|
||
log_info("ReticulumWrapper", "check_shared_instance_available",
|
||
f"Connection to {host}:{port} timed out - no shared instance")
|
||
return False
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "check_shared_instance_available",
|
||
f"Error checking shared instance: {e}")
|
||
return False
|
||
|
||
def _create_config_file(
|
||
self,
|
||
interfaces: List[Dict],
|
||
use_shared_instance: bool = False,
|
||
rpc_key: str = None,
|
||
enable_transport: bool = True,
|
||
discover_interfaces: bool = False,
|
||
autoconnect_discovered_interfaces: int = 0,
|
||
interface_discovery_sources: List[str] = None,
|
||
required_discovery_value: int = 14,
|
||
):
|
||
"""
|
||
Create an RNS config file with the specified interfaces.
|
||
|
||
Args:
|
||
interfaces: List of interface configuration dictionaries
|
||
use_shared_instance: If True, configure as client to shared instance (no local interfaces)
|
||
rpc_key: Optional RPC key (hex string) for shared instance authentication.
|
||
Required on Android when connecting to another app's shared instance
|
||
(e.g., Sideband) because apps have separate config directories.
|
||
enable_transport: If True (default), enables transport mode to forward traffic for the mesh.
|
||
If False, only handles own traffic without relaying for other peers.
|
||
discover_interfaces: Enable RNS 1.1.x interface discovery (default False).
|
||
autoconnect_discovered_interfaces: Max interfaces to auto-connect (0=disabled, default).
|
||
interface_discovery_sources: List of trusted discovery source hashes (optional).
|
||
required_discovery_value: Proof-of-work stamp value for discovery (8-20, default 14).
|
||
"""
|
||
from datetime import datetime
|
||
|
||
config_path = os.path.join(self.storage_path, "config")
|
||
log_debug("ReticulumWrapper", "_create_config_file", f"Creating config file at: {config_path}")
|
||
log_debug("ReticulumWrapper", "_create_config_file", f"Number of interfaces: {len(interfaces)}")
|
||
log_debug("ReticulumWrapper", "_create_config_file", f"Use shared instance: {use_shared_instance}")
|
||
|
||
# Generate timestamp for config file
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
if use_shared_instance:
|
||
# Shared instance client mode - connect to existing RNS instance via TCP
|
||
# CRITICAL: On Android, we MUST use TCP because domain sockets don't work
|
||
# between different apps due to sandboxing. Without shared_instance_type = tcp,
|
||
# RNS defaults to domain sockets and won't find Sideband's shared instance.
|
||
transport_value = "yes" if enable_transport else "no"
|
||
config_lines = [
|
||
"################################################################################",
|
||
"# SHARED INSTANCE MODE",
|
||
"# ",
|
||
"# Columba is configured to connect to an external shared Reticulum instance",
|
||
"# (e.g., Sideband) running on this device. Interfaces are managed by that app.",
|
||
"################################################################################",
|
||
"",
|
||
"# Reticulum Configuration for Columba (Shared Instance Client)",
|
||
f"# Generated: {timestamp}",
|
||
"# Mode: Shared instance client",
|
||
"",
|
||
"[reticulum]",
|
||
f" enable_transport = {transport_value}",
|
||
" share_instance = yes",
|
||
" shared_instance_type = tcp",
|
||
" shared_instance_port = 37428",
|
||
]
|
||
# Add RPC key if provided (required on Android for inter-app shared instance)
|
||
# Export from Sideband: Connectivity → Share Instance Access
|
||
if rpc_key:
|
||
config_lines.append(f" rpc_key = {rpc_key}")
|
||
log_info("ReticulumWrapper", "_create_config_file", "Added RPC key to config")
|
||
else:
|
||
log_warning("ReticulumWrapper", "_create_config_file",
|
||
"No RPC key provided - RPC calls to shared instance may fail")
|
||
config_lines.extend([
|
||
"",
|
||
"# No interfaces defined - using shared instance's interfaces",
|
||
"[interfaces]"
|
||
])
|
||
else:
|
||
# Standalone mode - create our own RNS instance with specified interfaces
|
||
transport_value = "yes" if enable_transport else "no"
|
||
discover_value = "yes" if discover_interfaces else "no"
|
||
config_lines = [
|
||
"################################################################################",
|
||
"# DO NOT EDIT THIS FILE MANUALLY",
|
||
"# ",
|
||
"# This file is automatically generated from the app's Interface Management UI.",
|
||
"# Any manual changes will be overwritten when the app restarts.",
|
||
"# ",
|
||
"# To manage network interfaces:",
|
||
"# 1. Open Columba app",
|
||
"# 2. Go to Settings tab",
|
||
"# 3. Tap 'Network Interfaces'",
|
||
"# 4. Use the UI to add, edit, or configure interfaces",
|
||
"################################################################################",
|
||
"",
|
||
"# Reticulum Configuration for Columba",
|
||
f"# Generated: {timestamp}",
|
||
f"# Interfaces: {len(interfaces)}",
|
||
"",
|
||
"[reticulum]",
|
||
f" enable_transport = {transport_value}", # Enable/disable transport mode (mesh forwarding)
|
||
" share_instance = no",
|
||
]
|
||
# RNS 1.1.x Interface Discovery Settings
|
||
if discover_interfaces or autoconnect_discovered_interfaces > 0:
|
||
config_lines.append(f" discover_interfaces = {discover_value}")
|
||
if autoconnect_discovered_interfaces > 0:
|
||
config_lines.append(f" autoconnect_discovered_interfaces = {autoconnect_discovered_interfaces}")
|
||
if interface_discovery_sources:
|
||
sources_str = ", ".join(interface_discovery_sources)
|
||
config_lines.append(f" interface_discovery_sources = {sources_str}")
|
||
if required_discovery_value != 14: # Only write non-default values
|
||
config_lines.append(f" required_discovery_value = {required_discovery_value}")
|
||
config_lines.extend([
|
||
"",
|
||
"[interfaces]"
|
||
])
|
||
|
||
# Add each interface (only for standalone mode - shared instance uses external interfaces)
|
||
if use_shared_instance:
|
||
log_debug("ReticulumWrapper", "_create_config_file", "Skipping interface definitions - using shared instance")
|
||
for iface in ([] if use_shared_instance else interfaces):
|
||
iface_type = iface.get("type")
|
||
iface_name = iface.get("name", "Unnamed Interface")
|
||
log_debug("ReticulumWrapper", "_create_config_file", f"Adding interface: {iface_name} ({iface_type})")
|
||
|
||
config_lines.append(f" # {iface_name}")
|
||
config_lines.append(f" [[{iface_name}]]")
|
||
|
||
if iface_type == "AutoInterface":
|
||
config_lines.append(" type = AutoInterface")
|
||
config_lines.append(" enabled = yes")
|
||
|
||
# Add optional AutoInterface parameters
|
||
group_id = iface.get("group_id", "")
|
||
if group_id:
|
||
config_lines.append(f" group_id = {group_id}")
|
||
|
||
discovery_scope = iface.get("discovery_scope", "link")
|
||
if discovery_scope != "link":
|
||
config_lines.append(f" discovery_scope = {discovery_scope}")
|
||
|
||
# Only write ports if explicitly set (None = use RNS defaults)
|
||
discovery_port = iface.get("discovery_port")
|
||
if discovery_port is not None:
|
||
config_lines.append(f" discovery_port = {discovery_port}")
|
||
|
||
data_port = iface.get("data_port")
|
||
if data_port is not None:
|
||
config_lines.append(f" data_port = {data_port}")
|
||
|
||
mode = iface.get("mode", "full")
|
||
if mode != "full":
|
||
config_lines.append(f" mode = {mode}")
|
||
|
||
elif iface_type == "TCPClient":
|
||
config_lines.append(" type = TCPClientInterface")
|
||
config_lines.append(" enabled = yes")
|
||
|
||
target_host = iface.get("target_host", "127.0.0.1")
|
||
config_lines.append(f" target_host = {target_host}")
|
||
|
||
target_port = iface.get("target_port", 4242)
|
||
config_lines.append(f" target_port = {target_port}")
|
||
|
||
kiss_framing = iface.get("kiss_framing", False)
|
||
if kiss_framing:
|
||
config_lines.append(" kiss_framing = True")
|
||
|
||
# IFAC parameters
|
||
network_name = iface.get("network_name")
|
||
if network_name:
|
||
config_lines.append(f" network_name = {network_name}")
|
||
|
||
passphrase = iface.get("passphrase")
|
||
if passphrase:
|
||
config_lines.append(f" passphrase = {passphrase}")
|
||
|
||
mode = iface.get("mode", "full")
|
||
if mode != "full":
|
||
config_lines.append(f" mode = {mode}")
|
||
|
||
# RNS 1.1.x Bootstrap Interface feature
|
||
bootstrap_only = iface.get("bootstrap_only", False)
|
||
if bootstrap_only:
|
||
config_lines.append(" bootstrap_only = yes")
|
||
|
||
elif iface_type == "TCPServer":
|
||
config_lines.append(" type = TCPServerInterface")
|
||
config_lines.append(" enabled = yes")
|
||
|
||
listen_ip = iface.get("listen_ip", "0.0.0.0")
|
||
config_lines.append(f" listen_ip = {listen_ip}")
|
||
|
||
listen_port = iface.get("listen_port", 4242)
|
||
config_lines.append(f" listen_port = {listen_port}")
|
||
|
||
mode = iface.get("mode", "full")
|
||
if mode != "full":
|
||
config_lines.append(f" mode = {mode}")
|
||
|
||
elif iface_type == "RNode":
|
||
connection_mode = iface.get("connection_mode", "classic")
|
||
|
||
if connection_mode == "tcp":
|
||
# TCP/WiFi RNode - uses Android RNodeInterface with tcp_host parameter
|
||
tcp_host = iface.get("tcp_host", "")
|
||
tcp_port = iface.get("tcp_port", 7633)
|
||
|
||
# Validate TCP host - skip this interface if empty
|
||
if not tcp_host or not tcp_host.strip():
|
||
log_error("ReticulumWrapper", "_create_config_file",
|
||
f"Skipping RNode TCP interface '{iface_name}': tcp_host is empty")
|
||
continue
|
||
|
||
config_lines.append(" type = RNodeInterface")
|
||
config_lines.append(" enabled = yes")
|
||
config_lines.append(f" tcp_host = {tcp_host}")
|
||
config_lines.append(f" tcp_port = {tcp_port}")
|
||
|
||
# LoRa parameters
|
||
frequency = iface.get("frequency", 915000000)
|
||
bandwidth = iface.get("bandwidth", 125000)
|
||
tx_power = iface.get("tx_power", 7)
|
||
spreading_factor = iface.get("spreading_factor", 7)
|
||
coding_rate = iface.get("coding_rate", 5)
|
||
|
||
config_lines.append(f" frequency = {frequency}")
|
||
config_lines.append(f" bandwidth = {bandwidth}")
|
||
config_lines.append(f" txpower = {tx_power}")
|
||
config_lines.append(f" spreadingfactor = {spreading_factor}")
|
||
config_lines.append(f" codingrate = {coding_rate}")
|
||
|
||
# Optional airtime limits
|
||
st_alock = iface.get("st_alock")
|
||
lt_alock = iface.get("lt_alock")
|
||
if st_alock is not None:
|
||
config_lines.append(f" airtime_limit_short = {st_alock}")
|
||
if lt_alock is not None:
|
||
config_lines.append(f" airtime_limit_long = {lt_alock}")
|
||
|
||
mode = iface.get("mode", "full")
|
||
if mode != "full":
|
||
config_lines.append(f" interface_mode = {mode}")
|
||
|
||
log_info("ReticulumWrapper", "_create_config_file",
|
||
f"RNode TCP config written: tcp_host={tcp_host}")
|
||
else:
|
||
# Bluetooth/USB RNode - handled specially via ColumbaRNodeInterface
|
||
# Don't write to config file - standard RNodeInterface uses jnius which doesn't work with Chaquopy
|
||
# Store the config for later use by ColumbaRNodeInterface
|
||
rnode_config = {
|
||
"name": iface.get("name", "RNode LoRa"),
|
||
"target_device_name": iface.get("target_device_name", iface.get("port", "")),
|
||
"connection_mode": connection_mode,
|
||
"usb_device_id": iface.get("usb_device_id"),
|
||
"usb_vendor_id": iface.get("usb_vendor_id"),
|
||
"usb_product_id": iface.get("usb_product_id"),
|
||
"frequency": iface.get("frequency", 915000000),
|
||
"bandwidth": iface.get("bandwidth", 125000),
|
||
"tx_power": iface.get("tx_power", 7),
|
||
"spreading_factor": iface.get("spreading_factor", 7),
|
||
"coding_rate": iface.get("coding_rate", 5),
|
||
"st_alock": iface.get("st_alock"),
|
||
"lt_alock": iface.get("lt_alock"),
|
||
"mode": iface.get("mode", "full"),
|
||
"enable_framebuffer": iface.get("enable_framebuffer", True), # Display Columba logo on RNode
|
||
}
|
||
self._pending_rnode_configs.append(rnode_config)
|
||
log_info("ReticulumWrapper", "_create_config_file",
|
||
f"RNode config stored for ColumbaRNodeInterface: mode={connection_mode}, device={rnode_config.get('target_device_name') or rnode_config.get('usb_device_id')}, total configs={len(self._pending_rnode_configs)}")
|
||
log_info("ReticulumWrapper", "_create_config_file",
|
||
f"RNode config details: usb_device_id={rnode_config.get('usb_device_id')}, "
|
||
f"usb_vendor_id={rnode_config.get('usb_vendor_id')}, "
|
||
f"usb_product_id={rnode_config.get('usb_product_id')}")
|
||
continue # Skip writing to config file for Bluetooth/USB
|
||
|
||
elif iface_type == "AndroidBLE":
|
||
config_lines.append(" type = AndroidBLE")
|
||
config_lines.append(" enabled = yes")
|
||
|
||
device_name = iface.get("device_name", "Reticulum-Android")
|
||
config_lines.append(f" device_name = {device_name}")
|
||
|
||
max_connections = iface.get("max_connections", 7)
|
||
config_lines.append(f" max_connections = {max_connections}")
|
||
|
||
mode = iface.get("mode", "full")
|
||
if mode != "full":
|
||
config_lines.append(f" mode = {mode}")
|
||
|
||
else:
|
||
log_warning("ReticulumWrapper", "_create_config_file", f"WARNING: Unknown interface type: {iface_type}")
|
||
continue
|
||
|
||
config_lines.append("") # Empty line between interfaces
|
||
|
||
config_content = "\n".join(config_lines)
|
||
log_debug("ReticulumWrapper", "_create_config_file", f"Generated config:\n{config_content}")
|
||
|
||
try:
|
||
# Ensure storage directory exists
|
||
os.makedirs(self.storage_path, exist_ok=True)
|
||
|
||
# Write config file
|
||
with open(config_path, 'w') as f:
|
||
f.write(config_content)
|
||
|
||
log_info("ReticulumWrapper", "_create_config_file", f"Config file created successfully")
|
||
return True
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_create_config_file", f"ERROR creating config file: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def initialize(
|
||
self,
|
||
config_json: str,
|
||
identity_file_path: Optional[str] = None,
|
||
incoming_message_limit_kb: int = 1024
|
||
) -> Dict:
|
||
"""
|
||
Initialize Reticulum with the given configuration.
|
||
|
||
Args:
|
||
config_json: JSON string containing configuration with:
|
||
- storagePath: str
|
||
- enabledInterfaces: list of interface configs
|
||
- logLevel: str (CRITICAL, ERROR, WARNING, INFO, DEBUG, VERBOSE)
|
||
- allowAnonymous: bool
|
||
identity_file_path: Optional path to a specific identity file to load.
|
||
If None, uses default_identity file (backward compatible).
|
||
incoming_message_limit_kb: Maximum incoming message size in KB (default 1024 = 1MB).
|
||
Set to 131072 (128MB) for effectively unlimited.
|
||
|
||
Returns:
|
||
Dict with 'success' and optional 'error' keys
|
||
"""
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
log_info("ReticulumWrapper", "initialize", "Initialization started")
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
|
||
try:
|
||
if self.initialized:
|
||
log_error("ReticulumWrapper", "initialize", "Already initialized")
|
||
return {"success": False, "error": "Already initialized"}
|
||
|
||
# CRITICAL: Deploy RNS patches BEFORE importing RNS
|
||
# This ensures Python loads patched code instead of cached buggy code
|
||
global RETICULUM_AVAILABLE, RNS, LXMF
|
||
|
||
if not RETICULUM_AVAILABLE:
|
||
log_info("ReticulumWrapper", "initialize", "Deploying RNS patches BEFORE first import")
|
||
try:
|
||
import pkgutil
|
||
# Find RNS location WITHOUT importing it
|
||
rns_spec = importlib.util.find_spec("RNS")
|
||
if rns_spec and rns_spec.origin:
|
||
rns_module_path = os.path.dirname(rns_spec.origin)
|
||
log_debug("ReticulumWrapper", "initialize", f"RNS module path: {rns_module_path}")
|
||
|
||
# CRITICAL: Delete Python bytecode cache before deploying patches
|
||
# Python may use cached .pyc files instead of our patched .py files
|
||
pycache_dir = os.path.join(rns_module_path, '__pycache__')
|
||
if os.path.isdir(pycache_dir):
|
||
log_info("ReticulumWrapper", "initialize", "Clearing Python bytecode cache (__pycache__)")
|
||
shutil.rmtree(pycache_dir, ignore_errors=True)
|
||
|
||
# Also delete any standalone .pyc files
|
||
for pyc_file in ['Destination.pyc', '__init__.pyc']:
|
||
pyc_path = os.path.join(rns_module_path, pyc_file)
|
||
if os.path.exists(pyc_path):
|
||
os.remove(pyc_path)
|
||
log_debug("ReticulumWrapper", "initialize", f"Deleted {pyc_file}")
|
||
|
||
# Deploy patches - list of (resource_path, dest_subpath) tuples
|
||
patch_files = [
|
||
('Destination.py', 'Destination.py'),
|
||
('__init__.py', '__init__.py'),
|
||
]
|
||
patches_applied = 0
|
||
|
||
for resource_subpath, dest_subpath in patch_files:
|
||
try:
|
||
patch_resource_path = f"patches/RNS/{resource_subpath}"
|
||
patch_data = pkgutil.get_data(__name__.split('.')[0], patch_resource_path)
|
||
|
||
if patch_data:
|
||
patch_dest = os.path.join(rns_module_path, dest_subpath)
|
||
# Ensure parent directory exists
|
||
os.makedirs(os.path.dirname(patch_dest), exist_ok=True)
|
||
with open(patch_dest, 'wb') as dest:
|
||
dest.write(patch_data)
|
||
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Applied patch: {dest_subpath}")
|
||
patches_applied += 1
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "initialize", f"Failed to apply patch {dest_subpath}: {e}")
|
||
|
||
if patches_applied > 0:
|
||
log_info("ReticulumWrapper", "initialize", f"Successfully applied {patches_applied} RNS patch(es)")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "initialize", f"ERROR deploying RNS patches: {e}")
|
||
import traceback
|
||
log_error("ReticulumWrapper", "initialize", f"Traceback: {traceback.format_exc()}")
|
||
|
||
# NOW import RNS and LXMF for the first time (will load patched code)
|
||
log_info("ReticulumWrapper", "initialize", "Importing RNS and LXMF (will use patched code)")
|
||
try:
|
||
import RNS as _RNS
|
||
import LXMF as _LXMF
|
||
RNS = _RNS
|
||
LXMF = _LXMF
|
||
RETICULUM_AVAILABLE = True
|
||
log_info("ReticulumWrapper", "initialize", "✓ RNS and LXMF imported successfully")
|
||
|
||
# Configure TCPClientInterface for asynchronous startup
|
||
# This prevents blocking when TCP targets are unreachable
|
||
# The interface's initial_connect() already handles success/failure
|
||
# and spawns reconnect threads - we just move it off the main thread
|
||
try:
|
||
from RNS.Interfaces.TCPInterface import TCPClientInterface
|
||
TCPClientInterface.SYNCHRONOUS_START = False
|
||
log_info("ReticulumWrapper", "initialize", "✓ TCPClientInterface configured for async startup")
|
||
except (ImportError, AttributeError) as tcp_err:
|
||
log_warning("ReticulumWrapper", "initialize", f"Could not configure async TCP startup: {tcp_err}")
|
||
|
||
except ImportError as e:
|
||
RETICULUM_AVAILABLE = False
|
||
log_error("ReticulumWrapper", "initialize", f"Failed to import RNS/LXMF: {e}")
|
||
return {"success": False, "error": f"Reticulum not available: {e}"}
|
||
|
||
log_debug("ReticulumWrapper", "initialize", f"RETICULUM_AVAILABLE = {RETICULUM_AVAILABLE}")
|
||
if not RETICULUM_AVAILABLE:
|
||
log_error("ReticulumWrapper", "initialize", "Reticulum not available")
|
||
return {"success": False, "error": "Reticulum not available"}
|
||
|
||
log_debug("ReticulumWrapper", "initialize", f"Parsing config JSON (length: {len(config_json)})")
|
||
log_debug("ReticulumWrapper", "initialize", f"Config JSON: {config_json}")
|
||
|
||
config = json.loads(config_json)
|
||
log_info("ReticulumWrapper", "initialize", "Config parsed successfully")
|
||
log_debug("ReticulumWrapper", "initialize", f"Config keys: {list(config.keys())}")
|
||
log_debug("ReticulumWrapper", "initialize", f"storagePath: {config.get('storagePath')}")
|
||
log_debug("ReticulumWrapper", "initialize", f"logLevel: {config.get('logLevel')}")
|
||
log_debug("ReticulumWrapper", "initialize", f"enabledInterfaces: {config.get('enabledInterfaces')}")
|
||
|
||
# Extract identity_file_path from config if provided
|
||
# This allows Kotlin to pass it via JSON config instead of as a separate parameter
|
||
if not identity_file_path and 'identity_file_path' in config:
|
||
identity_file_path = config['identity_file_path']
|
||
log_info("ReticulumWrapper", "initialize", f"Identity file path from config: {identity_file_path}")
|
||
|
||
# Extract display_name from config if provided
|
||
display_name = config.get('display_name', 'Anonymous Peer')
|
||
log_info("ReticulumWrapper", "initialize", f"Display name from config: {display_name}")
|
||
|
||
# Extract prefer_own_instance setting (defaults to False)
|
||
prefer_own_instance = config.get('prefer_own_instance', False)
|
||
log_info("ReticulumWrapper", "initialize", f"Prefer own instance: {prefer_own_instance}")
|
||
|
||
# Extract rpc_key for shared instance authentication (optional)
|
||
# On Android, apps have separate config directories, so RPC key must be shared
|
||
# Export from Sideband: Connectivity → Share Instance Access
|
||
rpc_key = config.get('rpc_key', None)
|
||
if rpc_key:
|
||
log_info("ReticulumWrapper", "initialize", "RPC key provided for shared instance auth")
|
||
else:
|
||
log_debug("ReticulumWrapper", "initialize", "No RPC key provided")
|
||
|
||
# Extract enable_transport setting (defaults to True)
|
||
# When True, this device forwards traffic for the mesh network
|
||
# When False, only handles its own traffic
|
||
enable_transport = config.get('enable_transport', True)
|
||
log_info("ReticulumWrapper", "initialize", f"Transport node enabled: {enable_transport}")
|
||
|
||
# Extract RNS 1.1.x Interface Discovery settings
|
||
discover_interfaces = config.get('discover_interfaces', False)
|
||
autoconnect_discovered_interfaces = config.get('autoconnect_discovered_interfaces', 0)
|
||
interface_discovery_sources = config.get('interface_discovery_sources', None)
|
||
required_discovery_value = config.get('required_discovery_value', 14)
|
||
if discover_interfaces or autoconnect_discovered_interfaces > 0:
|
||
log_info("ReticulumWrapper", "initialize",
|
||
f"Interface discovery enabled: discover={discover_interfaces}, "
|
||
f"autoconnect={autoconnect_discovered_interfaces}")
|
||
|
||
# Check for shared instance if user doesn't prefer their own
|
||
use_shared_instance = False
|
||
if not prefer_own_instance:
|
||
log_info("ReticulumWrapper", "initialize", "Checking for shared Reticulum instance...")
|
||
if self.check_shared_instance_available():
|
||
use_shared_instance = True
|
||
log_info("ReticulumWrapper", "initialize", "✓ Will connect to shared instance")
|
||
else:
|
||
log_info("ReticulumWrapper", "initialize", "No shared instance found - will create own instance")
|
||
else:
|
||
log_info("ReticulumWrapper", "initialize", "User prefers own instance - skipping shared instance check")
|
||
|
||
# Store shared instance status
|
||
self.is_shared_instance = use_shared_instance
|
||
|
||
# Create config file from interface configurations
|
||
log_info("ReticulumWrapper", "initialize", "Creating RNS config file from interface configurations")
|
||
enabled_interfaces = config.get('enabledInterfaces', [])
|
||
# Respect user's choice - if they want 0 interfaces, allow it
|
||
# RNS will run without interfaces (no network connectivity)
|
||
|
||
if not self._create_config_file(
|
||
enabled_interfaces,
|
||
use_shared_instance=use_shared_instance,
|
||
rpc_key=rpc_key,
|
||
enable_transport=enable_transport,
|
||
discover_interfaces=discover_interfaces,
|
||
autoconnect_discovered_interfaces=autoconnect_discovered_interfaces,
|
||
interface_discovery_sources=interface_discovery_sources,
|
||
required_discovery_value=required_discovery_value,
|
||
):
|
||
return {"success": False, "error": "Failed to create config file"}
|
||
|
||
# Set log level
|
||
log_info("ReticulumWrapper", "initialize", "Setting RNS log level")
|
||
log_level_map = {
|
||
"CRITICAL": RNS.LOG_CRITICAL,
|
||
"ERROR": RNS.LOG_ERROR,
|
||
"WARNING": RNS.LOG_WARNING,
|
||
"INFO": RNS.LOG_INFO,
|
||
"DEBUG": RNS.LOG_DEBUG,
|
||
"VERBOSE": RNS.LOG_VERBOSE,
|
||
"EXTREME": RNS.LOG_EXTREME
|
||
}
|
||
log_level_str = config.get("logLevel", "DEBUG").upper()
|
||
log_level = log_level_map.get(log_level_str, RNS.LOG_DEBUG)
|
||
log_debug("ReticulumWrapper", "initialize", f"Setting RNS.loglevel to {log_level_str} ({log_level})")
|
||
RNS.loglevel = log_level
|
||
|
||
# Add ble-reticulum to path for imports
|
||
# Note: When running under Chaquopy, the external/ble-reticulum/src is bundled in sourceSets
|
||
try:
|
||
_ble_reticulum_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'external', 'ble-reticulum', 'src'))
|
||
if os.path.exists(_ble_reticulum_path) and _ble_reticulum_path not in sys.path:
|
||
sys.path.insert(0, _ble_reticulum_path)
|
||
except (OSError, FileNotFoundError):
|
||
# Under Chaquopy, __file__ may be a virtual path - modules are bundled and directly importable
|
||
pass
|
||
|
||
# Deploy custom AndroidBLE interface to RNS interfaces directory
|
||
# This allows RNS to discover and load it like any built-in interface
|
||
log_info("ReticulumWrapper", "initialize", "Deploying AndroidBLE custom interface and driver")
|
||
try:
|
||
interfaces_dir = os.path.join(self.storage_path, "interfaces")
|
||
os.makedirs(interfaces_dir, exist_ok=True)
|
||
drivers_dir = os.path.join(interfaces_dir, "drivers")
|
||
os.makedirs(drivers_dir, exist_ok=True)
|
||
log_debug("ReticulumWrapper", "initialize", f"Interfaces directory: {interfaces_dir}")
|
||
log_debug("ReticulumWrapper", "initialize", f"Drivers directory: {drivers_dir}")
|
||
|
||
# Deploy BLE interface files from bundled ble_modules directory
|
||
# Import ble_modules to trigger extraction from APK (Chaquopy requirement)
|
||
log_debug("ReticulumWrapper", "initialize", "Deploying BLE modules from bundled sources")
|
||
import pkgutil
|
||
import ble_reticulum # Triggers extraction of package files from APK to filesystem
|
||
import ble_modules # Android-specific BLE modules
|
||
|
||
# Deploy bluetooth_driver base interface
|
||
log_debug("ReticulumWrapper", "initialize", "Deploying bluetooth_driver from bundled source")
|
||
bluetooth_driver_bytes = pkgutil.get_data('ble_reticulum', 'bluetooth_driver.py')
|
||
bluetooth_driver_dest = os.path.join(interfaces_dir, "bluetooth_driver.py")
|
||
with open(bluetooth_driver_dest, 'wb') as f:
|
||
f.write(bluetooth_driver_bytes)
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Deployed bluetooth_driver to {bluetooth_driver_dest}")
|
||
|
||
# Deploy linux_bluetooth_driver
|
||
log_debug("ReticulumWrapper", "initialize", "Deploying linux_bluetooth_driver from bundled source")
|
||
linux_bluetooth_driver_bytes = pkgutil.get_data('ble_reticulum', 'linux_bluetooth_driver.py')
|
||
linux_bluetooth_driver_dest = os.path.join(interfaces_dir, "linux_bluetooth_driver.py")
|
||
with open(linux_bluetooth_driver_dest, 'wb') as f:
|
||
f.write(linux_bluetooth_driver_bytes)
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Deployed linux_bluetooth_driver to {linux_bluetooth_driver_dest}")
|
||
|
||
# Deploy BLEFragmentation
|
||
log_debug("ReticulumWrapper", "initialize", "Deploying BLEFragmentation from bundled source")
|
||
ble_fragmentation_bytes = pkgutil.get_data('ble_reticulum', 'BLEFragmentation.py')
|
||
ble_fragmentation_dest = os.path.join(interfaces_dir, "BLEFragmentation.py")
|
||
with open(ble_fragmentation_dest, 'wb') as f:
|
||
f.write(ble_fragmentation_bytes)
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Deployed BLEFragmentation to {ble_fragmentation_dest}")
|
||
|
||
# Deploy BLEGATTServer
|
||
log_debug("ReticulumWrapper", "initialize", "Deploying BLEGATTServer from bundled source")
|
||
ble_gatt_server_bytes = pkgutil.get_data('ble_reticulum', 'BLEGATTServer.py')
|
||
ble_gatt_server_dest = os.path.join(interfaces_dir, "BLEGATTServer.py")
|
||
with open(ble_gatt_server_dest, 'wb') as f:
|
||
f.write(ble_gatt_server_bytes)
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Deployed BLEGATTServer to {ble_gatt_server_dest}")
|
||
|
||
# Deploy BLEInterface
|
||
log_debug("ReticulumWrapper", "initialize", "Deploying BLEInterface from bundled source")
|
||
ble_interface_bytes = pkgutil.get_data('ble_reticulum', 'BLEInterface.py')
|
||
ble_interface_dest = os.path.join(interfaces_dir, "BLEInterface.py")
|
||
with open(ble_interface_dest, 'wb') as f:
|
||
f.write(ble_interface_bytes)
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Deployed BLEInterface to {ble_interface_dest}")
|
||
|
||
# Deploy AndroidBLEInterface
|
||
log_debug("ReticulumWrapper", "initialize", "Deploying AndroidBLEInterface from bundled source")
|
||
android_ble_interface_bytes = pkgutil.get_data('ble_modules', 'android_ble_interface.py')
|
||
android_ble_interface_dest = os.path.join(interfaces_dir, "AndroidBLE.py")
|
||
with open(android_ble_interface_dest, 'wb') as f:
|
||
f.write(android_ble_interface_bytes)
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Deployed AndroidBLEInterface to {android_ble_interface_dest}")
|
||
|
||
# Deploy AndroidBLEDriver
|
||
log_debug("ReticulumWrapper", "initialize", "Deploying AndroidBLEDriver from bundled source")
|
||
android_ble_driver_bytes = pkgutil.get_data('ble_modules', 'android_ble_driver.py')
|
||
android_ble_driver_dest = os.path.join(drivers_dir, "android_ble_driver.py")
|
||
with open(android_ble_driver_dest, 'wb') as f:
|
||
f.write(android_ble_driver_bytes)
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Deployed AndroidBLEDriver to {android_ble_driver_dest}")
|
||
|
||
# Create __init__.py in drivers directory
|
||
init_dest = os.path.join(drivers_dir, "__init__.py")
|
||
with open(init_dest, 'w') as f:
|
||
f.write("# Drivers package\n")
|
||
log_debug("ReticulumWrapper", "initialize", f"✓ Created {init_dest}")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "initialize", f"ERROR deploying AndroidBLE: {type(e).__name__}: {e}")
|
||
import traceback
|
||
log_error("ReticulumWrapper", "initialize", f"Traceback: {traceback.format_exc()}")
|
||
# Non-fatal - continue, but interface won't be discovered
|
||
|
||
# Initialize Reticulum - it will load config from the config file we created
|
||
log_info("ReticulumWrapper", "initialize", "Creating RNS.Reticulum instance")
|
||
log_debug("ReticulumWrapper", "initialize", f"configdir = {self.storage_path}")
|
||
|
||
# Track which interfaces failed to initialize
|
||
self.failed_interfaces = []
|
||
|
||
# Proactively check if AutoInterface ports are available
|
||
# This prevents RNS from calling sys.exit(255) on port conflicts
|
||
has_autointerface = any(
|
||
iface.get('type') == 'AutoInterface'
|
||
for iface in enabled_interfaces
|
||
)
|
||
log_debug("ReticulumWrapper", "initialize", f"has_autointerface = {has_autointerface}")
|
||
|
||
# Pre-check if AutoInterface ports are available
|
||
# AutoInterface uses IPv6 multicast (discovery) and IPv6 link-local (data)
|
||
if has_autointerface:
|
||
log_info("ReticulumWrapper", "initialize", "Pre-checking AutoInterface port availability...")
|
||
try:
|
||
import socket
|
||
|
||
# AutoInterface uses:
|
||
# - Port 29716 for discovery (IPv6 multicast)
|
||
# - Port 42671 for data (IPv6 link-local)
|
||
# Check both by trying to bind an IPv6 UDP socket
|
||
|
||
ports_to_check = [29716, 42671]
|
||
port_conflict = False
|
||
|
||
for test_port in ports_to_check:
|
||
log_debug("ReticulumWrapper", "initialize", f"Testing IPv6 UDP bind to port {test_port}...")
|
||
try:
|
||
# Use IPv6 socket since AutoInterface uses IPv6
|
||
# Don't use SO_REUSEADDR - we want to detect conflicts
|
||
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
|
||
# Bind to all IPv6 interfaces
|
||
sock.bind(('::', test_port))
|
||
sock.close()
|
||
log_debug("ReticulumWrapper", "initialize", f"Port {test_port} is available")
|
||
except OSError as port_error:
|
||
if port_error.errno == 98: # Address already in use
|
||
log_warning("ReticulumWrapper", "initialize",
|
||
f"⚠️ Port {test_port} is already in use - another Reticulum app may be running")
|
||
port_conflict = True
|
||
self.failed_interfaces.append({
|
||
"name": "AutoInterface",
|
||
"error": f"Port {test_port} already in use (another Reticulum app may be running)",
|
||
"recoverable": True
|
||
})
|
||
break
|
||
else:
|
||
log_debug("ReticulumWrapper", "initialize", f"Port check failed with error: {port_error}")
|
||
|
||
if port_conflict:
|
||
log_info("ReticulumWrapper", "initialize", "Disabling AutoInterface to prevent crash...")
|
||
self._remove_autointerface_from_config()
|
||
has_autointerface = False
|
||
log_info("ReticulumWrapper", "initialize", "AutoInterface removed from config")
|
||
|
||
except Exception as check_error:
|
||
log_debug("ReticulumWrapper", "initialize", f"Port pre-check failed: {check_error}")
|
||
# Non-critical, continue
|
||
|
||
# Now initialize RNS
|
||
log_info("ReticulumWrapper", "initialize", "Calling RNS.Reticulum()...")
|
||
try:
|
||
self.reticulum = RNS.Reticulum(configdir=self.storage_path)
|
||
log_info("ReticulumWrapper", "initialize", "RNS.Reticulum created successfully")
|
||
except SystemExit as e:
|
||
# This shouldn't happen now that we pre-check, but keep as safety net
|
||
log_warning("ReticulumWrapper", "initialize", f"RNS called sys.exit({e.code}) - likely interface failure")
|
||
raise
|
||
except OSError as e:
|
||
log_error("ReticulumWrapper", "initialize", f"OSError during RNS init: {e}")
|
||
raise
|
||
|
||
# Clear stale BLE paths (bug workaround)
|
||
log_info("ReticulumWrapper", "initialize", "Clearing stale BLE paths from previous session")
|
||
self._clear_stale_ble_paths()
|
||
|
||
# Extract Transport identity for BLE Protocol v2
|
||
log_info("ReticulumWrapper", "initialize", "Extracting Transport identity hash for BLE")
|
||
transport_identity = RNS.Transport.identity
|
||
if transport_identity:
|
||
transport_identity_hash = transport_identity.hash # 16 bytes
|
||
log_debug("ReticulumWrapper", "initialize", f"Transport identity hash: {transport_identity_hash.hex()}")
|
||
|
||
# Store for later retrieval
|
||
self.transport_identity_hash = transport_identity_hash
|
||
log_info("ReticulumWrapper", "initialize", "Transport identity stored for BLE")
|
||
else:
|
||
log_warning("ReticulumWrapper", "initialize", "Could not get Transport identity")
|
||
self.transport_identity_hash = None
|
||
|
||
# Verify loaded interfaces match expectations
|
||
loaded_count = len(RNS.Transport.interfaces)
|
||
expected_count = len(enabled_interfaces)
|
||
log_info("ReticulumWrapper", "initialize", f"RNS loaded with {loaded_count} interfaces")
|
||
|
||
if loaded_count != expected_count:
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
log_warning("ReticulumWrapper", "initialize", "Interface count mismatch!")
|
||
log_warning("ReticulumWrapper", "initialize", f"Expected: {expected_count} interfaces")
|
||
log_warning("ReticulumWrapper", "initialize", f"Loaded: {loaded_count} interfaces")
|
||
log_warning("ReticulumWrapper", "initialize", "This may indicate a configuration problem")
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
else:
|
||
log_info("ReticulumWrapper", "initialize", f"✓ Verified: {loaded_count} interface(s) loaded as expected")
|
||
|
||
# List loaded interfaces
|
||
for idx, iface in enumerate(RNS.Transport.interfaces):
|
||
log_debug("ReticulumWrapper", "initialize", f"Interface {idx}: {iface} ({type(iface).__name__})")
|
||
|
||
# Register announce handlers for different aspects
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
log_info("ReticulumWrapper", "initialize", "Registering aspect-specific announce handlers...")
|
||
|
||
try:
|
||
for aspect, handler in self._announce_handlers.items():
|
||
RNS.Transport.register_announce_handler(handler)
|
||
log_info("ReticulumWrapper", "initialize", f"✅ Registered handler for aspect: {aspect}")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "initialize", f"⚠️ Announce handler registration failed: {e}")
|
||
log_warning("ReticulumWrapper", "initialize", "This is expected in Chaquopy - Transport will still process announces automatically")
|
||
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
|
||
# Load identity for LXMF (use provided path or default)
|
||
if identity_file_path:
|
||
log_info("ReticulumWrapper", "initialize", f"Loading identity from specified path: {identity_file_path}")
|
||
identity_path = identity_file_path
|
||
else:
|
||
log_info("ReticulumWrapper", "initialize", "Loading default identity for LXMF")
|
||
identity_path = os.path.join(self.storage_path, "default_identity")
|
||
|
||
default_identity = None
|
||
|
||
if os.path.exists(identity_path):
|
||
try:
|
||
default_identity = RNS.Identity.from_file(identity_path)
|
||
log_info("ReticulumWrapper", "initialize", f"Loaded identity: {default_identity.hash.hex()[:16]}")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "initialize", f"Could not load identity from {identity_path}: {e}")
|
||
# If a specific path was provided and failed, don't fall back - raise error
|
||
if identity_file_path:
|
||
raise Exception(f"Failed to load identity from {identity_path}: {e}")
|
||
|
||
if not default_identity:
|
||
# If a specific identity file path was provided but doesn't exist, fail clearly
|
||
# (Kotlin should have recovered the file from keyData before calling initialize)
|
||
if identity_file_path:
|
||
raise Exception(f"identity_file_missing:{identity_path}")
|
||
|
||
# Create a new identity only if no specific path was requested
|
||
log_info("ReticulumWrapper", "initialize", f"No identity found, creating new identity at {identity_path}")
|
||
default_identity = RNS.Identity()
|
||
try:
|
||
# Ensure the directory exists
|
||
os.makedirs(os.path.dirname(identity_path), exist_ok=True)
|
||
default_identity.to_file(identity_path)
|
||
log_info("ReticulumWrapper", "initialize", f"Saved new identity: {default_identity.hash.hex()[:16]}")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "initialize", f"Could not save identity to {identity_path}: {e}")
|
||
raise Exception(f"Failed to create identity: {e}")
|
||
|
||
# Initialize LXMF router with the default identity
|
||
log_info("ReticulumWrapper", "initialize", "Creating LXMF router with default identity")
|
||
log_info("ReticulumWrapper", "initialize", f"Incoming message limit: {incoming_message_limit_kb}KB")
|
||
self.router = LXMF.LXMRouter(
|
||
storagepath=self.storage_path,
|
||
identity=default_identity,
|
||
autopeer=True,
|
||
delivery_limit=incoming_message_limit_kb,
|
||
propagation_limit=incoming_message_limit_kb
|
||
)
|
||
log_info("ReticulumWrapper", "initialize", "LXMF router created")
|
||
|
||
# Create local LXMF destination for receiving messages
|
||
log_info("ReticulumWrapper", "initialize", "Creating local LXMF delivery destination")
|
||
log_debug("ReticulumWrapper", "initialize", f"Using identity hash: {default_identity.hash.hex()[:16]}")
|
||
self.local_lxmf_destination = self.router.register_delivery_identity(
|
||
default_identity,
|
||
display_name=display_name
|
||
)
|
||
# Store display name for use in announces
|
||
self.display_name = display_name
|
||
# Store default identity for use in propagation node requests
|
||
self.default_identity = default_identity
|
||
log_info("ReticulumWrapper", "initialize", f"Local LXMF destination: {self.local_lxmf_destination.hexhash}")
|
||
log_debug("ReticulumWrapper", "initialize", f"(Identity hash: {default_identity.hash.hex()}, Dest hash: {self.local_lxmf_destination.hexhash})")
|
||
|
||
# Register delivery callback to capture incoming messages
|
||
log_info("ReticulumWrapper", "initialize", "Registering delivery callback for incoming messages")
|
||
self.router.register_delivery_callback(self._on_lxmf_delivery)
|
||
log_info("ReticulumWrapper", "initialize", "✅ Delivery callback registered")
|
||
|
||
# Add LXMF destination to tracking dict so it can be announced
|
||
self.destinations[self.local_lxmf_destination.hexhash] = self.local_lxmf_destination
|
||
log_debug("ReticulumWrapper", "initialize", "Added LXMF destination to tracking dict")
|
||
|
||
# Set last poll time to current time to only return new announces after initialization
|
||
self.last_announce_poll_time = time.time()
|
||
log_debug("ReticulumWrapper", "initialize", "Set last_announce_poll_time to current time")
|
||
|
||
self.initialized = True
|
||
|
||
# Start background service threads (Sideband-inspired)
|
||
# These run while self.initialized is True and exit cleanly on shutdown
|
||
self._start_opportunistic_timer() # Opportunistic message timeout checker
|
||
self._start_heartbeat_thread() # Heartbeat for Kotlin health monitoring
|
||
self._start_maintenance_thread() # Interface recovery and maintenance
|
||
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
log_info("ReticulumWrapper", "initialize", "Reticulum initialized successfully")
|
||
log_info("ReticulumWrapper", "initialize", f"Shared instance mode: {self.is_shared_instance}")
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
return {"success": True, "is_shared_instance": self.is_shared_instance}
|
||
|
||
except Exception as e:
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
log_error("ReticulumWrapper", "initialize", "ERROR initializing Reticulum")
|
||
log_error("ReticulumWrapper", "initialize", f"Exception type: {type(e).__name__}")
|
||
log_error("ReticulumWrapper", "initialize", f"Exception message: {str(e)}")
|
||
log_separator("ReticulumWrapper", "initialize")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def _remove_autointerface_from_config(self):
|
||
"""
|
||
Remove AutoInterface from the RNS config file.
|
||
This is used when AutoInterface fails to bind (address already in use)
|
||
to retry initialization without it.
|
||
"""
|
||
config_path = os.path.join(self.storage_path, "config")
|
||
|
||
if not os.path.exists(config_path):
|
||
log_warning("ReticulumWrapper", "_remove_autointerface_from_config", "Config file not found")
|
||
return
|
||
|
||
try:
|
||
# Read current config
|
||
with open(config_path, 'r') as f:
|
||
lines = f.readlines()
|
||
|
||
# Remove AutoInterface section
|
||
new_lines = []
|
||
skip_section = False
|
||
for line in lines:
|
||
# Check if we're entering AutoInterface section
|
||
if line.strip().startswith("[[") and "Auto Discovery" in line:
|
||
skip_section = True
|
||
log_debug("ReticulumWrapper", "_remove_autointerface_from_config", "Removing AutoInterface section")
|
||
continue
|
||
|
||
# Check if we're leaving the section (new section starts)
|
||
if skip_section and line.strip().startswith("[["):
|
||
skip_section = False
|
||
|
||
# Keep line if not in AutoInterface section
|
||
if not skip_section:
|
||
new_lines.append(line)
|
||
|
||
# Write modified config back
|
||
with open(config_path, 'w') as f:
|
||
f.writelines(new_lines)
|
||
|
||
log_info("ReticulumWrapper", "_remove_autointerface_from_config", "✓ AutoInterface removed from config file")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_remove_autointerface_from_config", f"Failed to modify config: {e}")
|
||
raise
|
||
|
||
def _setup_interface(self, iface_config: Dict):
|
||
"""Set up a network interface based on configuration"""
|
||
iface_type = iface_config.get("type")
|
||
|
||
if iface_type == "AutoInterface":
|
||
log_info("ReticulumWrapper", "_setup_interface", "Setting up AutoInterface")
|
||
# AutoInterface automatically discovers peers on local network
|
||
auto_iface = RNS.Interfaces.AutoInterface.AutoInterface(
|
||
RNS.Transport,
|
||
"AutoInterface"
|
||
)
|
||
auto_iface.OUT = True
|
||
RNS.Transport.interfaces.append(auto_iface)
|
||
|
||
elif iface_type == "TCPClientInterface":
|
||
log_info("ReticulumWrapper", "_setup_interface", f"Setting up TCPClientInterface: {iface_config}")
|
||
target_host = iface_config.get("host", "127.0.0.1")
|
||
target_port = iface_config.get("port", 4242)
|
||
tcp_iface = RNS.Interfaces.TCPInterface.TCPClientInterface(
|
||
RNS.Transport,
|
||
"TCPClientInterface",
|
||
target_host,
|
||
target_port
|
||
)
|
||
tcp_iface.OUT = True
|
||
RNS.Transport.interfaces.append(tcp_iface)
|
||
|
||
elif iface_type == "UDPInterface":
|
||
log_info("ReticulumWrapper", "_setup_interface", f"Setting up UDPInterface: {iface_config}")
|
||
port = iface_config.get("port", 4242)
|
||
udp_iface = RNS.Interfaces.UDPInterface.UDPInterface(
|
||
RNS.Transport,
|
||
"UDPInterface",
|
||
port
|
||
)
|
||
udp_iface.OUT = True
|
||
RNS.Transport.interfaces.append(udp_iface)
|
||
|
||
else:
|
||
log_info("ReticulumWrapper", "_setup_interface", f"Unknown interface type: {iface_type}")
|
||
|
||
def _announce_handler(self, aspect, destination_hash, announced_identity, app_data, announce_packet_hash=None):
|
||
"""
|
||
Internal handler for announces from RNS.
|
||
This is called by RNS when an announce is received.
|
||
|
||
Args:
|
||
aspect: The aspect filter that matched this announce (e.g., "lxmf.delivery", "nomadnetwork.node")
|
||
destination_hash: The destination hash that announced
|
||
announced_identity: The RNS.Identity object of the announcing peer
|
||
app_data: Application-specific data included in the announce
|
||
announce_packet_hash: Hash of the announce packet (optional, for future use)
|
||
"""
|
||
log_separator("ReticulumWrapper", "_announce_handler", "!", 60)
|
||
log_info("ReticulumWrapper", "_announce_handler", "🔔 _announce_handler CALLED! (CALLBACK PATH WORKING)")
|
||
log_info("ReticulumWrapper", "_announce_handler", f"Aspect: {aspect}")
|
||
log_info("ReticulumWrapper", "_announce_handler", f"Destination: {destination_hash.hex()[:16]}...")
|
||
log_separator("ReticulumWrapper", "_announce_handler", "!", 60)
|
||
try:
|
||
log_debug("ReticulumWrapper", "_announce_handler", f"Announce received from: {destination_hash.hex()}")
|
||
log_debug("ReticulumWrapper", "_announce_handler", f"Aspect: {aspect}")
|
||
log_debug("ReticulumWrapper", "_announce_handler", f"Has identity: {announced_identity is not None}")
|
||
log_debug("ReticulumWrapper", "_announce_handler", f"App data: {app_data}")
|
||
|
||
# Get hop count
|
||
hops = RNS.Transport.hops_to(destination_hash)
|
||
if hops is None or hops == RNS.Transport.PATHFINDER_M:
|
||
hops = 0 # Direct or unknown
|
||
|
||
# Get receiving interface from announce_table packet
|
||
receiving_interface = None
|
||
try:
|
||
if hasattr(RNS.Transport, 'announce_table') and destination_hash in RNS.Transport.announce_table:
|
||
announce_entry = RNS.Transport.announce_table[destination_hash]
|
||
if len(announce_entry) > 5:
|
||
packet = announce_entry[5] # IDX_AT_PACKET
|
||
if packet and hasattr(packet, 'receiving_interface'):
|
||
interface_obj = packet.receiving_interface
|
||
if interface_obj:
|
||
# Build formatted interface string: "ClassName[UserConfiguredName]"
|
||
# This allows Kotlin to extract both the friendly name and the interface type
|
||
class_name = type(interface_obj).__name__
|
||
user_name = getattr(interface_obj, 'name', None)
|
||
if user_name and user_name != class_name:
|
||
receiving_interface = f"{class_name}[{user_name}]"
|
||
else:
|
||
receiving_interface = class_name
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "_announce_handler",
|
||
f"Could not extract interface: {e}")
|
||
|
||
# Extract display name using LXMF's canonical implementation
|
||
# Use the correct function based on aspect:
|
||
# - lxmf.delivery: display_name_from_app_data() for peer names
|
||
# - lxmf.propagation: pn_name_from_app_data() for propagation node names
|
||
display_name = None
|
||
if LXMF is not None and app_data:
|
||
try:
|
||
if aspect == "lxmf.propagation":
|
||
display_name = LXMF.pn_name_from_app_data(app_data)
|
||
log_debug("ReticulumWrapper", "_announce_handler",
|
||
f"LXMF.pn_name_from_app_data returned: {display_name}")
|
||
else:
|
||
display_name = LXMF.display_name_from_app_data(app_data)
|
||
log_debug("ReticulumWrapper", "_announce_handler",
|
||
f"LXMF.display_name_from_app_data returned: {display_name}")
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "_announce_handler",
|
||
f"LXMF name extraction failed: {e}")
|
||
|
||
# Extract stamp cost using LXMF's canonical functions
|
||
stamp_cost = None
|
||
stamp_cost_flexibility = None
|
||
peering_cost = None
|
||
if LXMF is not None and app_data:
|
||
try:
|
||
if aspect == "lxmf.propagation":
|
||
stamp_cost = LXMF.pn_stamp_cost_from_app_data(app_data)
|
||
# Also extract flexibility and peering cost for propagation nodes
|
||
if LXMF.pn_announce_data_is_valid(app_data):
|
||
from RNS.vendor import umsgpack
|
||
data = umsgpack.unpackb(app_data)
|
||
stamp_cost_flexibility = int(data[5][1])
|
||
peering_cost = int(data[5][2])
|
||
else:
|
||
# Log why validation failed - helps diagnose auto-selection issues
|
||
log_info("ReticulumWrapper", "_announce_handler",
|
||
f"⚠️ Propagation node {destination_hash[:16]}... has invalid/deprecated announce data "
|
||
f"(pn_announce_data_is_valid=False), stamp_cost_flexibility will be NULL")
|
||
log_debug("ReticulumWrapper", "_announce_handler",
|
||
f"PN stamp cost: {stamp_cost}, flex: {stamp_cost_flexibility}, peer: {peering_cost}")
|
||
else:
|
||
stamp_cost = LXMF.stamp_cost_from_app_data(app_data)
|
||
log_debug("ReticulumWrapper", "_announce_handler",
|
||
f"Peer stamp cost: {stamp_cost}")
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "_announce_handler",
|
||
f"Stamp cost extraction failed: {e}")
|
||
|
||
# Create announce event dict (Transport already stores identity/app_data)
|
||
announce_event = {
|
||
'destination_hash': destination_hash,
|
||
'identity_hash': destination_hash, # For single destinations
|
||
'public_key': announced_identity.get_public_key() if announced_identity else b'',
|
||
'app_data': app_data if app_data else b'',
|
||
'display_name': display_name, # Pre-parsed by LXMF (may be None)
|
||
'stamp_cost': stamp_cost, # Pre-parsed by LXMF (may be None)
|
||
'stamp_cost_flexibility': stamp_cost_flexibility, # For propagation nodes
|
||
'peering_cost': peering_cost, # For propagation nodes
|
||
'aspect': aspect, # Include aspect (e.g., "lxmf.delivery", "nomadnetwork.node")
|
||
'hops': hops,
|
||
'timestamp': int(time.time() * 1000), # milliseconds
|
||
'interface': receiving_interface, # Add interface name
|
||
}
|
||
|
||
# Handle RMSP map server announces specially
|
||
if aspect == "rmsp.maps" and app_data:
|
||
try:
|
||
# Parse RMSP announce and register server
|
||
self.parse_rmsp_announce(destination_hash, announced_identity, app_data, hops)
|
||
|
||
# Also parse RMSP-specific fields for Kotlin
|
||
# Import umsgpack from RNS vendor (same as used elsewhere in this function)
|
||
from RNS.vendor import umsgpack as rmsp_msgpack
|
||
|
||
rmsp_data = rmsp_msgpack.unpackb(app_data)
|
||
announce_event['rmsp_server_name'] = rmsp_data.get('n', 'Unknown')
|
||
announce_event['rmsp_version'] = rmsp_data.get('v', '0.0.0')
|
||
announce_event['rmsp_coverage'] = rmsp_data.get('c', [])
|
||
announce_event['rmsp_zoom_range'] = rmsp_data.get('z', [0, 15])
|
||
announce_event['rmsp_formats'] = rmsp_data.get('f', ['pmtiles'])
|
||
announce_event['rmsp_layers'] = rmsp_data.get('l', ['osm'])
|
||
announce_event['rmsp_updated'] = rmsp_data.get('u', 0)
|
||
announce_event['rmsp_size'] = rmsp_data.get('s')
|
||
|
||
log_info("ReticulumWrapper", "_announce_handler",
|
||
f"RMSP server announce: {announce_event['rmsp_server_name']}")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "_announce_handler",
|
||
f"Failed to parse RMSP announce data: {e}")
|
||
|
||
# Store in pending queue for Kotlin to retrieve
|
||
with self.announce_lock:
|
||
self.pending_announces.append(announce_event)
|
||
|
||
# Notify Kotlin immediately via bridge (event-driven announce delivery)
|
||
if self.kotlin_reticulum_bridge:
|
||
try:
|
||
# Defensive check: ensure bridge is still valid
|
||
if not hasattr(self.kotlin_reticulum_bridge, 'notifyAnnounceReceived'):
|
||
log_error("ReticulumWrapper", "_announce_handler",
|
||
f"Bridge object corrupted: {type(self.kotlin_reticulum_bridge)} = {self.kotlin_reticulum_bridge}")
|
||
self.kotlin_reticulum_bridge = None
|
||
else:
|
||
self.kotlin_reticulum_bridge.notifyAnnounceReceived()
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_announce_handler",
|
||
f"Kotlin announce notification failed: {e}")
|
||
|
||
# Also call any registered callbacks (for compatibility)
|
||
for callback in self.announce_callbacks:
|
||
try:
|
||
callback(announce_event)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_announce_handler", f"Error in announce callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_announce_handler", f"Error in announce handler: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def shutdown(self) -> Dict:
|
||
"""Shutdown Reticulum properly, cleaning up all resources"""
|
||
try:
|
||
log_separator("ReticulumWrapper", "shutdown", "=", 60)
|
||
log_debug("ReticulumWrapper", "shutdown", "shutdown() called")
|
||
log_separator("ReticulumWrapper", "shutdown", "=", 60)
|
||
|
||
if not self.initialized:
|
||
log_info("ReticulumWrapper", "shutdown", "Not initialized, nothing to shutdown")
|
||
return {"success": True}
|
||
|
||
# Step 1: Deregister announce handler
|
||
if RETICULUM_AVAILABLE and self.reticulum:
|
||
try:
|
||
log_debug("ReticulumWrapper", "shutdown", "Deregistering announce handlers")
|
||
for aspect, handler in self._announce_handlers.items():
|
||
RNS.Transport.deregister_announce_handler(handler)
|
||
log_debug("ReticulumWrapper", "shutdown", f"Deregistered handler for aspect: {aspect}")
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "shutdown", f"Note: couldn't deregister announce handlers: {e}")
|
||
|
||
# Step 2: Clean up LXMF router
|
||
if self.router:
|
||
log_debug("ReticulumWrapper", "shutdown", "Cleaning up LXMF router")
|
||
try:
|
||
# Clear any message callbacks
|
||
if hasattr(self.router, 'message_received_callback'):
|
||
self.router.message_received_callback = None
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "shutdown", f"Warning - error cleaning up LXMF router: {e}")
|
||
self.router = None
|
||
log_debug("ReticulumWrapper", "shutdown", "LXMF router cleaned up")
|
||
|
||
# Step 3: Detach RNS interfaces
|
||
# Note: We don't try to stop daemon threads - they'll keep running until process ends
|
||
# Just detach interfaces to release resources
|
||
if RETICULUM_AVAILABLE and self.reticulum:
|
||
try:
|
||
log_debug("ReticulumWrapper", "shutdown", f"Detaching {len(RNS.Transport.interfaces)} interface(s)")
|
||
for iface in list(RNS.Transport.interfaces):
|
||
try:
|
||
if hasattr(iface, 'detach'):
|
||
iface.detach()
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "shutdown", f"Warning - couldn't detach interface {iface}: {e}")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "shutdown", f"Warning - error detaching interfaces: {e}")
|
||
|
||
# Step 4: Clear RNS singleton instance and Transport global state (critical!)
|
||
# RNS uses class variables for singletons and global state tracking
|
||
# We MUST clear these or reinitialize will fail
|
||
if RETICULUM_AVAILABLE:
|
||
try:
|
||
log_debug("ReticulumWrapper", "shutdown", "Clearing RNS.Reticulum singleton instance")
|
||
# Access the private class variable to clear the singleton
|
||
RNS.Reticulum._Reticulum__instance = None
|
||
log_debug("ReticulumWrapper", "shutdown", "RNS singleton cleared")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "shutdown", f"Warning - couldn't clear RNS singleton: {e}")
|
||
|
||
try:
|
||
log_debug("ReticulumWrapper", "shutdown", "Clearing RNS.Transport global state")
|
||
# Clear Transport owner (prevents reinit)
|
||
if hasattr(RNS.Transport, 'owner'):
|
||
log_debug("ReticulumWrapper", "shutdown", f"Clearing Transport.owner")
|
||
RNS.Transport.owner = None
|
||
# Clear Transport's interface lists
|
||
if hasattr(RNS.Transport, 'interfaces'):
|
||
log_debug("ReticulumWrapper", "shutdown", f"Clearing {len(RNS.Transport.interfaces)} interfaces")
|
||
RNS.Transport.interfaces.clear()
|
||
# CRITICAL: Clear local client interfaces (prevents shared instance connection)
|
||
if hasattr(RNS.Transport, 'local_client_interfaces'):
|
||
log_debug("ReticulumWrapper", "shutdown", f"Clearing {len(RNS.Transport.local_client_interfaces)} local client interfaces")
|
||
RNS.Transport.local_client_interfaces.clear()
|
||
# Clear local client caches
|
||
if hasattr(RNS.Transport, 'local_client_rssi_cache'):
|
||
RNS.Transport.local_client_rssi_cache.clear()
|
||
if hasattr(RNS.Transport, 'local_client_snr_cache'):
|
||
RNS.Transport.local_client_snr_cache.clear()
|
||
if hasattr(RNS.Transport, 'local_client_q_cache'):
|
||
RNS.Transport.local_client_q_cache.clear()
|
||
# Clear Transport's destination registries
|
||
if hasattr(RNS.Transport, 'destinations'):
|
||
log_debug("ReticulumWrapper", "shutdown", f"Clearing {len(RNS.Transport.destinations)} registered destinations")
|
||
RNS.Transport.destinations.clear()
|
||
if hasattr(RNS.Transport, 'destination_table'):
|
||
log_debug("ReticulumWrapper", "shutdown", f"Clearing destination_table with {len(RNS.Transport.destination_table)} entries")
|
||
RNS.Transport.destination_table.clear()
|
||
if hasattr(RNS.Transport, 'announce_table'):
|
||
log_debug("ReticulumWrapper", "shutdown", f"Clearing announce_table with {len(RNS.Transport.announce_table)} entries")
|
||
RNS.Transport.announce_table.clear()
|
||
if hasattr(RNS.Transport, 'held_announces'):
|
||
log_debug("ReticulumWrapper", "shutdown", f"Clearing held_announces")
|
||
RNS.Transport.held_announces.clear()
|
||
if hasattr(RNS.Transport, 'announce_handlers'):
|
||
log_debug("ReticulumWrapper", "shutdown", f"Clearing announce_handlers")
|
||
RNS.Transport.announce_handlers.clear()
|
||
log_info("ReticulumWrapper", "shutdown", "RNS.Transport global state cleared successfully")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "shutdown", f"Warning - couldn't clear Transport state: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# Step 5: Clear all wrapper state
|
||
log_debug("ReticulumWrapper", "shutdown", "Clearing wrapper state")
|
||
self.reticulum = None
|
||
self.initialized = False
|
||
self.announce_callbacks.clear()
|
||
self.message_callbacks.clear()
|
||
self.link_callbacks.clear()
|
||
self.destinations.clear()
|
||
self.identities.clear()
|
||
self.pending_announces.clear()
|
||
self.seen_announce_hashes.clear()
|
||
self.seen_message_hashes.clear()
|
||
|
||
# Step 6: Force garbage collection
|
||
# Note: When service restarts, the process is killed so threads stop automatically
|
||
# This cleanup is mainly for when shutdown() is called without restart
|
||
log_debug("ReticulumWrapper", "shutdown", "Running garbage collection...")
|
||
import gc
|
||
gc.collect()
|
||
log_debug("ReticulumWrapper", "shutdown", "Garbage collection complete")
|
||
|
||
log_separator("ReticulumWrapper", "shutdown", "=", 60)
|
||
log_debug("ReticulumWrapper", "shutdown", "Shutdown complete")
|
||
log_separator("ReticulumWrapper", "shutdown", "=", 60)
|
||
return {"success": True}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "shutdown", f"Error shutting down: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_status(self) -> str:
|
||
"""Get current network status"""
|
||
if not RETICULUM_AVAILABLE or not self.reticulum:
|
||
return "SHUTDOWN"
|
||
# TODO: Implement proper status checking
|
||
return "READY"
|
||
|
||
def create_identity(self) -> Dict:
|
||
"""
|
||
Create a new Reticulum identity.
|
||
|
||
Returns:
|
||
Dict with 'hash', 'public_key', and 'private_key' as byte arrays
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
# Mock identity for testing
|
||
return {
|
||
'hash': os.urandom(16),
|
||
'public_key': os.urandom(32),
|
||
'private_key': os.urandom(32)
|
||
}
|
||
|
||
identity = RNS.Identity()
|
||
return {
|
||
'hash': identity.hash,
|
||
'public_key': identity.get_public_key(),
|
||
'private_key': identity.get_private_key()
|
||
}
|
||
except Exception as e:
|
||
raise RuntimeError(f"Failed to create identity: {e}")
|
||
|
||
def load_identity(self, path: str) -> Dict:
|
||
"""Load an identity from file"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
raise NotImplementedError("Mock mode")
|
||
|
||
identity = RNS.Identity.from_file(path)
|
||
return {
|
||
'hash': identity.hash,
|
||
'public_key': identity.get_public_key(),
|
||
'private_key': identity.get_private_key()
|
||
}
|
||
except Exception as e:
|
||
raise RuntimeError(f"Failed to load identity: {e}")
|
||
|
||
def save_identity(self, private_key: bytes, path: str) -> Dict:
|
||
"""Save an identity to file"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
return {"success": True}
|
||
|
||
# Reconstruct identity from private key and save
|
||
identity = RNS.Identity()
|
||
identity.load_private_key(private_key)
|
||
identity.to_file(path)
|
||
return {"success": True}
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def create_destination(
|
||
self,
|
||
identity_dict: Dict,
|
||
direction: str,
|
||
dest_type: str,
|
||
app_name: str,
|
||
aspects: List[str]
|
||
) -> Dict:
|
||
"""Create a destination"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
import hashlib
|
||
# Mock destination
|
||
dest_str = app_name + "".join(aspects)
|
||
dest_hash = hashlib.sha256(dest_str.encode()).digest()[:16]
|
||
return {
|
||
'hash': dest_hash,
|
||
'hex_hash': dest_hash.hex(),
|
||
}
|
||
|
||
# Reconstruct identity from dict
|
||
identity = RNS.Identity()
|
||
identity.load_private_key(identity_dict['private_key'])
|
||
|
||
# Map direction and type
|
||
rns_direction = RNS.Destination.IN if direction == "IN" else RNS.Destination.OUT
|
||
|
||
if dest_type == "SINGLE":
|
||
rns_type = RNS.Destination.SINGLE
|
||
elif dest_type == "GROUP":
|
||
rns_type = RNS.Destination.GROUP
|
||
else:
|
||
rns_type = RNS.Destination.PLAIN
|
||
|
||
# Create destination
|
||
destination = RNS.Destination(
|
||
identity,
|
||
rns_direction,
|
||
rns_type,
|
||
app_name,
|
||
*aspects
|
||
)
|
||
|
||
# Store destination for later use (use hex hash as key)
|
||
self.destinations[destination.hexhash] = destination
|
||
|
||
return {
|
||
'hash': destination.hash,
|
||
'hex_hash': destination.hexhash,
|
||
}
|
||
except Exception as e:
|
||
raise RuntimeError(f"Failed to create destination: {e}")
|
||
|
||
def announce_destination(self, dest_hash, app_data=None) -> Dict:
|
||
"""Announce a destination on the network"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized:
|
||
return {"success": False, "error": "Reticulum not initialized"}
|
||
|
||
# Convert hash to bytes if it's a jarray/list (from Chaquopy)
|
||
if hasattr(dest_hash, '__iter__') and not isinstance(dest_hash, (bytes, bytearray)):
|
||
dest_hash = bytes(dest_hash)
|
||
|
||
# Convert app_data to bytes if it's a jarray/list (from Chaquopy)
|
||
if app_data is not None:
|
||
if hasattr(app_data, '__iter__') and not isinstance(app_data, (bytes, bytearray, str)):
|
||
app_data = bytes(app_data)
|
||
else:
|
||
# Use stored display_name as default app_data for LXMF announces
|
||
app_data = self.display_name.encode('utf-8') if self.display_name else None
|
||
|
||
# Convert hash to hex for dict lookup
|
||
hex_hash = dest_hash.hex()
|
||
log_debug("ReticulumWrapper", "announce_destination", f"Looking up destination with hash: {hex_hash}")
|
||
|
||
# Try our tracking dict first
|
||
destination = self.destinations.get(hex_hash)
|
||
|
||
# If not found, check if it's the LXMF destination
|
||
if not destination and self.local_lxmf_destination:
|
||
if hex_hash == self.local_lxmf_destination.hexhash:
|
||
log_debug("ReticulumWrapper", "announce_destination", f"Using local LXMF destination for announce")
|
||
destination = self.local_lxmf_destination
|
||
|
||
if not destination:
|
||
log_debug("ReticulumWrapper", "announce_destination", f"Destination not found. Available: {list(self.destinations.keys())}, LXMF: {self.local_lxmf_destination.hexhash if self.local_lxmf_destination else 'None'}")
|
||
return {"success": False, "error": f"Destination not found (hash: {hex_hash})"}
|
||
|
||
# Announce the destination
|
||
log_debug("ReticulumWrapper", "announce_destination", f"Announcing destination {hex_hash[:16]}... with app_data: {app_data}")
|
||
destination.announce(app_data=app_data)
|
||
log_info("ReticulumWrapper", "announce_destination", f"✅ Announced destination: {hex_hash[:16]}")
|
||
|
||
# Piggyback telephony announce on LXMF announces
|
||
if self.local_lxmf_destination and destination == self.local_lxmf_destination:
|
||
self._announce_telephony()
|
||
|
||
return {"success": True}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "announce_destination", f"Error announcing destination: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def _announce_telephony(self):
|
||
"""Announce our LXST telephony destination alongside LXMF.
|
||
|
||
Called after LXMF announces so remote peers can discover both our
|
||
messaging and telephony paths in a single announce cycle.
|
||
"""
|
||
if self._call_manager and hasattr(self._call_manager, 'destination') and self._call_manager.destination:
|
||
try:
|
||
self._call_manager.destination.announce()
|
||
self._call_manager._last_announce = __import__('time').time()
|
||
log_debug("ReticulumWrapper", "_announce_telephony", "Telephony destination announced")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "_announce_telephony", f"Could not announce telephony: {e}")
|
||
|
||
def send_packet(self, dest_hash: bytes, data: bytes, packet_type: str = "DATA") -> Dict:
|
||
"""Send a packet to a destination"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
return {
|
||
'receipt_hash': os.urandom(32),
|
||
'delivered': True,
|
||
'timestamp': int(1000 * __import__('time').time())
|
||
}
|
||
|
||
# TODO: Implement packet sending
|
||
# This requires maintaining a map of destination objects
|
||
return {
|
||
'receipt_hash': b'',
|
||
'delivered': False,
|
||
'timestamp': int(1000 * __import__('time').time())
|
||
}
|
||
except Exception as e:
|
||
raise RuntimeError(f"Failed to send packet: {e}")
|
||
|
||
def register_message_callback(self, callback: Callable):
|
||
"""Register a callback for incoming messages"""
|
||
self.message_callbacks.append(callback)
|
||
if RETICULUM_AVAILABLE and self.router:
|
||
self.router.register_delivery_callback(self._on_message)
|
||
|
||
def _on_lxmf_delivery(self, lxmf_message):
|
||
"""
|
||
Delivery callback for LXMF router - called when messages are received.
|
||
|
||
Phase 2.2 Enhancement: Now invokes Kotlin callback for event-driven notifications.
|
||
Also adds to pending_inbound queue for backward compatibility with polling.
|
||
"""
|
||
log_separator("ReticulumWrapper", "_on_lxmf_delivery", "!", 80)
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery", f"📨 _on_lxmf_delivery CALLED! Message received!")
|
||
log_separator("ReticulumWrapper", "_on_lxmf_delivery", "!", 80)
|
||
try:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery", f"Message from: {lxmf_message.source_hash.hex()[:16]}")
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery", f"Message to: {lxmf_message.destination_hash.hex()[:16]}")
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery", f"Content length: {len(lxmf_message.content)} bytes")
|
||
|
||
# ✅ PHASE 3: Check for location telemetry FIRST
|
||
# Location-only messages should NOT be added to the regular message queue
|
||
# Priority: FIELD_TELEMETRY (0x02) > FIELD_COLUMBA_META (0x70) > Legacy field 7
|
||
is_location_only = False
|
||
|
||
# Check if message has text content (needed for empty message filtering)
|
||
content = lxmf_message.content
|
||
has_text_content = False
|
||
if content:
|
||
if isinstance(content, bytes):
|
||
has_text_content = len(content.strip()) > 0
|
||
elif isinstance(content, str):
|
||
has_text_content = len(content.strip()) > 0
|
||
|
||
if self.kotlin_location_received_callback and hasattr(lxmf_message, 'fields') and lxmf_message.fields:
|
||
|
||
location_event = None
|
||
telemetry_source = None
|
||
|
||
# Priority 1: Check FIELD_TELEMETRY (0x02) - Sideband-compatible format
|
||
if FIELD_TELEMETRY in lxmf_message.fields:
|
||
telemetry_source = "FIELD_TELEMETRY"
|
||
try:
|
||
packed_data = lxmf_message.fields[FIELD_TELEMETRY]
|
||
location_event = unpack_location_telemetry(packed_data)
|
||
if location_event:
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📍 Sideband-compatible telemetry received in FIELD_TELEMETRY (0x02)")
|
||
|
||
# ✅ TELEMETRY COLLECTOR HOST: Store incoming telemetry
|
||
if self.telemetry_collector_enabled:
|
||
# Extract appearance from FIELD_ICON_APPEARANCE if present
|
||
appearance = None
|
||
if FIELD_ICON_APPEARANCE in lxmf_message.fields:
|
||
appearance = lxmf_message.fields[FIELD_ICON_APPEARANCE]
|
||
|
||
# Get timestamp from unpacked location event
|
||
entry_timestamp = location_event.get('ts', 0)
|
||
if entry_timestamp > 1e12: # Milliseconds
|
||
entry_timestamp = entry_timestamp // 1000 # Convert to seconds
|
||
|
||
self._store_telemetry_for_collector(
|
||
source_hash_hex=lxmf_message.source_hash.hex(),
|
||
packed_telemetry=packed_data,
|
||
timestamp=entry_timestamp,
|
||
appearance=appearance
|
||
)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Failed to unpack FIELD_TELEMETRY: {e}")
|
||
|
||
# Priority 1.5: Check FIELD_TELEMETRY_STREAM (0x03) - Bulk telemetry from collector
|
||
if FIELD_TELEMETRY_STREAM in lxmf_message.fields:
|
||
# Telemetry stream messages are always location-only (shouldn't appear in chat)
|
||
if not has_text_content:
|
||
is_location_only = True
|
||
telemetry_source = "FIELD_TELEMETRY_STREAM"
|
||
|
||
try:
|
||
stream_data = lxmf_message.fields[FIELD_TELEMETRY_STREAM]
|
||
if stream_data and len(stream_data) > 0:
|
||
stream_entries = unpack_telemetry_stream(stream_data)
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📍 Telemetry stream received with {len(stream_entries)} entries from collector")
|
||
|
||
# Invoke callback for each entry in the stream
|
||
for stream_entry in stream_entries:
|
||
try:
|
||
self.kotlin_location_received_callback(json.dumps(stream_entry))
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"✅ Stream entry callback invoked for source {stream_entry.get('source_hash', 'unknown')[:16]}...")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"⚠️ Error invoking stream entry callback: {e}")
|
||
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📍 Location-only telemetry stream message, skipping message queue")
|
||
else:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Empty telemetry stream field received (0 entries from collector)")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Failed to unpack FIELD_TELEMETRY_STREAM: {e}")
|
||
|
||
# Priority 2: Check FIELD_COLUMBA_META (0x70) for cease signals
|
||
if FIELD_COLUMBA_META in lxmf_message.fields:
|
||
try:
|
||
meta_data = lxmf_message.fields[FIELD_COLUMBA_META]
|
||
if isinstance(meta_data, bytes):
|
||
meta_data = meta_data.decode('utf-8')
|
||
if isinstance(meta_data, str):
|
||
meta = json.loads(meta_data)
|
||
|
||
# Check for cease signal
|
||
if meta.get('cease', False):
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📍 Cease signal received via FIELD_COLUMBA_META")
|
||
location_event = {
|
||
"type": "location_share",
|
||
"cease": True,
|
||
"ts": int(time.time() * 1000),
|
||
}
|
||
telemetry_source = "FIELD_COLUMBA_META (cease)"
|
||
|
||
# Merge Columba-specific metadata if we have a location event
|
||
elif location_event:
|
||
if 'expires' in meta:
|
||
location_event['expires'] = meta['expires']
|
||
if 'approxRadius' in meta:
|
||
location_event['approxRadius'] = meta['approxRadius']
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Failed to parse FIELD_COLUMBA_META: {e}")
|
||
|
||
# Priority 3: Fallback to legacy field 7 for backwards compatibility
|
||
if location_event is None and LEGACY_LOCATION_FIELD in lxmf_message.fields:
|
||
telemetry_source = "Legacy field 7"
|
||
try:
|
||
legacy_data = lxmf_message.fields[LEGACY_LOCATION_FIELD]
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📍 Legacy location telemetry received in field 7")
|
||
|
||
# Legacy format: JSON string as bytes or string
|
||
if isinstance(legacy_data, bytes):
|
||
location_json = legacy_data.decode('utf-8')
|
||
elif isinstance(legacy_data, str):
|
||
location_json = legacy_data
|
||
else:
|
||
location_json = None
|
||
|
||
if location_json:
|
||
location_event = json.loads(location_json)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Failed to parse legacy field 7: {e}")
|
||
|
||
# Process the location event if we have one
|
||
if location_event:
|
||
if not has_text_content:
|
||
is_location_only = True
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📍 Location-only message detected ({telemetry_source}), skipping message queue")
|
||
|
||
# Add source hash and appearance from FIELD_ICON_APPEARANCE
|
||
location_event['source_hash'] = lxmf_message.source_hash.hex()
|
||
|
||
if FIELD_ICON_APPEARANCE in lxmf_message.fields:
|
||
try:
|
||
icon_data = lxmf_message.fields[FIELD_ICON_APPEARANCE]
|
||
if isinstance(icon_data, list) and len(icon_data) >= 3:
|
||
location_event['appearance'] = {
|
||
'icon_name': icon_data[0],
|
||
'foreground_color': icon_data[1].hex() if isinstance(icon_data[1], bytes) else icon_data[1],
|
||
'background_color': icon_data[2].hex() if isinstance(icon_data[2], bytes) else icon_data[2],
|
||
}
|
||
except Exception:
|
||
pass
|
||
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Location: lat={location_event.get('lat')}, lng={location_event.get('lng')}, cease={location_event.get('cease', False)}")
|
||
|
||
try:
|
||
self.kotlin_location_received_callback(json.dumps(location_event))
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
"✅ Location callback invoked successfully")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"⚠️ Error invoking location callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# ✅ TELEMETRY COLLECTOR HOST: Handle FIELD_COMMANDS telemetry requests
|
||
if self.telemetry_collector_enabled and hasattr(lxmf_message, 'fields') and lxmf_message.fields:
|
||
if FIELD_COMMANDS in lxmf_message.fields:
|
||
try:
|
||
commands = lxmf_message.fields[FIELD_COMMANDS]
|
||
# Format: [{command_id: [args...]}] - list of command dicts
|
||
if isinstance(commands, list):
|
||
for command_dict in commands:
|
||
if isinstance(command_dict, dict):
|
||
# Check for telemetry request command (0x01)
|
||
if COMMAND_TELEMETRY_REQUEST in command_dict:
|
||
args = command_dict[COMMAND_TELEMETRY_REQUEST]
|
||
# Args: [timebase, is_collector_request]
|
||
timebase = args[0] if len(args) > 0 else 0
|
||
is_collector_request = args[1] if len(args) > 1 else True
|
||
|
||
if is_collector_request:
|
||
requester_hash = lxmf_message.source_hash.hex().lower()
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📡 Telemetry request received from {requester_hash[:16]} (timebase={timebase})")
|
||
|
||
# ✅ Check allowed requesters list (must be explicitly allowed)
|
||
if requester_hash not in self.telemetry_allowed_requesters:
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📡 Telemetry request BLOCKED from {requester_hash[:16]} (not in allowed list)")
|
||
# Mark as handled so it doesn't go to message queue
|
||
is_location_only = True
|
||
continue # Skip processing, move to next command
|
||
|
||
# Get the requester's identity - following Sideband's pattern
|
||
requester_identity = RNS.Identity.recall(lxmf_message.source_hash)
|
||
|
||
if requester_identity is None:
|
||
# Identity not cached - request path from network (Sideband pattern)
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Identity for {lxmf_message.source_hash.hex()[:16]} not recalled, requesting path...")
|
||
RNS.Transport.request_path(lxmf_message.source_hash)
|
||
# Queue for retry after path resolution
|
||
def retry_send():
|
||
import time
|
||
time.sleep(2) # Wait for path resolution
|
||
retry_identity = RNS.Identity.recall(lxmf_message.source_hash)
|
||
if retry_identity:
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Identity recalled on retry, sending telemetry stream")
|
||
self._send_telemetry_stream_response(
|
||
lxmf_message.source_hash,
|
||
retry_identity,
|
||
timebase
|
||
)
|
||
else:
|
||
log_warning("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Still cannot recall identity for {lxmf_message.source_hash.hex()[:16]} after retry")
|
||
import threading
|
||
threading.Thread(target=retry_send, daemon=True).start()
|
||
else:
|
||
self._send_telemetry_stream_response(
|
||
lxmf_message.source_hash,
|
||
requester_identity,
|
||
timebase
|
||
)
|
||
|
||
# Mark as handled (don't add to regular message queue)
|
||
is_location_only = True # Reuse this flag to skip message queue
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Failed to process FIELD_COMMANDS: {e}")
|
||
|
||
# ✅ Check for emoji reaction (Field 16 with reaction_to key)
|
||
# Reactions are lightweight messages with empty content and reaction data in Field 16
|
||
APP_EXTENSIONS_FIELD = 16
|
||
is_reaction = False
|
||
|
||
if hasattr(lxmf_message, 'fields') and lxmf_message.fields:
|
||
if APP_EXTENSIONS_FIELD in lxmf_message.fields:
|
||
field_16 = lxmf_message.fields[APP_EXTENSIONS_FIELD]
|
||
if isinstance(field_16, dict) and 'reaction_to' in field_16:
|
||
# This is a reaction message
|
||
is_reaction = True
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"😀 Reaction detected in field {APP_EXTENSIONS_FIELD}")
|
||
|
||
# Process reaction
|
||
try:
|
||
reaction_event = {
|
||
'reaction_to': field_16.get('reaction_to', ''),
|
||
'emoji': field_16.get('emoji', ''),
|
||
'sender': field_16.get('sender', ''),
|
||
'source_hash': lxmf_message.source_hash.hex(),
|
||
'timestamp': int(time.time() * 1000)
|
||
}
|
||
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Reaction: {reaction_event['emoji']} to message {reaction_event['reaction_to'][:16]}... from {reaction_event['sender'][:16]}...")
|
||
|
||
# Invoke Kotlin callback if registered
|
||
if self.kotlin_reaction_received_callback:
|
||
self.kotlin_reaction_received_callback(json.dumps(reaction_event))
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
"✅ Reaction callback invoked successfully")
|
||
else:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
"No reaction callback registered - reaction will be processed via polling")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"⚠️ Error processing reaction: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# Skip regular message processing for location-only or reaction-only messages
|
||
if is_location_only or is_reaction:
|
||
skip_reason = "location-only" if is_location_only else "reaction-only"
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Skipping regular message processing for {skip_reason} message")
|
||
return
|
||
|
||
# Skip truly empty messages (probe messages for link speed measurement)
|
||
# These have no text content and no meaningful fields (image, file, telemetry, etc.)
|
||
meaningful_fields = {
|
||
FIELD_TELEMETRY, # 0x02 - Location/sensor data
|
||
FIELD_TELEMETRY_STREAM, # 0x03 - Bulk telemetry from collector
|
||
FIELD_FILE_ATTACHMENTS, # 0x05
|
||
FIELD_IMAGE, # 0x06
|
||
FIELD_AUDIO, # 0x07
|
||
}
|
||
has_meaningful_fields = False
|
||
if hasattr(lxmf_message, 'fields') and lxmf_message.fields:
|
||
has_meaningful_fields = any(f in lxmf_message.fields for f in meaningful_fields)
|
||
|
||
if not has_text_content and not has_meaningful_fields:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Skipping empty probe message from {lxmf_message.source_hash.hex()[:16]}")
|
||
return
|
||
|
||
# Add to pending_inbound queue (maintains backward compatibility with polling)
|
||
if not hasattr(self.router, 'pending_inbound'):
|
||
log_warning("ReticulumWrapper", "_on_lxmf_delivery", "Warning: Router has no pending_inbound, creating one")
|
||
self.router.pending_inbound = []
|
||
|
||
if lxmf_message not in self.router.pending_inbound:
|
||
# Capture hop count and receiving interface at delivery time
|
||
# For opportunistic messages: LXMF captures from packet directly (path_table may not exist)
|
||
# For link-based messages: use path_table lookup (always populated during link setup)
|
||
try:
|
||
source_hash = lxmf_message.source_hash
|
||
|
||
# Track whether we captured hops and interface
|
||
captured_hops = False
|
||
captured_interface = False
|
||
|
||
# First check if LXMF captured receiving_interface and hops from the packet
|
||
# (set by our patched delivery_packet for opportunistic messages)
|
||
# Only use if it's a real interface (has string name), not a Mock auto-attribute
|
||
receiving_interface = getattr(lxmf_message, 'receiving_interface', None)
|
||
if receiving_interface is not None:
|
||
# Use class name to identify interface type (reliable, not user-configured)
|
||
class_name = type(receiving_interface).__name__
|
||
# AutoInterfacePeer -> AutoInterface, otherwise use class name directly
|
||
if "AutoInterface" in class_name:
|
||
interface_type = "AutoInterface"
|
||
else:
|
||
interface_type = class_name
|
||
lxmf_message._columba_interface = interface_type
|
||
captured_interface = True
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📡 Got interface from LXMF message (opportunistic): {interface_type}")
|
||
|
||
# Extract signal quality metrics from interface
|
||
rssi, snr = extract_signal_metrics(receiving_interface)
|
||
if rssi is not None:
|
||
lxmf_message._columba_rssi = rssi
|
||
if snr is not None:
|
||
lxmf_message._columba_snr = snr
|
||
|
||
receiving_hops = getattr(lxmf_message, 'receiving_hops', None)
|
||
if isinstance(receiving_hops, int):
|
||
lxmf_message._columba_hops = receiving_hops
|
||
captured_hops = True
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📡 Got hops from LXMF message (opportunistic): {receiving_hops}")
|
||
|
||
# Fallback to path_table for link-based messages or if LXMF didn't capture
|
||
if not captured_interface or not captured_hops:
|
||
if RNS.Transport.has_path(source_hash):
|
||
# Only capture hops from path_table if we don't already have them
|
||
if not captured_hops:
|
||
hops = RNS.Transport.hops_to(source_hash)
|
||
if hops is not None and hops >= 0:
|
||
lxmf_message._columba_hops = hops
|
||
captured_hops = True
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📡 Captured hop count from path_table: {hops}")
|
||
|
||
# Only capture interface from path_table if we don't already have it
|
||
if not captured_interface:
|
||
path_entry = RNS.Transport.path_table.get(source_hash)
|
||
if path_entry is not None and len(path_entry) > 5 and path_entry[5] is not None:
|
||
interface_obj = path_entry[5]
|
||
# Use class name to identify interface type (reliable, not user-configured)
|
||
class_name = type(interface_obj).__name__
|
||
if "AutoInterface" in class_name:
|
||
interface_type = "AutoInterface"
|
||
else:
|
||
interface_type = class_name
|
||
lxmf_message._columba_interface = interface_type
|
||
captured_interface = True
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"📡 Captured interface from path_table: {interface_type}")
|
||
|
||
# Extract signal quality metrics from interface
|
||
rssi, snr = extract_signal_metrics(interface_obj)
|
||
if rssi is not None:
|
||
lxmf_message._columba_rssi = rssi
|
||
if snr is not None:
|
||
lxmf_message._columba_snr = snr
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"⚠️ Could not capture hop count/interface: {e}")
|
||
|
||
self.router.pending_inbound.append(lxmf_message)
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery", f"✅ Added message to pending_inbound queue (now has {len(self.router.pending_inbound)} messages)")
|
||
else:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery", "Message already in pending_inbound")
|
||
|
||
# Parse icon appearance from message fields (Sideband/MeshChat interoperability)
|
||
icon_appearance = None
|
||
if hasattr(lxmf_message, 'fields') and lxmf_message.fields and FIELD_ICON_APPEARANCE in lxmf_message.fields:
|
||
try:
|
||
icon_data = lxmf_message.fields[FIELD_ICON_APPEARANCE]
|
||
if isinstance(icon_data, list) and len(icon_data) >= 3:
|
||
icon_appearance = {
|
||
'icon_name': icon_data[0],
|
||
'foreground_color': icon_data[1].hex() if isinstance(icon_data[1], bytes) else icon_data[1],
|
||
'background_color': icon_data[2].hex() if isinstance(icon_data[2], bytes) else icon_data[2],
|
||
}
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Parsed icon appearance: {icon_appearance['icon_name']}")
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery", f"Failed to parse icon appearance: {e}")
|
||
|
||
# ✅ PHASE 2.2: Invoke Kotlin callback with FULL message data (truly event-driven)
|
||
# Send complete message so Kotlin doesn't need to poll
|
||
if self.kotlin_message_received_callback:
|
||
try:
|
||
# Build full message event with all data
|
||
content = lxmf_message.content.decode('utf-8') if isinstance(lxmf_message.content, bytes) else str(lxmf_message.content)
|
||
message_event = {
|
||
'message_hash': lxmf_message.hash.hex() if lxmf_message.hash else "unknown",
|
||
'content': content,
|
||
'source_hash': lxmf_message.source_hash.hex(),
|
||
'destination_hash': lxmf_message.destination_hash.hex(),
|
||
'timestamp': int(lxmf_message.timestamp * 1000) if lxmf_message.timestamp else int(time.time() * 1000),
|
||
'icon_appearance': icon_appearance,
|
||
'full_message': True, # Flag indicating this has full data, no polling needed
|
||
}
|
||
# Add hop count and receiving interface if captured
|
||
# Use getattr with explicit default to avoid MagicMock issues in tests
|
||
columba_hops = getattr(lxmf_message, '_columba_hops', None)
|
||
if isinstance(columba_hops, int):
|
||
message_event['hops'] = columba_hops
|
||
columba_interface = getattr(lxmf_message, '_columba_interface', None)
|
||
if isinstance(columba_interface, str):
|
||
message_event['receiving_interface'] = columba_interface
|
||
|
||
# Add signal quality metrics if captured
|
||
add_signal_to_message_event(
|
||
message_event,
|
||
getattr(lxmf_message, '_columba_rssi', None),
|
||
getattr(lxmf_message, '_columba_snr', None)
|
||
)
|
||
|
||
# Get sender's public key from RNS identity cache
|
||
try:
|
||
source_identity = RNS.Identity.recall(lxmf_message.source_hash)
|
||
if source_identity is not None:
|
||
public_key = source_identity.get_public_key()
|
||
# Only add if it's actual bytes (not a Mock object)
|
||
if isinstance(public_key, bytes):
|
||
message_event['public_key'] = public_key.hex()
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery", f"Could not get public key: {e}")
|
||
|
||
# Extract LXMF fields (attachments, reactions, etc.)
|
||
if hasattr(lxmf_message, 'fields') and lxmf_message.fields:
|
||
fields_serialized = {}
|
||
for key, value in lxmf_message.fields.items():
|
||
if key == 5 and isinstance(value, list):
|
||
# Field 5: file attachments
|
||
serialized_attachments = []
|
||
for attachment in value:
|
||
if isinstance(attachment, (list, tuple)) and len(attachment) >= 2:
|
||
filename, file_data = attachment[0], attachment[1]
|
||
if isinstance(file_data, bytes):
|
||
serialized_attachments.append({
|
||
'filename': str(filename),
|
||
'data': file_data.hex(),
|
||
'size': len(file_data)
|
||
})
|
||
if serialized_attachments:
|
||
fields_serialized['5'] = serialized_attachments
|
||
elif key == 16 and isinstance(value, dict):
|
||
# Field 16: app extensions (reactions, replies)
|
||
fields_serialized['16'] = value
|
||
elif key == 4 and isinstance(value, list) and len(value) >= 3:
|
||
# Field 4: icon appearance (already parsed above)
|
||
pass
|
||
elif key in (6, 7) and isinstance(value, (list, tuple)) and len(value) >= 2:
|
||
# Field 6/7: image/audio
|
||
if isinstance(value[1], bytes):
|
||
fields_serialized[str(key)] = [value[0], value[1].hex()]
|
||
else:
|
||
fields_serialized[str(key)] = str(value)
|
||
if fields_serialized:
|
||
message_event['fields'] = json.dumps(fields_serialized)
|
||
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"Invoking Kotlin callback with full message data ({len(content)} chars)...")
|
||
self.kotlin_message_received_callback(json.dumps(message_event))
|
||
log_info("ReticulumWrapper", "_on_lxmf_delivery",
|
||
"✅ Kotlin callback invoked with full message (event-driven)")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_lxmf_delivery",
|
||
f"⚠️ Error invoking Kotlin callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# Continue anyway - message is in queue for polling fallback
|
||
else:
|
||
log_debug("ReticulumWrapper", "_on_lxmf_delivery",
|
||
"No Kotlin callback registered - relying on polling")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_lxmf_delivery", f"Error in delivery callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _on_message(self, message):
|
||
"""Internal callback handler for LXMF messages"""
|
||
msg_dict = {
|
||
'content': message.content,
|
||
'source': message.source_hash,
|
||
'destination': message.destination_hash,
|
||
'timestamp': message.timestamp
|
||
}
|
||
for callback in self.message_callbacks:
|
||
try:
|
||
callback(msg_dict)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message", f"Error in message callback: {e}")
|
||
|
||
def register_announce_callback(self, callback: Callable):
|
||
"""
|
||
Register a callback for network announces.
|
||
The callback will be called with a dict containing:
|
||
- destination_hash: bytes
|
||
- identity_hash: bytes
|
||
- public_key: bytes
|
||
- app_data: bytes
|
||
- hops: int
|
||
- timestamp: int (milliseconds)
|
||
"""
|
||
log_info("ReticulumWrapper", "register_announce_callback", f"Registering announce callback: {callback}")
|
||
self.announce_callbacks.append(callback)
|
||
return {"success": True}
|
||
|
||
def get_pending_announces(self) -> List[Dict]:
|
||
"""
|
||
Retrieve all pending announces and clear the queue.
|
||
This is called by Kotlin to poll for new announces.
|
||
|
||
Returns:
|
||
List of announce event dicts
|
||
"""
|
||
with self.announce_lock:
|
||
announces = self.pending_announces.copy()
|
||
self.pending_announces.clear()
|
||
return announces
|
||
|
||
def poll_received_announces(self) -> List[Dict]:
|
||
"""
|
||
Poll for received announces from Transport's announce_table.
|
||
|
||
Transport automatically processes announces and stores them. We just
|
||
poll for new entries since last check.
|
||
|
||
Returns:
|
||
List of new announce event dicts since last poll
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.initialized:
|
||
log_debug("ReticulumWrapper", "poll_received_announces", "Skipping: not initialized")
|
||
return []
|
||
|
||
try:
|
||
new_announces = []
|
||
|
||
# Poll announce_table for new announces (Transport manages this automatically)
|
||
if not hasattr(RNS.Transport, 'announce_table'):
|
||
log_warning("ReticulumWrapper", "poll_received_announces", "RNS.Transport has no announce_table attribute!")
|
||
return []
|
||
|
||
table_size = len(RNS.Transport.announce_table)
|
||
log_debug("ReticulumWrapper", "poll_received_announces", f"Polling announce_table with {table_size} entries, {len(self.seen_announce_hashes)} already seen")
|
||
|
||
current_time = time.time()
|
||
|
||
# Check each announce in the table
|
||
for dest_hash in list(RNS.Transport.announce_table.keys()):
|
||
hash_hex = dest_hash.hex()
|
||
|
||
# Skip if we've already seen this announce
|
||
if hash_hex in self.seen_announce_hashes:
|
||
continue
|
||
|
||
# Mark as seen
|
||
self.seen_announce_hashes.add(hash_hex)
|
||
|
||
# Try to recall identity and app_data (Transport stored these)
|
||
announced_identity = None
|
||
try:
|
||
announced_identity = RNS.Identity.recall(dest_hash)
|
||
except:
|
||
pass
|
||
|
||
app_data = b''
|
||
try:
|
||
app_data = RNS.Identity.recall_app_data(dest_hash)
|
||
except:
|
||
pass
|
||
|
||
# Get hops from Transport
|
||
hops = RNS.Transport.hops_to(dest_hash)
|
||
if hops is None or hops == RNS.Transport.PATHFINDER_M:
|
||
hops = 0
|
||
|
||
# Get receiving interface from announce_table packet
|
||
receiving_interface = None
|
||
try:
|
||
announce_entry = RNS.Transport.announce_table.get(dest_hash)
|
||
if announce_entry is not None and len(announce_entry) > 5:
|
||
packet = announce_entry[5] # IDX_AT_PACKET
|
||
if packet and hasattr(packet, 'receiving_interface'):
|
||
interface_obj = packet.receiving_interface
|
||
if interface_obj:
|
||
# Build formatted interface string: "ClassName[UserConfiguredName]"
|
||
# This allows Kotlin to extract both the friendly name and the interface type
|
||
class_name = type(interface_obj).__name__
|
||
user_name = getattr(interface_obj, 'name', None)
|
||
if user_name and user_name != class_name:
|
||
receiving_interface = f"{class_name}[{user_name}]"
|
||
else:
|
||
receiving_interface = class_name
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "poll_received_announces",
|
||
f"Could not extract interface: {e}")
|
||
|
||
# Create simple announce event
|
||
# NOTE: dest_hash is the DESTINATION hash (e.g., "lxmf.delivery", "nomadnetwork.node")
|
||
# identity_hash is the raw IDENTITY hash (16 bytes from the public key)
|
||
# They are NOT the same! identity_hash = identity.hash, dest_hash = hash(identity + app + aspect)
|
||
identity_hash = announced_identity.hash if announced_identity else dest_hash
|
||
|
||
announce_event = {
|
||
'destination_hash': dest_hash,
|
||
'identity_hash': identity_hash,
|
||
'public_key': announced_identity.get_public_key() if announced_identity else b'',
|
||
'app_data': app_data,
|
||
'hops': hops,
|
||
'timestamp': int(current_time * 1000),
|
||
'interface': receiving_interface,
|
||
}
|
||
|
||
new_announces.append(announce_event)
|
||
log_info("ReticulumWrapper", "poll_received_announces",
|
||
f"✅ NEW ANNOUNCE: {hash_hex[:16]}... (hops={hops}, app_data={len(app_data)}B)")
|
||
|
||
self.last_announce_poll_time = current_time
|
||
return new_announces
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "poll_received_announces", f"Error polling announces: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return []
|
||
|
||
def send_lxmf_message(self, dest_hash: bytes, content: str, source_identity_private_key: bytes, image_data: bytes = None, image_format: str = None, file_attachments: list = None, icon_name: str = None, icon_fg_color: str = None, icon_bg_color: str = None) -> Dict:
|
||
"""
|
||
Send an LXMF message to a destination.
|
||
|
||
Args:
|
||
dest_hash: Identity hash bytes (16 bytes) - will be converted to LXMF destination hash
|
||
content: Message content string
|
||
source_identity_private_key: Private key of sender identity
|
||
image_data: Optional image data bytes
|
||
image_format: Optional image format (e.g., 'jpg', 'png', 'webp')
|
||
file_attachments: Optional list of [filename, bytes] tuples for file attachments (Field 5)
|
||
icon_name: Optional icon name for FIELD_ICON_APPEARANCE (Sideband/MeshChat interop)
|
||
icon_fg_color: Optional foreground color hex string (3 bytes RGB)
|
||
icon_bg_color: Optional background color hex string (3 bytes RGB)
|
||
|
||
Returns:
|
||
Dict with 'success', 'message_hash', 'timestamp' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(dest_hash, '__iter__') and not isinstance(dest_hash, (bytes, bytearray)):
|
||
dest_hash = bytes(dest_hash)
|
||
if hasattr(source_identity_private_key, '__iter__') and not isinstance(source_identity_private_key, (bytes, bytearray)):
|
||
source_identity_private_key = bytes(source_identity_private_key)
|
||
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "=", 80)
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"========== LXMF MESSAGE SEND (V4 - Hash Conversion Fix) ==========")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Received identity hash: {dest_hash.hex()}")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Hash length: {len(dest_hash)} bytes")
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "=", 80)
|
||
|
||
# Reconstruct source identity from private key
|
||
source_identity = RNS.Identity()
|
||
try:
|
||
source_identity.load_private_key(source_identity_private_key)
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Loaded source identity, hash={source_identity.hash.hex()[:16]}")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_lxmf_message", f"❌ ERROR loading private key: {e}")
|
||
raise
|
||
|
||
# Get our local LXMF destination hash (sender)
|
||
if not self.local_lxmf_destination:
|
||
raise ValueError("Local LXMF destination not created")
|
||
|
||
source_dest_hash = self.local_lxmf_destination.hash
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Our LXMF destination hash: {source_dest_hash.hex()}")
|
||
|
||
# NOTE: The UI can pass either an identity hash OR a destination hash
|
||
# We need to try both recall methods to handle both cases
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Attempting to recall identity from hash {dest_hash.hex()[:16]}...")
|
||
|
||
# === HASH TYPE DIAGNOSTICS ===
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "-", 60)
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"🔍 HASH TYPE ANALYSIS:")
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f" Input hash length: {len(dest_hash)} bytes")
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f" Input hash: {dest_hash.hex()}")
|
||
if len(dest_hash) == 16:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", " Type: Identity hash (16 bytes)")
|
||
elif len(dest_hash) == 32:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", " Type: LXMF Destination hash (32 bytes)")
|
||
# Try to extract identity hash from first 16 bytes
|
||
potential_identity_hash = dest_hash[:16]
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f" Potential identity hash (first 16 bytes): {potential_identity_hash.hex()}")
|
||
else:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message", f" Type: UNKNOWN ({len(dest_hash)} bytes)")
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "-", 60)
|
||
|
||
# First, try to recall the identity from the hash
|
||
recipient_identity = None
|
||
dest_hash_hex = dest_hash.hex()
|
||
|
||
try:
|
||
# Try as destination hash first (this is what LXMF uses)
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if recipient_identity:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Recalled identity from destination hash via RNS.Identity.recall()")
|
||
else:
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Not found as destination hash, trying as identity hash...")
|
||
# Try with from_identity_hash=True
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if recipient_identity:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Recalled identity from identity hash via RNS.Identity.recall()")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_lxmf_message", f"Error recalling identity from Reticulum: {e}")
|
||
|
||
# If Reticulum recall failed, try our local cache
|
||
if not recipient_identity and dest_hash_hex in self.identities:
|
||
recipient_identity = self.identities[dest_hash_hex]
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Retrieved identity from local cache")
|
||
|
||
if not recipient_identity:
|
||
# Request path from network (triggers announces from peers who know destination)
|
||
log_info("ReticulumWrapper", "send_lxmf_message",
|
||
f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
|
||
try:
|
||
RNS.Transport.request_path(dest_hash)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message", f"Error requesting path: {e}")
|
||
|
||
# Wait up to 5 seconds for path response
|
||
for attempt in range(10):
|
||
time.sleep(0.5)
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if recipient_identity:
|
||
log_info("ReticulumWrapper", "send_lxmf_message",
|
||
f"✅ Identity resolved after path request (attempt {attempt + 1})")
|
||
break
|
||
|
||
if not recipient_identity:
|
||
error_msg = f"Cannot send message: Recipient identity {dest_hash.hex()[:16]} not known. Path requested but no response received."
|
||
log_error("ReticulumWrapper", "send_lxmf_message", f"❌ {error_msg}")
|
||
return {"success": False, "error": error_msg}
|
||
|
||
# Create outgoing LXMF destination object from the recalled identity
|
||
# The router.handle_outbound() REQUIRES a destination object, not just a hash!
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Creating outgoing LXMF destination object...")
|
||
recipient_lxmf_destination = RNS.Destination(
|
||
recipient_identity,
|
||
RNS.Destination.OUT, # OUT for outgoing messages
|
||
RNS.Destination.SINGLE,
|
||
"lxmf", # App name
|
||
"delivery" # Aspect
|
||
)
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"Created destination object with hash: {recipient_lxmf_destination.hash.hex()}")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Original hash received: {dest_hash.hex()}")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Destination object hash: {recipient_lxmf_destination.hash.hex()}")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Are they equal? {dest_hash == recipient_lxmf_destination.hash}")
|
||
|
||
# Prepare fields dictionary for attachments
|
||
fields = None
|
||
|
||
# LXMF field 6 = IMAGE, format: [format_string, bytes_data]
|
||
if image_data and image_format:
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(image_data, '__iter__') and not isinstance(image_data, (bytes, bytearray)):
|
||
image_data = bytes(image_data)
|
||
|
||
fields = {
|
||
6: [image_format, image_data]
|
||
}
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"📎 Attaching image: {len(image_data)} bytes, format={image_format}")
|
||
|
||
# LXMF field 5 = FILE_ATTACHMENTS, format: list of [filename, bytes_data]
|
||
if file_attachments:
|
||
if fields is None:
|
||
fields = {}
|
||
|
||
# Convert each attachment to proper format
|
||
field_5_data = []
|
||
for attachment in file_attachments:
|
||
# Handle different input formats
|
||
if isinstance(attachment, (list, tuple)) and len(attachment) >= 2:
|
||
filename = attachment[0]
|
||
data = attachment[1]
|
||
elif isinstance(attachment, dict):
|
||
filename = attachment.get('filename', 'unknown')
|
||
data = attachment.get('data', b'')
|
||
else:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message", f"Skipping invalid attachment format: {type(attachment)}")
|
||
continue
|
||
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(data, '__iter__') and not isinstance(data, (bytes, bytearray)):
|
||
data = bytes(data)
|
||
|
||
field_5_data.append([filename, data])
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"📎 File attachment: {filename} ({len(data)} bytes)")
|
||
|
||
if field_5_data:
|
||
fields[5] = field_5_data
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"📎 Attaching {len(field_5_data)} file(s)")
|
||
|
||
# Add icon appearance to outgoing messages if provided (Sideband/MeshChat interop)
|
||
# Format: [icon_name, fg_bytes(3), bg_bytes(3)] - same as Sideband
|
||
if icon_name and icon_fg_color and icon_bg_color:
|
||
if fields is None:
|
||
fields = {}
|
||
fg_bytes = bytes.fromhex(icon_fg_color)
|
||
bg_bytes = bytes.fromhex(icon_bg_color)
|
||
fields[FIELD_ICON_APPEARANCE] = [
|
||
icon_name,
|
||
fg_bytes,
|
||
bg_bytes
|
||
]
|
||
log_info("ReticulumWrapper", "send_lxmf_message",
|
||
f"📎 Adding icon appearance: {icon_name}, fg={icon_fg_color} ({fg_bytes.hex()}), bg={icon_bg_color} ({bg_bytes.hex()})")
|
||
|
||
# Create LXMF message using destination OBJECTS
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Creating LXMessage with destination objects...")
|
||
lxmf_message = LXMF.LXMessage(
|
||
destination=recipient_lxmf_destination, # ✅ Destination OBJECT!
|
||
source=self.local_lxmf_destination, # ✅ Our local LXMF destination OBJECT!
|
||
content=content.encode('utf-8'),
|
||
title="", # Optional
|
||
fields=fields
|
||
)
|
||
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ LXMessage created successfully!")
|
||
|
||
# Register delivery status callbacks (event-driven architecture - no polling!)
|
||
try:
|
||
lxmf_message.register_delivery_callback(self._on_message_delivered)
|
||
lxmf_message.register_failed_callback(self._on_message_failed)
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", "Delivery status callbacks registered")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message",
|
||
f"Could not register delivery callbacks: {e}")
|
||
|
||
# Announce our LXMF destination before sending to ensure recipient has our identity
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Announcing our LXMF destination before sending...")
|
||
try:
|
||
self.local_lxmf_destination.announce(app_data=self.display_name.encode('utf-8'))
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Announced our LXMF destination with display name: {self.display_name}")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message", f"Warning: Could not announce before sending: {e}")
|
||
|
||
# Also announce telephony destination so recipient can discover our call path
|
||
self._announce_telephony()
|
||
|
||
# === PRE-SEND DIAGNOSTICS ===
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "-", 60)
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", "=== PRE-SEND ROUTE ANALYSIS ===")
|
||
|
||
dest_lxmf_hash = recipient_lxmf_destination.hash
|
||
dest_identity_hash = recipient_identity.hash
|
||
has_path = False
|
||
|
||
try:
|
||
import RNS.Transport as Transport
|
||
path_table = Transport.path_table if hasattr(Transport, 'path_table') else {}
|
||
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Target identity hash: {dest_identity_hash.hex()[:16]}...")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Target LXMF dest hash: {dest_lxmf_hash.hex()[:16]}...")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Path table has {len(path_table)} entries")
|
||
|
||
path_info = path_table.get(dest_lxmf_hash)
|
||
if path_info is not None:
|
||
has_path = True
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Path exists to {dest_lxmf_hash.hex()[:16]} - will send via path-based routing")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f" Path info: {path_info}")
|
||
else:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"ℹ️ No path to {dest_lxmf_hash.hex()[:16]} - LXMF will establish Link")
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f" 📎 Link-based delivery provides reliable transport with automatic retries")
|
||
|
||
# Log all known paths for debugging
|
||
if len(path_table) > 0:
|
||
all_hashes = [h.hex()[:16] for h in list(path_table.keys())]
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Known paths in table: {all_hashes[:10]}")
|
||
else:
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", "Path table is empty (paths may have expired)")
|
||
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message", f"Could not check path_table: {e}")
|
||
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "-", 60)
|
||
|
||
# Send via router (LXMF handles routing intelligently)
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Handing message to LXMF router...")
|
||
self.router.handle_outbound(lxmf_message)
|
||
|
||
# Hash is populated after handle_outbound
|
||
msg_hash = lxmf_message.hash if lxmf_message.hash else b'unknown'
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Message hash: {msg_hash.hex()[:16] if msg_hash != b'unknown' else 'unknown'}")
|
||
|
||
# Check if message transitioned to SENT state (0x04)
|
||
# LXMF.LXMessage.SENT means message was successfully transmitted to the network
|
||
try:
|
||
if hasattr(lxmf_message, 'state') and lxmf_message.state == LXMF.LXMessage.SENT:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Message state: SENT (0x04) - transmitted to network")
|
||
self._on_message_sent(lxmf_message)
|
||
else:
|
||
current_state = lxmf_message.state if hasattr(lxmf_message, 'state') else 'unknown'
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Message state after send: {current_state}")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message", f"Could not check message state: {e}")
|
||
|
||
# === POST-SEND DIAGNOSTICS ===
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "-", 60)
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", "=== POST-SEND DELIVERY STATUS ===")
|
||
|
||
# Check if Link was established
|
||
try:
|
||
link_established = False
|
||
active_link = None
|
||
|
||
# Check for active links to this destination
|
||
if hasattr(RNS.Transport, 'active_links'):
|
||
for link in RNS.Transport.active_links:
|
||
if hasattr(link, 'destination') and link.destination:
|
||
if link.destination.hash == dest_lxmf_hash or link.destination.hash == dest_identity_hash:
|
||
link_established = True
|
||
active_link = link
|
||
break
|
||
|
||
if link_established and active_link:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Active Link established to destination")
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f" 📎 Link ID: {active_link.link_id.hex()[:16]}... (Link ensures reliable delivery)")
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f" Link state: {active_link.status if hasattr(active_link, 'status') else 'ACTIVE'}")
|
||
elif has_path:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Sent via path-based routing")
|
||
else:
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f" Link may be establishing... (check logs for Link registration)")
|
||
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Could not check Link status: {e}")
|
||
|
||
# Check router's outbound queue
|
||
try:
|
||
if hasattr(self.router, 'pending_outbound'):
|
||
outbound_count = len(self.router.pending_outbound) if self.router.pending_outbound else 0
|
||
if outbound_count > 0:
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"📤 Router queue: {outbound_count} messages pending delivery")
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f" ⏳ Messages will be delivered when Link establishes or path becomes available")
|
||
else:
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Router queue: empty (message sent immediately)")
|
||
else:
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", "Router does not expose pending_outbound queue")
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "send_lxmf_message", f"Could not check pending_outbound: {e}")
|
||
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "-", 60)
|
||
|
||
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ LXMF message sent successfully!")
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "=", 80)
|
||
|
||
return {
|
||
"success": True,
|
||
"message_hash": lxmf_message.hash if lxmf_message.hash else b'',
|
||
"timestamp": int(time.time() * 1000),
|
||
"destination_hash": recipient_lxmf_destination.hash # Return actual LXMF destination hash used
|
||
}
|
||
|
||
except Exception as e:
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "=", 80)
|
||
log_error("ReticulumWrapper", "send_lxmf_message", f"❌ ERROR sending LXMF message: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
log_separator("ReticulumWrapper", "send_lxmf_message", "=", 80)
|
||
return {"success": False, "error": str(e)}
|
||
|
||
# ==================== LOCATION TELEMETRY ====================
|
||
|
||
def send_location_telemetry(self, dest_hash: bytes, location_json: str, source_identity_private_key: bytes,
|
||
icon_name: str = None, icon_fg_color: str = None, icon_bg_color: str = None) -> Dict:
|
||
"""
|
||
Send location telemetry to a destination via LXMF FIELD_TELEMETRY (0x02).
|
||
|
||
Uses Sideband's msgpack-packed Telemeter format for interoperability.
|
||
Cease signals are sent via FIELD_COLUMBA_META (0x70) for Columba clients.
|
||
|
||
Args:
|
||
dest_hash: Identity hash bytes (16 bytes) of the recipient
|
||
location_json: JSON string with location data (lat, lng, acc, ts, expires, cease)
|
||
source_identity_private_key: Private key of sender identity
|
||
icon_name: Optional icon name for FIELD_ICON_APPEARANCE (Sideband/MeshChat interop)
|
||
icon_fg_color: Optional foreground color hex string (3 bytes RGB)
|
||
icon_bg_color: Optional background color hex string (3 bytes RGB)
|
||
|
||
Returns:
|
||
Dict with 'success', 'message_hash', 'timestamp' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(dest_hash, '__iter__') and not isinstance(dest_hash, (bytes, bytearray)):
|
||
dest_hash = bytes(dest_hash)
|
||
if hasattr(source_identity_private_key, '__iter__') and not isinstance(source_identity_private_key, (bytes, bytearray)):
|
||
source_identity_private_key = bytes(source_identity_private_key)
|
||
|
||
log_info("ReticulumWrapper", "send_location_telemetry",
|
||
f"📍 Sending location telemetry to {dest_hash.hex()[:16]}...")
|
||
|
||
# Reconstruct source identity from private key
|
||
source_identity = RNS.Identity()
|
||
try:
|
||
source_identity.load_private_key(source_identity_private_key)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_location_telemetry", f"❌ ERROR loading private key: {e}")
|
||
raise
|
||
|
||
# Get our local LXMF destination
|
||
if not self.local_lxmf_destination:
|
||
raise ValueError("Local LXMF destination not created")
|
||
|
||
# Recall recipient identity
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
|
||
# Try local cache
|
||
dest_hash_hex = dest_hash.hex()
|
||
if not recipient_identity and dest_hash_hex in self.identities:
|
||
recipient_identity = self.identities[dest_hash_hex]
|
||
|
||
if not recipient_identity:
|
||
# Request path from network (triggers announces from peers who know destination)
|
||
log_info("ReticulumWrapper", "send_location_telemetry",
|
||
f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
|
||
try:
|
||
RNS.Transport.request_path(dest_hash)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_location_telemetry", f"Error requesting path: {e}")
|
||
|
||
# Wait up to 5 seconds for path response
|
||
for attempt in range(10):
|
||
time.sleep(0.5)
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if recipient_identity:
|
||
log_info("ReticulumWrapper", "send_location_telemetry",
|
||
f"✅ Identity resolved after path request (attempt {attempt + 1})")
|
||
break
|
||
|
||
if not recipient_identity:
|
||
error_msg = f"Recipient identity {dest_hash.hex()[:16]} not known. Path requested but no response received."
|
||
log_error("ReticulumWrapper", "send_location_telemetry", f"❌ {error_msg}")
|
||
return {"success": False, "error": error_msg}
|
||
|
||
# Create outgoing LXMF destination
|
||
recipient_lxmf_destination = RNS.Destination(
|
||
recipient_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
|
||
# Parse and validate location JSON
|
||
try:
|
||
location_data = json.loads(location_json)
|
||
log_debug("ReticulumWrapper", "send_location_telemetry",
|
||
f"Location data: lat={location_data.get('lat')}, lng={location_data.get('lng')}, cease={location_data.get('cease', False)}")
|
||
except json.JSONDecodeError as e:
|
||
return {"success": False, "error": f"Invalid location JSON: {e}"}
|
||
|
||
# Build LXMF fields based on message type
|
||
fields = {}
|
||
|
||
is_cease = location_data.get('cease', False)
|
||
|
||
if is_cease:
|
||
# Cease message: only send Columba-specific metadata (Sideband doesn't understand cease)
|
||
# Sideband will just let the location become stale naturally
|
||
fields[FIELD_COLUMBA_META] = json.dumps({"cease": True})
|
||
log_debug("ReticulumWrapper", "send_location_telemetry", "Sending cease signal via FIELD_COLUMBA_META")
|
||
else:
|
||
# Normal location update: use Sideband-compatible Telemeter format
|
||
packed_telemetry = pack_location_telemetry(
|
||
lat=location_data['lat'],
|
||
lon=location_data['lng'],
|
||
accuracy=location_data.get('acc', 0.0),
|
||
timestamp_ms=location_data.get('ts', int(time.time() * 1000)),
|
||
altitude=location_data.get('altitude', 0.0),
|
||
speed=location_data.get('speed', 0.0),
|
||
bearing=location_data.get('bearing', 0.0),
|
||
)
|
||
fields[FIELD_TELEMETRY] = packed_telemetry
|
||
|
||
# Also include Columba-specific metadata for enhanced Columba-to-Columba features
|
||
columba_meta = {}
|
||
if location_data.get('expires'):
|
||
columba_meta['expires'] = location_data['expires']
|
||
if location_data.get('approxRadius', 0) > 0:
|
||
columba_meta['approxRadius'] = location_data['approxRadius']
|
||
if columba_meta:
|
||
fields[FIELD_COLUMBA_META] = json.dumps(columba_meta)
|
||
|
||
log_debug("ReticulumWrapper", "send_location_telemetry",
|
||
f"Sending Sideband-compatible telemetry in FIELD_TELEMETRY (0x02)")
|
||
|
||
# Attach icon appearance if provided (Sideband/MeshChat interop)
|
||
if icon_name and icon_fg_color and icon_bg_color:
|
||
try:
|
||
fg_bytes = bytes.fromhex(icon_fg_color)
|
||
bg_bytes = bytes.fromhex(icon_bg_color)
|
||
fields[FIELD_ICON_APPEARANCE] = [icon_name, fg_bytes, bg_bytes]
|
||
log_debug("ReticulumWrapper", "send_location_telemetry",
|
||
f"📎 Adding icon appearance: {icon_name}")
|
||
except (ValueError, TypeError) as e:
|
||
log_warning("ReticulumWrapper", "send_location_telemetry",
|
||
f"Invalid icon color format, skipping appearance: {e}")
|
||
|
||
# Create LXMF message with location telemetry
|
||
lxmf_message = LXMF.LXMessage(
|
||
destination=recipient_lxmf_destination,
|
||
source=self.local_lxmf_destination,
|
||
content="".encode('utf-8'), # Empty content - location is in FIELD_TELEMETRY
|
||
title="",
|
||
fields=fields
|
||
)
|
||
|
||
# Register delivery callbacks
|
||
try:
|
||
lxmf_message.register_delivery_callback(self._on_message_delivered)
|
||
lxmf_message.register_failed_callback(self._on_message_failed)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_location_telemetry",
|
||
f"Could not register delivery callbacks: {e}")
|
||
|
||
# Send via router
|
||
self.router.handle_outbound(lxmf_message)
|
||
|
||
log_info("ReticulumWrapper", "send_location_telemetry",
|
||
f"✅ Location telemetry sent to {dest_hash.hex()[:16]}")
|
||
|
||
return {
|
||
"success": True,
|
||
"message_hash": lxmf_message.hash.hex() if lxmf_message.hash else "",
|
||
"timestamp": int(time.time() * 1000),
|
||
"destination_hash": recipient_lxmf_destination.hash.hex() if recipient_lxmf_destination.hash else ""
|
||
}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_location_telemetry", f"❌ ERROR sending location telemetry: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def send_telemetry_request(self, dest_hash: bytes, source_identity_private_key: bytes,
|
||
timebase: float = None, is_collector_request: bool = True) -> Dict:
|
||
"""
|
||
Send a telemetry request to a collector via LXMF FIELD_COMMANDS.
|
||
|
||
The collector will respond with FIELD_TELEMETRY_STREAM containing
|
||
all known telemetry entries since the specified timebase.
|
||
|
||
Args:
|
||
dest_hash: Identity hash bytes (16 bytes) of the collector
|
||
source_identity_private_key: Private key of sender identity
|
||
timebase: Optional Unix timestamp to request telemetry since (None = all)
|
||
is_collector_request: True if requesting from a collector (default)
|
||
|
||
Returns:
|
||
Dict with 'success', 'message_hash', 'timestamp' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(dest_hash, '__iter__') and not isinstance(dest_hash, (bytes, bytearray)):
|
||
dest_hash = bytes(dest_hash)
|
||
if hasattr(source_identity_private_key, '__iter__') and not isinstance(source_identity_private_key, (bytes, bytearray)):
|
||
source_identity_private_key = bytes(source_identity_private_key)
|
||
|
||
log_info("ReticulumWrapper", "send_telemetry_request",
|
||
f"📡 Sending telemetry request to collector {dest_hash.hex()[:16]}...")
|
||
|
||
# Reconstruct source identity from private key
|
||
source_identity = RNS.Identity()
|
||
try:
|
||
source_identity.load_private_key(source_identity_private_key)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_telemetry_request", f"❌ ERROR loading private key: {e}")
|
||
raise
|
||
|
||
# Get our local LXMF destination
|
||
if not self.local_lxmf_destination:
|
||
raise ValueError("Local LXMF destination not created")
|
||
|
||
# Recall recipient identity (the collector)
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
|
||
# Try local cache
|
||
dest_hash_hex = dest_hash.hex()
|
||
if not recipient_identity and dest_hash_hex in self.identities:
|
||
recipient_identity = self.identities[dest_hash_hex]
|
||
|
||
if not recipient_identity:
|
||
# Request path from network (triggers announces from peers who know destination)
|
||
log_info("ReticulumWrapper", "send_telemetry_request",
|
||
f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
|
||
try:
|
||
RNS.Transport.request_path(dest_hash)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_telemetry_request", f"Error requesting path: {e}")
|
||
|
||
# Wait up to 5 seconds for path response
|
||
for attempt in range(10):
|
||
time.sleep(0.5)
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if recipient_identity:
|
||
log_info("ReticulumWrapper", "send_telemetry_request",
|
||
f"✅ Identity resolved after path request (attempt {attempt + 1})")
|
||
break
|
||
|
||
if not recipient_identity:
|
||
error_msg = f"Collector identity {dest_hash.hex()[:16]} not known. Path requested but no response received."
|
||
log_error("ReticulumWrapper", "send_telemetry_request", f"❌ {error_msg}")
|
||
return {"success": False, "error": error_msg}
|
||
|
||
# Create outgoing LXMF destination
|
||
recipient_lxmf_destination = RNS.Destination(
|
||
recipient_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
|
||
# Build FIELD_COMMANDS with telemetry request
|
||
# Format: [{command_id: [args...]}] (list of dicts, matching Sideband)
|
||
# For telemetry request: [{0x01: [timebase, is_collector_request]}]
|
||
command_args = [timebase, is_collector_request]
|
||
commands = [{COMMAND_TELEMETRY_REQUEST: command_args}]
|
||
fields = {FIELD_COMMANDS: commands}
|
||
|
||
log_debug("ReticulumWrapper", "send_telemetry_request",
|
||
f"Sending FIELD_COMMANDS with telemetry request (timebase={timebase})")
|
||
|
||
# Create LXMF message with command
|
||
lxmf_message = LXMF.LXMessage(
|
||
destination=recipient_lxmf_destination,
|
||
source=self.local_lxmf_destination,
|
||
content="".encode('utf-8'), # Empty content - command is in FIELD_COMMANDS
|
||
title="",
|
||
fields=fields
|
||
)
|
||
|
||
# Register delivery callbacks
|
||
try:
|
||
lxmf_message.register_delivery_callback(self._on_message_delivered)
|
||
lxmf_message.register_failed_callback(self._on_message_failed)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_telemetry_request",
|
||
f"Could not register delivery callbacks: {e}")
|
||
|
||
# Send via router
|
||
self.router.handle_outbound(lxmf_message)
|
||
|
||
log_info("ReticulumWrapper", "send_telemetry_request",
|
||
f"✅ Telemetry request sent to collector {dest_hash.hex()[:16]}")
|
||
|
||
return {
|
||
"success": True,
|
||
"message_hash": lxmf_message.hash.hex() if lxmf_message.hash else "",
|
||
"timestamp": int(time.time() * 1000),
|
||
"destination_hash": recipient_lxmf_destination.hash.hex() if recipient_lxmf_destination.hash else ""
|
||
}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_telemetry_request", f"❌ ERROR sending telemetry request: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
# ==================== MESSAGE SIZE LIMITS ====================
|
||
|
||
def set_incoming_message_size_limit(self, limit_kb: int) -> Dict:
|
||
"""
|
||
Set the incoming message size limit.
|
||
|
||
This controls the maximum size of LXMF messages that can be received,
|
||
both for direct delivery and propagation node transfers. Both limits
|
||
are kept in sync for simplicity.
|
||
|
||
Args:
|
||
limit_kb: Size limit in KB (e.g., 1024 for 1MB, 131072 for 128MB "unlimited")
|
||
|
||
Returns:
|
||
Dict with 'success' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
# Set both limits to keep direct and propagation transfers in sync
|
||
self.router.delivery_per_transfer_limit = limit_kb
|
||
self.router.propagation_per_transfer_limit = limit_kb
|
||
log_info("ReticulumWrapper", "set_incoming_message_size_limit",
|
||
f"Set incoming message limit to {limit_kb}KB (delivery and propagation)")
|
||
|
||
return {"success": True}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "set_incoming_message_size_limit", f"Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
# ==================== PROPAGATION NODE SUPPORT ====================
|
||
|
||
def set_outbound_propagation_node(self, dest_hash: bytes) -> Dict:
|
||
"""
|
||
Set the propagation node to use for PROPAGATED delivery.
|
||
|
||
Args:
|
||
dest_hash: 16-byte destination hash of the propagation node, or None to clear
|
||
|
||
Returns:
|
||
Dict with 'success' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
# Convert jarray to bytes if needed
|
||
if dest_hash is not None:
|
||
if hasattr(dest_hash, '__iter__') and not isinstance(dest_hash, (bytes, bytearray)):
|
||
dest_hash = bytes(dest_hash)
|
||
|
||
if dest_hash is None:
|
||
# LXMF doesn't support passing None to clear the propagation node
|
||
# Just clear our internal tracking - LXMF will handle having no valid node
|
||
self.active_propagation_node = None
|
||
log_info("ReticulumWrapper", "set_outbound_propagation_node", "Cleared propagation node")
|
||
else:
|
||
self.router.set_outbound_propagation_node(dest_hash)
|
||
self.active_propagation_node = dest_hash
|
||
log_info("ReticulumWrapper", "set_outbound_propagation_node",
|
||
f"Set propagation node to {dest_hash.hex()[:16]}...")
|
||
|
||
return {"success": True}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "set_outbound_propagation_node", f"Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_outbound_propagation_node(self) -> Dict:
|
||
"""
|
||
Get the currently configured propagation node.
|
||
|
||
Returns:
|
||
Dict with 'success', 'propagation_node' (hex string or None) or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
node = self.router.get_outbound_propagation_node()
|
||
return {
|
||
"success": True,
|
||
"propagation_node": node.hex() if node else None
|
||
}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_outbound_propagation_node", f"Error: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def request_messages_from_propagation_node(self, identity_private_key: bytes = None, max_messages: int = 256) -> Dict:
|
||
"""
|
||
Request/sync messages from the configured propagation node.
|
||
|
||
This is the key method for RECEIVING messages that were sent via propagation.
|
||
When messages are sent to a propagation node, the recipient must explicitly
|
||
request them. Call this method periodically (e.g., every 30 seconds) to
|
||
retrieve waiting messages.
|
||
|
||
Args:
|
||
identity_private_key: Optional private key bytes to use for requesting messages.
|
||
If None, uses the default identity.
|
||
max_messages: Maximum number of messages to retrieve (default 256)
|
||
|
||
Returns:
|
||
Dict with 'success' or 'error', and 'state' indicating the transfer state
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
# Check if propagation node is configured
|
||
if not self.active_propagation_node:
|
||
return {
|
||
"success": False,
|
||
"error": "No propagation node configured",
|
||
"errorCode": "NO_PROPAGATION_NODE"
|
||
}
|
||
|
||
# Get or create identity for requesting messages
|
||
if identity_private_key is not None:
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(identity_private_key, '__iter__') and not isinstance(identity_private_key, (bytes, bytearray)):
|
||
identity_private_key = bytes(identity_private_key)
|
||
|
||
# Load identity from private key
|
||
identity = RNS.Identity.from_bytes(identity_private_key)
|
||
log_info("ReticulumWrapper", "request_messages_from_propagation_node",
|
||
f"Using provided identity: {identity.hash.hex()[:16]}...")
|
||
else:
|
||
# Use default identity
|
||
identity = self.default_identity
|
||
log_info("ReticulumWrapper", "request_messages_from_propagation_node",
|
||
f"Using default identity: {identity.hash.hex()[:16]}...")
|
||
|
||
log_info("ReticulumWrapper", "request_messages_from_propagation_node",
|
||
f"📡 Requesting up to {max_messages} messages from propagation node {self.active_propagation_node.hex()[:16]}...")
|
||
|
||
# Reset last propagation state and progress to force callback on any change.
|
||
# This is critical: if heartbeat loop is in 1-second idle mode, we might miss
|
||
# fast state transitions. By resetting to None/0, we ensure the next state
|
||
# (including COMPLETE) will be detected as a change and trigger the callback.
|
||
self._last_propagation_state = None
|
||
self._last_propagation_progress = 0.0
|
||
|
||
# Request messages from the propagation node
|
||
self.router.request_messages_from_propagation_node(identity, max_messages=max_messages)
|
||
|
||
return {
|
||
"success": True,
|
||
"state": self.router.propagation_transfer_state
|
||
}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "request_messages_from_propagation_node", f"Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_propagation_state(self) -> Dict:
|
||
"""
|
||
Get the current propagation sync state and progress.
|
||
|
||
State values:
|
||
0 (PR_IDLE): Inactive
|
||
1 (PR_PATH_REQUESTED): Path discovery in progress
|
||
2 (PR_LINK_ESTABLISHING): Connection pending
|
||
3 (PR_LINK_ESTABLISHED): Connected and ready
|
||
4 (PR_REQUEST_SENT): Message list requested
|
||
5 (PR_RECEIVING): Messages downloading
|
||
7 (PR_COMPLETE): Transfer finished
|
||
|
||
Returns:
|
||
Dict with 'success', 'state', 'progress', 'messages_received' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
state = self.router.propagation_transfer_state
|
||
progress = self.router.propagation_transfer_progress
|
||
|
||
# propagation_transfer_last_result contains the number of messages received
|
||
# in the last completed transfer
|
||
last_result = getattr(self.router, 'propagation_transfer_last_result', None) or 0
|
||
|
||
# Map state to human-readable string using LXMF constants
|
||
state_names = {
|
||
LXMF.LXMRouter.PR_IDLE: "idle",
|
||
LXMF.LXMRouter.PR_PATH_REQUESTED: "path_requested",
|
||
LXMF.LXMRouter.PR_LINK_ESTABLISHING: "link_establishing",
|
||
LXMF.LXMRouter.PR_LINK_ESTABLISHED: "link_established",
|
||
LXMF.LXMRouter.PR_REQUEST_SENT: "request_sent",
|
||
LXMF.LXMRouter.PR_RECEIVING: "receiving",
|
||
LXMF.LXMRouter.PR_RESPONSE_RECEIVED: "response_received",
|
||
LXMF.LXMRouter.PR_COMPLETE: "complete",
|
||
LXMF.LXMRouter.PR_NO_PATH: "no_path",
|
||
LXMF.LXMRouter.PR_LINK_FAILED: "link_failed",
|
||
LXMF.LXMRouter.PR_TRANSFER_FAILED: "transfer_failed",
|
||
LXMF.LXMRouter.PR_NO_IDENTITY_RCVD: "no_identity_rcvd",
|
||
LXMF.LXMRouter.PR_NO_ACCESS: "no_access",
|
||
}
|
||
state_name = state_names.get(state, f"unknown_{state}")
|
||
|
||
return {
|
||
"success": True,
|
||
"state": state,
|
||
"state_name": state_name,
|
||
"progress": progress,
|
||
"messages_received": last_result
|
||
}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_propagation_state", f"Error: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def send_lxmf_message_with_method(self, dest_hash: bytes, content: str, source_identity_private_key: bytes,
|
||
delivery_method: str = "direct", try_propagation_on_fail: bool = True,
|
||
image_data: bytes = None, image_format: str = None,
|
||
image_data_path: str = None,
|
||
file_attachments: list = None, file_attachment_paths: list = None,
|
||
reply_to_message_id: str = None,
|
||
icon_name: str = None, icon_fg_color: str = None, icon_bg_color: str = None) -> Dict:
|
||
"""
|
||
Send an LXMF message with explicit delivery method.
|
||
|
||
Args:
|
||
dest_hash: Identity hash bytes (16 bytes)
|
||
content: Message content string
|
||
source_identity_private_key: Private key of sender identity
|
||
delivery_method: "opportunistic", "direct", or "propagated"
|
||
try_propagation_on_fail: If True and direct fails, retry via propagation
|
||
image_data: Optional image data bytes (for small images)
|
||
image_format: Optional image format (e.g., 'jpg', 'png', 'webp')
|
||
image_data_path: Optional file path for large images to bypass Binder IPC limits.
|
||
File is read from disk and deleted after reading.
|
||
file_attachments: Optional list of [filename, bytes] pairs for Field 5 (small files)
|
||
file_attachment_paths: Optional list of [filename, path] pairs for large files
|
||
Files are read from disk to bypass Android Binder IPC limits.
|
||
Temp files are deleted after reading.
|
||
reply_to_message_id: Optional message ID being replied to (stored in Field 16)
|
||
icon_name: Optional icon name for FIELD_ICON_APPEARANCE (Sideband/MeshChat interop)
|
||
icon_fg_color: Optional foreground color hex string (3 bytes RGB)
|
||
icon_bg_color: Optional background color hex string (3 bytes RGB)
|
||
|
||
Returns:
|
||
Dict with 'success', 'message_hash', 'timestamp', 'delivery_method' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized", "delivery_method": None}
|
||
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(dest_hash, '__iter__') and not isinstance(dest_hash, (bytes, bytearray)):
|
||
dest_hash = bytes(dest_hash)
|
||
if hasattr(source_identity_private_key, '__iter__') and not isinstance(source_identity_private_key, (bytes, bytearray)):
|
||
source_identity_private_key = bytes(source_identity_private_key)
|
||
|
||
log_separator("ReticulumWrapper", "send_lxmf_message_with_method", "=", 80)
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Sending message with method={delivery_method}, try_propagation_on_fail={try_propagation_on_fail}")
|
||
|
||
# Map delivery method string to LXMF constant
|
||
method_map = {
|
||
"opportunistic": LXMF.LXMessage.OPPORTUNISTIC,
|
||
"direct": LXMF.LXMessage.DIRECT,
|
||
"propagated": LXMF.LXMessage.PROPAGATED,
|
||
}
|
||
|
||
lxmf_method = method_map.get(delivery_method.lower(), LXMF.LXMessage.DIRECT)
|
||
|
||
# Check size for OPPORTUNISTIC - max 295 bytes content
|
||
content_bytes = content.encode('utf-8')
|
||
if lxmf_method == LXMF.LXMessage.OPPORTUNISTIC:
|
||
if len(content_bytes) > 295:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Content too large for OPPORTUNISTIC ({len(content_bytes)} bytes > 295), falling back to DIRECT")
|
||
lxmf_method = LXMF.LXMessage.DIRECT
|
||
if image_data or image_data_path or file_attachments:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
"OPPORTUNISTIC doesn't support attachments, falling back to DIRECT")
|
||
lxmf_method = LXMF.LXMessage.DIRECT
|
||
|
||
# Check if PROPAGATED requires a propagation node - fall back to DIRECT if not available
|
||
# The message will retry via propagation when direct fails (if try_propagation_on_fail=True)
|
||
if lxmf_method == LXMF.LXMessage.PROPAGATED and not self.active_propagation_node:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
"No propagation node set, falling back to DIRECT (will retry via propagation later)")
|
||
lxmf_method = LXMF.LXMessage.DIRECT
|
||
|
||
# Reconstruct source identity
|
||
source_identity = RNS.Identity()
|
||
source_identity.load_private_key(source_identity_private_key)
|
||
|
||
# Recall recipient identity
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if not recipient_identity and dest_hash.hex() in self.identities:
|
||
recipient_identity = self.identities[dest_hash.hex()]
|
||
|
||
if not recipient_identity:
|
||
# Request path from network (triggers announces from peers who know destination)
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
|
||
try:
|
||
RNS.Transport.request_path(dest_hash)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message_with_method", f"Error requesting path: {e}")
|
||
|
||
# Wait up to 5 seconds for path response
|
||
for attempt in range(10):
|
||
time.sleep(0.5)
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if recipient_identity:
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"✅ Identity resolved after path request (attempt {attempt + 1})")
|
||
break
|
||
|
||
if not recipient_identity:
|
||
return {"success": False, "error": f"Recipient identity {dest_hash.hex()[:16]} not known. Path requested but no response received.", "delivery_method": None}
|
||
|
||
# Create destination
|
||
recipient_lxmf_destination = RNS.Destination(
|
||
recipient_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
|
||
# If image_data_path is provided, read from file (for large images bypassing Binder IPC)
|
||
if image_data_path and image_format:
|
||
import os
|
||
try:
|
||
with open(image_data_path, 'rb') as f:
|
||
image_data = f.read()
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"📎 Read large image from temp file: {len(image_data)} bytes")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Failed to read image from temp file: {e}")
|
||
return {"success": False, "error": f"Failed to read image file: {e}", "delivery_method": None}
|
||
finally:
|
||
# Always try to delete temp file (best effort cleanup)
|
||
try:
|
||
if os.path.exists(image_data_path):
|
||
os.remove(image_data_path)
|
||
log_debug("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Deleted temp image file: {image_data_path}")
|
||
except Exception as del_err:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Failed to delete temp image file: {del_err}")
|
||
|
||
# Prepare fields if image or file attachments provided
|
||
fields = None
|
||
if image_data and image_format:
|
||
if hasattr(image_data, '__iter__') and not isinstance(image_data, (bytes, bytearray)):
|
||
image_data = bytes(image_data)
|
||
fields = {FIELD_IMAGE: [image_format, image_data]}
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"📎 Attaching image: {len(image_data)} bytes, format={image_format}, "
|
||
f"field_key={FIELD_IMAGE}, format_type={type(image_format).__name__}, "
|
||
f"data_type={type(image_data).__name__}")
|
||
|
||
# Add file attachments to Field 5 if provided
|
||
converted_attachments = []
|
||
|
||
# Process small file attachments (bytes passed via Binder)
|
||
if file_attachments:
|
||
# Convert Java ArrayList to Python list if needed
|
||
if hasattr(file_attachments, 'toArray'):
|
||
file_attachments = list(file_attachments.toArray())
|
||
elif hasattr(file_attachments, '__iter__') and not isinstance(file_attachments, (list, tuple)):
|
||
file_attachments = list(file_attachments)
|
||
# Convert each attachment: [filename, bytes]
|
||
for attachment in file_attachments:
|
||
# Convert Java List to Python list if needed
|
||
if hasattr(attachment, 'toArray'):
|
||
attachment = list(attachment.toArray())
|
||
elif hasattr(attachment, '__iter__') and not isinstance(attachment, (list, tuple)):
|
||
attachment = list(attachment)
|
||
if len(attachment) >= 2:
|
||
filename = str(attachment[0])
|
||
data = attachment[1]
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(data, '__iter__') and not isinstance(data, (bytes, bytearray)):
|
||
data = bytes(data)
|
||
converted_attachments.append([filename, data])
|
||
|
||
# Process large file attachments (read from disk paths)
|
||
# These files were written to temp by Kotlin to bypass Binder IPC limits
|
||
if file_attachment_paths:
|
||
# Convert Java ArrayList to Python list if needed
|
||
if hasattr(file_attachment_paths, 'toArray'):
|
||
file_attachment_paths = list(file_attachment_paths.toArray())
|
||
elif hasattr(file_attachment_paths, '__iter__') and not isinstance(file_attachment_paths, (list, tuple)):
|
||
file_attachment_paths = list(file_attachment_paths)
|
||
|
||
for path_info in file_attachment_paths:
|
||
# Convert Java List to Python list if needed
|
||
if hasattr(path_info, 'toArray'):
|
||
path_info = list(path_info.toArray())
|
||
elif hasattr(path_info, '__iter__') and not isinstance(path_info, (list, tuple)):
|
||
path_info = list(path_info)
|
||
|
||
if len(path_info) >= 2:
|
||
filename = str(path_info[0])
|
||
file_path = str(path_info[1])
|
||
try:
|
||
with open(file_path, 'rb') as f:
|
||
data = f.read()
|
||
converted_attachments.append([filename, data])
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"📎 Read large file from disk: {filename} ({len(data)} bytes)")
|
||
# Delete the temp file after reading
|
||
try:
|
||
import os
|
||
os.remove(file_path)
|
||
log_debug("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"🗑️ Deleted temp file: {file_path}")
|
||
except Exception as del_err:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Failed to delete temp file {file_path}: {del_err}")
|
||
except Exception as read_err:
|
||
log_error("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Failed to read file from {file_path}: {read_err}")
|
||
|
||
if converted_attachments:
|
||
if fields is None:
|
||
fields = {}
|
||
fields[5] = converted_attachments
|
||
total_size = sum(len(a[1]) for a in converted_attachments)
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"📎 Attaching {len(converted_attachments)} file(s): {total_size} bytes total")
|
||
|
||
# Add Field 16 (app extensions) for reply_to and future features
|
||
# Field 16 is a dict that can contain: {"reply_to": "message_id", "reactions": {...}, etc.}
|
||
if reply_to_message_id:
|
||
if fields is None:
|
||
fields = {}
|
||
# Build app extensions dict
|
||
app_extensions = {"reply_to": reply_to_message_id}
|
||
fields[16] = app_extensions
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"📎 Replying to message: {reply_to_message_id[:16]}...")
|
||
|
||
# Add icon appearance to outgoing messages if provided (Sideband/MeshChat interop)
|
||
# Format: [icon_name, fg_bytes(3), bg_bytes(3)] - same as Sideband
|
||
if icon_name and icon_fg_color and icon_bg_color:
|
||
if fields is None:
|
||
fields = {}
|
||
fg_bytes = bytes.fromhex(icon_fg_color)
|
||
bg_bytes = bytes.fromhex(icon_bg_color)
|
||
fields[FIELD_ICON_APPEARANCE] = [
|
||
icon_name,
|
||
fg_bytes,
|
||
bg_bytes
|
||
]
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"📎 Adding icon appearance: {icon_name}, fg={icon_fg_color} ({fg_bytes.hex()}), bg={icon_bg_color} ({bg_bytes.hex()})")
|
||
|
||
# Create LXMF message with specified delivery method
|
||
lxmf_message = LXMF.LXMessage(
|
||
destination=recipient_lxmf_destination,
|
||
source=self.local_lxmf_destination,
|
||
content=content_bytes,
|
||
title="",
|
||
fields=fields,
|
||
desired_method=lxmf_method
|
||
)
|
||
|
||
# Store retry flag on message for use in _on_message_failed
|
||
if try_propagation_on_fail and self.active_propagation_node and lxmf_method != LXMF.LXMessage.PROPAGATED:
|
||
lxmf_message.try_propagation_on_fail = True
|
||
else:
|
||
lxmf_message.try_propagation_on_fail = False
|
||
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"LXMessage created with desired_method={lxmf_method}")
|
||
|
||
# Register callbacks
|
||
lxmf_message.register_delivery_callback(self._on_message_delivered)
|
||
lxmf_message.register_failed_callback(self._on_message_failed)
|
||
|
||
# Send via router
|
||
self.router.handle_outbound(lxmf_message)
|
||
|
||
msg_hash = lxmf_message.hash if lxmf_message.hash else b''
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"✅ Message {msg_hash.hex()[:16] if msg_hash else 'unknown'}... handed to router")
|
||
|
||
# Check if message transitioned to SENT state (0x04) immediately
|
||
# This happens for PROPAGATED messages when the relay accepts them
|
||
try:
|
||
if hasattr(lxmf_message, 'state') and lxmf_message.state == LXMF.LXMessage.SENT:
|
||
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"✅ Message state: SENT (0x04) - transmitted to network")
|
||
self._on_message_sent(lxmf_message)
|
||
else:
|
||
current_state = lxmf_message.state if hasattr(lxmf_message, 'state') else 'unknown'
|
||
log_debug("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Message state after send: {current_state}")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Could not check message state: {e}")
|
||
|
||
# Track opportunistic messages for timeout fallback
|
||
# If an opportunistic message doesn't get delivered within the timeout period,
|
||
# we'll trigger the failure callback to initiate propagation fallback
|
||
if lxmf_method == LXMF.LXMessage.OPPORTUNISTIC and lxmf_message.try_propagation_on_fail and msg_hash:
|
||
self._opportunistic_messages[msg_hash.hex()] = {
|
||
'message': lxmf_message,
|
||
'sent_time': time.time()
|
||
}
|
||
log_debug("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Tracking opportunistic message {msg_hash.hex()[:16]}... for timeout fallback")
|
||
# Ensure timer is running
|
||
self._start_opportunistic_timer()
|
||
|
||
# Track PROPAGATED messages with file attachments for pending notification
|
||
# When propagation succeeds, we'll send a lightweight notification to the recipient
|
||
if lxmf_method == LXMF.LXMessage.PROPAGATED and msg_hash:
|
||
if hasattr(lxmf_message, 'fields') and lxmf_message.fields and 5 in lxmf_message.fields:
|
||
self._pending_file_notifications[msg_hash.hex()] = lxmf_message
|
||
log_debug("ReticulumWrapper", "send_lxmf_message_with_method",
|
||
f"Tracking {msg_hash.hex()[:16]}... for pending file notification after propagation")
|
||
|
||
# Map method back to string for return
|
||
method_names = {
|
||
LXMF.LXMessage.OPPORTUNISTIC: "opportunistic",
|
||
LXMF.LXMessage.DIRECT: "direct",
|
||
LXMF.LXMessage.PROPAGATED: "propagated",
|
||
}
|
||
|
||
return {
|
||
"success": True,
|
||
"message_hash": msg_hash,
|
||
"timestamp": int(time.time() * 1000),
|
||
"delivery_method": method_names.get(lxmf_method, "unknown"),
|
||
"destination_hash": recipient_lxmf_destination.hash
|
||
}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_lxmf_message_with_method", f"❌ ERROR: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e), "delivery_method": None}
|
||
|
||
def send_reaction(self, dest_hash: bytes, target_message_id: str, emoji: str,
|
||
source_identity_private_key: bytes) -> Dict:
|
||
"""
|
||
Send an emoji reaction to a message via LXMF.
|
||
|
||
Reactions are sent as lightweight LXMF messages with Field 16 containing:
|
||
- reaction_to: The message ID being reacted to
|
||
- emoji: The emoji reaction
|
||
- sender: The sender's identity hash (for aggregation on receiver side)
|
||
|
||
Args:
|
||
dest_hash: Identity hash bytes (16 bytes) of the message recipient
|
||
target_message_id: The message ID being reacted to
|
||
emoji: The emoji reaction (e.g., "👍", "❤️", "😂")
|
||
source_identity_private_key: Private key of sender identity
|
||
|
||
Returns:
|
||
Dict with 'success', 'message_hash', 'timestamp' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return {"success": False, "error": "LXMF not initialized"}
|
||
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(dest_hash, '__iter__') and not isinstance(dest_hash, (bytes, bytearray)):
|
||
dest_hash = bytes(dest_hash)
|
||
if hasattr(source_identity_private_key, '__iter__') and not isinstance(source_identity_private_key, (bytes, bytearray)):
|
||
source_identity_private_key = bytes(source_identity_private_key)
|
||
|
||
log_separator("ReticulumWrapper", "send_reaction", "=", 80)
|
||
log_info("ReticulumWrapper", "send_reaction",
|
||
f"Sending reaction '{emoji}' to message {target_message_id[:16]}...")
|
||
|
||
# Reconstruct source identity from private key
|
||
source_identity = RNS.Identity()
|
||
source_identity.load_private_key(source_identity_private_key)
|
||
sender_hash_hex = source_identity.hash.hex()
|
||
log_debug("ReticulumWrapper", "send_reaction",
|
||
f"Loaded source identity, hash={sender_hash_hex[:16]}")
|
||
|
||
# Recall recipient identity
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if not recipient_identity and dest_hash.hex() in self.identities:
|
||
recipient_identity = self.identities[dest_hash.hex()]
|
||
|
||
if not recipient_identity:
|
||
# Request path from network
|
||
log_info("ReticulumWrapper", "send_reaction",
|
||
f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
|
||
try:
|
||
RNS.Transport.request_path(dest_hash)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "send_reaction", f"Error requesting path: {e}")
|
||
|
||
# Wait up to 5 seconds for path response
|
||
wait_start = time.time()
|
||
while time.time() - wait_start < 5:
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if recipient_identity:
|
||
break
|
||
time.sleep(0.1)
|
||
|
||
if not recipient_identity:
|
||
return {"success": False, "error": f"Recipient identity {dest_hash.hex()[:16]} not known"}
|
||
|
||
# Create destination
|
||
recipient_lxmf_destination = RNS.Destination(
|
||
recipient_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
|
||
# Build Field 16 with reaction data
|
||
# Format: {"reaction_to": "msg_id", "emoji": "👍", "sender": "sender_hash_hex"}
|
||
app_extensions = {
|
||
"reaction_to": target_message_id,
|
||
"emoji": emoji,
|
||
"sender": sender_hash_hex
|
||
}
|
||
fields = {16: app_extensions}
|
||
|
||
log_debug("ReticulumWrapper", "send_reaction",
|
||
f"Field 16: {app_extensions}")
|
||
|
||
# Reactions are small, use OPPORTUNISTIC for fast delivery
|
||
# Empty content - Sideband doesn't support reactions anyway
|
||
lxmf_message = LXMF.LXMessage(
|
||
destination=recipient_lxmf_destination,
|
||
source=self.local_lxmf_destination,
|
||
content=b"", # Empty content - reaction data is in fields
|
||
title="",
|
||
fields=fields,
|
||
desired_method=LXMF.LXMessage.OPPORTUNISTIC
|
||
)
|
||
|
||
# Register callbacks
|
||
lxmf_message.register_delivery_callback(self._on_message_delivered)
|
||
lxmf_message.register_failed_callback(self._on_message_failed)
|
||
|
||
# Send via router
|
||
self.router.handle_outbound(lxmf_message)
|
||
|
||
msg_hash = lxmf_message.hash if lxmf_message.hash else b''
|
||
log_info("ReticulumWrapper", "send_reaction",
|
||
f"✅ Reaction {msg_hash.hex()[:16] if msg_hash else 'unknown'}... sent")
|
||
|
||
return {
|
||
"success": True,
|
||
"message_hash": msg_hash,
|
||
"timestamp": int(time.time() * 1000),
|
||
"target_message_id": target_message_id,
|
||
"emoji": emoji
|
||
}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "send_reaction", f"❌ ERROR: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def _on_message_delivered(self, lxmf_message):
|
||
"""
|
||
Callback invoked by LXMF when a sent message acknowledgment is received.
|
||
|
||
For DIRECT messages: Called when recipient sends back proof of delivery.
|
||
For PROPAGATED messages: Called when relay accepts the message (NOT end recipient).
|
||
|
||
LXMF sets different states before calling this callback:
|
||
- DELIVERED (0x08): Direct delivery confirmed by recipient
|
||
- SENT (0x04): Propagated to relay, awaiting recipient sync
|
||
|
||
Args:
|
||
lxmf_message: The LXMF.LXMessage that was acknowledged
|
||
"""
|
||
try:
|
||
msg_hash = lxmf_message.hash.hex() if lxmf_message.hash else "unknown"
|
||
|
||
# Determine status based on LXMF message state
|
||
# LXMF sets state=DELIVERED for direct, state=SENT for propagated
|
||
if lxmf_message.state == LXMF.LXMessage.DELIVERED:
|
||
status = 'delivered'
|
||
log_info("ReticulumWrapper", "_on_message_delivered",
|
||
f"✅ Message {msg_hash[:16]}... DELIVERED (confirmed by recipient)")
|
||
else:
|
||
# state == SENT means propagated (relay accepted, recipient unknown)
|
||
status = 'propagated'
|
||
log_info("ReticulumWrapper", "_on_message_delivered",
|
||
f"📤 Message {msg_hash[:16]}... PROPAGATED (stored on relay)")
|
||
|
||
# If this message was tracked for pending file notification, send it now
|
||
# The notification tells the recipient to sync with the relay to get the file
|
||
if msg_hash in self._pending_file_notifications:
|
||
tracked_message = self._pending_file_notifications.pop(msg_hash)
|
||
log_info("ReticulumWrapper", "_on_message_delivered",
|
||
f"📬 Sending pending file notification now that propagation confirmed")
|
||
self._send_pending_file_notification(tracked_message)
|
||
|
||
# Remove from opportunistic tracking (if it was being tracked)
|
||
if msg_hash in self._opportunistic_messages:
|
||
del self._opportunistic_messages[msg_hash]
|
||
log_debug("ReticulumWrapper", "_on_message_delivered",
|
||
f"Removed {msg_hash[:16]}... from opportunistic tracking ({status})")
|
||
|
||
# Create status event for Kotlin
|
||
status_event = {
|
||
'message_hash': msg_hash,
|
||
'status': status,
|
||
'timestamp': int(time.time() * 1000)
|
||
}
|
||
|
||
# Invoke Kotlin callback if registered (same pattern as BLE bridge)
|
||
if self.kotlin_delivery_status_callback:
|
||
try:
|
||
import json
|
||
self.kotlin_delivery_status_callback(json.dumps(status_event))
|
||
log_debug("ReticulumWrapper", "_on_message_delivered",
|
||
"Kotlin callback invoked successfully")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message_delivered",
|
||
f"Error invoking Kotlin callback: {e}")
|
||
else:
|
||
log_warning("ReticulumWrapper", "_on_message_delivered",
|
||
"No Kotlin callback registered - delivery status not reported")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message_delivered",
|
||
f"Error in delivery callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _on_message_failed(self, lxmf_message):
|
||
"""
|
||
Callback invoked by LXMF when a sent message delivery fails.
|
||
This is called when delivery times out or is otherwise unsuccessful.
|
||
|
||
Handles three cases:
|
||
1. First failure with try_propagation_on_fail=True: Retry via current propagation node
|
||
2. Propagation retry failed, retries < max: Request alternative relay from Kotlin
|
||
3. Max retries exceeded or no alternatives: Fail message permanently
|
||
|
||
Args:
|
||
lxmf_message: The LXMF.LXMessage that failed
|
||
"""
|
||
try:
|
||
msg_hash = lxmf_message.hash.hex() if lxmf_message.hash else "unknown"
|
||
|
||
# CRITICAL FIX for issue #257: Guard against spurious failure callbacks
|
||
# LXMF may call failure callback for propagated messages because it expects
|
||
# delivery confirmation that will never come from the relay. If this message
|
||
# already successfully reached 'propagated' status, ignore this failure callback.
|
||
if msg_hash in self._successfully_propagated:
|
||
log_info("ReticulumWrapper", "_on_message_failed",
|
||
f"⚠️ Message {msg_hash[:16]}... already propagated, ignoring spurious failure callback")
|
||
return # Do NOT fail - already delivered to relay
|
||
|
||
# Remove from opportunistic tracking (if it was being tracked)
|
||
if msg_hash in self._opportunistic_messages:
|
||
del self._opportunistic_messages[msg_hash]
|
||
log_debug("ReticulumWrapper", "_on_message_failed",
|
||
f"Removed {msg_hash[:16]}... from opportunistic tracking (failed)")
|
||
|
||
# Initialize tracking attributes if not present
|
||
if not hasattr(lxmf_message, 'propagation_retry_attempted'):
|
||
lxmf_message.propagation_retry_attempted = False
|
||
if not hasattr(lxmf_message, 'tried_relays'):
|
||
lxmf_message.tried_relays = []
|
||
|
||
# Case 1: First failure - try propagation if enabled (Sideband pattern)
|
||
if (hasattr(lxmf_message, 'try_propagation_on_fail') and
|
||
lxmf_message.try_propagation_on_fail and
|
||
self.active_propagation_node and
|
||
not lxmf_message.propagation_retry_attempted):
|
||
|
||
log_info("ReticulumWrapper", "_on_message_failed",
|
||
f"📡 Message {msg_hash[:16]}... direct delivery failed, retrying via propagation node")
|
||
|
||
# Mark that we've attempted propagation and track the relay
|
||
lxmf_message.propagation_retry_attempted = True
|
||
lxmf_message.tried_relays.append(self.active_propagation_node)
|
||
|
||
# Clear retry flag to prevent infinite loop
|
||
lxmf_message.try_propagation_on_fail = False
|
||
# Reset delivery attempts
|
||
lxmf_message.delivery_attempts = 0
|
||
# Clear packed state so message can be re-packed
|
||
lxmf_message.packed = None
|
||
# Clear propagation-specific state for fresh stamp generation
|
||
lxmf_message.propagation_packed = None
|
||
lxmf_message.propagation_stamp = None
|
||
# Request deferred stamp generation - propagation nodes require valid stamps
|
||
lxmf_message.defer_propagation_stamp = True
|
||
# Switch to PROPAGATED delivery
|
||
lxmf_message.desired_method = LXMF.LXMessage.PROPAGATED
|
||
|
||
# Re-submit to router (will go through pending_deferred_stamps for stamp generation)
|
||
self.router.handle_outbound(lxmf_message)
|
||
|
||
# Check if propagation succeeded immediately (state = SENT)
|
||
# LXMF doesn't reliably call delivery callback for propagated messages,
|
||
# so we detect success here and emit 'propagated' status
|
||
try:
|
||
if hasattr(lxmf_message, 'state') and lxmf_message.state == LXMF.LXMessage.SENT:
|
||
log_info("ReticulumWrapper", "_on_message_failed",
|
||
f"✅ Propagation retry succeeded immediately for {msg_hash[:16]}...")
|
||
self._on_message_sent(lxmf_message)
|
||
return # Propagation confirmed, don't need to report as retrying
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "_on_message_failed",
|
||
f"Could not check propagation state: {e}")
|
||
|
||
# If message has file attachments, track it for notification AFTER propagation succeeds
|
||
# We don't send the notification immediately - wait until the relay confirms receipt
|
||
if hasattr(lxmf_message, 'fields') and lxmf_message.fields and 5 in lxmf_message.fields:
|
||
self._pending_file_notifications[msg_hash] = lxmf_message
|
||
log_debug("ReticulumWrapper", "_on_message_failed",
|
||
f"Tracking {msg_hash[:16]}... for pending file notification after propagation")
|
||
|
||
# Notify Kotlin of retry (status = "retrying_propagated")
|
||
if self.kotlin_delivery_status_callback:
|
||
try:
|
||
import json
|
||
status_event = {
|
||
'message_hash': msg_hash,
|
||
'status': 'retrying_propagated',
|
||
'timestamp': int(time.time() * 1000)
|
||
}
|
||
self.kotlin_delivery_status_callback(json.dumps(status_event))
|
||
log_debug("ReticulumWrapper", "_on_message_failed",
|
||
"Kotlin callback invoked with retrying_propagated status")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message_failed",
|
||
f"Error invoking Kotlin callback: {e}")
|
||
return # Don't report as failed - we're retrying
|
||
|
||
# Case 2: Propagation retry failed - try alternative relay if available
|
||
if (lxmf_message.propagation_retry_attempted and
|
||
len(lxmf_message.tried_relays) < self._max_relay_retries and
|
||
self.kotlin_request_alternative_relay_callback):
|
||
|
||
log_info("ReticulumWrapper", "_on_message_failed",
|
||
f"📡 Message {msg_hash[:16]}... propagation failed, requesting alternative relay "
|
||
f"(tried {len(lxmf_message.tried_relays)}/{self._max_relay_retries})")
|
||
|
||
# Store message for later retry when alternative is provided
|
||
self._pending_relay_fallback_messages[msg_hash] = lxmf_message
|
||
|
||
# Request alternative from Kotlin (exclude already tried relays)
|
||
import json
|
||
request = {
|
||
'message_hash': msg_hash,
|
||
'exclude_relays': [r.hex() if isinstance(r, bytes) else str(r) for r in lxmf_message.tried_relays],
|
||
'timestamp': int(time.time() * 1000)
|
||
}
|
||
try:
|
||
self.kotlin_request_alternative_relay_callback(json.dumps(request))
|
||
log_debug("ReticulumWrapper", "_on_message_failed",
|
||
f"Requested alternative relay for {msg_hash[:16]}...")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message_failed",
|
||
f"Error requesting alternative relay: {e}")
|
||
# Fall through to permanent failure
|
||
del self._pending_relay_fallback_messages[msg_hash]
|
||
|
||
# Notify Kotlin of status
|
||
if self.kotlin_delivery_status_callback:
|
||
try:
|
||
status_event = {
|
||
'message_hash': msg_hash,
|
||
'status': 'retrying_alternative_relay',
|
||
'tried_count': len(lxmf_message.tried_relays),
|
||
'timestamp': int(time.time() * 1000)
|
||
}
|
||
self.kotlin_delivery_status_callback(json.dumps(status_event))
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message_failed",
|
||
f"Error notifying status: {e}")
|
||
return # Don't report as failed yet - waiting for alternative
|
||
|
||
# Case 3: Max retries exceeded or no callback - fail permanently
|
||
if len(lxmf_message.tried_relays) >= self._max_relay_retries:
|
||
reason = 'max_relay_retries_exceeded'
|
||
else:
|
||
reason = 'delivery_failed'
|
||
self._fail_message_permanently(lxmf_message, reason)
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message_failed",
|
||
f"Error in failed callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _fail_message_permanently(self, lxmf_message, reason: str):
|
||
"""
|
||
Mark a message as permanently failed and notify Kotlin.
|
||
|
||
Args:
|
||
lxmf_message: The LXMF.LXMessage that failed
|
||
reason: Reason for failure (e.g., 'max_relay_retries_exceeded', 'no_relays_available')
|
||
"""
|
||
msg_hash = lxmf_message.hash.hex() if lxmf_message.hash else "unknown"
|
||
|
||
log_error("ReticulumWrapper", "_fail_message_permanently",
|
||
f"❌ Message {msg_hash[:16]}... FAILED permanently: {reason}")
|
||
|
||
# Remove from pending if present
|
||
if msg_hash in self._pending_relay_fallback_messages:
|
||
del self._pending_relay_fallback_messages[msg_hash]
|
||
|
||
# Notify Kotlin with failure reason
|
||
if self.kotlin_delivery_status_callback:
|
||
try:
|
||
import json
|
||
status_event = {
|
||
'message_hash': msg_hash,
|
||
'status': 'failed',
|
||
'reason': reason,
|
||
'timestamp': int(time.time() * 1000)
|
||
}
|
||
self.kotlin_delivery_status_callback(json.dumps(status_event))
|
||
log_debug("ReticulumWrapper", "_fail_message_permanently",
|
||
"Kotlin callback invoked successfully")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_fail_message_permanently",
|
||
f"Error invoking Kotlin callback: {e}")
|
||
else:
|
||
log_warning("ReticulumWrapper", "_fail_message_permanently",
|
||
"No Kotlin callback registered - failure status not reported")
|
||
|
||
def _extract_file_summary(self, lxmf_message) -> dict:
|
||
"""
|
||
Extract summary of file attachments from an LXMF message.
|
||
|
||
Args:
|
||
lxmf_message: LXMF.LXMessage with fields
|
||
|
||
Returns:
|
||
Dict with first_filename, file_count, total_size, or None if no attachments
|
||
"""
|
||
try:
|
||
if not hasattr(lxmf_message, 'fields') or not lxmf_message.fields:
|
||
return None
|
||
|
||
# Field 5 = FILE_ATTACHMENTS: list of [filename, bytes] tuples
|
||
if 5 not in lxmf_message.fields:
|
||
return None
|
||
|
||
attachments = lxmf_message.fields[5]
|
||
if not attachments or not isinstance(attachments, list):
|
||
return None
|
||
|
||
first_filename = "file"
|
||
file_count = 0
|
||
total_size = 0
|
||
|
||
for attachment in attachments:
|
||
if isinstance(attachment, (list, tuple)) and len(attachment) >= 2:
|
||
filename = str(attachment[0]) if attachment[0] else "file"
|
||
data = attachment[1]
|
||
size = len(data) if isinstance(data, (bytes, bytearray)) else 0
|
||
|
||
if file_count == 0:
|
||
first_filename = filename
|
||
|
||
file_count += 1
|
||
total_size += size
|
||
|
||
if file_count == 0:
|
||
return None
|
||
|
||
return {
|
||
'first_filename': first_filename,
|
||
'file_count': file_count,
|
||
'total_size': total_size
|
||
}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_extract_file_summary",
|
||
f"Error extracting file summary: {e}")
|
||
return None
|
||
|
||
def _send_pending_file_notification(self, lxmf_message):
|
||
"""
|
||
Send a lightweight notification to recipient that a file is coming via propagation.
|
||
|
||
This is called when direct delivery fails and we fall back to propagation for a
|
||
message with file attachments. The notification lets the recipient know they
|
||
need to sync with the relay to receive the file.
|
||
|
||
Args:
|
||
lxmf_message: The original LXMF.LXMessage being retried via propagation
|
||
"""
|
||
try:
|
||
# Extract file summary
|
||
file_summary = self._extract_file_summary(lxmf_message)
|
||
if not file_summary:
|
||
log_debug("ReticulumWrapper", "_send_pending_file_notification",
|
||
"No file attachments found, skipping notification")
|
||
return
|
||
|
||
msg_hash = lxmf_message.hash.hex() if lxmf_message.hash else "unknown"
|
||
|
||
# Build notification with Field 16 (APP_EXTENSIONS_FIELD)
|
||
import json
|
||
notification_data = {
|
||
"pending_file_notification": {
|
||
"original_message_id": msg_hash,
|
||
"filename": file_summary['first_filename'],
|
||
"file_count": file_summary['file_count'],
|
||
"total_size": file_summary['total_size'],
|
||
"timestamp": int(time.time() * 1000)
|
||
}
|
||
}
|
||
fields = {16: notification_data}
|
||
|
||
# Create notification message - use OPPORTUNISTIC for fast delivery
|
||
notification_msg = LXMF.LXMessage(
|
||
destination=lxmf_message.destination,
|
||
source=lxmf_message.source,
|
||
content=b"", # Empty content - notification only
|
||
title="",
|
||
fields=fields
|
||
)
|
||
notification_msg.desired_method = LXMF.LXMessage.OPPORTUNISTIC
|
||
|
||
# Submit to router
|
||
self.router.handle_outbound(notification_msg)
|
||
|
||
log_info("ReticulumWrapper", "_send_pending_file_notification",
|
||
f"📤 Sent pending file notification for {msg_hash[:16]}... "
|
||
f"({file_summary['file_count']} files, {file_summary['total_size']} bytes)")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_send_pending_file_notification",
|
||
f"Error sending notification: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def on_alternative_relay_received(self, relay_hash):
|
||
"""
|
||
Called from Kotlin when an alternative relay is provided.
|
||
|
||
When propagation to the current relay fails, Kotlin is asked to find an alternative.
|
||
This method is called with the result - either a new relay hash or None if none available.
|
||
|
||
Args:
|
||
relay_hash: 16-byte destination hash of alternative relay (bytes or jarray),
|
||
or None if no alternatives available
|
||
"""
|
||
try:
|
||
if not self._pending_relay_fallback_messages:
|
||
log_warning("ReticulumWrapper", "on_alternative_relay_received",
|
||
"No pending messages for relay fallback")
|
||
return
|
||
|
||
if relay_hash is None:
|
||
# No alternatives available - fail all pending messages
|
||
log_warning("ReticulumWrapper", "on_alternative_relay_received",
|
||
f"No alternative relays available, failing {len(self._pending_relay_fallback_messages)} messages")
|
||
for msg_hash, message in list(self._pending_relay_fallback_messages.items()):
|
||
self._fail_message_permanently(message, 'no_relays_available')
|
||
self._pending_relay_fallback_messages.clear()
|
||
return
|
||
|
||
# Convert jarray from Java if needed
|
||
if hasattr(relay_hash, '__iter__') and not isinstance(relay_hash, (bytes, bytearray)):
|
||
relay_hash = bytes(relay_hash)
|
||
|
||
relay_hex = relay_hash.hex() if isinstance(relay_hash, bytes) else str(relay_hash)
|
||
log_info("ReticulumWrapper", "on_alternative_relay_received",
|
||
f"📡 Received alternative relay: {relay_hex[:16]}..., retrying {len(self._pending_relay_fallback_messages)} messages")
|
||
|
||
# Update active propagation node (directly set to bypass init checks in fallback)
|
||
self.active_propagation_node = relay_hash
|
||
# Also update router if available
|
||
if self.initialized and self.router:
|
||
try:
|
||
self.router.set_outbound_propagation_node(relay_hash)
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "on_alternative_relay_received",
|
||
f"Could not update router propagation node: {e}")
|
||
|
||
# Retry all pending messages with new relay
|
||
for msg_hash, message in list(self._pending_relay_fallback_messages.items()):
|
||
# Track this relay attempt
|
||
if not hasattr(message, 'tried_relays'):
|
||
message.tried_relays = []
|
||
message.tried_relays.append(relay_hash)
|
||
|
||
# Reset for fresh retry
|
||
message.delivery_attempts = 0
|
||
message.packed = None
|
||
message.propagation_packed = None
|
||
message.propagation_stamp = None
|
||
message.defer_propagation_stamp = True
|
||
message.desired_method = LXMF.LXMessage.PROPAGATED
|
||
|
||
# Re-submit to router
|
||
self.router.handle_outbound(message)
|
||
|
||
# Notify Kotlin
|
||
if self.kotlin_delivery_status_callback:
|
||
try:
|
||
import json
|
||
status = {
|
||
'message_hash': msg_hash,
|
||
'status': 'retrying_propagated',
|
||
'relay_hash': relay_hex,
|
||
'timestamp': int(time.time() * 1000)
|
||
}
|
||
self.kotlin_delivery_status_callback(json.dumps(status))
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "on_alternative_relay_received",
|
||
f"Error notifying status: {e}")
|
||
|
||
self._pending_relay_fallback_messages.clear()
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "on_alternative_relay_received",
|
||
f"Error handling alternative relay: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _on_message_sent(self, lxmf_message):
|
||
"""
|
||
Called when a sent message reaches SENT state (0x04).
|
||
This means the message was successfully transmitted to the network,
|
||
but delivery proof has not yet been received.
|
||
|
||
For PROPAGATED messages, SENT state means the relay confirmed receipt.
|
||
LXMF doesn't reliably call the delivery callback for propagated messages,
|
||
so we detect propagation success here and emit 'propagated' status.
|
||
|
||
Note: This is NOT a callback from LXMF (no such callback exists).
|
||
We check the message state directly after handle_outbound().
|
||
|
||
Args:
|
||
lxmf_message: The LXMF.LXMessage that reached SENT state
|
||
"""
|
||
try:
|
||
msg_hash = lxmf_message.hash.hex() if lxmf_message.hash else "unknown"
|
||
|
||
# For PROPAGATED messages, SENT state means relay stored the message
|
||
# LXMF doesn't reliably call delivery callback for relay acceptance
|
||
if hasattr(lxmf_message, 'desired_method') and lxmf_message.desired_method == LXMF.LXMessage.PROPAGATED:
|
||
status = 'propagated'
|
||
log_info("ReticulumWrapper", "_on_message_sent",
|
||
f"📤 Message {msg_hash[:16]}... PROPAGATED (stored on relay)")
|
||
|
||
# Send pending file notification if tracked (for fallback propagation with attachments)
|
||
if msg_hash in self._pending_file_notifications:
|
||
tracked_message = self._pending_file_notifications.pop(msg_hash)
|
||
log_info("ReticulumWrapper", "_on_message_sent",
|
||
f"📬 Sending pending file notification now that propagation confirmed")
|
||
self._send_pending_file_notification(tracked_message)
|
||
|
||
# Track this message as successfully propagated (Issue #257 fix)
|
||
# This prevents spurious failure callbacks from degrading the status
|
||
self._successfully_propagated[msg_hash] = time.time()
|
||
log_debug("ReticulumWrapper", "_on_message_sent",
|
||
f"Tracking {msg_hash[:16]}... as successfully propagated")
|
||
else:
|
||
status = 'sent'
|
||
log_info("ReticulumWrapper", "_on_message_sent",
|
||
f"📤 Message {msg_hash[:16]}... SENT to network!")
|
||
|
||
# Create status event for Kotlin
|
||
status_event = {
|
||
'message_hash': msg_hash,
|
||
'status': status,
|
||
'timestamp': int(time.time() * 1000)
|
||
}
|
||
|
||
# Invoke Kotlin callback if registered (same pattern as delivery/failed)
|
||
if self.kotlin_delivery_status_callback:
|
||
try:
|
||
import json
|
||
self.kotlin_delivery_status_callback(json.dumps(status_event))
|
||
log_debug("ReticulumWrapper", "_on_message_sent",
|
||
"Kotlin callback invoked successfully")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message_sent",
|
||
f"Error invoking Kotlin callback: {e}")
|
||
else:
|
||
log_warning("ReticulumWrapper", "_on_message_sent",
|
||
"No Kotlin callback registered - sent status not reported")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_on_message_sent",
|
||
f"Error in sent callback: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _start_opportunistic_timer(self):
|
||
"""
|
||
Start the timer thread that checks for opportunistic message timeouts.
|
||
This is called when the first opportunistic message is tracked, or during initialize().
|
||
The timer thread is a daemon thread and will exit cleanly when the app shuts down.
|
||
"""
|
||
if self._opportunistic_timer is None or not self._opportunistic_timer.is_alive():
|
||
self._opportunistic_timer = threading.Thread(
|
||
target=self._opportunistic_timeout_loop,
|
||
daemon=True
|
||
)
|
||
self._opportunistic_timer.start()
|
||
log_info("ReticulumWrapper", "_start_opportunistic_timer",
|
||
"Started opportunistic message timeout checker")
|
||
|
||
def _opportunistic_timeout_loop(self):
|
||
"""
|
||
Background loop that periodically checks for timed-out opportunistic messages.
|
||
Runs every _opportunistic_check_interval seconds while self.initialized is True.
|
||
Also performs periodic cleanup of stale propagated message tracking entries.
|
||
"""
|
||
log_debug("ReticulumWrapper", "_opportunistic_timeout_loop", "Timeout loop started")
|
||
last_propagated_cleanup = time.time()
|
||
propagated_cleanup_interval = 3600 # Run cleanup every hour
|
||
|
||
while self.initialized:
|
||
time.sleep(self._opportunistic_check_interval)
|
||
if self._opportunistic_messages: # Only check if there are messages to track
|
||
self._check_opportunistic_timeouts()
|
||
|
||
# Periodically cleanup stale propagated tracking entries (Issue #257)
|
||
now = time.time()
|
||
if now - last_propagated_cleanup >= propagated_cleanup_interval:
|
||
self._cleanup_stale_propagated_tracking()
|
||
last_propagated_cleanup = now
|
||
|
||
log_debug("ReticulumWrapper", "_opportunistic_timeout_loop", "Timeout loop exiting (not initialized)")
|
||
|
||
def _check_opportunistic_timeouts(self):
|
||
"""
|
||
Check for opportunistic messages that have timed out waiting for delivery.
|
||
For any that have exceeded the timeout threshold, trigger the failure callback
|
||
to initiate propagation fallback.
|
||
|
||
This is the key fix for the issue where opportunistic messages to offline
|
||
recipients get stuck in SENT state forever.
|
||
"""
|
||
now = time.time()
|
||
timed_out = []
|
||
|
||
# Collect timed-out messages (iterate over copy to avoid modification during iteration)
|
||
for msg_hash, tracking in list(self._opportunistic_messages.items()):
|
||
elapsed = now - tracking['sent_time']
|
||
if elapsed >= self._opportunistic_timeout_seconds:
|
||
timed_out.append((msg_hash, tracking['message']))
|
||
|
||
# Process timed-out messages
|
||
for msg_hash, lxmf_message in timed_out:
|
||
log_info("ReticulumWrapper", "_check_opportunistic_timeouts",
|
||
f"⏱️ Opportunistic message {msg_hash[:16]}... timed out after "
|
||
f"{self._opportunistic_timeout_seconds}s, triggering propagation fallback")
|
||
# Remove from tracking dict (will also be removed in _on_message_failed, but do it here
|
||
# to prevent any race condition where the message could be processed twice)
|
||
if msg_hash in self._opportunistic_messages:
|
||
del self._opportunistic_messages[msg_hash]
|
||
# Trigger the failure callback which handles propagation fallback
|
||
self._on_message_failed(lxmf_message)
|
||
|
||
# ========================================================================
|
||
# Service Heartbeat (Sideband-inspired process monitoring)
|
||
# ========================================================================
|
||
|
||
def _start_heartbeat_thread(self):
|
||
"""
|
||
Start the heartbeat thread that updates the timestamp every second.
|
||
Kotlin monitors this timestamp and restarts the service if it becomes stale.
|
||
This is called during initialize() after setting self.initialized = True.
|
||
"""
|
||
if self._heartbeat_thread is None or not self._heartbeat_thread.is_alive():
|
||
self._heartbeat_thread = threading.Thread(
|
||
target=self._heartbeat_loop,
|
||
daemon=True
|
||
)
|
||
self._heartbeat_thread.start()
|
||
log_info("ReticulumWrapper", "_start_heartbeat_thread",
|
||
"Started service heartbeat thread (1s interval)")
|
||
|
||
def _get_propagation_state_name(self, state: int) -> str:
|
||
"""Map LXMF propagation state integer to human-readable name."""
|
||
state_names = {
|
||
LXMF.LXMRouter.PR_IDLE: "idle",
|
||
LXMF.LXMRouter.PR_PATH_REQUESTED: "path_requested",
|
||
LXMF.LXMRouter.PR_LINK_ESTABLISHING: "link_establishing",
|
||
LXMF.LXMRouter.PR_LINK_ESTABLISHED: "link_established",
|
||
LXMF.LXMRouter.PR_REQUEST_SENT: "request_sent",
|
||
LXMF.LXMRouter.PR_RECEIVING: "receiving",
|
||
LXMF.LXMRouter.PR_RESPONSE_RECEIVED: "response_received",
|
||
LXMF.LXMRouter.PR_COMPLETE: "complete",
|
||
LXMF.LXMRouter.PR_NO_PATH: "no_path",
|
||
LXMF.LXMRouter.PR_LINK_FAILED: "link_failed",
|
||
LXMF.LXMRouter.PR_TRANSFER_FAILED: "transfer_failed",
|
||
LXMF.LXMRouter.PR_NO_IDENTITY_RCVD: "no_identity_rcvd",
|
||
LXMF.LXMRouter.PR_NO_ACCESS: "no_access",
|
||
}
|
||
return state_names.get(state, f"unknown_{state}")
|
||
|
||
def _check_propagation_state_change(self):
|
||
"""
|
||
Check if LXMF propagation state or progress changed and notify Kotlin if so.
|
||
Called from heartbeat loop at higher frequency during active sync.
|
||
|
||
Fires callback when:
|
||
- State changes (idle → receiving → complete, etc.)
|
||
- Progress changes by more than 1% during active transfer (state 5 = RECEIVING)
|
||
"""
|
||
if not self.router or not self.kotlin_propagation_state_callback:
|
||
return
|
||
|
||
try:
|
||
current_state = self.router.propagation_transfer_state
|
||
progress = getattr(self.router, 'propagation_transfer_progress', 0.0) or 0.0
|
||
messages_received = getattr(self.router, 'propagation_transfer_last_result', 0) or 0
|
||
|
||
# Determine if we should send a callback:
|
||
# 1. State changed
|
||
# 2. Progress changed by more than 1% during active receiving (state 5)
|
||
state_changed = current_state != self._last_propagation_state
|
||
progress_changed = (current_state == 5 and # STATE_RECEIVING
|
||
abs(progress - self._last_propagation_progress) >= 0.01)
|
||
|
||
if state_changed or progress_changed:
|
||
self._last_propagation_state = current_state
|
||
self._last_propagation_progress = progress
|
||
|
||
state_info = {
|
||
"state": current_state,
|
||
"state_name": self._get_propagation_state_name(current_state),
|
||
"progress": progress,
|
||
"messages_received": messages_received
|
||
}
|
||
self.kotlin_propagation_state_callback(json.dumps(state_info))
|
||
|
||
if state_changed:
|
||
log_debug("ReticulumWrapper", "_check_propagation_state_change",
|
||
f"Propagation state changed: {state_info['state_name']} ({current_state})")
|
||
else:
|
||
log_debug("ReticulumWrapper", "_check_propagation_state_change",
|
||
f"Propagation progress: {progress:.1%}")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_check_propagation_state_change", f"Error: {e}")
|
||
|
||
def _heartbeat_loop(self):
|
||
"""
|
||
Background loop that updates the heartbeat timestamp.
|
||
Also monitors propagation state changes for real-time sync progress.
|
||
Uses faster interval (100ms) during active sync, slower (1s) when idle.
|
||
"""
|
||
log_debug("ReticulumWrapper", "_heartbeat_loop", "Heartbeat loop started")
|
||
while self.initialized:
|
||
self._heartbeat_timestamp = time.time()
|
||
|
||
# Check propagation state changes (for real-time sync progress)
|
||
self._check_propagation_state_change()
|
||
|
||
# Use faster interval during active sync (100ms), slower when idle (1s)
|
||
if (self.router and
|
||
self._last_propagation_state is not None and
|
||
self._last_propagation_state not in (0, 7, 0xf0, 0xf1, 0xf2, 0xf3, 0xf4)):
|
||
# Active sync in progress - check more frequently
|
||
time.sleep(0.1)
|
||
else:
|
||
# Idle or complete - normal heartbeat interval
|
||
time.sleep(1)
|
||
log_debug("ReticulumWrapper", "_heartbeat_loop", "Heartbeat loop exiting (not initialized)")
|
||
|
||
def get_heartbeat(self) -> float:
|
||
"""
|
||
Get the current heartbeat timestamp.
|
||
Kotlin calls this periodically to check if the Python process is responsive.
|
||
|
||
Returns:
|
||
Unix timestamp of the last heartbeat, or 0.0 if not initialized
|
||
"""
|
||
return self._heartbeat_timestamp
|
||
|
||
# ========================================================================
|
||
# Service Maintenance (Sideband-inspired interface recovery)
|
||
# ========================================================================
|
||
|
||
def _start_maintenance_thread(self):
|
||
"""
|
||
Start the maintenance thread that periodically checks for failed interfaces
|
||
and attempts to reinitialize them. This is called during initialize().
|
||
"""
|
||
if self._maintenance_thread is None or not self._maintenance_thread.is_alive():
|
||
self._maintenance_thread = threading.Thread(
|
||
target=self._maintenance_loop,
|
||
daemon=True
|
||
)
|
||
self._maintenance_thread.start()
|
||
log_info("ReticulumWrapper", "_start_maintenance_thread",
|
||
f"Started service maintenance thread ({self._interface_reinit_interval}s interval)")
|
||
|
||
def _maintenance_loop(self):
|
||
"""
|
||
Background loop that performs periodic maintenance tasks:
|
||
1. Checks for failed interfaces and attempts reinit
|
||
2. Could be extended for other maintenance tasks
|
||
|
||
Runs while self.initialized is True.
|
||
"""
|
||
log_debug("ReticulumWrapper", "_maintenance_loop", "Maintenance loop started")
|
||
while self.initialized:
|
||
time.sleep(1) # Check every second, but only reinit per interval
|
||
|
||
# Check if it's time to retry failed interfaces
|
||
now = time.time()
|
||
if (len(self.failed_interfaces) > 0 and
|
||
now - self._last_interface_reinit_attempt >= self._interface_reinit_interval):
|
||
self._retry_failed_interfaces()
|
||
self._last_interface_reinit_attempt = now
|
||
|
||
log_debug("ReticulumWrapper", "_maintenance_loop", "Maintenance loop exiting (not initialized)")
|
||
|
||
def _retry_failed_interfaces(self):
|
||
"""
|
||
Attempt to reinitialize interfaces that failed during startup.
|
||
This gives the service a chance to recover from transient failures.
|
||
"""
|
||
if not self.failed_interfaces:
|
||
return
|
||
|
||
log_info("ReticulumWrapper", "_retry_failed_interfaces",
|
||
f"Attempting to reinitialize {len(self.failed_interfaces)} failed interface(s)")
|
||
|
||
# For now, just log the failed interfaces - full reinit would require
|
||
# more complex logic to re-parse the config and recreate interfaces
|
||
for iface_info in self.failed_interfaces:
|
||
iface_type = iface_info.get('type', 'Unknown')
|
||
error = iface_info.get('error', 'Unknown error')
|
||
log_info("ReticulumWrapper", "_retry_failed_interfaces",
|
||
f" - {iface_type}: {error}")
|
||
|
||
# TODO: Implement actual interface reinit when we have the config available
|
||
# For now, clearing the list prevents repeated logging
|
||
# A more complete implementation would:
|
||
# 1. Store the original config for failed interfaces
|
||
# 2. Attempt to recreate the interface
|
||
# 3. On success, add to RNS.Transport.interfaces
|
||
# 4. On failure, keep in failed_interfaces for next retry
|
||
|
||
def _cleanup_stale_propagated_tracking(self):
|
||
"""
|
||
Remove entries from _successfully_propagated that are older than TTL.
|
||
This prevents memory leaks in long-running service.
|
||
|
||
Called periodically from _opportunistic_timeout_loop (Issue #257 fix).
|
||
"""
|
||
if not self._successfully_propagated:
|
||
return # Nothing to clean up
|
||
|
||
now = time.time()
|
||
stale = []
|
||
|
||
for msg_hash, timestamp in list(self._successfully_propagated.items()):
|
||
age = now - timestamp
|
||
if age >= self._propagated_tracking_ttl_seconds:
|
||
stale.append(msg_hash)
|
||
|
||
if stale:
|
||
for msg_hash in stale:
|
||
del self._successfully_propagated[msg_hash]
|
||
log_debug("ReticulumWrapper", "_cleanup_stale_propagated_tracking",
|
||
f"Cleaned up {len(stale)} stale propagated tracking entries")
|
||
|
||
def get_transport_identity_hash(self) -> bytes:
|
||
"""
|
||
Get the Reticulum Transport identity hash for BLE Protocol v2.
|
||
This is the 16-byte identity hash used for stable peer identification.
|
||
|
||
Returns:
|
||
16-byte identity hash, or None if not available
|
||
"""
|
||
if not self.initialized or self.transport_identity_hash is None:
|
||
log_warning("ReticulumWrapper", "get_transport_identity_hash", "WARNING: get_transport_identity_hash called before initialization")
|
||
return None
|
||
|
||
log_debug("ReticulumWrapper", "get_transport_identity_hash", f"Returning transport identity hash: {self.transport_identity_hash.hex()}")
|
||
return self.transport_identity_hash
|
||
|
||
def get_lxmf_identity(self) -> Dict:
|
||
"""
|
||
Get the LXMF router's identity.
|
||
This should be used for both announces and messaging to ensure consistency.
|
||
|
||
Returns:
|
||
Dict with identity data (hash, public_key, private_key)
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.router:
|
||
return {"error": "LXMF router not initialized"}
|
||
|
||
try:
|
||
identity = self.router.identity
|
||
return {
|
||
'hash': identity.hash,
|
||
'public_key': identity.get_public_key(),
|
||
'private_key': identity.get_private_key()
|
||
}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_lxmf_identity", f"Error getting LXMF identity: {e}")
|
||
return {"error": str(e)}
|
||
|
||
def recall_identity(self, destination_hash_hex: str) -> Dict:
|
||
"""
|
||
Attempt to recall an identity from Reticulum's local cache by destination hash.
|
||
|
||
This checks the known_destinations cache for a previously seen identity
|
||
that announced with this destination hash.
|
||
|
||
Args:
|
||
destination_hash_hex: The destination hash as a hex string (32 chars)
|
||
|
||
Returns:
|
||
Dict with:
|
||
- {"found": True, "public_key": "hex..."} if identity is found
|
||
- {"found": False} if identity is not in cache
|
||
- {"error": "..."} if an error occurred
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
return {"found": False, "error": "Reticulum not available"}
|
||
|
||
# Convert hex string to bytes
|
||
dest_hash = bytes.fromhex(destination_hash_hex)
|
||
log_debug("ReticulumWrapper", "recall_identity", f"Attempting to recall identity for dest hash: {destination_hash_hex[:16]}...")
|
||
|
||
# Try to recall the identity from Reticulum's cache
|
||
identity = RNS.Identity.recall(dest_hash)
|
||
|
||
if identity:
|
||
public_key = identity.get_public_key()
|
||
log_info("ReticulumWrapper", "recall_identity", f"Found identity in cache for {destination_hash_hex[:16]}...")
|
||
return {
|
||
"found": True,
|
||
"public_key": public_key.hex()
|
||
}
|
||
else:
|
||
log_debug("ReticulumWrapper", "recall_identity", f"No identity found in cache for {destination_hash_hex[:16]}...")
|
||
return {"found": False}
|
||
|
||
except ValueError as e:
|
||
log_error("ReticulumWrapper", "recall_identity", f"Invalid hex string: {e}")
|
||
return {"found": False, "error": f"Invalid hex string: {e}"}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "recall_identity", f"Error recalling identity: {e}")
|
||
return {"found": False, "error": str(e)}
|
||
|
||
def store_peer_identity(self, identity_hash: bytes, public_key: bytes) -> Dict:
|
||
"""
|
||
Store a peer's identity in Reticulum's identity store so it can be recalled later.
|
||
This is crucial for allowing message sending after app restarts.
|
||
|
||
Args:
|
||
identity_hash: The identity hash of the peer (16 bytes)
|
||
public_key: The public key of the peer (32 bytes)
|
||
|
||
Returns:
|
||
Dict with 'success' boolean and optional 'error' message
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
return {"success": False, "error": "Reticulum not available"}
|
||
|
||
# Convert jarray to bytes if needed
|
||
if hasattr(identity_hash, '__iter__') and not isinstance(identity_hash, (bytes, bytearray)):
|
||
identity_hash = bytes(identity_hash)
|
||
if hasattr(public_key, '__iter__') and not isinstance(public_key, (bytes, bytearray)):
|
||
public_key = bytes(public_key)
|
||
|
||
# NOTE: identity_hash parameter is now the actual IDENTITY hash (16 bytes from public key)
|
||
# NOT a destination hash (which would be 16 bytes derived from identity + app + aspect)
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f"Storing peer identity {identity_hash.hex()[:16]}... with public key (len={len(public_key)})")
|
||
|
||
# Create an Identity instance from the public key
|
||
identity = RNS.Identity(create_keys=False)
|
||
identity.load_public_key(public_key)
|
||
|
||
actual_identity_hash = identity.hash
|
||
log_info("ReticulumWrapper", "store_peer_identity", f"Created identity with hash: {actual_identity_hash.hex()[:16]}")
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f"Expected identity hash from DB: {identity_hash.hex()[:16]}")
|
||
|
||
# Check if the identity hash matches what's in the database
|
||
# If not, log warning but USE THE ACTUAL HASH (public key is source of truth)
|
||
if actual_identity_hash != identity_hash:
|
||
log_warning("ReticulumWrapper", "store_peer_identity",
|
||
f"⚠️ Identity hash mismatch: DB has {identity_hash.hex()[:16]} but public key hashes to {actual_identity_hash.hex()[:16]}")
|
||
log_warning("ReticulumWrapper", "store_peer_identity",
|
||
f"⚠️ Using actual hash from public key. Database may have stale/incorrect hash.")
|
||
# Continue with actual hash - don't fail the restoration
|
||
|
||
# Store the identity using Reticulum's internal mechanisms
|
||
# Since Transport.identity_table isn't publicly accessible, we need to use
|
||
# the methods that Reticulum provides for identity storage
|
||
try:
|
||
# Create and register the LXMF destination
|
||
# This is what Reticulum caches when announces arrive
|
||
lxmf_destination = RNS.Destination(
|
||
identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf", "delivery"
|
||
)
|
||
lxmf_dest_hash = lxmf_destination.hash
|
||
log_info("ReticulumWrapper", "store_peer_identity", f"Created LXMF destination with hash: {lxmf_dest_hash.hex()[:16]}")
|
||
|
||
# Try to register this destination with Reticulum
|
||
# Option 1: Try registering with the Transport layer
|
||
try:
|
||
# Register the destination hash in Transport so it can be recalled
|
||
RNS.Transport.register_destination(lxmf_destination)
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f"Registered destination with Transport.register_destination()")
|
||
except AttributeError:
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f"Transport.register_destination() not available")
|
||
except Exception as reg_err:
|
||
log_error("ReticulumWrapper", "store_peer_identity", f"Error calling register_destination: {reg_err}")
|
||
|
||
# Option 2: Store in our own cache for this session
|
||
# Store in self.identities for local recall
|
||
# IMPORTANT: Store using MULTIPLE hashes to handle all lookup scenarios:
|
||
# 1. Actual identity hash (computed from public key)
|
||
# 2. Database identity hash (may differ due to data issues)
|
||
# 3. LXMF destination hash (for send_lxmf_message lookup)
|
||
actual_identity_hash_hex = actual_identity_hash.hex()
|
||
db_identity_hash_hex = identity_hash.hex() # Hash from database (may be wrong)
|
||
lxmf_dest_hash_hex = lxmf_dest_hash.hex()
|
||
|
||
self.identities[actual_identity_hash_hex] = identity # Store by actual identity hash
|
||
self.identities[lxmf_dest_hash_hex] = identity # Store by LXMF dest hash (for send lookup)
|
||
|
||
# ALSO store by database hash if it differs (handles data integrity issues)
|
||
if actual_identity_hash != identity_hash:
|
||
self.identities[db_identity_hash_hex] = identity
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f" - by DB hash (mismatched): {db_identity_hash_hex[:16]}")
|
||
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f"Stored identity in local cache:")
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f" - by actual identity hash: {actual_identity_hash_hex[:16]}")
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f" - by LXMF dest hash: {lxmf_dest_hash_hex[:16]}")
|
||
|
||
# Option 3: Try to make Reticulum cache it by calling recall
|
||
# This might trigger internal caching
|
||
test_recall = RNS.Identity.recall(lxmf_dest_hash)
|
||
if test_recall:
|
||
log_info("ReticulumWrapper", "store_peer_identity", f"✅ Identity already recallable")
|
||
else:
|
||
log_warning("ReticulumWrapper", "store_peer_identity", f"Warning: Identity not yet recallable via RNS.Identity.recall()")
|
||
|
||
log_info("ReticulumWrapper", "store_peer_identity", f"✅ Stored peer identity and LXMF destination")
|
||
return {"success": True}
|
||
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "store_peer_identity", f"Could not store identity: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "store_peer_identity", f"Error storing peer identity: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def restore_all_peer_identities(self, peer_data) -> Dict:
|
||
"""
|
||
Restore multiple peer identities at once (e.g., on app startup).
|
||
|
||
Args:
|
||
peer_data: JSON string or List of dicts with 'identity_hash' and 'public_key' keys
|
||
|
||
Returns:
|
||
Dict with 'success' count and 'errors' list
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
return {"success_count": 0, "errors": ["Reticulum not available"]}
|
||
|
||
# Parse JSON string if needed
|
||
if isinstance(peer_data, str):
|
||
import json
|
||
peer_data = json.loads(peer_data)
|
||
|
||
log_debug("ReticulumWrapper", "restore_all_peer_identities", f"restore_all_peer_identities called with {len(peer_data)} peers")
|
||
|
||
success_count = 0
|
||
errors = []
|
||
|
||
for i, peer in enumerate(peer_data):
|
||
try:
|
||
identity_hash_str = peer.get('identity_hash')
|
||
public_key_str = peer.get('public_key')
|
||
|
||
if not identity_hash_str:
|
||
errors.append(f"Peer {i}: missing identity_hash (keys: {list(peer.keys())})")
|
||
continue
|
||
if not public_key_str:
|
||
errors.append(f"Peer {i}: missing public_key")
|
||
continue
|
||
|
||
# Convert hex string to bytes
|
||
identity_hash = bytes.fromhex(identity_hash_str)
|
||
|
||
# Decode base64 string to bytes
|
||
import base64
|
||
public_key = base64.b64decode(public_key_str)
|
||
|
||
result = self.store_peer_identity(identity_hash, public_key)
|
||
if result.get('success'):
|
||
success_count += 1
|
||
if i < 3: # Log first few successes
|
||
log_info("ReticulumWrapper", "restore_all_peer_identities", f"Successfully restored peer {i}: {identity_hash_str[:16]}")
|
||
else:
|
||
error_msg = f"Failed to restore {identity_hash_str[:16]}: {result.get('error')}"
|
||
errors.append(error_msg)
|
||
log_error("ReticulumWrapper", "restore_all_peer_identities", f"{error_msg}")
|
||
except Exception as e:
|
||
error_msg = f"Error processing peer {i}: {e}"
|
||
errors.append(error_msg)
|
||
log_error("ReticulumWrapper", "restore_all_peer_identities", f"{error_msg}")
|
||
|
||
log_info("ReticulumWrapper", "restore_all_peer_identities", f"Restored {success_count} peer identities, {len(errors)} errors")
|
||
return {"success_count": success_count, "errors": errors}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "restore_all_peer_identities", f"Error restoring peer identities: {e}")
|
||
return {"success_count": 0, "errors": [str(e)]}
|
||
|
||
def bulk_restore_announce_identities(self, announce_data) -> Dict:
|
||
"""
|
||
Bulk restore announce identities by directly populating Identity.known_destinations.
|
||
For announces, we have destination_hash directly - no computation needed.
|
||
This is much faster than creating full RNS Identity/Destination objects.
|
||
|
||
Args:
|
||
announce_data: JSON string or List of dicts with 'destination_hash' and 'public_key' keys
|
||
|
||
Returns:
|
||
Dict with 'success_count' and 'errors' list
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
return {"success_count": 0, "errors": ["Reticulum not available"]}
|
||
|
||
# Parse JSON string if needed
|
||
if isinstance(announce_data, str):
|
||
import json
|
||
announce_data = json.loads(announce_data)
|
||
|
||
log_debug("ReticulumWrapper", "bulk_restore_announce_identities",
|
||
f"Bulk restoring {len(announce_data)} announce identities")
|
||
|
||
import time
|
||
import base64
|
||
|
||
success_count = 0
|
||
errors = []
|
||
expected_key_size = RNS.Identity.KEYSIZE // 8 # Convert bits to bytes (512 bits = 64 bytes)
|
||
|
||
for i, announce in enumerate(announce_data):
|
||
try:
|
||
dest_hash_str = announce.get('destination_hash')
|
||
public_key_str = announce.get('public_key')
|
||
|
||
if not dest_hash_str:
|
||
errors.append(f"Announce {i}: missing destination_hash")
|
||
continue
|
||
if not public_key_str:
|
||
errors.append(f"Announce {i}: missing public_key")
|
||
continue
|
||
|
||
# Convert hex string to bytes
|
||
dest_hash = bytes.fromhex(dest_hash_str)
|
||
|
||
# Decode base64 string to bytes
|
||
public_key = base64.b64decode(public_key_str)
|
||
|
||
# Validate public key size
|
||
if len(public_key) != expected_key_size:
|
||
errors.append(f"Announce {i}: Invalid public key size {len(public_key)}, expected {expected_key_size}")
|
||
continue
|
||
|
||
# Directly populate Identity.known_destinations
|
||
# Format: [timestamp, packet_hash, public_key, app_data]
|
||
RNS.Identity.known_destinations[dest_hash] = [
|
||
time.time(), # timestamp
|
||
None, # packet_hash (not needed for recall)
|
||
public_key, # public key bytes
|
||
None # app_data
|
||
]
|
||
|
||
# Also store in local identities cache for wrapper lookups
|
||
# Create a lightweight identity object for local cache
|
||
identity = RNS.Identity(create_keys=False)
|
||
identity.load_public_key(public_key)
|
||
self.identities[dest_hash_str] = identity
|
||
|
||
success_count += 1
|
||
|
||
except Exception as e:
|
||
errors.append(f"Error processing announce {i}: {e}")
|
||
|
||
log_info("ReticulumWrapper", "bulk_restore_announce_identities",
|
||
f"Bulk restored {success_count} announce identities, {len(errors)} errors")
|
||
return {"success_count": success_count, "errors": errors}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "bulk_restore_announce_identities",
|
||
f"Error bulk restoring announce identities: {e}")
|
||
return {"success_count": 0, "errors": [str(e)]}
|
||
|
||
def bulk_restore_peer_identities(self, peer_data) -> Dict:
|
||
"""
|
||
Bulk restore peer identities using lightweight hash computation.
|
||
For peer identities, we must compute destination_hash from identity_hash.
|
||
This is faster than creating full RNS Identity/Destination objects.
|
||
|
||
Args:
|
||
peer_data: JSON string or List of dicts with 'identity_hash' and 'public_key' keys
|
||
|
||
Returns:
|
||
Dict with 'success_count' and 'errors' list
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
return {"success_count": 0, "errors": ["Reticulum not available"]}
|
||
|
||
# Parse JSON string if needed
|
||
if isinstance(peer_data, str):
|
||
import json
|
||
peer_data = json.loads(peer_data)
|
||
|
||
log_debug("ReticulumWrapper", "bulk_restore_peer_identities",
|
||
f"Bulk restoring {len(peer_data)} peer identities")
|
||
|
||
import time
|
||
import base64
|
||
|
||
success_count = 0
|
||
errors = []
|
||
expected_key_size = RNS.Identity.KEYSIZE // 8 # Convert bits to bytes
|
||
|
||
# Precompute the LXMF name_hash (constant for all LXMF delivery destinations)
|
||
# This is: hash("lxmf.delivery")[:NAME_HASH_LENGTH//8]
|
||
lxmf_name = "lxmf.delivery"
|
||
lxmf_name_hash = RNS.Identity.full_hash(lxmf_name.encode("utf-8"))[:(RNS.Identity.NAME_HASH_LENGTH // 8)]
|
||
|
||
for i, peer in enumerate(peer_data):
|
||
try:
|
||
identity_hash_str = peer.get('identity_hash')
|
||
public_key_str = peer.get('public_key')
|
||
|
||
if not identity_hash_str:
|
||
errors.append(f"Peer {i}: missing identity_hash")
|
||
continue
|
||
if not public_key_str:
|
||
errors.append(f"Peer {i}: missing public_key")
|
||
continue
|
||
|
||
# Decode base64 string to bytes
|
||
public_key = base64.b64decode(public_key_str)
|
||
|
||
# Validate public key size
|
||
if len(public_key) != expected_key_size:
|
||
errors.append(f"Peer {i}: Invalid public key size {len(public_key)}, expected {expected_key_size}")
|
||
continue
|
||
|
||
# Compute the identity hash from the public key (this is the authoritative hash)
|
||
# Identity hash = truncated_hash(public_key)
|
||
actual_identity_hash = RNS.Identity.truncated_hash(public_key)
|
||
|
||
# Compute the LXMF delivery destination hash
|
||
# dest_hash = full_hash(name_hash + identity_hash)[:TRUNCATED_HASHLENGTH//8]
|
||
addr_hash_material = lxmf_name_hash + actual_identity_hash
|
||
dest_hash = RNS.Identity.full_hash(addr_hash_material)[:RNS.Reticulum.TRUNCATED_HASHLENGTH // 8]
|
||
|
||
# Directly populate Identity.known_destinations
|
||
# Format: [timestamp, packet_hash, public_key, app_data]
|
||
RNS.Identity.known_destinations[dest_hash] = [
|
||
time.time(), # timestamp
|
||
None, # packet_hash (not needed for recall)
|
||
public_key, # public key bytes
|
||
None # app_data
|
||
]
|
||
|
||
# Also store in local identities cache for wrapper lookups
|
||
identity = RNS.Identity(create_keys=False)
|
||
identity.load_public_key(public_key)
|
||
|
||
# Store by multiple keys for lookup flexibility
|
||
self.identities[actual_identity_hash.hex()] = identity
|
||
self.identities[dest_hash.hex()] = identity
|
||
|
||
success_count += 1
|
||
|
||
except Exception as e:
|
||
errors.append(f"Error processing peer {i}: {e}")
|
||
|
||
log_info("ReticulumWrapper", "bulk_restore_peer_identities",
|
||
f"Bulk restored {success_count} peer identities, {len(errors)} errors")
|
||
return {"success_count": success_count, "errors": errors}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "bulk_restore_peer_identities",
|
||
f"Error bulk restoring peer identities: {e}")
|
||
return {"success_count": 0, "errors": [str(e)]}
|
||
|
||
def get_lxmf_destination(self) -> Dict:
|
||
"""
|
||
Get the local LXMF delivery destination hash.
|
||
|
||
Returns:
|
||
Dict with destination data
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.local_lxmf_destination:
|
||
return {"error": "LXMF destination not created"}
|
||
|
||
return {
|
||
'hash': self.local_lxmf_destination.hash,
|
||
'hex_hash': self.local_lxmf_destination.hexhash
|
||
}
|
||
|
||
def poll_received_messages(self) -> List[Dict]:
|
||
"""
|
||
Fetch and clear pending LXMF messages from the queue.
|
||
|
||
Called by:
|
||
- Startup drain: catches messages that arrived before callback registration
|
||
- Event-driven fetch: retrieves message when callback notification fires
|
||
|
||
Returns:
|
||
List of received message dicts
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.initialized or not self.router:
|
||
return []
|
||
|
||
try:
|
||
new_messages = []
|
||
|
||
# Check pending inbound messages
|
||
if hasattr(self.router, 'pending_inbound') and self.router.pending_inbound:
|
||
log_info("ReticulumWrapper", "poll_received_messages", f"📬 Found {len(self.router.pending_inbound)} pending message(s)")
|
||
|
||
for lxmf_message in list(self.router.pending_inbound):
|
||
try:
|
||
msg_hash = lxmf_message.hash.hex()
|
||
if msg_hash in self.seen_message_hashes:
|
||
continue
|
||
|
||
self.seen_message_hashes.add(msg_hash)
|
||
|
||
# Extract message data
|
||
message_event = {
|
||
'message_hash': msg_hash,
|
||
'content': lxmf_message.content.decode('utf-8') if isinstance(lxmf_message.content, bytes) else str(lxmf_message.content),
|
||
'source_hash': lxmf_message.source_hash,
|
||
'destination_hash': lxmf_message.destination_hash,
|
||
'timestamp': int(lxmf_message.timestamp * 1000) if lxmf_message.timestamp else int(time.time() * 1000)
|
||
}
|
||
|
||
# Use hop count and interface captured at delivery time
|
||
# (values are stored on message object in _on_lxmf_delivery)
|
||
if hasattr(lxmf_message, '_columba_hops'):
|
||
message_event['hops'] = lxmf_message._columba_hops
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"📡 Hop count (captured at delivery): {lxmf_message._columba_hops}")
|
||
if hasattr(lxmf_message, '_columba_interface'):
|
||
message_event['receiving_interface'] = lxmf_message._columba_interface
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"📡 Receiving interface (captured at delivery): {lxmf_message._columba_interface}")
|
||
|
||
# Add signal quality metrics if captured at delivery time
|
||
add_signal_to_message_event(
|
||
message_event,
|
||
getattr(lxmf_message, '_columba_rssi', None),
|
||
getattr(lxmf_message, '_columba_snr', None)
|
||
)
|
||
|
||
# Try to get sender's public key from RNS identity cache
|
||
try:
|
||
source_identity = RNS.Identity.recall(lxmf_message.source_hash)
|
||
if source_identity is not None:
|
||
public_key = source_identity.get_public_key()
|
||
if public_key:
|
||
message_event['public_key'] = public_key
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"✅ Found public key for sender {lxmf_message.source_hash.hex()[:16]}")
|
||
else:
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"⚠️ No identity found for sender {lxmf_message.source_hash.hex()[:16]}")
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"⚠️ Error getting public key: {e}")
|
||
|
||
# Extract LXMF fields (attachments, images, etc.)
|
||
if hasattr(lxmf_message, 'fields') and lxmf_message.fields:
|
||
fields_serialized = {}
|
||
for key, value in lxmf_message.fields.items():
|
||
# Handle different LXMF field formats
|
||
# Field 5 (FILE_ATTACHMENTS): list of [filename, bytes] tuples
|
||
# Field 6 (IMAGE): ['format', bytes] e.g. ['jpg', b'\xff\xd8...']
|
||
# Field 7 (AUDIO): ['format', bytes]
|
||
|
||
if key == 5 and isinstance(value, list):
|
||
# Field 5 is a list of [filename, bytes] tuples
|
||
serialized_attachments = []
|
||
for attachment in value:
|
||
if isinstance(attachment, (list, tuple)) and len(attachment) >= 2:
|
||
filename = attachment[0]
|
||
file_data = attachment[1]
|
||
if isinstance(file_data, bytes):
|
||
serialized_attachments.append({
|
||
'filename': str(filename),
|
||
'data': file_data.hex(),
|
||
'size': len(file_data)
|
||
})
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"Field 5: file '{filename}' ({len(file_data)} bytes)")
|
||
if serialized_attachments:
|
||
fields_serialized['5'] = serialized_attachments
|
||
log_info("ReticulumWrapper", "poll_received_messages",
|
||
f"📎 Field 5: extracted {len(serialized_attachments)} file attachment(s)")
|
||
|
||
elif key == 16 and isinstance(value, dict):
|
||
# Field 16 is app extensions dict: {"reply_to": "...", "reactions": {...}, etc.}
|
||
fields_serialized['16'] = value
|
||
|
||
# Check for reaction message (has reaction_to key)
|
||
if 'reaction_to' in value:
|
||
# Mark message as a reaction for easy identification by Kotlin
|
||
message_event['is_reaction'] = True
|
||
message_event['reaction_to'] = value.get('reaction_to', '')
|
||
message_event['reaction_emoji'] = value.get('emoji', '')
|
||
message_event['reaction_sender'] = value.get('sender', '')
|
||
log_info("ReticulumWrapper", "poll_received_messages",
|
||
f"😀 Field 16: reaction '{value.get('emoji', '')}' to message {value['reaction_to'][:16]}...")
|
||
elif 'reply_to' in value:
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"Field 16: reply to message {value['reply_to'][:16]}...")
|
||
else:
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"Field 16: app extensions with keys {list(value.keys())}")
|
||
|
||
elif key == 4 and isinstance(value, list) and len(value) >= 3:
|
||
# Field 4 (FIELD_ICON_APPEARANCE): [icon_name, fg_rgb, bg_rgb]
|
||
try:
|
||
icon_appearance = {
|
||
'icon_name': value[0],
|
||
'foreground_color': value[1].hex() if isinstance(value[1], bytes) else value[1],
|
||
'background_color': value[2].hex() if isinstance(value[2], bytes) else value[2],
|
||
}
|
||
message_event['icon_appearance'] = icon_appearance
|
||
fields_serialized['4'] = icon_appearance
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"Field 4: icon appearance '{icon_appearance['icon_name']}'")
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"Failed to parse icon appearance: {e}")
|
||
fields_serialized[str(key)] = str(value)
|
||
|
||
elif isinstance(value, (list, tuple)) and len(value) >= 2:
|
||
# Image/audio format: [format_string, bytes_data]
|
||
if isinstance(value[1], bytes):
|
||
fields_serialized[str(key)] = value[1].hex()
|
||
log_debug("ReticulumWrapper", "poll_received_messages",
|
||
f"Field {key}: extracted {len(value[1])} bytes ({value[0] if value[0] else 'unknown'} format)")
|
||
else:
|
||
fields_serialized[str(key)] = str(value)
|
||
elif isinstance(value, bytes):
|
||
fields_serialized[str(key)] = value.hex()
|
||
else:
|
||
fields_serialized[str(key)] = str(value)
|
||
message_event['fields'] = fields_serialized
|
||
log_info("ReticulumWrapper", "poll_received_messages", f"📎 Message has {len(fields_serialized)} field(s): {list(fields_serialized.keys())}")
|
||
|
||
new_messages.append(message_event)
|
||
log_debug("ReticulumWrapper", "poll_received_messages", f"📨 Found new message from {lxmf_message.source_hash.hex()[:16]}")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "poll_received_messages", f"Error processing message: {e}")
|
||
|
||
if new_messages:
|
||
log_debug("ReticulumWrapper", "poll_received_messages", f"poll_received_messages() returning {len(new_messages)} new messages")
|
||
# CRITICAL FIX: Clear processed messages from pending_inbound queue
|
||
# This prevents messages from being stuck in the queue forever
|
||
if hasattr(self.router, 'pending_inbound') and self.router.pending_inbound:
|
||
log_debug("ReticulumWrapper", "poll_received_messages", f"Clearing {len(self.router.pending_inbound)} messages from pending_inbound queue")
|
||
self.router.pending_inbound.clear()
|
||
|
||
return new_messages
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "poll_received_messages", f"Error polling messages: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return []
|
||
|
||
def has_path(self, dest_hash: bytes) -> bool:
|
||
"""Check if a path to destination exists"""
|
||
if not RETICULUM_AVAILABLE or not self.reticulum:
|
||
return True # Mock mode
|
||
|
||
return RNS.Transport.has_path(dest_hash)
|
||
|
||
def request_path(self, dest_hash: bytes) -> Dict:
|
||
"""Request a path to a destination"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE:
|
||
return {"success": True}
|
||
|
||
RNS.Transport.request_path(dest_hash)
|
||
return {"success": True}
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_hop_count(self, dest_hash: bytes) -> Optional[int]:
|
||
"""Get hop count to destination"""
|
||
if not RETICULUM_AVAILABLE or not self.reticulum:
|
||
return 3 # Mock value
|
||
|
||
try:
|
||
if RNS.Transport.has_path(dest_hash):
|
||
return RNS.Transport.hops_to(dest_hash)
|
||
return None
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_hop_count", f"Error: {e}")
|
||
return None
|
||
|
||
def probe_link_speed(self, dest_hash: bytes, timeout_seconds: float = 10.0,
|
||
delivery_method: str = "direct") -> Dict:
|
||
"""
|
||
Probe the link speed to a destination by checking existing links or
|
||
establishing one via establish_link().
|
||
|
||
This provides link speed data for adaptive image compression.
|
||
|
||
Args:
|
||
dest_hash: Destination hash as bytes (16 bytes)
|
||
timeout_seconds: How long to wait for link establishment (default 10s)
|
||
delivery_method: "direct" or "propagated" - affects which link to check/establish
|
||
|
||
Returns:
|
||
Dict with:
|
||
- status: "success", "no_path", "no_identity", "timeout", or "error"
|
||
- establishment_rate_bps: Bits/sec from link handshake (or None)
|
||
- expected_rate_bps: Bits/sec from actual transfers (or None)
|
||
- rtt_seconds: Round-trip time in seconds (or None)
|
||
- hops: Number of hops to destination (or None)
|
||
- link_reused: True if an existing active link was used
|
||
- delivery_method: "direct" or "propagated" - which method was used
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.router:
|
||
return {
|
||
"status": "not_initialized",
|
||
"establishment_rate_bps": None,
|
||
"expected_rate_bps": None,
|
||
"rtt_seconds": None,
|
||
"hops": None,
|
||
"link_reused": False,
|
||
"delivery_method": delivery_method,
|
||
"next_hop_bitrate_bps": None,
|
||
"link_mtu": None
|
||
}
|
||
|
||
try:
|
||
# Convert jarray to bytes (Chaquopy passes Kotlin ByteArray as jarray)
|
||
dest_hash = bytes(dest_hash)
|
||
dest_hash_hex = dest_hash.hex()
|
||
log_info("ReticulumWrapper", "probe_link_speed",
|
||
f"Probing link speed to {dest_hash_hex[:16]}... (method: {delivery_method})")
|
||
|
||
# Helper to find link in both direct_links and backchannel_links
|
||
def find_link(dest_hash_bytes, dest_hash_hex_str):
|
||
"""Find a link by checking direct_links and backchannel_links."""
|
||
# Check direct_links first
|
||
if dest_hash_bytes in self.router.direct_links:
|
||
return self.router.direct_links[dest_hash_bytes]
|
||
if dest_hash_hex_str in self.router.direct_links:
|
||
return self.router.direct_links[dest_hash_hex_str]
|
||
# Check backchannel_links (incoming links from peer)
|
||
if dest_hash_bytes in self.router.backchannel_links:
|
||
return self.router.backchannel_links[dest_hash_bytes]
|
||
if dest_hash_hex_str in self.router.backchannel_links:
|
||
return self.router.backchannel_links[dest_hash_hex_str]
|
||
return None
|
||
|
||
# Helper to get next hop interface bitrate
|
||
def get_next_hop_bitrate() -> int:
|
||
try:
|
||
if RNS.Transport.has_path(dest_hash):
|
||
next_hop_iface = RNS.Transport.next_hop_interface(dest_hash)
|
||
if next_hop_iface and hasattr(next_hop_iface, 'bitrate'):
|
||
return next_hop_iface.bitrate
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
# Helper to get link stats
|
||
def get_link_stats(link, reused: bool, method: str) -> Dict:
|
||
return {
|
||
"status": "success",
|
||
"establishment_rate_bps": link.get_establishment_rate(),
|
||
"expected_rate_bps": link.get_expected_rate(),
|
||
"rtt_seconds": link.rtt,
|
||
"hops": RNS.Transport.hops_to(dest_hash) if RNS.Transport.has_path(dest_hash) else None,
|
||
"link_reused": reused,
|
||
"delivery_method": method,
|
||
"next_hop_bitrate_bps": get_next_hop_bitrate(),
|
||
"link_mtu": link.get_mtu() if hasattr(link, 'get_mtu') else None
|
||
}
|
||
|
||
# 1. If propagated delivery, check propagation link AND backchannel link
|
||
if delivery_method == "propagated":
|
||
# Check if there's a backchannel link with expected_rate from the recipient
|
||
# This tells us actual measured throughput from prior transfers with this peer
|
||
backchannel_expected_rate = None
|
||
backchannel_link = find_link(dest_hash, dest_hash_hex)
|
||
if backchannel_link is not None and backchannel_link.status == RNS.Link.ACTIVE:
|
||
backchannel_expected_rate = backchannel_link.get_expected_rate()
|
||
if backchannel_expected_rate:
|
||
log_info("ReticulumWrapper", "probe_link_speed",
|
||
f"Found backchannel expected_rate: {backchannel_expected_rate} bps")
|
||
|
||
if self.router.outbound_propagation_link is not None:
|
||
link = self.router.outbound_propagation_link
|
||
if link.status == RNS.Link.ACTIVE:
|
||
log_info("ReticulumWrapper", "probe_link_speed",
|
||
f"Using existing propagation link")
|
||
stats = get_link_stats(link, True, "propagated")
|
||
# Use backchannel expected_rate if available (more relevant for this peer)
|
||
if backchannel_expected_rate:
|
||
stats["expected_rate_bps"] = backchannel_expected_rate
|
||
return stats
|
||
|
||
# No active propagation link - return heuristics only
|
||
# Use status="success" since heuristic data is valid for compression recommendations
|
||
log_info("ReticulumWrapper", "probe_link_speed",
|
||
f"No active propagation link, returning heuristics")
|
||
return {
|
||
"status": "success",
|
||
"establishment_rate_bps": None,
|
||
"expected_rate_bps": backchannel_expected_rate, # Include if available
|
||
"rtt_seconds": None,
|
||
"hops": None,
|
||
"link_reused": False,
|
||
"delivery_method": "propagated",
|
||
"next_hop_bitrate_bps": get_next_hop_bitrate(),
|
||
"link_mtu": None
|
||
}
|
||
|
||
# 2. Check for existing active link (direct or backchannel)
|
||
link = find_link(dest_hash, dest_hash_hex)
|
||
if link is not None and link.status == RNS.Link.ACTIVE:
|
||
log_info("ReticulumWrapper", "probe_link_speed",
|
||
f"Reusing existing link to {dest_hash_hex[:16]}")
|
||
return get_link_stats(link, True, "direct")
|
||
|
||
# 3. No existing link - use establish_link() to create one
|
||
log_info("ReticulumWrapper", "probe_link_speed",
|
||
f"No existing link, establishing link to {dest_hash_hex[:16]}...")
|
||
|
||
result = self.establish_link(dest_hash, timeout_seconds)
|
||
|
||
if result.get("success"):
|
||
# Link established - get stats from the link
|
||
link = find_link(dest_hash, dest_hash_hex)
|
||
if link is not None and link.status == RNS.Link.ACTIVE:
|
||
return get_link_stats(link, result.get("already_existed", False), "direct")
|
||
else:
|
||
# Link reported success but we can't find it - return what we have
|
||
return {
|
||
"status": "success",
|
||
"establishment_rate_bps": result.get("establishment_rate_bps"),
|
||
"expected_rate_bps": None,
|
||
"rtt_seconds": None,
|
||
"hops": RNS.Transport.hops_to(dest_hash) if RNS.Transport.has_path(dest_hash) else None,
|
||
"link_reused": result.get("already_existed", False),
|
||
"delivery_method": "direct",
|
||
"next_hop_bitrate_bps": get_next_hop_bitrate(),
|
||
"link_mtu": None
|
||
}
|
||
else:
|
||
# Link establishment failed - check if we have heuristic data
|
||
hops = RNS.Transport.hops_to(dest_hash) if RNS.Transport.has_path(dest_hash) else None
|
||
next_hop_bps = get_next_hop_bitrate()
|
||
|
||
# If we have useful heuristic data (hop count or next hop bitrate),
|
||
# return "success" so the UI can show recommendations based on that.
|
||
# Only return failure status when we have no useful info.
|
||
if hops is not None or next_hop_bps is not None:
|
||
log_info("ReticulumWrapper", "probe_link_speed",
|
||
f"Link failed but have heuristics: hops={hops}, next_hop_bps={next_hop_bps}")
|
||
return {
|
||
"status": "success",
|
||
"establishment_rate_bps": None,
|
||
"expected_rate_bps": None,
|
||
"rtt_seconds": None,
|
||
"hops": hops,
|
||
"link_reused": False,
|
||
"delivery_method": "direct",
|
||
"next_hop_bitrate_bps": next_hop_bps,
|
||
"link_mtu": None
|
||
}
|
||
|
||
# No heuristic data - return actual failure status
|
||
error = result.get("error", "unknown")
|
||
if "Identity not known" in error:
|
||
status = "no_identity"
|
||
elif "No path" in error:
|
||
status = "no_path"
|
||
elif "Timeout" in error:
|
||
status = "timeout"
|
||
else:
|
||
status = "failed"
|
||
|
||
return {
|
||
"status": status,
|
||
"establishment_rate_bps": None,
|
||
"expected_rate_bps": None,
|
||
"rtt_seconds": None,
|
||
"hops": None,
|
||
"link_reused": False,
|
||
"delivery_method": "direct",
|
||
"next_hop_bitrate_bps": None,
|
||
"link_mtu": None
|
||
}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "probe_link_speed", f"Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {
|
||
"status": "error",
|
||
"error": str(e),
|
||
"establishment_rate_bps": None,
|
||
"expected_rate_bps": None,
|
||
"rtt_seconds": None,
|
||
"hops": None,
|
||
"link_reused": False,
|
||
"delivery_method": delivery_method,
|
||
"next_hop_bitrate_bps": None,
|
||
"link_mtu": None
|
||
}
|
||
|
||
def establish_link(self, dest_hash: bytes, timeout_seconds: float = 10.0) -> Dict:
|
||
"""
|
||
Establish a link to a destination for real-time connectivity.
|
||
|
||
This is used to:
|
||
1. Show "Online" status when link is active
|
||
2. Enable instant link speed probing for transfer time estimates
|
||
|
||
Args:
|
||
dest_hash: Destination hash as bytes (16 bytes)
|
||
timeout_seconds: How long to wait for link establishment
|
||
|
||
Returns:
|
||
Dict with:
|
||
- success: True if link is now active
|
||
- link_active: True if link is active
|
||
- establishment_rate_bps: Link speed in bits/sec (if active)
|
||
- error: Error message (if failed)
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.router:
|
||
return {"success": False, "link_active": False, "error": "Not initialized"}
|
||
|
||
try:
|
||
dest_hash = bytes(dest_hash)
|
||
dest_hash_hex = dest_hash.hex()
|
||
log_info("ReticulumWrapper", "establish_link",
|
||
f"Establishing link to {dest_hash_hex[:16]}...")
|
||
|
||
# Check if link already exists (in direct_links OR backchannel_links)
|
||
# Links are bidirectional, so either direction counts
|
||
link = None
|
||
if dest_hash in self.router.direct_links:
|
||
link = self.router.direct_links[dest_hash]
|
||
elif dest_hash_hex in self.router.direct_links:
|
||
link = self.router.direct_links[dest_hash_hex]
|
||
# Also check backchannel_links (incoming links from peer)
|
||
if link is None and dest_hash in self.router.backchannel_links:
|
||
link = self.router.backchannel_links[dest_hash]
|
||
elif link is None and dest_hash_hex in self.router.backchannel_links:
|
||
link = self.router.backchannel_links[dest_hash_hex]
|
||
|
||
# Fallback: Check Transport.active_links for incoming links not yet in backchannel_links
|
||
if link is None and hasattr(RNS.Transport, 'active_links'):
|
||
for active_link in RNS.Transport.active_links:
|
||
if active_link.status == RNS.Link.ACTIVE:
|
||
try:
|
||
remote_identity = active_link.get_remote_identity()
|
||
if remote_identity:
|
||
remote_dest = RNS.Destination(
|
||
remote_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
if remote_dest.hash == dest_hash or remote_dest.hash.hex() == dest_hash_hex:
|
||
log_info("ReticulumWrapper", "establish_link",
|
||
f"Found existing incoming link via Transport.active_links")
|
||
link = active_link
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
# Helper to get next hop interface bitrate
|
||
def get_next_hop_bitrate(hash_to_check) -> int:
|
||
try:
|
||
if RNS.Transport.has_path(hash_to_check):
|
||
next_hop_iface = RNS.Transport.next_hop_interface(hash_to_check)
|
||
if next_hop_iface and hasattr(next_hop_iface, 'bitrate'):
|
||
return next_hop_iface.bitrate
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
# Helper to build full link stats response
|
||
def get_full_link_stats(link, already_existed: bool, hash_for_path) -> Dict:
|
||
return {
|
||
"success": True,
|
||
"link_active": True,
|
||
"establishment_rate_bps": link.get_establishment_rate(),
|
||
"expected_rate_bps": link.get_expected_rate(),
|
||
"rtt_seconds": link.rtt,
|
||
"hops": RNS.Transport.hops_to(hash_for_path) if RNS.Transport.has_path(hash_for_path) else None,
|
||
"link_mtu": link.mtu if hasattr(link, 'mtu') else None,
|
||
"next_hop_bitrate_bps": get_next_hop_bitrate(hash_for_path),
|
||
"already_existed": already_existed
|
||
}
|
||
|
||
if link is not None:
|
||
if link.status == RNS.Link.ACTIVE:
|
||
log_info("ReticulumWrapper", "establish_link",
|
||
f"Link already active to {dest_hash_hex[:16]} (existing)")
|
||
return get_full_link_stats(link, True, dest_hash)
|
||
else:
|
||
# Clean up stale link from both dicts before proceeding
|
||
self.router.direct_links.pop(dest_hash, None)
|
||
self.router.direct_links.pop(dest_hash_hex, None)
|
||
self.router.backchannel_links.pop(dest_hash, None)
|
||
self.router.backchannel_links.pop(dest_hash_hex, None)
|
||
link = None
|
||
|
||
# No existing link - try to establish one
|
||
# The recipient's LXMF router will accept incoming links to lxmf.delivery
|
||
|
||
# DEBUG: Log target hash
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Target dest_hash: {dest_hash_hex}")
|
||
|
||
# Try to recall identity using multiple methods (matching send_lxmf_message pattern)
|
||
recipient_identity = None
|
||
try:
|
||
# Try as destination hash first (this is what LXMF uses)
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if recipient_identity:
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
"Recalled identity from destination hash")
|
||
else:
|
||
# Try with from_identity_hash=True
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if recipient_identity:
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
"Recalled identity from identity hash")
|
||
except Exception as e:
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Error recalling identity from Reticulum: {e}")
|
||
|
||
# Fallback to local cache
|
||
if not recipient_identity and dest_hash_hex in self.identities:
|
||
recipient_identity = self.identities[dest_hash_hex]
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
"Retrieved identity from local cache")
|
||
|
||
if not recipient_identity:
|
||
log_warning("ReticulumWrapper", "establish_link",
|
||
f"Cannot establish link - identity not known for {dest_hash_hex[:16]}")
|
||
return {"success": False, "link_active": False, "error": "Identity not known"}
|
||
|
||
# DEBUG: Log recalled identity hash
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Recalled identity hash: {recipient_identity.hash.hex()}")
|
||
|
||
# Create destination for link (same as LXMF uses for DIRECT delivery)
|
||
recipient_dest = RNS.Destination(
|
||
recipient_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
|
||
# DEBUG: Log created destination hash and compare
|
||
created_hash = recipient_dest.hash.hex()
|
||
hashes_match = recipient_dest.hash == dest_hash
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Created dest.hash: {created_hash}")
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Hashes match: {hashes_match}")
|
||
|
||
# Always check for existing link using created destination hash
|
||
# (links are stored under recipient_dest.hash, which may differ from input dest_hash)
|
||
link = None
|
||
if recipient_dest.hash in self.router.direct_links:
|
||
link = self.router.direct_links[recipient_dest.hash]
|
||
elif recipient_dest.hash in self.router.backchannel_links:
|
||
link = self.router.backchannel_links[recipient_dest.hash]
|
||
if link is not None:
|
||
if link.status == RNS.Link.ACTIVE:
|
||
log_info("ReticulumWrapper", "establish_link",
|
||
f"Link already active (found via created hash) to {created_hash[:16]}")
|
||
return get_full_link_stats(link, True, recipient_dest.hash)
|
||
else:
|
||
# Clean up stale link before proceeding
|
||
self.router.direct_links.pop(recipient_dest.hash, None)
|
||
self.router.backchannel_links.pop(recipient_dest.hash, None)
|
||
link = None
|
||
|
||
# Check if path exists to the created destination
|
||
# Use recipient_dest.hash since that's where we'll create the link
|
||
has_path = RNS.Transport.has_path(recipient_dest.hash)
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Transport.has_path({recipient_dest.hash.hex()[:16]}): {has_path}")
|
||
|
||
# Only request path if we don't have one (avoid unnecessary network traffic)
|
||
# If the existing path is stale, link establishment will fail and we can retry
|
||
if not has_path:
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Requesting path to {recipient_dest.hash.hex()[:16]}...")
|
||
RNS.Transport.request_path(recipient_dest.hash)
|
||
|
||
# Wait for path if we don't have one
|
||
if not has_path:
|
||
for _ in range(10):
|
||
time.sleep(0.5)
|
||
if RNS.Transport.has_path(recipient_dest.hash):
|
||
log_debug("ReticulumWrapper", "establish_link", "Path discovered")
|
||
has_path = True
|
||
break
|
||
if not has_path:
|
||
log_warning("ReticulumWrapper", "establish_link",
|
||
f"No path available to {recipient_dest.hash.hex()[:16]}")
|
||
return {"success": False, "link_active": False, "error": "No path available"}
|
||
|
||
if not hashes_match:
|
||
log_warning("ReticulumWrapper", "establish_link",
|
||
f"HASH MISMATCH! Target={dest_hash_hex[:16]}, Created={created_hash[:16]}")
|
||
|
||
# Establish link using RNS.Link - matching LXMF's exact approach
|
||
log_info("ReticulumWrapper", "establish_link",
|
||
f"Establishing new link to {dest_hash_hex[:16]}...")
|
||
|
||
# Create a flag to track establishment
|
||
link_established = [False]
|
||
link_rate = [None]
|
||
|
||
def on_link_established(link):
|
||
log_info("ReticulumWrapper", "establish_link",
|
||
f"Link callback: link to {dest_hash_hex[:16]} established!")
|
||
link_established[0] = True
|
||
link_rate[0] = link.get_establishment_rate()
|
||
|
||
# Create link with callback (like LXMF does)
|
||
link = RNS.Link(recipient_dest, established_callback=on_link_established)
|
||
|
||
# Store in router.direct_links using created destination hash
|
||
# (LXMF uses lxmessage.get_destination().hash which is the created destination's hash)
|
||
self.router.direct_links[recipient_dest.hash] = link
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Stored link in router.direct_links with key {recipient_dest.hash.hex()[:16]}")
|
||
|
||
# Wait for link establishment with timeout
|
||
# Use try/finally to ensure cleanup on all exit paths
|
||
try:
|
||
start_time = time.time()
|
||
while time.time() - start_time < timeout_seconds:
|
||
if link_established[0] or link.status == RNS.Link.ACTIVE:
|
||
log_info("ReticulumWrapper", "establish_link",
|
||
f"Link established to {dest_hash_hex[:16]} in {time.time() - start_time:.2f}s")
|
||
|
||
# Identify ourselves on the link so the remote peer's LXMRouter
|
||
# adds this to their backchannel_links (via delivery_remote_identified callback)
|
||
try:
|
||
if self.router.identity:
|
||
link.identify(self.router.identity)
|
||
log_debug("ReticulumWrapper", "establish_link",
|
||
f"Identified ourselves on link to enable backchannel")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "establish_link",
|
||
f"Failed to identify on link: {e}")
|
||
|
||
return get_full_link_stats(link, False, recipient_dest.hash)
|
||
elif link.status == RNS.Link.CLOSED:
|
||
log_warning("ReticulumWrapper", "establish_link",
|
||
f"Link closed during establishment to {dest_hash_hex[:16]}")
|
||
return {"success": False, "link_active": False, "error": "Link closed"}
|
||
time.sleep(0.1)
|
||
|
||
# Timeout
|
||
link.teardown()
|
||
log_info("ReticulumWrapper", "establish_link",
|
||
f"Link establishment timed out to {dest_hash_hex[:16]} (peer may be offline)")
|
||
return {"success": False, "link_active": False, "error": "Timeout"}
|
||
finally:
|
||
# Clean up failed/inactive links from direct_links
|
||
# Only remove if it's still our link (not replaced by concurrent call)
|
||
if link.status != RNS.Link.ACTIVE:
|
||
if self.router.direct_links.get(recipient_dest.hash) is link:
|
||
self.router.direct_links.pop(recipient_dest.hash, None)
|
||
|
||
except Exception as e:
|
||
# Clean up stored link if variables are in scope
|
||
try:
|
||
if recipient_dest and recipient_dest.hash in self.router.direct_links:
|
||
try:
|
||
link.teardown()
|
||
except:
|
||
pass
|
||
self.router.direct_links.pop(recipient_dest.hash, None)
|
||
except NameError:
|
||
pass # Variables not yet defined
|
||
log_error("ReticulumWrapper", "establish_link", f"Error: {e}")
|
||
return {"success": False, "link_active": False, "error": str(e)}
|
||
|
||
def close_link(self, dest_hash: bytes) -> Dict:
|
||
"""
|
||
Close an active link to a destination.
|
||
|
||
Called when conversation has been inactive for too long.
|
||
|
||
Args:
|
||
dest_hash: Destination hash as bytes (16 bytes)
|
||
|
||
Returns:
|
||
Dict with:
|
||
- success: True if link was closed or didn't exist
|
||
- was_active: True if link was active before closing
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.router:
|
||
return {"success": True, "was_active": False}
|
||
|
||
try:
|
||
dest_hash = bytes(dest_hash)
|
||
dest_hash_hex = dest_hash.hex()
|
||
|
||
# Find link - try input hash first
|
||
link = None
|
||
link_key = None
|
||
if dest_hash in self.router.direct_links:
|
||
link = self.router.direct_links[dest_hash]
|
||
link_key = dest_hash
|
||
elif dest_hash_hex in self.router.direct_links:
|
||
link = self.router.direct_links[dest_hash_hex]
|
||
link_key = dest_hash_hex
|
||
|
||
# If not found, try via created destination hash (handles mismatch case)
|
||
if link is None:
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if not recipient_identity and dest_hash_hex in self.identities:
|
||
recipient_identity = self.identities[dest_hash_hex]
|
||
|
||
if recipient_identity:
|
||
recipient_dest = RNS.Destination(
|
||
recipient_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
if recipient_dest.hash in self.router.direct_links:
|
||
link = self.router.direct_links[recipient_dest.hash]
|
||
link_key = recipient_dest.hash
|
||
|
||
if link is None:
|
||
log_debug("ReticulumWrapper", "close_link",
|
||
f"No link found to {dest_hash_hex[:16]}")
|
||
return {"success": True, "was_active": False}
|
||
|
||
was_active = link.status == RNS.Link.ACTIVE
|
||
|
||
if was_active:
|
||
log_info("ReticulumWrapper", "close_link",
|
||
f"Closing link to {dest_hash_hex[:16]}")
|
||
link.teardown()
|
||
|
||
# Remove from direct_links
|
||
if link_key and link_key in self.router.direct_links:
|
||
self.router.direct_links.pop(link_key)
|
||
|
||
return {"success": True, "was_active": was_active}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "close_link", f"Error: {e}")
|
||
return {"success": False, "was_active": False, "error": str(e)}
|
||
|
||
def get_link_status(self, dest_hash: bytes) -> Dict:
|
||
"""
|
||
Check if a link is active to a destination.
|
||
|
||
Checks both direct_links (outgoing) and backchannel_links (incoming).
|
||
A link is bidirectional, so either direction counts as "active".
|
||
|
||
Args:
|
||
dest_hash: Destination hash as bytes (16 bytes)
|
||
|
||
Returns:
|
||
Dict with:
|
||
- active: True if link is currently active
|
||
- establishment_rate_bps: Link speed in bits/sec (if active)
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.router:
|
||
return {"active": False}
|
||
|
||
try:
|
||
dest_hash = bytes(dest_hash)
|
||
dest_hash_hex = dest_hash.hex()
|
||
|
||
# Helper to find link in a dictionary
|
||
def find_link_in_dict(links_dict, hash_bytes, hash_hex):
|
||
if hash_bytes in links_dict:
|
||
return links_dict[hash_bytes]
|
||
if hash_hex in links_dict:
|
||
return links_dict[hash_hex]
|
||
return None
|
||
|
||
# Check direct_links (links we initiated)
|
||
link = find_link_in_dict(self.router.direct_links, dest_hash, dest_hash_hex)
|
||
|
||
# Check backchannel_links (links peer initiated to us)
|
||
if link is None:
|
||
link = find_link_in_dict(self.router.backchannel_links, dest_hash, dest_hash_hex)
|
||
|
||
# Fallback: Check RNS.Transport.active_links for incoming links
|
||
# This is rarely needed since link.identify() populates backchannel_links
|
||
if link is None and hasattr(RNS.Transport, 'active_links'):
|
||
for active_link in RNS.Transport.active_links:
|
||
if active_link.status == RNS.Link.ACTIVE:
|
||
try:
|
||
remote_identity = active_link.get_remote_identity()
|
||
if remote_identity:
|
||
remote_dest = RNS.Destination(
|
||
remote_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
if remote_dest.hash == dest_hash or remote_dest.hash.hex() == dest_hash_hex:
|
||
link = active_link
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
# If not found, try via created destination hash (handles mismatch case)
|
||
if link is None:
|
||
recipient_identity = RNS.Identity.recall(dest_hash)
|
||
if not recipient_identity:
|
||
recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
|
||
if not recipient_identity and dest_hash_hex in self.identities:
|
||
recipient_identity = self.identities[dest_hash_hex]
|
||
|
||
if recipient_identity:
|
||
recipient_dest = RNS.Destination(
|
||
recipient_identity,
|
||
RNS.Destination.OUT,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf",
|
||
"delivery"
|
||
)
|
||
created_hash = recipient_dest.hash
|
||
created_hex = created_hash.hex()
|
||
# Check both dictionaries with created hash
|
||
link = find_link_in_dict(self.router.direct_links, created_hash, created_hex)
|
||
if link is None:
|
||
link = find_link_in_dict(self.router.backchannel_links, created_hash, created_hex)
|
||
|
||
if link is not None and link.status == RNS.Link.ACTIVE:
|
||
# Helper to get next hop interface bitrate
|
||
def get_next_hop_bitrate(hash_to_check) -> int:
|
||
try:
|
||
if RNS.Transport.has_path(hash_to_check):
|
||
next_hop_iface = RNS.Transport.next_hop_interface(hash_to_check)
|
||
if next_hop_iface and hasattr(next_hop_iface, 'bitrate'):
|
||
return next_hop_iface.bitrate
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
return {
|
||
"active": True,
|
||
"establishment_rate_bps": link.get_establishment_rate(),
|
||
"expected_rate_bps": link.get_expected_rate(),
|
||
"rtt_seconds": link.rtt,
|
||
"hops": RNS.Transport.hops_to(dest_hash) if RNS.Transport.has_path(dest_hash) else None,
|
||
"link_mtu": link.mtu if hasattr(link, 'mtu') else None,
|
||
"next_hop_bitrate_bps": get_next_hop_bitrate(dest_hash),
|
||
}
|
||
|
||
return {"active": False}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_link_status", f"Error: {e}")
|
||
return {"active": False, "error": str(e)}
|
||
|
||
def get_debug_info(self) -> Dict:
|
||
"""
|
||
Get comprehensive debug information about Reticulum status.
|
||
|
||
Returns:
|
||
Dict with debug information including:
|
||
- initialized: bool
|
||
- reticulum_available: bool
|
||
- interfaces: list of interface info
|
||
- transport_status: transport state info
|
||
"""
|
||
info = {
|
||
'initialized': self.initialized,
|
||
'reticulum_available': RETICULUM_AVAILABLE,
|
||
'storage_path': self.storage_path,
|
||
'failed_interfaces': self.failed_interfaces, # Interfaces that failed to initialize
|
||
}
|
||
|
||
if RETICULUM_AVAILABLE and self.reticulum:
|
||
try:
|
||
# Get interface information
|
||
interfaces = []
|
||
for iface in RNS.Transport.interfaces:
|
||
iface_info = {
|
||
'name': str(iface),
|
||
'type': type(iface).__name__,
|
||
'online': hasattr(iface, 'online') and iface.online if hasattr(iface, 'online') else True,
|
||
}
|
||
interfaces.append(iface_info)
|
||
info['interfaces'] = interfaces
|
||
|
||
# Transport information
|
||
# Check actual transport enabled status from RNS
|
||
info['transport_enabled'] = RNS.Reticulum.transport_enabled()
|
||
info['transport_identity'] = RNS.Transport.identity != None
|
||
|
||
except Exception as e:
|
||
info['error'] = f"Error getting debug info: {e}"
|
||
log_error("ReticulumWrapper", "get_debug_info", f"Error in get_debug_info: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
else:
|
||
info['interfaces'] = []
|
||
info['transport_enabled'] = False
|
||
|
||
return info
|
||
|
||
def get_failed_interfaces(self) -> str:
|
||
"""
|
||
Get list of interfaces that failed to initialize.
|
||
|
||
Returns:
|
||
JSON string containing list of failed interfaces with error details.
|
||
Each entry has: name, error, and optionally recoverable flag.
|
||
"""
|
||
return json.dumps(self.failed_interfaces if hasattr(self, 'failed_interfaces') else [])
|
||
|
||
def get_interface_stats(self, interface_name: str) -> Optional[str]:
|
||
"""
|
||
Get statistics for a specific interface.
|
||
|
||
Args:
|
||
interface_name: The name of the interface to get stats for
|
||
|
||
Returns:
|
||
JSON string containing interface stats (online, rxb, txb) or None if not found
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.reticulum:
|
||
return None
|
||
|
||
try:
|
||
for iface in RNS.Transport.interfaces:
|
||
if iface.name == interface_name:
|
||
stats = {
|
||
"name": iface.name,
|
||
"online": iface.online if hasattr(iface, 'online') else True,
|
||
"rxb": iface.rxb if hasattr(iface, 'rxb') else 0,
|
||
"txb": iface.txb if hasattr(iface, 'txb') else 0,
|
||
}
|
||
# Add RNode-specific stats if available
|
||
if hasattr(iface, 'r_stat_rssi'):
|
||
stats["rssi"] = iface.r_stat_rssi
|
||
if hasattr(iface, 'r_stat_snr'):
|
||
stats["snr"] = iface.r_stat_snr
|
||
if hasattr(iface, 'r_frequency'):
|
||
stats["frequency"] = iface.r_frequency
|
||
if hasattr(iface, 'r_bandwidth'):
|
||
stats["bandwidth"] = iface.r_bandwidth
|
||
if hasattr(iface, 'r_sf'):
|
||
stats["spreading_factor"] = iface.r_sf
|
||
if hasattr(iface, 'r_tx_power'):
|
||
stats["tx_power"] = iface.r_tx_power
|
||
# Add connecting state (true if reconnection is in progress)
|
||
if hasattr(iface, '_reconnecting'):
|
||
stats["connecting"] = iface._reconnecting
|
||
|
||
return json.dumps(stats)
|
||
|
||
log_debug("ReticulumWrapper", "get_interface_stats",
|
||
f"Interface '{interface_name}' not found")
|
||
return None
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_interface_stats",
|
||
f"Error getting interface stats: {e}")
|
||
return None
|
||
|
||
# ==================== RNS 1.1.x Interface Discovery ====================
|
||
|
||
def get_discovered_interfaces(self) -> str:
|
||
"""
|
||
Get list of discovered interfaces from RNS 1.1.x discovery system.
|
||
|
||
Returns:
|
||
JSON string containing array of discovered interface info with full details:
|
||
[{"name": "...", "type": "...", "transport_id": "...", "status": "available",
|
||
"status_code": 1000, "last_heard": 1234567890, ...}]
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.reticulum:
|
||
return json.dumps([])
|
||
|
||
try:
|
||
# RNS 1.1.0+ provides discovered_interfaces() which returns a list
|
||
# of dicts with interface discovery info
|
||
if hasattr(RNS.Reticulum, 'discovered_interfaces'):
|
||
discovered_list = RNS.Reticulum.discovered_interfaces()
|
||
result = []
|
||
|
||
log_debug("ReticulumWrapper", "get_discovered_interfaces",
|
||
f"Raw discovered_interfaces() returned {len(discovered_list)} items")
|
||
|
||
for iface_info in discovered_list:
|
||
try:
|
||
# Extract transport_id from the info dict
|
||
transport_id = iface_info.get('transport_id', b'')
|
||
if isinstance(transport_id, bytes):
|
||
transport_id = transport_id.hex()
|
||
|
||
# Extract network_id
|
||
network_id = iface_info.get('network_id', b'')
|
||
if isinstance(network_id, bytes):
|
||
network_id = network_id.hex()
|
||
|
||
# Extract interface details from discovery info
|
||
iface_data = {
|
||
# Core identification
|
||
'name': iface_info.get('name', 'Unknown'),
|
||
'type': iface_info.get('type', 'Unknown'),
|
||
'transport_id': transport_id,
|
||
'network_id': network_id,
|
||
|
||
# Status information
|
||
'status': iface_info.get('status', 'unknown'),
|
||
'status_code': iface_info.get('status_code', 100),
|
||
'last_heard': iface_info.get('last_heard', 0),
|
||
'heard_count': iface_info.get('heard_count', 0),
|
||
'hops': iface_info.get('hops', 0),
|
||
'stamp_value': iface_info.get('value', iface_info.get('stamp_value', 0)),
|
||
|
||
# TCP-specific (TCPServerInterface, I2PInterface)
|
||
'reachable_on': iface_info.get('reachable_on'),
|
||
'port': iface_info.get('port'),
|
||
|
||
# Radio-specific (RNodeInterface, WeaveInterface, KISSInterface)
|
||
'frequency': iface_info.get('frequency'),
|
||
'bandwidth': iface_info.get('bandwidth'),
|
||
'spreading_factor': iface_info.get('sf'),
|
||
'coding_rate': iface_info.get('cr'),
|
||
'modulation': iface_info.get('modulation'),
|
||
'channel': iface_info.get('channel'),
|
||
|
||
# Location (optional)
|
||
'latitude': iface_info.get('latitude'),
|
||
'longitude': iface_info.get('longitude'),
|
||
'height': iface_info.get('height'),
|
||
}
|
||
|
||
# Clean up None values to reduce JSON size
|
||
iface_data = {k: v for k, v in iface_data.items() if v is not None}
|
||
result.append(iface_data)
|
||
|
||
log_debug("ReticulumWrapper", "get_discovered_interfaces",
|
||
f"Processed interface: {iface_data.get('name')} ({iface_data.get('type')})")
|
||
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "get_discovered_interfaces",
|
||
f"Error processing discovered interface: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
continue
|
||
|
||
# Sort by status (available first), then by stamp_value (higher first)
|
||
result.sort(key=lambda x: (-x.get('status_code', 0), -x.get('stamp_value', 0)))
|
||
|
||
log_debug("ReticulumWrapper", "get_discovered_interfaces",
|
||
f"Found {len(result)} discovered interfaces")
|
||
return json.dumps(result)
|
||
else:
|
||
log_debug("ReticulumWrapper", "get_discovered_interfaces",
|
||
"RNS.Reticulum.discovered_interfaces() not available (requires RNS 1.1.0+)")
|
||
return json.dumps([])
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_discovered_interfaces",
|
||
f"Error getting discovered interfaces: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return json.dumps([])
|
||
|
||
def _get_discovery_status_name(self, status_code: int) -> str:
|
||
"""Convert RNS discovery status code to human-readable name."""
|
||
if status_code >= 1000:
|
||
return "available"
|
||
elif status_code >= 100:
|
||
return "unknown"
|
||
else:
|
||
return "stale"
|
||
|
||
def is_discovery_enabled(self) -> bool:
|
||
"""
|
||
Check if interface discovery and auto-connect is enabled.
|
||
|
||
Returns:
|
||
True if RNS is configured to auto-connect discovered interfaces.
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.reticulum:
|
||
return False
|
||
|
||
try:
|
||
# RNS 1.1.0+ provides should_autoconnect_discovered_interfaces() method
|
||
if hasattr(RNS.Reticulum, 'should_autoconnect_discovered_interfaces'):
|
||
return RNS.Reticulum.should_autoconnect_discovered_interfaces()
|
||
return False
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "is_discovery_enabled",
|
||
f"Error checking discovery status: {e}")
|
||
return False
|
||
|
||
def get_autoconnected_interface_endpoints(self) -> str:
|
||
"""
|
||
Get list of currently auto-connected AND online interface endpoints.
|
||
|
||
Auto-connected interfaces are created dynamically by RNS discovery
|
||
and have the 'autoconnect_hash' attribute set.
|
||
|
||
Returns:
|
||
JSON string containing array of endpoint strings like "host:port"
|
||
for TCP-based auto-connected interfaces that are currently online.
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.reticulum:
|
||
return json.dumps([])
|
||
|
||
try:
|
||
endpoints = []
|
||
for iface in RNS.Transport.interfaces:
|
||
# Auto-connected interfaces have autoconnect_hash attribute
|
||
# Also check that the interface is actually online
|
||
if hasattr(iface, "autoconnect_hash") and getattr(iface, "online", False):
|
||
# For TCP-based interfaces (BackboneInterface, TCPClientInterface)
|
||
if hasattr(iface, "target_ip") and hasattr(iface, "target_port"):
|
||
endpoint = f"{iface.target_ip}:{iface.target_port}"
|
||
endpoints.append(endpoint)
|
||
log_debug("ReticulumWrapper", "get_autoconnected_interface_endpoints",
|
||
f"Found auto-connected online interface: {iface.name} -> {endpoint}")
|
||
|
||
log_debug("ReticulumWrapper", "get_autoconnected_interface_endpoints",
|
||
f"Found {len(endpoints)} auto-connected online endpoints")
|
||
return json.dumps(endpoints)
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_autoconnected_interface_endpoints",
|
||
f"Error getting auto-connected endpoints: {e}")
|
||
return json.dumps([])
|
||
|
||
def get_path_table(self) -> List[str]:
|
||
"""
|
||
Get list of destination hashes from the RNS path table.
|
||
Returns hex-encoded destination hashes for all known paths.
|
||
|
||
Returns:
|
||
List of hex-encoded destination hashes (e.g., ["abc123...", "def456..."])
|
||
"""
|
||
if not RETICULUM_AVAILABLE or not self.reticulum:
|
||
return [] # Return empty list in mock mode
|
||
|
||
try:
|
||
destination_hashes = []
|
||
# Access RNS.Transport.path_table directly
|
||
# path_table is a dict where keys are destination hashes (bytes)
|
||
for dest_hash in RNS.Transport.path_table:
|
||
# Convert bytes to hex string for Kotlin compatibility
|
||
destination_hashes.append(dest_hash.hex())
|
||
|
||
log_debug("ReticulumWrapper", "get_path_table",
|
||
f"Retrieved {len(destination_hashes)} paths from path table")
|
||
return destination_hashes
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_path_table",
|
||
f"Error getting path table: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return []
|
||
|
||
def get_local_identity_info(self) -> Optional[Dict]:
|
||
"""
|
||
Get information about the local identity if one has been created.
|
||
|
||
Returns:
|
||
Dict with identity info or None if no identity exists
|
||
"""
|
||
# For now, we don't have a persistent "local" identity
|
||
# This would be implemented when we add identity management
|
||
return None
|
||
|
||
def create_and_announce_test_destination(self, app_name: str = "columba") -> Dict:
|
||
"""
|
||
Create a test destination and announce it.
|
||
Useful for debugging to verify announces are working.
|
||
|
||
Args:
|
||
app_name: Application name for the destination
|
||
|
||
Returns:
|
||
Dict with 'success', 'dest_hash', 'hex_hash' or 'error'
|
||
"""
|
||
try:
|
||
if not RETICULUM_AVAILABLE or not self.initialized:
|
||
return {"success": False, "error": "Reticulum not initialized"}
|
||
|
||
# Create a test identity
|
||
test_identity = RNS.Identity()
|
||
|
||
# Create a destination
|
||
destination = RNS.Destination(
|
||
test_identity,
|
||
RNS.Destination.IN,
|
||
RNS.Destination.SINGLE,
|
||
app_name,
|
||
"debug"
|
||
)
|
||
|
||
# Store it (use hex hash as key)
|
||
self.destinations[destination.hexhash] = destination
|
||
|
||
# Announce with debug app data
|
||
app_data = b"Columba Debug Test"
|
||
destination.announce(app_data=app_data)
|
||
|
||
log_info("ReticulumWrapper", "create_and_announce_test_destination", f"Test destination announced: {destination.hexhash}")
|
||
|
||
return {
|
||
"success": True,
|
||
"dest_hash": destination.hash,
|
||
"hex_hash": destination.hexhash,
|
||
"identity_hash": test_identity.hash,
|
||
"app_data": app_data
|
||
}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "create_and_announce_test_destination", f"Error creating test destination: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}
|
||
|
||
# ========== Threading Safety Test Methods ==========
|
||
# These methods are used to verify Python/Chaquopy threading safety
|
||
|
||
def echo(self, message: str) -> str:
|
||
"""
|
||
Simple echo method for testing Python threading safety.
|
||
Returns the input message unchanged.
|
||
|
||
Thread-safe: Yes (no shared state access)
|
||
GIL: Automatically serializes Python bytecode execution
|
||
"""
|
||
return message
|
||
|
||
def simple_method(self, value: int) -> int:
|
||
"""
|
||
Simple method for stress testing Python calls.
|
||
Returns the input value unchanged.
|
||
|
||
Thread-safe: Yes (no shared state access)
|
||
GIL: Automatically serializes Python bytecode execution
|
||
"""
|
||
return value
|
||
|
||
def sleep(self, seconds: float) -> None:
|
||
"""
|
||
Sleep for specified seconds - used to test long-running operations.
|
||
Tests that long operations don't block other threads from calling Python.
|
||
|
||
Thread-safe: Yes (time.sleep releases GIL)
|
||
GIL: Released during sleep, allowing other threads to execute
|
||
"""
|
||
time.sleep(seconds)
|
||
|
||
# ========== BLE Interface Support Methods ==========
|
||
# These methods enable Android BLE interface integration
|
||
|
||
# ========== DEPRECATED BLE Methods (driver-based architecture) ==========
|
||
# These methods are no longer used with the new driver-based architecture
|
||
# but are kept as stubs for backward compatibility during transition.
|
||
|
||
def ble_packet_received(self, address: str, data: bytes) -> None:
|
||
"""
|
||
DEPRECATED: No longer used with driver-based architecture.
|
||
|
||
BLE packets are now handled directly by AndroidBLEDriver callbacks.
|
||
This method is kept for backward compatibility but does nothing.
|
||
"""
|
||
log_warning("ReticulumWrapper", "ble_packet_received",
|
||
"DEPRECATED: ble_packet_received() called but no longer used in driver-based architecture")
|
||
|
||
def poll_ble_incoming(self) -> List[Dict]:
|
||
"""
|
||
DEPRECATED: No longer used with driver-based architecture.
|
||
|
||
BLE interface now uses event-driven callbacks instead of polling.
|
||
This method is kept for backward compatibility but returns empty list.
|
||
"""
|
||
log_warning("ReticulumWrapper", "poll_ble_incoming",
|
||
"DEPRECATED: poll_ble_incoming() called but no longer used in driver-based architecture")
|
||
return []
|
||
|
||
def send_via_ble(self, address: str, data: bytes) -> Dict:
|
||
"""
|
||
DEPRECATED: No longer used with driver-based architecture.
|
||
|
||
AndroidBLEInterface now sends directly via AndroidBLEDriver.
|
||
This method is kept for backward compatibility but returns failure.
|
||
"""
|
||
log_warning("ReticulumWrapper", "send_via_ble",
|
||
"DEPRECATED: send_via_ble() called but no longer used in driver-based architecture")
|
||
return {'success': False, 'error': 'Method deprecated - use driver-based architecture'}
|
||
|
||
def set_kotlin_ble_callback(self, callback) -> None:
|
||
"""
|
||
DEPRECATED: No longer used with driver-based architecture.
|
||
|
||
BLE communication now uses KotlinBLEBridge directly via Chaquopy.
|
||
This method is kept for backward compatibility but does nothing.
|
||
"""
|
||
log_warning("ReticulumWrapper", "set_kotlin_ble_callback",
|
||
"DEPRECATED: set_kotlin_ble_callback() called but no longer used in driver-based architecture")
|
||
|
||
def initialize_ble_interface(self) -> Dict:
|
||
"""
|
||
Initialize the Android BLE interface with driver-based architecture.
|
||
|
||
RNS has already loaded the AndroidBLEInterface from config/interfaces directory.
|
||
This method finds that instance and starts it. The interface will create its
|
||
own AndroidBLEDriver internally to access the Kotlin BLE bridge.
|
||
|
||
Returns:
|
||
Dict with 'success' boolean and optional 'error' string
|
||
"""
|
||
try:
|
||
if not self.initialized:
|
||
return {'success': False, 'error': 'Reticulum not initialized'}
|
||
|
||
# Find the AndroidBLEInterface instance that RNS already created
|
||
# It should be in RNS.Transport.interfaces list
|
||
# Use name-based matching since the class loaded by RNS is from a different module path
|
||
ble_interface = None
|
||
for interface in RNS.Transport.interfaces:
|
||
# Match by class name since isinstance() won't work across module boundaries
|
||
if type(interface).__name__ == 'AndroidBLEInterface':
|
||
ble_interface = interface
|
||
log_debug("ReticulumWrapper", "initialize_ble_interface", f"Found AndroidBLEInterface: {interface}")
|
||
break
|
||
|
||
if not ble_interface:
|
||
log_warning("ReticulumWrapper", "initialize_ble_interface", "WARNING: AndroidBLEInterface not found in RNS.Transport.interfaces")
|
||
log_debug("ReticulumWrapper", "initialize_ble_interface", f"Available interfaces: {[type(i).__name__ for i in RNS.Transport.interfaces]}")
|
||
return {'success': False, 'error': 'AndroidBLEInterface not loaded by RNS'}
|
||
|
||
# Store reference to BLE interface
|
||
self.ble_interface = ble_interface
|
||
|
||
log_info("ReticulumWrapper", "initialize_ble_interface", f"✓ AndroidBLEInterface found and connected")
|
||
log_debug("ReticulumWrapper", "initialize_ble_interface", f"Interface name: {ble_interface.name}")
|
||
|
||
# Start the BLE interface with driver-based architecture
|
||
if not ble_interface.online:
|
||
log_info("ReticulumWrapper", "initialize_ble_interface", f"Starting AndroidBLEInterface with driver...")
|
||
ble_interface.start()
|
||
log_info("ReticulumWrapper", "initialize_ble_interface", f"✅ AndroidBLEInterface started, online={ble_interface.online}")
|
||
else:
|
||
log_warning("ReticulumWrapper", "initialize_ble_interface", f"AndroidBLEInterface already online, skipping start()")
|
||
|
||
return {'success': True}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "initialize_ble_interface", f"ERROR initializing BLE interface bridge: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
def initialize_rnode_interface(self) -> Dict:
|
||
"""
|
||
Initialize all RNode interfaces with Kotlin bridge architecture.
|
||
|
||
Unlike BLE interface which is loaded by RNS from config, RNode interfaces
|
||
are created directly here using ColumbaRNodeInterface. This is because
|
||
the standard RNodeInterface uses jnius which is incompatible with Chaquopy.
|
||
|
||
The RNode configs were stored in _pending_rnode_configs during config creation.
|
||
This method creates interfaces for ALL pending configs, supporting multiple RNodes.
|
||
|
||
Returns:
|
||
Dict with 'success' boolean and optional 'error' string
|
||
"""
|
||
# Prevent concurrent initialization (race condition fix)
|
||
# Acquire lock before checking/setting initialization flag to prevent race condition
|
||
# (Double-check locking without proper memory barriers is broken in Python)
|
||
with self._rnode_init_lock:
|
||
if self._rnode_initializing:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
"RNode initialization already in progress, skipping duplicate call")
|
||
return {'success': True, 'message': 'Initialization already in progress'}
|
||
self._rnode_initializing = True
|
||
|
||
try:
|
||
if not self.initialized:
|
||
return {'success': False, 'error': 'Reticulum not initialized'}
|
||
|
||
# First, try to reconnect any existing offline interfaces
|
||
# For USB interfaces, we need to check if the matching USB device is actually connected
|
||
# before attempting reconnection to avoid disconnecting other interfaces
|
||
import usb_bridge
|
||
connected_usb_devices = []
|
||
usb_bridge_available = usb_bridge.is_available()
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"USB bridge available: {usb_bridge_available}")
|
||
if usb_bridge_available:
|
||
result = usb_bridge.get_connected_usb_devices()
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"get_connected_usb_devices result: success={result.get('success')}, count={len(result.get('devices', []))}")
|
||
if result.get('success'):
|
||
connected_usb_devices = result.get('devices', [])
|
||
for dev in connected_usb_devices:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f" Connected USB device: VID={hex(dev.get('vendor_id', 0))}, PID={hex(dev.get('product_id', 0))}, device_id={dev.get('device_id')}")
|
||
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Checking {len(self.rnode_interfaces)} existing RNode interface(s)")
|
||
for name, iface in self.rnode_interfaces.items():
|
||
conn_mode = getattr(iface, 'connection_mode', 'UNKNOWN')
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Interface '{name}': online={iface.online}, connection_mode={conn_mode}")
|
||
if not iface.online:
|
||
# For USB mode interfaces, check if the matching USB device is actually connected
|
||
if hasattr(iface, 'connection_mode') and iface.connection_mode == 'usb':
|
||
vid = getattr(iface, 'usb_vendor_id', None)
|
||
pid = getattr(iface, 'usb_product_id', None)
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Interface '{name}' is USB mode: VID={hex(vid) if vid else None}, PID={hex(pid) if pid else None}")
|
||
if vid is not None and pid is not None:
|
||
# Check if any connected USB device matches this interface's VID/PID
|
||
matching_device = None
|
||
for dev in connected_usb_devices:
|
||
if dev.get('vendor_id') == vid and dev.get('product_id') == pid:
|
||
matching_device = dev
|
||
break
|
||
|
||
if matching_device is None:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Skipping '{name}' - no USB device with VID={hex(vid)}, PID={hex(pid)} connected")
|
||
continue
|
||
else:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Found matching USB device for '{name}': VID={hex(vid)}, PID={hex(pid)}, device_id={matching_device.get('device_id')}")
|
||
else:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Interface '{name}' is NOT USB mode, skipping VID/PID check")
|
||
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Reconnecting existing offline RNode interface '{name}'...")
|
||
if iface.start():
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"✅ RNode interface '{name}' reconnected, online={iface.online}")
|
||
else:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"RNode interface '{name}' already online, skipping")
|
||
|
||
# Check if we have pending RNode configs (for initial creation)
|
||
if not hasattr(self, '_pending_rnode_configs') or not self._pending_rnode_configs:
|
||
if not self.rnode_interfaces:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface", "No RNode config pending, skipping")
|
||
return {'success': True, 'message': 'No RNode interface configured'}
|
||
else:
|
||
return {'success': True, 'message': 'Existing interfaces checked'}
|
||
|
||
# Check if Kotlin bridge is available
|
||
if self.kotlin_rnode_bridge is None:
|
||
return {'success': False, 'error': 'KotlinRNodeBridge not set. Call set_rnode_bridge() first.'}
|
||
|
||
# Import ColumbaRNodeInterface
|
||
from rnode_interface import ColumbaRNodeInterface
|
||
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Creating {len(self._pending_rnode_configs)} ColumbaRNodeInterface(s)")
|
||
|
||
# Create interfaces for ALL pending configs
|
||
for config in self._pending_rnode_configs:
|
||
iface_name = config['name']
|
||
|
||
# Skip if interface already exists
|
||
if iface_name in self.rnode_interfaces:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Interface '{iface_name}' already exists, skipping")
|
||
continue
|
||
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Creating ColumbaRNodeInterface for '{iface_name}' (device={config.get('target_device_name') or config.get('usb_device_id')})")
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Config for '{iface_name}': connection_mode={config.get('connection_mode')}, "
|
||
f"usb_device_id={config.get('usb_device_id')}, "
|
||
f"usb_vendor_id={config.get('usb_vendor_id')}, "
|
||
f"usb_product_id={config.get('usb_product_id')}")
|
||
|
||
# Create the RNode interface
|
||
# Note: ColumbaRNodeInterface gets kotlin_rnode_bridge from owner (self) via _get_kotlin_bridge()
|
||
rnode_iface = ColumbaRNodeInterface(
|
||
owner=self,
|
||
name=iface_name,
|
||
config=config
|
||
)
|
||
|
||
# Set up error callback to surface RNode errors to Kotlin/UI
|
||
def make_error_callback(interface_name):
|
||
def on_rnode_error(error_code, error_message):
|
||
log_error("ReticulumWrapper", "RNodeError", f"[{interface_name}] RNode error ({error_code}): {error_message}")
|
||
if self.kotlin_rnode_bridge:
|
||
try:
|
||
self.kotlin_rnode_bridge.notifyError(error_code, error_message)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "RNodeError", f"Failed to notify Kotlin: {e}")
|
||
return on_rnode_error
|
||
|
||
rnode_iface.setOnErrorReceived(make_error_callback(iface_name))
|
||
|
||
# Set up online status callback to notify Kotlin when interface comes online
|
||
def make_status_callback(interface_name):
|
||
def on_online_status_change(is_online):
|
||
log_info("ReticulumWrapper", "RNodeStatus",
|
||
f"████ RNODE ONLINE STATUS CHANGED ████ [{interface_name}] online={is_online}")
|
||
if self.kotlin_rnode_bridge:
|
||
try:
|
||
self.kotlin_rnode_bridge.notifyOnlineStatusChanged(is_online)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "RNodeStatus",
|
||
f"Failed to notify Kotlin of online status: {e}")
|
||
return on_online_status_change
|
||
|
||
if hasattr(rnode_iface, 'setOnOnlineStatusChanged'):
|
||
rnode_iface.setOnOnlineStatusChanged(make_status_callback(iface_name))
|
||
log_debug("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Set online status callback for '{iface_name}'")
|
||
|
||
# Start the interface FIRST before registering with Transport
|
||
# This ensures we catch any fatal initialization errors before committing
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface", f"Starting ColumbaRNodeInterface '{iface_name}'...")
|
||
start_success = rnode_iface.start()
|
||
|
||
# Register with RNS Transport after starting
|
||
# The interface is registered even if start() returns False because:
|
||
# 1. The interface has auto-reconnect capability
|
||
# 2. start() failure may be transient (Bluetooth not ready, device not in range)
|
||
# 3. RNS Transport checks online status before sending
|
||
RNS.Transport.interfaces.append(rnode_iface)
|
||
self.rnode_interfaces[iface_name] = rnode_iface
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Registered ColumbaRNodeInterface '{iface_name}' with RNS Transport (start_success={start_success})")
|
||
|
||
if start_success:
|
||
log_info("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"✅ ColumbaRNodeInterface '{iface_name}' started successfully, online={rnode_iface.online}")
|
||
else:
|
||
# Interface failed to start initially, but it has auto-reconnect capability
|
||
# Don't return failure - the interface is registered and will auto-reconnect
|
||
log_warning("ReticulumWrapper", "initialize_rnode_interface",
|
||
f"Initial RNode connection failed for '{iface_name}', but interface registered with auto-reconnect enabled")
|
||
|
||
# Clear the pending configs
|
||
self._pending_rnode_configs = []
|
||
|
||
return {'success': True, 'message': f'Initialized {len(self.rnode_interfaces)} RNode interface(s)'}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "initialize_rnode_interface", f"ERROR initializing RNode interface: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
finally:
|
||
self._rnode_initializing = False
|
||
|
||
# ========== Identity Management Methods ==========
|
||
|
||
def _resolve_identity_file_path(self, identity_hash: str) -> Optional[str]:
|
||
"""
|
||
Resolve an identity hash to its actual file path.
|
||
|
||
Handles both legacy 'default_identity' files and new 'identity_{hash}' files.
|
||
|
||
Args:
|
||
identity_hash: 32-char hex hash of the identity
|
||
|
||
Returns:
|
||
Absolute file path if found, None otherwise
|
||
"""
|
||
# First try the new format: identity_{hash}
|
||
new_format_path = os.path.join(self.storage_path, f"identity_{identity_hash}")
|
||
if os.path.exists(new_format_path):
|
||
return new_format_path
|
||
|
||
# Check if it's the default_identity file
|
||
default_identity_path = os.path.join(self.storage_path, "default_identity")
|
||
log_debug("ReticulumWrapper", "_resolve_identity_file_path", f"Checking default_identity at {default_identity_path}")
|
||
if os.path.exists(default_identity_path):
|
||
try:
|
||
identity = RNS.Identity.from_file(default_identity_path)
|
||
file_hash = identity.hash.hex()
|
||
log_debug("ReticulumWrapper", "_resolve_identity_file_path", f"default_identity hash: {file_hash[:16]}, looking for: {identity_hash[:16]}")
|
||
if file_hash == identity_hash:
|
||
log_debug("ReticulumWrapper", "_resolve_identity_file_path", f"Match found: {default_identity_path}")
|
||
return default_identity_path
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_resolve_identity_file_path", f"Failed to load default_identity: {e}")
|
||
|
||
log_debug("ReticulumWrapper", "_resolve_identity_file_path", f"No file found for hash {identity_hash[:16]}")
|
||
return None
|
||
|
||
def create_identity(self, display_name: str = None) -> Dict:
|
||
"""
|
||
Create a new Reticulum identity and save it to a file.
|
||
|
||
Args:
|
||
display_name: User-friendly name for the identity (not stored in file, used by caller)
|
||
|
||
Returns:
|
||
Dict with:
|
||
- identity_hash: 32-char hex hash of the identity
|
||
- destination_hash: LXMF destination hash
|
||
- file_path: Path to the saved identity file
|
||
- key_data: Raw 64-byte private key data for backup
|
||
- display_name: Echo of the provided display name
|
||
"""
|
||
try:
|
||
log_info("ReticulumWrapper", "create_identity", f"Creating new identity for '{display_name}'")
|
||
|
||
# Create new identity
|
||
identity = RNS.Identity()
|
||
identity_hash = identity.hash.hex()
|
||
|
||
# Save to file with identity hash in filename
|
||
file_path = os.path.join(self.storage_path, f"identity_{identity_hash}")
|
||
identity.to_file(file_path)
|
||
|
||
log_info("ReticulumWrapper", "create_identity", f"Identity saved: {identity_hash[:16]}... -> {file_path}")
|
||
|
||
# Read the key data from the file for backup purposes
|
||
with open(file_path, 'rb') as f:
|
||
key_data = f.read()
|
||
|
||
# Create LXMF destination to get destination hash
|
||
# Create an RNS.Destination with LXMF aspects
|
||
temp_destination = RNS.Destination(
|
||
identity,
|
||
RNS.Destination.IN,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf", "delivery"
|
||
)
|
||
destination_hash = temp_destination.hash.hex()
|
||
|
||
log_info("ReticulumWrapper", "create_identity", f"LXMF destination hash: {destination_hash}")
|
||
|
||
return {
|
||
'identity_hash': identity_hash,
|
||
'destination_hash': destination_hash,
|
||
'file_path': file_path,
|
||
'key_data': key_data,
|
||
'display_name': display_name
|
||
}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "create_identity", f"Failed to create identity: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {'error': str(e)}
|
||
|
||
def list_identity_files(self) -> List[Dict]:
|
||
"""
|
||
Scan storage directory for identity files.
|
||
|
||
Returns:
|
||
List of dicts, each containing:
|
||
- identity_hash: 32-char hex hash
|
||
- file_path: Absolute path to identity file
|
||
"""
|
||
try:
|
||
log_info("ReticulumWrapper", "list_identity_files", f"Scanning for identity files in {self.storage_path}")
|
||
|
||
identities = []
|
||
|
||
# Check for old default_identity file
|
||
default_identity_path = os.path.join(self.storage_path, "default_identity")
|
||
if os.path.exists(default_identity_path):
|
||
try:
|
||
identity = RNS.Identity.from_file(default_identity_path)
|
||
identities.append({
|
||
'identity_hash': identity.hash.hex(),
|
||
'file_path': default_identity_path
|
||
})
|
||
log_debug("ReticulumWrapper", "list_identity_files", f"Found default_identity: {identity.hash.hex()[:16]}...")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "list_identity_files", f"Could not load default_identity: {e}")
|
||
|
||
# Scan for identity_* files
|
||
for filename in os.listdir(self.storage_path):
|
||
if filename.startswith('identity_'):
|
||
file_path = os.path.join(self.storage_path, filename)
|
||
try:
|
||
identity = RNS.Identity.from_file(file_path)
|
||
identities.append({
|
||
'identity_hash': identity.hash.hex(),
|
||
'file_path': file_path
|
||
})
|
||
log_debug("ReticulumWrapper", "list_identity_files", f"Found {filename}: {identity.hash.hex()[:16]}...")
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "list_identity_files", f"Could not load {filename}: {e}")
|
||
|
||
log_info("ReticulumWrapper", "list_identity_files", f"Found {len(identities)} identity file(s)")
|
||
return identities
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "list_identity_files", f"Failed to list identity files: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return []
|
||
|
||
def delete_identity_file(self, identity_hash: str) -> Dict:
|
||
"""
|
||
Remove an identity file from storage.
|
||
|
||
Args:
|
||
identity_hash: 32-char hex hash of the identity to delete
|
||
|
||
Returns:
|
||
Dict with 'success' boolean and optional 'error' string
|
||
"""
|
||
try:
|
||
log_info("ReticulumWrapper", "delete_identity_file", f"Deleting identity {identity_hash[:16]}...")
|
||
|
||
file_path = self._resolve_identity_file_path(identity_hash)
|
||
|
||
if file_path:
|
||
# Securely wipe file before deleting (overwrite with random data)
|
||
try:
|
||
file_size = os.path.getsize(file_path)
|
||
with open(file_path, 'wb') as f:
|
||
f.write(os.urandom(file_size))
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
except Exception as e:
|
||
log_warning("ReticulumWrapper", "delete_identity_file", f"Could not securely wipe file: {e}")
|
||
|
||
# Delete the file
|
||
os.remove(file_path)
|
||
log_info("ReticulumWrapper", "delete_identity_file", f"Identity file deleted: {file_path}")
|
||
return {'success': True}
|
||
else:
|
||
log_warning("ReticulumWrapper", "delete_identity_file", f"Identity file not found for hash: {identity_hash[:16]}...")
|
||
return {'success': False, 'error': 'File not found'}
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "delete_identity_file", f"Failed to delete identity file: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
def import_identity_file(self, file_data: bytes, display_name: str) -> Dict:
|
||
"""
|
||
Import an identity from raw file data.
|
||
|
||
Args:
|
||
file_data: Raw bytes of the identity file
|
||
display_name: User-friendly name for the identity
|
||
|
||
Returns:
|
||
Dict with:
|
||
- identity_hash: 32-char hex hash
|
||
- destination_hash: LXMF destination hash
|
||
- file_path: Path where identity was saved
|
||
- display_name: Echo of provided display name
|
||
"""
|
||
try:
|
||
log_info("ReticulumWrapper", "import_identity_file", f"Importing identity for '{display_name}'")
|
||
|
||
# Write to temporary file
|
||
temp_path = os.path.join(self.storage_path, "temp_identity_import")
|
||
with open(temp_path, 'wb') as f:
|
||
f.write(file_data)
|
||
|
||
# Load identity from temp file to validate and get hash
|
||
try:
|
||
identity = RNS.Identity.from_file(temp_path)
|
||
identity_hash = identity.hash.hex()
|
||
|
||
log_info("ReticulumWrapper", "import_identity_file", f"Loaded identity: {identity_hash[:16]}...")
|
||
|
||
# Move to final location with proper filename
|
||
final_path = os.path.join(self.storage_path, f"identity_{identity_hash}")
|
||
|
||
# Check if identity already exists
|
||
if os.path.exists(final_path):
|
||
os.remove(temp_path)
|
||
log_warning("ReticulumWrapper", "import_identity_file", f"Identity already exists: {identity_hash[:16]}...")
|
||
return {'error': f'Identity already exists: {identity_hash}'}
|
||
|
||
os.rename(temp_path, final_path)
|
||
log_info("ReticulumWrapper", "import_identity_file", f"Identity imported: {final_path}")
|
||
|
||
# Get LXMF destination hash
|
||
temp_destination = RNS.Destination(
|
||
identity,
|
||
RNS.Destination.IN,
|
||
RNS.Destination.SINGLE,
|
||
"lxmf", "delivery"
|
||
)
|
||
destination_hash = temp_destination.hash.hex()
|
||
|
||
return {
|
||
'identity_hash': identity_hash,
|
||
'destination_hash': destination_hash,
|
||
'file_path': final_path,
|
||
'key_data': file_data, # Original file bytes for backup
|
||
'display_name': display_name
|
||
}
|
||
|
||
except Exception as e:
|
||
# Clean up temp file on error
|
||
if os.path.exists(temp_path):
|
||
os.remove(temp_path)
|
||
raise Exception(f"Invalid identity file: {e}")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "import_identity_file", f"Failed to import identity: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {'error': str(e)}
|
||
|
||
def export_identity_file(self, identity_hash: str, file_path: str = None) -> bytes:
|
||
"""
|
||
Read an identity file and return its raw bytes for export.
|
||
|
||
Args:
|
||
identity_hash: 32-char hex hash of the identity to export
|
||
file_path: Optional direct path to the identity file (preferred if available)
|
||
|
||
Returns:
|
||
bytes: Raw identity file data, or empty bytes on error
|
||
"""
|
||
try:
|
||
log_info("ReticulumWrapper", "export_identity_file", f"Exporting identity {identity_hash[:16]}...")
|
||
|
||
# Use provided file_path if available, otherwise try to resolve
|
||
if not file_path:
|
||
file_path = self._resolve_identity_file_path(identity_hash)
|
||
|
||
if not file_path or not os.path.exists(file_path):
|
||
log_error("ReticulumWrapper", "export_identity_file", f"Identity file not found for hash: {identity_hash[:16]}...")
|
||
return bytes()
|
||
|
||
with open(file_path, 'rb') as f:
|
||
file_data = f.read()
|
||
|
||
log_info("ReticulumWrapper", "export_identity_file", f"Exported {len(file_data)} bytes")
|
||
return file_data
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "export_identity_file", f"Failed to export identity: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return bytes()
|
||
|
||
def recover_identity_file(self, identity_hash: str, key_data: bytes, file_path: str) -> Dict:
|
||
"""
|
||
Recover an identity file from backup key data stored in the database.
|
||
Used when the identity file is missing but key_data was backed up.
|
||
|
||
Args:
|
||
identity_hash: Expected 32-char hex hash of the identity
|
||
key_data: Raw 64-byte identity key data from database backup
|
||
file_path: Path where identity file should be restored
|
||
|
||
Returns:
|
||
Dict with:
|
||
- success: True if recovery succeeded
|
||
- file_path: Path where identity was restored
|
||
- error: Error message if recovery failed
|
||
"""
|
||
try:
|
||
log_info("ReticulumWrapper", "recover_identity_file",
|
||
f"Recovering identity {identity_hash[:16]}... to {file_path}")
|
||
|
||
if not key_data or len(key_data) != 64:
|
||
return {'success': False, 'error': f'Invalid key_data: expected 64 bytes, got {len(key_data) if key_data else 0}'}
|
||
|
||
# Write to temporary file first to validate
|
||
temp_path = os.path.join(self.storage_path, "temp_identity_recovery")
|
||
with open(temp_path, 'wb') as f:
|
||
f.write(key_data)
|
||
|
||
# Validate by loading it
|
||
try:
|
||
identity = RNS.Identity.from_file(temp_path)
|
||
recovered_hash = identity.hash.hex()
|
||
|
||
if recovered_hash != identity_hash:
|
||
os.remove(temp_path)
|
||
log_error("ReticulumWrapper", "recover_identity_file",
|
||
f"Hash mismatch: expected {identity_hash[:16]}, got {recovered_hash[:16]}")
|
||
return {'success': False, 'error': f'Hash mismatch: expected {identity_hash}, got {recovered_hash}'}
|
||
|
||
# Ensure parent directory exists
|
||
parent_dir = os.path.dirname(file_path)
|
||
if parent_dir and not os.path.exists(parent_dir):
|
||
os.makedirs(parent_dir, exist_ok=True)
|
||
|
||
# Move to final location
|
||
os.rename(temp_path, file_path)
|
||
log_info("ReticulumWrapper", "recover_identity_file",
|
||
f"Identity recovered successfully: {file_path}")
|
||
|
||
return {'success': True, 'file_path': file_path}
|
||
|
||
except Exception as e:
|
||
if os.path.exists(temp_path):
|
||
os.remove(temp_path)
|
||
raise Exception(f"Invalid key_data: {e}")
|
||
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "recover_identity_file", f"Failed to recover identity: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
# ============================================================================
|
||
# RMSP (Reticulum Map Service Protocol) Methods
|
||
# ============================================================================
|
||
|
||
def _get_rmsp_client(self):
|
||
"""
|
||
Get or create the RMSP client instance.
|
||
Lazy initialization to avoid importing until needed.
|
||
"""
|
||
if self._rmsp_client is None:
|
||
try:
|
||
from rmsp_client import get_rmsp_client
|
||
self._rmsp_client = get_rmsp_client()
|
||
self._rmsp_client.initialize()
|
||
log_info("ReticulumWrapper", "_get_rmsp_client", "RMSP client initialized")
|
||
except ImportError as e:
|
||
log_error("ReticulumWrapper", "_get_rmsp_client", f"Failed to import rmsp_client: {e}")
|
||
return None
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "_get_rmsp_client", f"Failed to initialize RMSP client: {e}")
|
||
return None
|
||
return self._rmsp_client
|
||
|
||
def parse_rmsp_announce(self, destination_hash: bytes, identity, app_data: bytes, hops: int = 0) -> Dict:
|
||
"""
|
||
Parse RMSP announce data and register the server.
|
||
|
||
Args:
|
||
destination_hash: Server's destination hash
|
||
identity: RNS Identity object
|
||
app_data: Announce app data (msgpack-encoded RMSP server info)
|
||
hops: Number of network hops
|
||
|
||
Returns:
|
||
Dict with server info or error
|
||
"""
|
||
try:
|
||
client = self._get_rmsp_client()
|
||
if client is None:
|
||
return {'success': False, 'error': 'RMSP client not available'}
|
||
|
||
server = client.parse_rmsp_announce(destination_hash, identity, app_data, hops)
|
||
if server:
|
||
log_info("ReticulumWrapper", "parse_rmsp_announce",
|
||
f"Registered RMSP server: {server.name}")
|
||
return {'success': True, 'server': server.to_dict()}
|
||
else:
|
||
return {'success': False, 'error': 'Failed to parse RMSP announce'}
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "parse_rmsp_announce", f"Error: {e}")
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
def get_rmsp_servers(self) -> List[Dict]:
|
||
"""
|
||
Get all known RMSP map servers.
|
||
|
||
Returns:
|
||
List of server info dicts
|
||
"""
|
||
try:
|
||
client = self._get_rmsp_client()
|
||
if client is None:
|
||
return []
|
||
return client.get_servers()
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_rmsp_servers", f"Error: {e}")
|
||
return []
|
||
|
||
def get_rmsp_servers_for_geohash(self, geohash: str) -> List[Dict]:
|
||
"""
|
||
Get RMSP servers that cover the given geohash area.
|
||
|
||
Args:
|
||
geohash: Geohash string for the area of interest
|
||
|
||
Returns:
|
||
List of server info dicts that cover this area
|
||
"""
|
||
try:
|
||
client = self._get_rmsp_client()
|
||
if client is None:
|
||
return []
|
||
return client.get_servers_for_geohash(geohash)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_rmsp_servers_for_geohash", f"Error: {e}")
|
||
return []
|
||
|
||
def get_nearest_rmsp_servers(self, limit: int = 5) -> List[Dict]:
|
||
"""
|
||
Get nearest RMSP servers by hop count.
|
||
|
||
Args:
|
||
limit: Maximum number of servers to return
|
||
|
||
Returns:
|
||
List of server info dicts sorted by hop count
|
||
"""
|
||
try:
|
||
client = self._get_rmsp_client()
|
||
if client is None:
|
||
return []
|
||
return client.get_nearest_servers(limit)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_nearest_rmsp_servers", f"Error: {e}")
|
||
return []
|
||
|
||
def query_rmsp_server(self, destination_hash_hex: str, geohash: str,
|
||
zoom_range: List[int] = None, format: str = None,
|
||
timeout: float = 30.0) -> Dict:
|
||
"""
|
||
Query an RMSP server for available map data.
|
||
|
||
Args:
|
||
destination_hash_hex: Server destination hash as hex string
|
||
geohash: Geohash area to query
|
||
zoom_range: Optional [min_zoom, max_zoom]
|
||
format: Optional format (pmtiles, micro)
|
||
timeout: Request timeout in seconds
|
||
|
||
Returns:
|
||
QueryResponse dict
|
||
"""
|
||
try:
|
||
client = self._get_rmsp_client()
|
||
if client is None:
|
||
return {'available': False, 'geohash': geohash, 'error_message': 'RMSP client not available'}
|
||
return client.query_server(destination_hash_hex, geohash, zoom_range, format, timeout)
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "query_rmsp_server", f"Error: {e}")
|
||
return {'available': False, 'geohash': geohash, 'error_message': str(e)}
|
||
|
||
def fetch_rmsp_tiles(self, destination_hash_hex: str, public_key: bytes,
|
||
geohash: str, zoom_range: List[int] = None,
|
||
format: str = None, timeout: float = 3600.0) -> bytes:
|
||
"""
|
||
Fetch tile data from an RMSP server.
|
||
|
||
Args:
|
||
destination_hash_hex: Server destination hash as hex string
|
||
public_key: Server's RNS identity public key (for establishing link)
|
||
geohash: Geohash area to fetch
|
||
zoom_range: Optional [min_zoom, max_zoom]
|
||
format: Optional format (pmtiles, micro)
|
||
timeout: Request timeout in seconds
|
||
|
||
Returns:
|
||
Raw tile data bytes, or empty bytes on failure
|
||
"""
|
||
try:
|
||
client = self._get_rmsp_client()
|
||
if client is None:
|
||
return bytes()
|
||
data = client.fetch_tiles(destination_hash_hex, public_key, geohash, zoom_range, format, timeout)
|
||
return data if data else bytes()
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "fetch_rmsp_tiles", f"Error: {e}")
|
||
return bytes()
|
||
|
||
def clear_rmsp_servers(self):
|
||
"""Clear all known RMSP servers."""
|
||
try:
|
||
client = self._get_rmsp_client()
|
||
if client:
|
||
client.clear_servers()
|
||
log_info("ReticulumWrapper", "clear_rmsp_servers", "Cleared all RMSP servers")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "clear_rmsp_servers", f"Error: {e}")
|
||
|
||
# ============================================================================
|
||
# Protocol Version Information (for About screen)
|
||
# ============================================================================
|
||
|
||
def get_reticulum_version(self) -> str:
|
||
"""Get Reticulum version string."""
|
||
try:
|
||
global RNS
|
||
if RNS and hasattr(RNS, '__version__'):
|
||
return RNS.__version__
|
||
return "unknown"
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_reticulum_version", f"Error: {e}")
|
||
return "unknown"
|
||
|
||
def get_lxmf_version(self) -> str:
|
||
"""Get LXMF version string."""
|
||
try:
|
||
global LXMF
|
||
if LXMF and hasattr(LXMF, '__version__'):
|
||
return LXMF.__version__
|
||
return "unknown"
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_lxmf_version", f"Error: {e}")
|
||
return "unknown"
|
||
|
||
def get_ble_reticulum_version(self) -> str:
|
||
"""Get BLE-Reticulum version string."""
|
||
try:
|
||
import pkg_resources
|
||
return pkg_resources.get_distribution("ble-reticulum").version
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_ble_reticulum_version", f"Error: {e}")
|
||
return "unknown"
|
||
|
||
# ============================================================================
|
||
# Memory Profiling (Debug builds only)
|
||
# ============================================================================
|
||
|
||
def enable_memory_profiling(self, interval_seconds: int = 300) -> None:
|
||
"""
|
||
Enable Python memory profiling with periodic snapshots.
|
||
|
||
Uses tracemalloc to detect memory leaks in Python/Reticulum layer.
|
||
Designed for debug builds - zero overhead when disabled.
|
||
|
||
Args:
|
||
interval_seconds: Snapshot interval (default 300 = 5 minutes)
|
||
"""
|
||
try:
|
||
from memory_profiler import start_profiling, schedule_periodic_snapshots
|
||
start_profiling(nframes=10)
|
||
schedule_periodic_snapshots(interval_seconds)
|
||
log_info("ReticulumWrapper", "enable_memory_profiling",
|
||
f"Memory profiling enabled with {interval_seconds}s snapshots")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "enable_memory_profiling", f"Error: {e}")
|
||
|
||
def disable_memory_profiling(self) -> None:
|
||
"""
|
||
Disable memory profiling and cleanup resources.
|
||
|
||
Safe to call even if profiling was not enabled.
|
||
"""
|
||
try:
|
||
from memory_profiler import stop_profiling
|
||
stop_profiling()
|
||
log_info("ReticulumWrapper", "disable_memory_profiling", "Memory profiling disabled")
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "disable_memory_profiling", f"Error: {e}")
|
||
|
||
def get_memory_profile(self) -> Dict:
|
||
"""
|
||
Get current memory profiling statistics.
|
||
|
||
Returns:
|
||
Dict with:
|
||
- profiling_active: Whether profiling is active
|
||
- current_mb: Current traced memory in MB
|
||
- peak_mb: Peak traced memory in MB
|
||
- overhead_kb: tracemalloc overhead in KB
|
||
"""
|
||
try:
|
||
from memory_profiler import get_memory_stats
|
||
return get_memory_stats()
|
||
except Exception as e:
|
||
log_error("ReticulumWrapper", "get_memory_profile", f"Error: {e}")
|
||
return {
|
||
"profiling_active": False,
|
||
"current_mb": 0.0,
|
||
"peak_mb": 0.0,
|
||
"overhead_kb": 0.0,
|
||
}
|