diff --git a/work/meshai/config.py b/work/meshai/config.py index 46c0cf8..1c0b23d 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -34,8 +34,15 @@ class ConnectionConfig: reconnect_initial_delay: float = 2.0 reconnect_max_delay: float = 60.0 reconnect_health_interval: float = 30.0 - # --- transport selection (Phase 1 seam; MeshCore support is future work) --- + # --- transport selection (Phase 1 seam; MeshCore support is Phase 2) --- transport: str = "meshtastic" # "meshtastic" | "meshcore" | "both" + # --- MeshCore transport settings (used when transport="meshcore") --- + meshcore_host: str = "100.64.0.9" # pyMC companion frame server host + meshcore_port: int = 5050 # pyMC companion frame server port + meshcore_channel_index: int = 0 # default channel index for broadcasts + meshcore_max_chars: int = 140 # placeholder max chars (Phase 3 chunker) + meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect + meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited) @dataclass diff --git a/work/meshai/main.py b/work/meshai/main.py index 786819b..17bc81e 100644 --- a/work/meshai/main.py +++ b/work/meshai/main.py @@ -83,8 +83,13 @@ class MeshAI: self._loop = asyncio.get_event_loop() # --- connection supervisor (watchdog): single source of truth for link # state + the only reconnect driver. Reconnects IN-PLACE so the container - # never needs to restart. --- - if getattr(self.config.connection, "reconnect", True): + # never needs to restart. + # Guard: watchdog is Meshtastic-specific — MeshCoreTransport manages its + # own reconnect via the meshcore lib's auto_reconnect parameter. --- + if ( + getattr(self.config.connection, "reconnect", True) + and isinstance(self.connector, MeshConnector) + ): self.connector._wake = asyncio.Event() self.connector.write_link_status("up") # we just connected ok self._supervisor_task = asyncio.create_task(self._connection_supervisor()) diff --git a/work/meshai/transport/factory.py b/work/meshai/transport/factory.py index 0373f11..11fedf5 100644 --- a/work/meshai/transport/factory.py +++ b/work/meshai/transport/factory.py @@ -26,10 +26,15 @@ def build_transport(config) -> MeshTransport: from meshai.connector import MeshtasticTransport return MeshtasticTransport(config) - if transport_name in ("meshcore", "both"): + if transport_name == "meshcore": + # Function-local import keeps the module importable without the meshcore + # lib installed (lazy import pattern mirrors the meshtastic case). + from meshai.transport.meshcore_transport import MeshCoreTransport + return MeshCoreTransport(config) + + if transport_name == "both": raise NotImplementedError( - f"Transport {transport_name!r} is not yet implemented. " - "This is a Phase 1 seam; MeshCore support lands in a later phase." + "Transport 'both' is not yet implemented (Phase 4 seam)." ) raise ValueError( diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py new file mode 100644 index 0000000..0ea7027 --- /dev/null +++ b/work/meshai/transport/meshcore_transport.py @@ -0,0 +1,390 @@ +"""MeshCore transport backend (Phase 2). + +Implements MeshTransport over a pyMC companion TCP frame server using the +meshcore lib. The meshcore lib is fully async; we bridge it into the sync +MeshTransport interface via a dedicated asyncio event loop running in a +daemon thread. Commands are dispatched with +``asyncio.run_coroutine_threadsafe()``. + +The meshcore lib is LAZY-IMPORTED inside methods so this module can be +imported (and the test suite can run) without the lib installed. +""" + +import asyncio +import logging +import threading +from typing import Callable, Optional + +from .base import MeshTransport +from ..connector import MeshMessage + +logger = logging.getLogger(__name__) + +# Default timeout for command futures (seconds). +_COMMAND_TIMEOUT = 10.0 + + +class MeshCoreTransport(MeshTransport): + """MeshTransport implementation over a pyMC companion TCP frame server. + + Async bridge: a dedicated asyncio event loop runs in a daemon thread + (``self._loop`` / ``self._loop_thread``). All coroutines are dispatched + via ``asyncio.run_coroutine_threadsafe(..., self._loop).result(timeout)``. + The meshcore client (``self._mc``) is created and used exclusively on that + loop. + + The transport is dormant until ``connect()`` is called. When + ``transport != "meshcore"`` in config the factory never instantiates this + class, so there is zero cost to existing meshtastic deployments. + """ + + def __init__(self, config) -> None: + self.config = config + self._mc = None # meshcore.MeshCore instance + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop_thread: Optional[threading.Thread] = None + self._connected: bool = False + self._self_info: dict = {} + self._message_callback: Optional[Callable] = None + self._callback_loop: Optional[asyncio.AbstractEventLoop] = None + self._loop_ready: threading.Event = threading.Event() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _run_coro(self, coro, timeout: float = _COMMAND_TIMEOUT): + """Submit *coro* to the dedicated event loop and block until done. + + Raises RuntimeError if the loop isn't running. + """ + if self._loop is None or not self._loop.is_running(): + raise RuntimeError("MeshCoreTransport: event loop is not running") + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result(timeout=timeout) + + def _stop_loop(self) -> None: + """Signal the event loop to stop and join the thread.""" + if self._loop is not None: + try: + self._loop.call_soon_threadsafe(self._loop.stop) + except Exception: + pass + if self._loop_thread is not None and self._loop_thread.is_alive(): + self._loop_thread.join(timeout=5.0) + self._loop = None + self._loop_thread = None + + # ------------------------------------------------------------------ + # Internal coroutines (run on the dedicated loop) + # ------------------------------------------------------------------ + + async def _do_connect(self, host: str, port: int, + auto_reconnect: bool, max_attempts: int): + """Lazy-import meshcore and create the TCP client.""" + from meshcore import MeshCore # noqa: PLC0415 (lazy import intentional) + mc = await MeshCore.create_tcp( + host, port, + auto_reconnect=auto_reconnect, + max_reconnect_attempts=max_attempts, + ) + return mc + + async def _setup_subscriptions(self) -> None: + """Start auto message fetching and subscribe to inbound events.""" + from meshcore import EventType # noqa: PLC0415 + await self._mc.start_auto_message_fetching() + self._mc.subscribe(EventType.CONTACT_MSG_RECV, self._on_dm_event) + self._mc.subscribe(EventType.CHANNEL_MSG_RECV, self._on_channel_event) + self._mc.subscribe(EventType.DISCONNECTED, self._on_disconnect_event) + self._mc.subscribe(EventType.CONNECTED, self._on_connect_event) + + async def _do_disconnect(self) -> None: + """Stop fetching and close the meshcore connection.""" + try: + await self._mc.stop_auto_message_fetching() + except Exception as exc: + logger.warning("MeshCoreTransport: stop_auto_message_fetching error: %s", exc) + try: + await self._mc.disconnect() + except Exception as exc: + logger.warning("MeshCoreTransport: mc.disconnect error: %s", exc) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def connect(self) -> None: + """Connect to the pyMC companion TCP frame server.""" + host = getattr(self.config, "meshcore_host", "100.64.0.9") + port = getattr(self.config, "meshcore_port", 5050) + auto_reconnect = getattr(self.config, "meshcore_auto_reconnect", True) + max_attempts = getattr(self.config, "meshcore_max_reconnect_attempts", 5) + + logger.info("MeshCoreTransport: connecting to %s:%d …", host, port) + + # Start the dedicated event loop in a daemon thread. + self._loop = asyncio.new_event_loop() + self._loop_ready.clear() + + def _run_loop() -> None: + asyncio.set_event_loop(self._loop) + self._loop.call_soon(self._loop_ready.set) + self._loop.run_forever() + + self._loop_thread = threading.Thread( + target=_run_loop, + name="meshcore-loop", + daemon=True, + ) + self._loop_thread.start() + self._loop_ready.wait(timeout=5.0) + + try: + mc = self._run_coro( + self._do_connect(host, port, auto_reconnect, max_attempts), + timeout=30.0, + ) + except Exception as exc: + logger.error("MeshCoreTransport: connect failed: %s", exc) + self._stop_loop() + raise + + if mc is None: + self._stop_loop() + raise RuntimeError( + f"MeshCore.create_tcp({host}:{port}) returned None — connection failed" + ) + + self._mc = mc + self._self_info = mc.self_info or {} + self._connected = True + + # Subscribe to inbound events on the dedicated loop. + self._run_coro(self._setup_subscriptions()) + + logger.info( + "MeshCoreTransport: connected as %s (pubkey %s)", + self._self_info.get("name", "unknown"), + self._self_info.get("public_key", "?"), + ) + + def disconnect(self) -> None: + """Disconnect and stop the event loop thread.""" + if self._mc is not None: + try: + self._run_coro(self._do_disconnect(), timeout=10.0) + except Exception as exc: + logger.warning("MeshCoreTransport: disconnect error: %s", exc) + self._mc = None + self._connected = False + self._stop_loop() + logger.info("MeshCoreTransport: disconnected") + + # ------------------------------------------------------------------ + # Message I/O + # ------------------------------------------------------------------ + + def send_message( + self, + text: str, + destination: Optional[str] = None, + channel: int = 0, + ) -> bool: + """Send a message via MeshCore. + + Args: + text: Message text (caller is responsible for length limits; see + ``meshcore_max_chars`` config field for the future chunker). + destination: hex pubkey string for a DM, or None for channel send. + channel: Channel index for channel sends (0 = default). + + Returns: + True if the send succeeded (not an error event). + """ + if self._mc is None: + logger.error("MeshCoreTransport: cannot send, not connected") + return False + + try: + if destination: + result = self._run_coro( + self._mc.commands.send_msg(destination, text) + ) + else: + # Channel-index semantics do NOT cross transports: the passed + # `channel` carries Meshtastic channel-index semantics (e.g. + # index 8) that have no relationship to MeshCore's separate + # channel table. The configured MeshCore channel is therefore + # authoritative for broadcasts, so we ignore `channel` here + # (this also avoids an explicit channel=0 being treated as + # falsy). + chan_idx = getattr(self.config, "meshcore_channel_index", 0) + result = self._run_coro( + self._mc.commands.send_chan_msg(chan_idx, text) + ) + success = not result.is_error() + if not success: + logger.warning("MeshCoreTransport: send returned error event") + return success + except Exception as exc: + logger.error("MeshCoreTransport: send_message failed: %s", exc) + return False + + def set_message_callback( + self, + callback: Callable, + loop: asyncio.AbstractEventLoop, + ) -> None: + """Store the meshai callback and its event loop for inbound dispatch.""" + self._message_callback = callback + self._callback_loop = loop + + # ------------------------------------------------------------------ + # Event normalization — factored out for hermetic unit testing + # ------------------------------------------------------------------ + + def _normalize_dm_event(self, event) -> Optional[MeshMessage]: + """Map a CONTACT_MSG_RECV event payload → MeshMessage. + + Separated from the subscription handler so tests can call it directly + without spinning up the loop thread. + """ + try: + payload = event.payload or {} + text = payload.get("text", "") + if not text: + return None + pubkey_prefix: str = payload.get("pubkey_prefix", "") + + # Best-effort contact name resolution. + sender_name = pubkey_prefix + if self._mc is not None: + try: + contact = self._mc.get_contact_by_key_prefix(pubkey_prefix) + if contact: + sender_name = contact.get("adv_name", pubkey_prefix) or pubkey_prefix + except Exception: + pass + + return MeshMessage( + sender_id=pubkey_prefix, + sender_name=sender_name, + text=text, + channel=0, + is_dm=True, + packet=None, + transport="meshcore", + ) + except Exception as exc: + logger.error("MeshCoreTransport: error normalizing DM event: %s", exc) + return None + + def _normalize_channel_event(self, event) -> Optional[MeshMessage]: + """Map a CHANNEL_MSG_RECV event payload → MeshMessage. + + Separated from the subscription handler so tests can call it directly + without spinning up the loop thread. + """ + try: + payload = event.payload or {} + text = payload.get("text", "") + if not text: + return None + channel_idx: int = payload.get("channel_idx", 0) + + # Channel messages carry no per-sender pubkey in the meshcore API. + channel_marker = f"chan:{channel_idx}" + + return MeshMessage( + sender_id=channel_marker, + sender_name=channel_marker, + text=text, + channel=channel_idx, + is_dm=False, + packet=None, + transport="meshcore", + ) + except Exception as exc: + logger.error("MeshCoreTransport: error normalizing channel event: %s", exc) + return None + + # ------------------------------------------------------------------ + # Internal event handlers (called by meshcore's event system) + # ------------------------------------------------------------------ + + def _on_dm_event(self, event) -> None: + """Handle CONTACT_MSG_RECV: normalize and dispatch to meshai.""" + msg = self._normalize_dm_event(event) + self._dispatch_message(msg) + + def _on_channel_event(self, event) -> None: + """Handle CHANNEL_MSG_RECV: normalize and dispatch to meshai.""" + msg = self._normalize_channel_event(event) + self._dispatch_message(msg) + + def _dispatch_message(self, msg: Optional[MeshMessage]) -> None: + """Marshal a MeshMessage onto the meshai event loop (thread-safe). + + Mirrors exactly what MeshtasticTransport._on_receive does: + loop.call_soon_threadsafe(lambda m=msg: asyncio.create_task(cb(m))) + """ + if msg is None or self._message_callback is None or self._callback_loop is None: + return + try: + self._callback_loop.call_soon_threadsafe( + lambda m=msg: asyncio.create_task(self._message_callback(m)) + ) + except Exception as exc: + logger.error("MeshCoreTransport: error dispatching message: %s", exc) + + def _on_disconnect_event(self, event=None) -> None: + """Track link state: DISCONNECTED.""" + self._connected = False + logger.warning("MeshCoreTransport: DISCONNECTED event received") + + def _on_connect_event(self, event=None) -> None: + """Track link state: CONNECTED (auto-reconnect succeeded).""" + self._connected = True + logger.info("MeshCoreTransport: CONNECTED event received") + + # ------------------------------------------------------------------ + # Node identity / topology (MeshTransport abstract methods) + # ------------------------------------------------------------------ + + @property + def my_node_id(self) -> Optional[str]: + """Our own public key (hex string), or None before connect.""" + return self._self_info.get("public_key") or None + + @property + def connected(self) -> bool: + """True when the transport has an active connection.""" + return self._connected and self._mc is not None + + def get_node_name(self, node_id: str) -> str: + """Resolve a pubkey prefix to a contact display name, or return node_id.""" + if self._mc is None: + return node_id + try: + contact = self._mc.get_contact_by_key_prefix(node_id) + if contact: + return contact.get("adv_name", node_id) or node_id + except Exception: + pass + return node_id + + def get_node_position(self, node_id: str) -> Optional[tuple]: + """Return (adv_lat, adv_lon) for a contact, or None if not available.""" + if self._mc is None: + return None + try: + contact = self._mc.get_contact_by_key_prefix(node_id) + if contact: + lat = contact.get("adv_lat") + lon = contact.get("adv_lon") + if lat is not None and lon is not None: + return (lat, lon) + except Exception: + pass + return None diff --git a/work/pyproject.toml b/work/pyproject.toml index 8c45cbb..3b542e5 100644 --- a/work/pyproject.toml +++ b/work/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ dependencies = [ "meshtastic>=2.3.0", + "meshcore>=2.3.7", "pyyaml>=6.0", "aiosqlite>=0.19.0", "openai>=1.0.0", diff --git a/work/requirements.txt b/work/requirements.txt index 706f35c..62acfbc 100644 --- a/work/requirements.txt +++ b/work/requirements.txt @@ -1,4 +1,5 @@ meshtastic>=2.3.0 +meshcore>=2.3.7 pyyaml>=6.0 aiosqlite>=0.19.0 openai>=1.0.0 diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py new file mode 100644 index 0000000..0f3eb48 --- /dev/null +++ b/work/tests/test_meshcore_transport.py @@ -0,0 +1,443 @@ +"""Tests for MeshCoreTransport (Phase 2). + +All tests are fully mocked — no real socket, no meshcore lib required. +The fake meshcore module is injected into sys.modules before any lazy-import +triggers, so the production code's lazy ``from meshcore import MeshCore`` +gets the mock transparently. +""" + +import asyncio +import sys +import threading +import types +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# Build and register a minimal fake meshcore module +# (must happen before importing production code that lazy-imports meshcore) +# --------------------------------------------------------------------------- + +def _build_fake_meshcore(): + mod = types.ModuleType("meshcore") + + class EventType: + CONTACT_MSG_RECV = "CONTACT_MSG_RECV" + CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV" + DISCONNECTED = "DISCONNECTED" + CONNECTED = "CONNECTED" + + mod.EventType = EventType + + class _FakeMeshCore: + """Minimal stand-in for meshcore.MeshCore.""" + + self_info = {"public_key": "aabbccdd1122", "name": "FakeNode"} + contacts = {} + + async def start_auto_message_fetching(self): + pass + + async def stop_auto_message_fetching(self): + pass + + async def disconnect(self): + pass + + def subscribe(self, event_type, callback): + pass + + def get_contact_by_key_prefix(self, prefix): + return None + + @classmethod + async def create_tcp(cls, host, port, + auto_reconnect=True, max_reconnect_attempts=5): + return cls() + + class commands: + @staticmethod + async def send_chan_msg(chan_idx, text): + result = MagicMock() + result.is_error.return_value = False + return result + + @staticmethod + async def send_msg(dst, text): + result = MagicMock() + result.is_error.return_value = False + return result + + mod.MeshCore = _FakeMeshCore + return mod + + +# Register before production imports so lazy-import finds the mock. +sys.modules.setdefault("meshcore", _build_fake_meshcore()) + + +# --------------------------------------------------------------------------- +# Production imports (after mock is in sys.modules) +# --------------------------------------------------------------------------- + +from meshai.config import ConnectionConfig # noqa: E402 +from meshai.connector import MeshMessage # noqa: E402 +from meshai.transport.base import MeshTransport # noqa: E402 +from meshai.transport.factory import build_transport # noqa: E402 +from meshai.transport.meshcore_transport import MeshCoreTransport # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mc_config(**overrides): + """Return a ConnectionConfig wired for meshcore.""" + cfg = ConnectionConfig( + transport="meshcore", + meshcore_host="127.0.0.1", + meshcore_port=5050, + meshcore_channel_index=0, + ) + for k, v in overrides.items(): + setattr(cfg, k, v) + return cfg + + +def _transport_with_mock_mc(mc_overrides=None): + """Create a MeshCoreTransport with _mc injected as a MagicMock. + + Also starts a real dedicated loop thread so _run_coro works. + Returns (transport, mc_mock, loop). + """ + cfg = _mc_config() + t = MeshCoreTransport(cfg) + + mc = MagicMock() + mc.get_contact_by_key_prefix.return_value = None + if mc_overrides: + for k, v in mc_overrides.items(): + setattr(mc, k, v) + + t._mc = mc + t._connected = True + + loop = asyncio.new_event_loop() + t._loop = loop + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + t._loop_thread = thread + + return t, mc, loop + + +def _cleanup(t): + """Stop the transport's dedicated event loop.""" + try: + if t._loop and t._loop.is_running(): + t._loop.call_soon_threadsafe(t._loop.stop) + if t._loop_thread and t._loop_thread.is_alive(): + t._loop_thread.join(timeout=2.0) + except Exception: + pass + + +def _make_dm_event(text="hello", pubkey_prefix="aabbcc001122", **extra): + e = MagicMock() + e.payload = {"type": "PRIV", "pubkey_prefix": pubkey_prefix, "text": text, **extra} + return e + + +def _make_channel_event(text="chan msg", channel_idx=2, **extra): + e = MagicMock() + e.payload = {"type": "CHAN", "channel_idx": channel_idx, "text": text, **extra} + return e + + +# --------------------------------------------------------------------------- +# 1. Factory / subclass tests +# --------------------------------------------------------------------------- + +class TestBuildTransport: + def test_returns_meshcore_transport(self): + t = build_transport(_mc_config()) + assert isinstance(t, MeshCoreTransport) + + def test_is_mesh_transport_subclass(self): + t = build_transport(_mc_config()) + assert isinstance(t, MeshTransport) + + +# --------------------------------------------------------------------------- +# 2. send_message — channel (no destination) +# --------------------------------------------------------------------------- + +class TestSendMessageChannel: + def test_returns_true_on_non_error_event(self): + t, mc, _ = _transport_with_mock_mc() + try: + ok = MagicMock() + ok.is_error.return_value = False + mc.commands.send_chan_msg = AsyncMock(return_value=ok) + assert t.send_message("hello") is True + mc.commands.send_chan_msg.assert_awaited_once() + finally: + _cleanup(t) + + def test_returns_false_on_error_event(self): + t, mc, _ = _transport_with_mock_mc() + try: + err = MagicMock() + err.is_error.return_value = True + mc.commands.send_chan_msg = AsyncMock(return_value=err) + assert t.send_message("hello") is False + finally: + _cleanup(t) + + def test_returns_false_when_not_connected(self): + cfg = _mc_config() + t = MeshCoreTransport(cfg) + # _mc is None, no loop started + assert t.send_message("test") is False + + def _transport_with_configured_index(self, index): + """Build a MeshCoreTransport whose config sets meshcore_channel_index.""" + cfg = _mc_config(meshcore_channel_index=index) + t = MeshCoreTransport(cfg) + ok = MagicMock() + ok.is_error.return_value = False + + loop = asyncio.new_event_loop() + mc = MagicMock() + mc.commands.send_chan_msg = AsyncMock(return_value=ok) + t._mc = mc + t._connected = True + t._loop = loop + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + t._loop_thread = thread + return t, mc + + def test_uses_config_channel_index_when_channel_zero(self): + t, mc = self._transport_with_configured_index(3) + try: + t.send_message("hi", channel=0) + # channel=0 must not be treated as falsy-fallthrough: broadcasts + # always use the configured meshcore_channel_index=3. + mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi") + finally: + _cleanup(t) + + def test_uses_config_channel_index_when_channel_default(self): + t, mc = self._transport_with_configured_index(3) + try: + t.send_message("hi") # default channel param + mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi") + finally: + _cleanup(t) + + def test_ignores_meshtastic_channel_index(self): + # channel=8 carries Meshtastic channel-index semantics that do NOT map + # to MeshCore's channel table; the configured index (3) is authoritative. + t, mc = self._transport_with_configured_index(3) + try: + t.send_message("hi", channel=8) + mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi") + finally: + _cleanup(t) + + +# --------------------------------------------------------------------------- +# 3. send_message — DM (destination provided) +# --------------------------------------------------------------------------- + +class TestSendMessageDM: + def test_dispatches_send_msg(self): + t, mc, _ = _transport_with_mock_mc() + try: + ok = MagicMock() + ok.is_error.return_value = False + mc.commands.send_msg = AsyncMock(return_value=ok) + result = t.send_message("hi DM", destination="aabbcc") + assert result is True + mc.commands.send_msg.assert_awaited_once_with("aabbcc", "hi DM") + finally: + _cleanup(t) + + def test_send_msg_error_returns_false(self): + t, mc, _ = _transport_with_mock_mc() + try: + err = MagicMock() + err.is_error.return_value = True + mc.commands.send_msg = AsyncMock(return_value=err) + assert t.send_message("hi", destination="deadbeef") is False + finally: + _cleanup(t) + + +# --------------------------------------------------------------------------- +# 4. Inbound normalization — direct method calls (hermetic, no threads) +# --------------------------------------------------------------------------- + +class TestNormalizeDmEvent: + def _t(self, mc=None): + t = MeshCoreTransport(_mc_config()) + t._mc = mc + return t + + def test_is_dm_true(self): + msg = self._t()._normalize_dm_event(_make_dm_event()) + assert msg is not None + assert msg.is_dm is True + + def test_transport_tag(self): + msg = self._t()._normalize_dm_event(_make_dm_event()) + assert msg.transport == "meshcore" + + def test_text_preserved(self): + msg = self._t()._normalize_dm_event(_make_dm_event(text="test text")) + assert msg.text == "test text" + + def test_packet_is_none(self): + msg = self._t()._normalize_dm_event(_make_dm_event()) + assert msg.packet is None + + def test_sender_id_is_pubkey_prefix(self): + msg = self._t()._normalize_dm_event(_make_dm_event(pubkey_prefix="deadbeef")) + assert msg.sender_id == "deadbeef" + + def test_sender_name_resolved_from_contact(self): + mc = MagicMock() + mc.get_contact_by_key_prefix.return_value = {"adv_name": "Alice"} + msg = self._t(mc)._normalize_dm_event(_make_dm_event(pubkey_prefix="aabbcc")) + assert msg.sender_name == "Alice" + + def test_sender_name_falls_back_to_prefix_when_no_contact(self): + mc = MagicMock() + mc.get_contact_by_key_prefix.return_value = None + msg = self._t(mc)._normalize_dm_event(_make_dm_event(pubkey_prefix="ffff00")) + assert msg.sender_name == "ffff00" + + def test_empty_text_returns_none(self): + assert self._t()._normalize_dm_event(_make_dm_event(text="")) is None + + +class TestNormalizeChannelEvent: + def _t(self): + t = MeshCoreTransport(_mc_config()) + return t + + def test_is_dm_false(self): + msg = self._t()._normalize_channel_event(_make_channel_event()) + assert msg is not None + assert msg.is_dm is False + + def test_transport_tag(self): + msg = self._t()._normalize_channel_event(_make_channel_event()) + assert msg.transport == "meshcore" + + def test_channel_idx_set(self): + msg = self._t()._normalize_channel_event(_make_channel_event(channel_idx=5)) + assert msg.channel == 5 + + def test_text_preserved(self): + msg = self._t()._normalize_channel_event(_make_channel_event(text="RF traffic")) + assert msg.text == "RF traffic" + + def test_empty_text_returns_none(self): + assert self._t()._normalize_channel_event(_make_channel_event(text="")) is None + + +# --------------------------------------------------------------------------- +# 5. Inbound dispatch → callback delivery (via _dispatch_message) +# --------------------------------------------------------------------------- + +class TestInboundDispatch: + """Verify that _dispatch_message delivers MeshMessage to registered callback.""" + + def _run_drain(self, loop, seconds=0.1): + async def _drain(): + await asyncio.sleep(seconds) + loop.run_until_complete(_drain()) + + def test_dm_event_delivered_to_callback(self): + loop = asyncio.new_event_loop() + received = [] + + async def cb(msg): + received.append(msg) + + t = MeshCoreTransport(_mc_config()) + t.set_message_callback(cb, loop) + + event = _make_dm_event(text="callback test") + msg = t._normalize_dm_event(event) + t._dispatch_message(msg) + + self._run_drain(loop) + loop.close() + + assert len(received) == 1 + assert received[0].is_dm is True + assert received[0].text == "callback test" + assert received[0].transport == "meshcore" + + def test_channel_event_delivered_to_callback(self): + loop = asyncio.new_event_loop() + received = [] + + async def cb(msg): + received.append(msg) + + t = MeshCoreTransport(_mc_config()) + t.set_message_callback(cb, loop) + + event = _make_channel_event(text="chan callback", channel_idx=1) + msg = t._normalize_channel_event(event) + t._dispatch_message(msg) + + self._run_drain(loop) + loop.close() + + assert len(received) == 1 + assert received[0].is_dm is False + assert received[0].channel == 1 + assert received[0].transport == "meshcore" + + def test_none_msg_not_dispatched(self): + loop = asyncio.new_event_loop() + received = [] + + async def cb(msg): + received.append(msg) + + t = MeshCoreTransport(_mc_config()) + t.set_message_callback(cb, loop) + t._dispatch_message(None) + + self._run_drain(loop) + loop.close() + + assert received == [] + + +# --------------------------------------------------------------------------- +# 6. my_node_id property +# --------------------------------------------------------------------------- + +class TestMyNodeId: + def test_returns_public_key_from_self_info(self): + t = MeshCoreTransport(_mc_config()) + t._self_info = {"public_key": "deadbeef1234", "name": "TestNode"} + assert t.my_node_id == "deadbeef1234" + + def test_returns_none_before_connect(self): + t = MeshCoreTransport(_mc_config()) + assert t.my_node_id is None + + def test_returns_none_when_self_info_empty(self): + t = MeshCoreTransport(_mc_config()) + t._self_info = {} + assert t.my_node_id is None diff --git a/work/tests/test_transport_abstraction.py b/work/tests/test_transport_abstraction.py index f27fd5c..91f8234 100644 --- a/work/tests/test_transport_abstraction.py +++ b/work/tests/test_transport_abstraction.py @@ -70,10 +70,14 @@ class TestBuildTransport: transport = build_transport(cfg) assert isinstance(transport, MeshtasticTransport) - def test_meshcore_raises_not_implemented(self): + def test_meshcore_returns_meshcore_transport(self): + # Phase 2: meshcore is now implemented; build_transport returns a + # MeshCoreTransport instance (MeshTransport subclass). + from meshai.transport.meshcore_transport import MeshCoreTransport cfg = self._config_with("meshcore") - with pytest.raises(NotImplementedError): - build_transport(cfg) + transport = build_transport(cfg) + assert isinstance(transport, MeshCoreTransport) + assert isinstance(transport, MeshTransport) def test_both_raises_not_implemented(self): cfg = self._config_with("both")