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
|
||||
|
||||
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
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