archy-messh/host-bridge/adapters/reticulum_adapter.py

97 lines
3.7 KiB
Python

"""Reticulum adapter — wraps RNS + LXMF (the standard host-side Reticulum
messaging stack; see docs/ARCHITECTURE.md for why archy-messh talks to a
real RNode-flashed radio via RNS rather than reimplementing Reticulum's
wire format from scratch).
Reticulum has no built-in broadcast-channel concept the way Meshtastic
("channel" PSK) or MeshCore ("public channel index") do — LXMF messaging is
addressed point-to-point between Destinations. To bridge broadcast-style
text, this adapter tracks a roster of peer LXMF delivery destinations
learned from their announces (any Reticulum/Sideband/NomadNet/MeshChat user
who has announced is added), and fans outbound bridge messages out to that
roster individually. This is the simplest correct v0; a dedicated shared
GROUP destination (symmetric-key, closer to a real "channel") is a known
follow-up once Phase 0 proves the roster approach works at all.
"""
import threading
from collections.abc import Callable
class ReticulumAdapter:
NETWORK = "reticulum"
ASPECT = "lxmf.delivery"
def __init__(
self,
on_message: Callable[[str, str, str], None],
storage_path: str,
display_name: str = "archy-messh-bridge",
):
self._on_message = on_message
self._storage_path = storage_path
self._display_name = display_name
self._router = None
self._identity = None
self._destination = None
self._peers: set[bytes] = set()
self._lock = threading.Lock()
# RNS.Transport.register_announce_handler() requires an object with
# an `aspect_filter` attribute — set here so `self` can be registered
# directly as the announce handler.
self.aspect_filter = self.ASPECT
def connect(self) -> None:
import RNS
import LXMF
RNS.Reticulum()
self._router = LXMF.LXMRouter(storagepath=self._storage_path)
self._identity = RNS.Identity()
self._destination = self._router.register_delivery_identity(
self._identity, display_name=self._display_name
)
self._router.register_delivery_callback(self._handle_delivery)
RNS.Transport.register_announce_handler(self)
self._router.announce(self._destination.hash)
def received_announce(self, destination_hash, announced_identity, app_data) -> None: # noqa: ANN001
if self._destination is not None and destination_hash == self._destination.hash:
return # ignore our own announce
with self._lock:
self._peers.add(destination_hash)
def _handle_delivery(self, message) -> None: # noqa: ANN001
import RNS
text = message.content_as_string()
sender = RNS.prettyhexrep(message.source_hash)
if text:
self._on_message(self.NETWORK, sender, text)
def send(self, text: str) -> None:
import RNS
import LXMF
if self._router is None or self._destination is None:
raise RuntimeError("ReticulumAdapter.send() called before connect()")
with self._lock:
peer_hashes = list(self._peers)
for peer_hash in peer_hashes:
identity = RNS.Identity.recall(peer_hash)
if identity is None:
continue # no path/identity known yet for this peer
dest = RNS.Destination(
identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery"
)
lxm = LXMF.LXMessage(
dest, self._destination, text, desired_method=LXMF.LXMessage.OPPORTUNISTIC
)
self._router.handle_outbound(lxm)
def close(self) -> None:
pass # RNS/LXMF manage their own threads; no explicit teardown needed for a v0 prototype