From 512e30a6fd98925cd04e5e47f7b4f6d319f1056f Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 30 Jun 2026 20:32:38 -0400 Subject: [PATCH] Add Phase 0 host-bridge: relay text across Meshtastic, MeshCore, Reticulum --- README.md | 12 +- host-bridge/README.md | 77 +++++++++++++ .../__pycache__/bridge_core.cpython-313.pyc | Bin 0 -> 3865 bytes host-bridge/__pycache__/main.cpython-313.pyc | Bin 0 -> 5682 bytes host-bridge/adapters/__init__.py | 0 .../meshcore_adapter.cpython-313.pyc | Bin 0 -> 5237 bytes .../meshtastic_adapter.cpython-313.pyc | Bin 0 -> 3369 bytes .../reticulum_adapter.cpython-313.pyc | Bin 0 -> 5499 bytes host-bridge/adapters/meshcore_adapter.py | 83 ++++++++++++++ host-bridge/adapters/meshtastic_adapter.py | 57 ++++++++++ host-bridge/adapters/reticulum_adapter.py | 96 ++++++++++++++++ host-bridge/bridge_core.py | 60 ++++++++++ host-bridge/main.py | 103 ++++++++++++++++++ host-bridge/requirements.txt | 4 + 14 files changed, 489 insertions(+), 3 deletions(-) create mode 100644 host-bridge/README.md create mode 100644 host-bridge/__pycache__/bridge_core.cpython-313.pyc create mode 100644 host-bridge/__pycache__/main.cpython-313.pyc create mode 100644 host-bridge/adapters/__init__.py create mode 100644 host-bridge/adapters/__pycache__/meshcore_adapter.cpython-313.pyc create mode 100644 host-bridge/adapters/__pycache__/meshtastic_adapter.cpython-313.pyc create mode 100644 host-bridge/adapters/__pycache__/reticulum_adapter.cpython-313.pyc create mode 100644 host-bridge/adapters/meshcore_adapter.py create mode 100644 host-bridge/adapters/meshtastic_adapter.py create mode 100644 host-bridge/adapters/reticulum_adapter.py create mode 100644 host-bridge/bridge_core.py create mode 100644 host-bridge/main.py create mode 100644 host-bridge/requirements.txt 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 0000000000000000000000000000000000000000..ced1d608a16e71101f0fd8c4690fc37d4a708d95 GIT binary patch literal 3865 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 DiWiNg literal 0 HcmV?d00001 diff --git a/host-bridge/__pycache__/main.cpython-313.pyc b/host-bridge/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..425128e02d697d9552a98d4f20211b7b5be9799f GIT binary patch literal 5682 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1ba44bfd1aca1d4ed49d95d09a420a85731ec818 GIT binary patch literal 5237 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 F@jnD4hv5JK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6d0a48b65081e8deb95069d2e07b7251b2743853 GIT binary patch literal 3369 zcmZ`+|4$sp6`#G`yIbxK4lpKUjq!vy3G3j)av*JSTRX8)>!4iSTS%?M?RwdpgQd&v zb#~7&{KQWcM2$$KqLQD=kB(HSKUK<~ko=T85eu}bA}eiw@NbvkD8Kf--TMMQJm}uc zym@bC-g}?-W;yKcP7!F^f4H%{ktF0lIB7PiF*uxr!8XwdCz_zeW`(z6JVtRW&WdkI zT%u%%j1o;!h!&rUMOr+*m{60I*9Ah}X5Nx-c)n#)qiB?U#_6}~f2HqpqwGD`r<=v{hi)n9TQYT6ca{9GzE@3BbSGgcTaR~Ed=0xcUp?B`I!qix-Za;W-> zN8R@w%I+I0Wt(}avF2!kv$DMk@}9BCa^=RkUa;2V`?t-82zXUfqdPtZDYY^Fc#rzX5`<9%8kk42Qb(scL*0COfgO1q88)Q z2pJ(-uO{7z-$~%#gs6#=u{g+Tz|q8u5ONLp(P%QEB^9zPEekxU^=Vx==CYQ$)1}Fv zCu!-BlhQJv5plXhP8y_p!cj)c^6o`Z?GO6UvMB0rL_vSt1Lp>*w!88{%5`*@V;BOd z>~cSlmR!#_o5(}!5Kkif4qpedO>Xzv&`0eywEVy{;;hK-Stek}@R#Tq%Pr>U1h6K2e|27a^OBmQc#CN|cl}m4asY4>H*e)qGxrVC zw^ym_AZRJ>If0_oTXHLQkpe0_=3k*!i8{;zXme_L$Qnneq$*C4aeLKr7O8D5aD%T> zqvE?4i_B-H?@{CU{c@?iDg|l%h?C-a=%u)hrv?e#LfC+iR&0QYmrs(Bw+X!K$FjvfoTk0}Ki$dy(dhK51S@j zB2CDixE`BoXgT7%*aUA8cp=nI+EHw3{6c8SL;NXuuXVd;+(l|?HhV_`kfI$=HQ=6* zl((->HBO*#J2jq1{F!c%6$1$$yC6~G?#fM=VGlJ*Tx5PAdOqjfa6pjE&&>VqH-%X> z9>xD@u1Py);*Iwv^#ZyRq6~w&TK-S4Y_Vm`;=Gu}4l6q;ImP4l?}*eb4<(`B>Tc^?1@GLJbz>D+@e>Knv%B?ol7@@ZSvqB!s3u{%}v|SIr zutF$$m}d>7{S=15bDdU(@;`w^?*$-7%p5()^lr^=&hBT<)-q=wX3o_`l6f(-PL;ya z8J-yC0FUZl9yu~S9UvY~SD7<=*(e*!747;(C)%B9aJ>Rw*=D51f zbD+%8UVahDDJ18C1X9s5{V-iP+Rj6BPV2ho8}K=H4Q6LUTXLVZMh1 zZ|JQ8`7=4_QPzJ|Pmy7IaOT{1UET3Bj|PT!%-z=>h&%d&V)g9h+Q4h|IPmKUlI{QK z#^!Q8i8Hw|`vG;HjC93ILVYkEpV}?f33Q*Ge}Y}TM~uI?bE;0D+Z}j<-H)kQoJP08 z3+2^Bkks{}Yl26zrVDsBeam&coUver=0jx;|1AjLu*isOpx8DnkbfTe^q2ewEMTC$ hVIXx;5QOhy2|@aymk2NYj|~2pcvYa^6Ck1X{{eOz7oPwC literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2857ff6facb60242a88968754738c1135156ee96 GIT binary patch literal 5499 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 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