diff --git a/work/meshai/config.py b/work/meshai/config.py index fe0e31d..46c0cf8 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -34,6 +34,8 @@ 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: str = "meshtastic" # "meshtastic" | "meshcore" | "both" @dataclass diff --git a/work/meshai/connector.py b/work/meshai/connector.py index e805b75..3b6f619 100644 --- a/work/meshai/connector.py +++ b/work/meshai/connector.py @@ -15,6 +15,7 @@ from meshtastic import BROADCAST_NUM from pubsub import pub from .config import ConnectionConfig +from .transport.base import MeshTransport logger = logging.getLogger(__name__) @@ -28,7 +29,11 @@ class MeshMessage: text: str # Message content channel: int # Channel index is_dm: bool # True if direct message to us - packet: dict # Raw packet for additional data + # Raw packet for additional data. Optional so non-Meshtastic transports + # (MeshCore, etc.) can leave it None while sharing the same dataclass. + packet: Optional[dict] = None + # Transport tag so consumers can branch on origin if needed. + transport: str = "meshtastic" _position: Optional[tuple[float, float]] = field(default=None, repr=False, init=False) @property @@ -37,8 +42,8 @@ class MeshMessage: return self._position -class MeshConnector: - """Manages connection to Meshtastic node.""" +class MeshtasticTransport(MeshTransport): + """Manages connection to a Meshtastic node (Meshtastic transport backend).""" def __init__(self, config: ConnectionConfig): self.config = config @@ -487,3 +492,10 @@ class MeshConnector: logger.error(f"Failed to send message: {e}") return False + +# --------------------------------------------------------------------------- +# Backward-compatibility alias. All existing imports of ``MeshConnector`` +# continue to work without any changes. New code should prefer +# ``MeshtasticTransport`` or use ``build_transport()`` from the factory. +# --------------------------------------------------------------------------- +MeshConnector = MeshtasticTransport diff --git a/work/meshai/main.py b/work/meshai/main.py index 5a17582..786819b 100644 --- a/work/meshai/main.py +++ b/work/meshai/main.py @@ -19,6 +19,7 @@ from .commands.status import set_start_time from .config import Config from .config_loader import load_config, get_config_dir_from_path from .connector import MeshConnector, MeshMessage +from .transport.factory import build_transport from .central_normalizer import init_geocoder_config from .context import MeshContext from .history import ConversationHistory @@ -372,8 +373,8 @@ class MeshAI: # Load persisted summaries into memory cache await self._load_summaries() - # Meshtastic connector - self.connector = MeshConnector(self.config.connection) + # Transport connector (factory selects backend from config.connection.transport) + self.connector = build_transport(self.config.connection) # Passive mesh context buffer ctx_cfg = self.config.context diff --git a/work/meshai/transport/__init__.py b/work/meshai/transport/__init__.py new file mode 100644 index 0000000..bd86125 --- /dev/null +++ b/work/meshai/transport/__init__.py @@ -0,0 +1,6 @@ +"""MeshTransport abstraction layer.""" + +from .base import MeshTransport +from .factory import build_transport + +__all__ = ["MeshTransport", "build_transport"] diff --git a/work/meshai/transport/base.py b/work/meshai/transport/base.py new file mode 100644 index 0000000..7171c30 --- /dev/null +++ b/work/meshai/transport/base.py @@ -0,0 +1,86 @@ +"""Abstract base class for mesh transport backends.""" + +import abc +import asyncio +from typing import Callable, Optional + + +class MeshTransport(abc.ABC): + """Abstract interface every mesh transport must satisfy. + + The surface matches exactly what downstream code (main.py, router.py, + responder.py, notification pipeline) calls on the connector today. + Transport-specific internals (watchdog hooks, socket probing, etc.) live + in the concrete subclass and are NOT part of this interface. + """ + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + @abc.abstractmethod + def connect(self) -> None: + """Establish the transport connection.""" + + @abc.abstractmethod + def disconnect(self) -> None: + """Close the transport connection.""" + + # ------------------------------------------------------------------ + # Message I/O + # ------------------------------------------------------------------ + + @abc.abstractmethod + def send_message( + self, + text: str, + destination: Optional[str] = None, + channel: int = 0, + ) -> bool: + """Send a text message. + + Args: + text: Message text to send. + destination: Node ID for a DM, or None for broadcast. + channel: Channel index to send on. + + Returns: + True if send was initiated successfully. + """ + + @abc.abstractmethod + def set_message_callback( + self, + callback: Callable, + loop: asyncio.AbstractEventLoop, + ) -> None: + """Register a callback for incoming messages. + + Args: + callback: Async callable invoked with a MeshMessage on each + received text message. + loop: The running asyncio event loop used to schedule the + callback from a background thread. + """ + + # ------------------------------------------------------------------ + # Node identity / topology + # ------------------------------------------------------------------ + + @property + @abc.abstractmethod + def my_node_id(self) -> Optional[str]: + """Our own node ID (hex string e.g. '!abcd1234'), or None.""" + + @property + @abc.abstractmethod + def connected(self) -> bool: + """True when the transport has an active connection.""" + + @abc.abstractmethod + def get_node_name(self, node_id: str) -> str: + """Return the cached display name for *node_id*, or *node_id* itself.""" + + @abc.abstractmethod + def get_node_position(self, node_id: str) -> Optional[tuple]: + """Return cached (latitude, longitude) for *node_id*, or None.""" diff --git a/work/meshai/transport/factory.py b/work/meshai/transport/factory.py new file mode 100644 index 0000000..0373f11 --- /dev/null +++ b/work/meshai/transport/factory.py @@ -0,0 +1,38 @@ +"""Transport factory: build the correct MeshTransport from config.""" + +from .base import MeshTransport + + +def build_transport(config) -> MeshTransport: + """Instantiate and return the configured MeshTransport. + + Args: + config: A ConnectionConfig (or duck-compatible object). The + ``transport`` field selects the backend; it defaults to + ``"meshtastic"`` so existing configs require no changes. + + Returns: + A concrete MeshTransport instance ready to be connected. + + Raises: + NotImplementedError: When the requested transport is known but not + yet implemented (e.g. ``"meshcore"`` or ``"both"``). + ValueError: When the transport name is unrecognised. + """ + transport_name = getattr(config, "transport", "meshtastic") + + if transport_name == "meshtastic": + # Import here to avoid a circular import at module load time. + from meshai.connector import MeshtasticTransport + return MeshtasticTransport(config) + + if transport_name in ("meshcore", "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." + ) + + raise ValueError( + f"Unknown transport {transport_name!r}. " + "Expected one of: 'meshtastic', 'meshcore', 'both'." + ) diff --git a/work/tests/test_transport_abstraction.py b/work/tests/test_transport_abstraction.py new file mode 100644 index 0000000..f27fd5c --- /dev/null +++ b/work/tests/test_transport_abstraction.py @@ -0,0 +1,144 @@ +"""Lightweight tests for the Phase-1 MeshTransport abstraction. + +These tests exercise the structural contracts introduced by the refactor: + - MeshtasticTransport is a concrete subclass of MeshTransport + - build_transport returns the right type for the default config + - build_transport raises appropriately for unimplemented / unknown transports + - MeshMessage has the new additive fields with the expected defaults + +No real radio, socket, or asyncio loop is required. +""" + +import pytest + +from meshai.transport.base import MeshTransport +from meshai.transport.factory import build_transport +from meshai.connector import MeshtasticTransport, MeshConnector, MeshMessage +from meshai.config import ConnectionConfig + + +# --------------------------------------------------------------------------- +# MeshtasticTransport hierarchy +# --------------------------------------------------------------------------- + + +class TestMeshtasticTransportABC: + def test_is_subclass_of_mesh_transport(self): + assert issubclass(MeshtasticTransport, MeshTransport) + + def test_backward_compat_alias_is_same_class(self): + """MeshConnector must still be MeshtasticTransport (alias, not a copy).""" + assert MeshConnector is MeshtasticTransport + + def test_implements_abstract_surface(self): + """MeshtasticTransport must not leave any abstract methods unimplemented.""" + abstract_methods = getattr(MeshTransport, "__abstractmethods__", set()) + # Build the set of methods MeshtasticTransport provides + provided = set(vars(MeshtasticTransport)) + # Every abstract method must be overridden (present in the class dict + # or resolvable via MRO without being abstract itself) + for method_name in abstract_methods: + attr = getattr(MeshtasticTransport, method_name, None) + assert attr is not None, f"abstract method {method_name!r} not implemented" + # The attribute must NOT still be abstract on the concrete class + assert not getattr(attr, "__isabstractmethod__", False), ( + f"{method_name!r} is still abstract on MeshtasticTransport" + ) + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +class TestBuildTransport: + def _default_config(self): + return ConnectionConfig() # transport defaults to "meshtastic" + + def _config_with(self, transport_name): + cfg = ConnectionConfig() + cfg.transport = transport_name + return cfg + + def test_default_config_returns_meshtastic_transport(self): + cfg = self._default_config() + transport = build_transport(cfg) + assert isinstance(transport, MeshtasticTransport) + + def test_explicit_meshtastic_returns_meshtastic_transport(self): + cfg = self._config_with("meshtastic") + transport = build_transport(cfg) + assert isinstance(transport, MeshtasticTransport) + + def test_meshcore_raises_not_implemented(self): + cfg = self._config_with("meshcore") + with pytest.raises(NotImplementedError): + build_transport(cfg) + + def test_both_raises_not_implemented(self): + cfg = self._config_with("both") + with pytest.raises(NotImplementedError): + build_transport(cfg) + + def test_unknown_transport_raises_value_error(self): + cfg = self._config_with("unknown_transport_xyz") + with pytest.raises(ValueError): + build_transport(cfg) + + +# --------------------------------------------------------------------------- +# MeshMessage additive fields +# --------------------------------------------------------------------------- + + +class TestMeshMessageAdditiveFields: + def _make_message(self, **overrides): + defaults = dict( + sender_id="!aabbccdd", + sender_name="TestNode", + text="hello", + channel=0, + is_dm=False, + ) + defaults.update(overrides) + return MeshMessage(**defaults) + + def test_transport_defaults_to_meshtastic(self): + msg = self._make_message() + assert msg.transport == "meshtastic" + + def test_transport_can_be_overridden(self): + msg = self._make_message(transport="meshcore") + assert msg.transport == "meshcore" + + def test_packet_defaults_to_none(self): + msg = self._make_message() + assert msg.packet is None + + def test_packet_can_be_set(self): + pkt = {"from": 12345, "decoded": {"text": "hi"}} + msg = self._make_message(packet=pkt) + assert msg.packet is pkt + + def test_existing_fields_unchanged(self): + """All pre-existing fields must still be present and functional.""" + msg = self._make_message( + sender_id="!11223344", + sender_name="Alpha", + text="test", + channel=3, + is_dm=True, + packet={"raw": True}, + ) + assert msg.sender_id == "!11223344" + assert msg.sender_name == "Alpha" + assert msg.text == "test" + assert msg.channel == 3 + assert msg.is_dm is True + assert msg.packet == {"raw": True} + + def test_sender_position_property_still_works(self): + msg = self._make_message() + assert msg.sender_position is None + msg._position = (43.5, -114.2) + assert msg.sender_position == (43.5, -114.2)