Add Phase 0 host-bridge: relay text across Meshtastic, MeshCore, Reticulum
This commit is contained in:
parent
1884a25a6d
commit
512e30a6fd
12
README.md
12
README.md
@ -13,13 +13,19 @@ the `archy` (Archipelago) codebase. This repo is intentionally standalone.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Pre-design. See `docs/ARCHITECTURE.md` (in progress) for the protocol
|
Architecture phase done — see `docs/ARCHITECTURE.md` for the protocol
|
||||||
research and bridge design space.
|
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
|
## Layout
|
||||||
|
|
||||||
- `docs/` — protocol research notes and architecture/design docs
|
- `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
|
- `hardware/` — reference hardware notes / board selection
|
||||||
|
|
||||||
## Non-goals (for now)
|
## Non-goals (for now)
|
||||||
|
|||||||
77
host-bridge/README.md
Normal file
77
host-bridge/README.md
Normal 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.
|
||||||
BIN
host-bridge/__pycache__/bridge_core.cpython-313.pyc
Normal file
BIN
host-bridge/__pycache__/bridge_core.cpython-313.pyc
Normal file
Binary file not shown.
BIN
host-bridge/__pycache__/main.cpython-313.pyc
Normal file
BIN
host-bridge/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
0
host-bridge/adapters/__init__.py
Normal file
0
host-bridge/adapters/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
83
host-bridge/adapters/meshcore_adapter.py
Normal file
83
host-bridge/adapters/meshcore_adapter.py
Normal 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)
|
||||||
57
host-bridge/adapters/meshtastic_adapter.py
Normal file
57
host-bridge/adapters/meshtastic_adapter.py
Normal 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()
|
||||||
96
host-bridge/adapters/reticulum_adapter.py
Normal file
96
host-bridge/adapters/reticulum_adapter.py
Normal 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
|
||||||
60
host-bridge/bridge_core.py
Normal file
60
host-bridge/bridge_core.py
Normal 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
103
host-bridge/main.py
Normal 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())
|
||||||
4
host-bridge/requirements.txt
Normal file
4
host-bridge/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
meshtastic>=2.7.10
|
||||||
|
meshcore>=2.3.7
|
||||||
|
rns>=1.3.5
|
||||||
|
lxmf>=1.0.1
|
||||||
Loading…
x
Reference in New Issue
Block a user