Dorian dbfdf1c851 fix: close relay loop from prefixed dedup mismatch
The dispatcher deduped on inbound text but relayed a prefixed copy
("[net/sender] text"), so a re-heard relayed message hashed differently
and was re-relayed, stacking another prefix each hop — an unbounded loop,
exactly what the dedup was meant to prevent.

Add MessageBus.mark_seen() and record the on-air relayed string before
sending, so the bridge recognises and drops its own relayed copies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:31:52 +01:00

109 lines
4.1 KiB
Python

#!/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}")
relayed = f"[{message.network}/{message.sender}] {message.text}"
# Record the exact on-air string so that if this relayed copy is
# re-heard from another network it's recognised as a duplicate and
# not re-relayed (which would stack another prefix each hop).
bus.mark_seen(relayed)
for network, adapter in adapters.items():
if network == message.network:
continue # don't echo back onto the network it came from
try:
adapter.send(relayed)
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())