Add Phase 0 host-bridge: relay text across Meshtastic, MeshCore, Reticulum
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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