refactor(transport): introduce MeshTransport abstraction (Phase 1)

Behavior-preserving seam for a future MeshCore transport. Adds a
MeshTransport ABC + factory; renames MeshConnector -> MeshtasticTransport
(with a back-compat alias); generalizes MeshMessage additively (transport
tag, optional packet); adds a `transport` config field defaulting to
"meshtastic". No runtime behavior change; full suite matches baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-02 15:15:50 +00:00
commit aaab9d74b4
7 changed files with 294 additions and 5 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
"""MeshTransport abstraction layer."""
from .base import MeshTransport
from .factory import build_transport
__all__ = ["MeshTransport", "build_transport"]

View file

@ -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."""

View file

@ -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'."
)

View file

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