archy-messh/host-bridge/adapters/meshcore_adapter.py

84 lines
3.1 KiB
Python

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