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

@ -13,13 +13,19 @@ the `archy` (Archipelago) codebase. This repo is intentionally standalone.
## Status
Pre-design. See `docs/ARCHITECTURE.md` (in progress) for the protocol
research and bridge design space.
Architecture phase done — see `docs/ARCHITECTURE.md` for the protocol
research, hardware constraints (target: Heltec WiFi LoRa 32 V3), and
phased plan. **Phase 0** (host-side bridge across 3 separate stock-firmware
boards) is written — see `host-bridge/` — but not yet hardware-tested; no
LoRa boards have been attached to the dev machine this was written on.
## Layout
- `docs/` — protocol research notes and architecture/design docs
- `firmware/` — device firmware (once design lands)
- `host-bridge/` — Phase 0: Python bridge relaying text between real
Meshtastic/MeshCore/RNode-Reticulum boards over each project's official
client protocol
- `firmware/` — Phase 1+ embedded firmware (once Phase 0 is validated on hardware)
- `hardware/` — reference hardware notes / board selection
## Non-goals (for now)

77
host-bridge/README.md Normal file
View File

@ -0,0 +1,77 @@
# host-bridge (Phase 0)
A host-side Python bridge that connects to one radio per network —
Meshtastic, MeshCore, and Reticulum (via an RNode-flashed board) — over
each project's **official** client protocol, and relays text messages
between all three. See `../docs/ARCHITECTURE.md` for why this is Phase 0
(prove the cross-network translation logic on 3 separate boards, no
custom firmware) rather than Phase 1 (one time-multiplexed radio on a
single Heltec V3).
## Status
**Written, not yet hardware-tested.** Every library call has been checked
against the upstream project's actual source/docs (see comments in each
`adapters/*.py` file for what was verified and how), and all four
dependencies (`meshtastic`, `meshcore`, `rns`, `lxmf`) install and import
cleanly. What's unverified is the actual on-air behavior once real radios
are attached — that requires the three boards described below.
## What you need to test this for real
- A Meshtastic-flashed board (e.g. Heltec V3) connected via USB serial or reachable over TCP (ESP32 WiFi build)
- A MeshCore companion-radio-flashed board, serial or TCP
- An RNode-flashed board (any RNode-compatible LoRa board) reachable via RNS's serial interface — configure this the same way archy's own `core/archipelago/src/mesh/reticulum.rs` KISS interface does, since this bridge uses the standard `rns`/`lxmf` Python packages, not a custom implementation
- All three radios need actual antennas/RF range to each other's respective real-world networks to see any traffic worth relaying — this is not simulatable without a radio in the loop
## Install
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
## Run
```bash
python3 main.py \
--meshtastic-port /dev/ttyUSB0 \
--meshcore-port /dev/ttyUSB1 \
--reticulum-storage ./rns-storage
```
Use `--meshtastic-host`/`--meshcore-host` instead of the `-port` flags if a
board is reachable over TCP (e.g. an ESP32 WiFi build) instead of USB
serial. Run `python3 main.py --help` for the full flag list.
## How relaying works
Each adapter pushes received text onto a shared, thread-safe queue
(`bridge_core.MessageBus`). A single dispatcher loop in `main.py` pulls
each message off, tags it with its origin network + sender, and re-sends
it on the other two networks — never back onto the network it came from.
Content-hash dedup (`MessageBus.is_duplicate`, 5-minute TTL by default)
stops the obvious relay loop (the bridge re-hearing its own relayed
copy). This is a known-simple v0 approach — it can't distinguish two
different messages that happen to have identical text within the TTL
window, and it doesn't help if there are *multiple* archy-messh bridges
active near each other. Good enough to prove the concept; revisit once
Phase 1 needs something more robust.
## Known gaps (by design, for v0)
- **Reticulum has no broadcast-channel primitive.** Meshtastic has channel
PSKs, MeshCore has public channel indices — Reticulum/LXMF messaging is
point-to-point between Destinations. `ReticulumAdapter` fakes broadcast
by tracking a roster of peer delivery-destinations learned from their
LXMF announces and fanning out to all of them individually. A shared
symmetric-key Reticulum `GROUP` destination would be a closer analog to
a real channel — noted as a follow-up, not implemented here.
- **MeshCore channel messages carry no sender identity at the protocol
level** (verified against `meshcore/reader.py``CHANNEL_MSG_RECV` has
no pubkey/name field). MeshCore apps convey sender identity by
convention inside the message text itself; this bridge doesn't attempt
to parse that out, so messages relayed *from* MeshCore show `?` as the
sender.

Binary file not shown.

Binary file not shown.

View File

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)

View File

@ -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()

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

View File

@ -0,0 +1,60 @@
"""Shared message bus + loop-prevention core for the archy-messh Phase 0 bridge.
Each protocol adapter runs on its own thread/event loop (Meshtastic's pubsub
callbacks fire from its internal reader thread, MeshCore is asyncio-native,
Reticulum/LXMF invoke callbacks from RNS's internal transport thread). All
three push into this single thread-safe queue so one dispatcher can fan
messages out across protocols without each adapter needing to know about
the others.
"""
import hashlib
import queue
import threading
import time
from dataclasses import dataclass
@dataclass
class BridgeMessage:
network: str # "meshtastic" | "meshcore" | "reticulum"
sender: str
text: str
received_at: float
class MessageBus:
"""Thread-safe inbox with content-hash dedup to prevent repeat loops.
Dedup is content-hash based (not per-network packet-id based), since the
whole point of the bridge is that the same text shows up natively on all
three networks once relayed. A short TTL window is enough to stop the
obvious loop (bridge re-hears its own relayed copy) without needing any
cross-protocol message-id scheme, which doesn't exist.
"""
def __init__(self, dedup_ttl_seconds: float = 300.0):
self._queue: "queue.Queue[BridgeMessage]" = queue.Queue()
self._dedup_ttl = dedup_ttl_seconds
self._seen: dict[str, float] = {}
self._lock = threading.Lock()
@staticmethod
def _content_hash(text: str) -> str:
return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()
def publish(self, message: BridgeMessage) -> None:
self._queue.put(message)
def is_duplicate(self, text: str) -> bool:
h = self._content_hash(text)
now = time.monotonic()
with self._lock:
self._seen = {k: v for k, v in self._seen.items() if v > now}
if h in self._seen:
return True
self._seen[h] = now + self._dedup_ttl
return False
def get(self, timeout: float | None = None) -> BridgeMessage:
return self._queue.get(timeout=timeout)

103
host-bridge/main.py Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""archy-messh Phase 0 host-bridge.
Connects to one radio per network (Meshtastic, MeshCore, Reticulum/RNode),
each over its official client protocol, and relays text messages between
all three. See docs/ARCHITECTURE.md for why this is Phase 0 (prove the
bridging logic on 3 separate boards) rather than Phase 1 (single
time-multiplexed radio on one Heltec V3).
NOT YET TESTED END-TO-END written without physical hardware attached to
the dev machine. Needs Meshtastic + MeshCore + RNode-flashed boards to
validate; each adapter's wire-level behavior is only as good as its
upstream library's docs/examples (cited in each adapter's docstring/header).
Usage (see README.md for full option list):
python3 main.py \\
--meshtastic-port /dev/ttyUSB0 \\
--meshcore-port /dev/ttyUSB1 \\
--reticulum-storage ./rns-storage
"""
import argparse
import sys
import time
from adapters.meshcore_adapter import MeshCoreAdapter
from adapters.meshtastic_adapter import MeshtasticAdapter
from adapters.reticulum_adapter import ReticulumAdapter
from bridge_core import BridgeMessage, MessageBus
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="archy-messh Phase 0 tri-protocol bridge")
p.add_argument("--meshtastic-port", help="Meshtastic serial device (e.g. /dev/ttyUSB0)")
p.add_argument("--meshtastic-host", help="Meshtastic TCP host, instead of serial")
p.add_argument("--meshcore-port", help="MeshCore serial device (e.g. /dev/ttyUSB1)")
p.add_argument("--meshcore-host", help="MeshCore TCP host, instead of serial")
p.add_argument("--meshcore-channel", type=int, default=0, help="MeshCore public channel index")
p.add_argument(
"--reticulum-storage", default="./rns-storage", help="LXMF/RNS storage directory"
)
p.add_argument(
"--dedup-ttl", type=float, default=300.0, help="Seconds to suppress repeat-content loops"
)
return p
def main() -> int:
args = build_arg_parser().parse_args()
bus = MessageBus(dedup_ttl_seconds=args.dedup_ttl)
def on_message(network: str, sender: str, text: str) -> None:
bus.publish(BridgeMessage(network=network, sender=sender, text=text, received_at=time.time()))
meshtastic = MeshtasticAdapter(on_message, port=args.meshtastic_port, host=args.meshtastic_host)
meshcore = MeshCoreAdapter(
on_message,
port=args.meshcore_port,
host=args.meshcore_host,
channel_idx=args.meshcore_channel,
)
reticulum = ReticulumAdapter(on_message, storage_path=args.reticulum_storage)
adapters = {
MeshtasticAdapter.NETWORK: meshtastic,
MeshCoreAdapter.NETWORK: meshcore,
ReticulumAdapter.NETWORK: reticulum,
}
print("Connecting to Meshtastic...")
meshtastic.connect()
print("Connecting to MeshCore...")
meshcore.connect()
print("Connecting to Reticulum...")
reticulum.connect()
print("All three adapters connected. Bridging...")
try:
while True:
message = bus.get()
if bus.is_duplicate(message.text):
continue
print(f"[{message.network}] {message.sender}: {message.text}")
for network, adapter in adapters.items():
if network == message.network:
continue # don't echo back onto the network it came from
try:
adapter.send(f"[{message.network}/{message.sender}] {message.text}")
except Exception as exc: # noqa: BLE001
print(f" ! failed to relay onto {network}: {exc}", file=sys.stderr)
except KeyboardInterrupt:
pass
finally:
meshtastic.close()
meshcore.close()
reticulum.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,4 @@
meshtastic>=2.7.10
meshcore>=2.3.7
rns>=1.3.5
lxmf>=1.0.1