diff --git a/README.md b/README.md index 0c951c6..fe76712 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/host-bridge/README.md b/host-bridge/README.md new file mode 100644 index 0000000..b617be5 --- /dev/null +++ b/host-bridge/README.md @@ -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. diff --git a/host-bridge/__pycache__/bridge_core.cpython-313.pyc b/host-bridge/__pycache__/bridge_core.cpython-313.pyc new file mode 100644 index 0000000..ced1d60 Binary files /dev/null and b/host-bridge/__pycache__/bridge_core.cpython-313.pyc differ diff --git a/host-bridge/__pycache__/main.cpython-313.pyc b/host-bridge/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..425128e Binary files /dev/null and b/host-bridge/__pycache__/main.cpython-313.pyc differ diff --git a/host-bridge/adapters/__init__.py b/host-bridge/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/host-bridge/adapters/__pycache__/meshcore_adapter.cpython-313.pyc b/host-bridge/adapters/__pycache__/meshcore_adapter.cpython-313.pyc new file mode 100644 index 0000000..1ba44bf Binary files /dev/null and b/host-bridge/adapters/__pycache__/meshcore_adapter.cpython-313.pyc differ diff --git a/host-bridge/adapters/__pycache__/meshtastic_adapter.cpython-313.pyc b/host-bridge/adapters/__pycache__/meshtastic_adapter.cpython-313.pyc new file mode 100644 index 0000000..6d0a48b Binary files /dev/null and b/host-bridge/adapters/__pycache__/meshtastic_adapter.cpython-313.pyc differ diff --git a/host-bridge/adapters/__pycache__/reticulum_adapter.cpython-313.pyc b/host-bridge/adapters/__pycache__/reticulum_adapter.cpython-313.pyc new file mode 100644 index 0000000..2857ff6 Binary files /dev/null and b/host-bridge/adapters/__pycache__/reticulum_adapter.cpython-313.pyc differ diff --git a/host-bridge/adapters/meshcore_adapter.py b/host-bridge/adapters/meshcore_adapter.py new file mode 100644 index 0000000..cc98ebd --- /dev/null +++ b/host-bridge/adapters/meshcore_adapter.py @@ -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) diff --git a/host-bridge/adapters/meshtastic_adapter.py b/host-bridge/adapters/meshtastic_adapter.py new file mode 100644 index 0000000..4e63436 --- /dev/null +++ b/host-bridge/adapters/meshtastic_adapter.py @@ -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() diff --git a/host-bridge/adapters/reticulum_adapter.py b/host-bridge/adapters/reticulum_adapter.py new file mode 100644 index 0000000..b56366f --- /dev/null +++ b/host-bridge/adapters/reticulum_adapter.py @@ -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 diff --git a/host-bridge/bridge_core.py b/host-bridge/bridge_core.py new file mode 100644 index 0000000..90e21f0 --- /dev/null +++ b/host-bridge/bridge_core.py @@ -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) diff --git a/host-bridge/main.py b/host-bridge/main.py new file mode 100644 index 0000000..99f51a1 --- /dev/null +++ b/host-bridge/main.py @@ -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()) diff --git a/host-bridge/requirements.txt b/host-bridge/requirements.txt new file mode 100644 index 0000000..b14f6b3 --- /dev/null +++ b/host-bridge/requirements.txt @@ -0,0 +1,4 @@ +meshtastic>=2.7.10 +meshcore>=2.3.7 +rns>=1.3.5 +lxmf>=1.0.1