Add Phase 0 host-bridge: relay text across Meshtastic, MeshCore, Reticulum

This commit is contained in:
archipelago
2026-06-30 20:32:38 -04:00
parent 1884a25a6d
commit 512e30a6fd
14 changed files with 489 additions and 3 deletions
View File
+83
View File
@@ -0,0 +1,83 @@
"""MeshCore adapter — wraps the official `meshcore` (meshcore_py) async client.
meshcore_py is asyncio-native (MeshCore.create_serial/create_tcp/create_ble,
event subscription via meshcore.subscribe(EventType.X, coroutine_handler)).
The rest of this bridge is thread/callback-based (Meshtastic's pubsub,
Reticulum's RNS transport thread), so this adapter owns a dedicated event
loop on a background thread and exposes plain synchronous connect()/send()/
close() methods, matching the shape of the other two adapters.
"""
import asyncio
import threading
from collections.abc import Callable
class MeshCoreAdapter:
NETWORK = "meshcore"
def __init__(
self,
on_message: Callable[[str, str, str], None],
port: str | None = None,
host: str | None = None,
tcp_port: int = 4000,
channel_idx: int = 0,
):
"""port: serial device path. host: TCP hostname/IP (mutually exclusive
with port). channel_idx: which MeshCore public channel to bridge —
channel 0 is the default public channel on stock firmware.
"""
self._on_message = on_message
self._port = port
self._host = host
self._tcp_port = tcp_port
self._channel_idx = channel_idx
self._mc = None
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
def connect(self) -> None:
ready = threading.Event()
self._thread = threading.Thread(target=self._run_loop, args=(ready,), daemon=True)
self._thread.start()
ready.wait(timeout=15)
def _run_loop(self, ready: threading.Event) -> None:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._connect_async())
ready.set()
self._loop.run_forever()
async def _connect_async(self) -> None:
from meshcore import EventType, MeshCore
if self._host:
self._mc = await MeshCore.create_tcp(self._host, self._tcp_port)
else:
self._mc = await MeshCore.create_serial(self._port or "/dev/ttyUSB0")
self._mc.subscribe(EventType.CHANNEL_MSG_RECV, self._handle_channel_msg)
async def _handle_channel_msg(self, event) -> None: # noqa: ANN001
data = event.payload
if data.get("channel_idx") != self._channel_idx:
return
text = data.get("text", "")
if text:
# CHANNEL_MSG_RECV carries no sender identity at the protocol
# level (verified against meshcore/reader.py) — MeshCore apps
# convey the sender by convention inside the text itself.
self._on_message(self.NETWORK, "?", text)
def send(self, text: str) -> None:
if self._mc is None or self._loop is None:
raise RuntimeError("MeshCoreAdapter.send() called before connect()")
asyncio.run_coroutine_threadsafe(
self._mc.commands.send_chan_msg(self._channel_idx, text), self._loop
)
def close(self) -> None:
if self._loop is not None:
self._loop.call_soon_threadsafe(self._loop.stop)
@@ -0,0 +1,57 @@
"""Meshtastic adapter — wraps the official `meshtastic` Python client.
Uses the same pubsub pattern as meshtastic/python's own examples
(examples/replymessage.py, examples/tcp_pubsub_send_and_receive.py):
subscribe to the "meshtastic.receive" topic, filter for text packets,
send via MeshInterface.sendText().
"""
from collections.abc import Callable
from pubsub import pub
class MeshtasticAdapter:
NETWORK = "meshtastic"
def __init__(
self,
on_message: Callable[[str, str, str], None],
port: str | None = None,
host: str | None = None,
):
"""port: serial device path (e.g. /dev/ttyUSB0). host: TCP hostname/IP.
Exactly one of port/host should be set; if neither is set, the
underlying library auto-detects a serial device.
"""
self._on_message = on_message
self._port = port
self._host = host
self._iface = None
def connect(self) -> None:
import meshtastic.serial_interface
import meshtastic.tcp_interface
pub.subscribe(self._handle_receive, "meshtastic.receive")
if self._host:
self._iface = meshtastic.tcp_interface.TCPInterface(hostname=self._host, timeout=10)
else:
self._iface = meshtastic.serial_interface.SerialInterface(devPath=self._port, timeout=10)
def _handle_receive(self, packet: dict, interface) -> None: # noqa: ANN001
text = packet.get("decoded", {}).get("text")
if not text:
return
sender = packet.get("fromId") or str(packet.get("from"))
self._on_message(self.NETWORK, sender, text)
def send(self, text: str) -> None:
if self._iface is None:
raise RuntimeError("MeshtasticAdapter.send() called before connect()")
self._iface.sendText(text)
def close(self) -> None:
if self._iface is not None:
self._iface.close()
+96
View File
@@ -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