Add Phase 0 host-bridge: relay text across Meshtastic, MeshCore, Reticulum
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user