mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
refactor(transport): introduce MeshTransport abstraction (Phase 1) (#3)
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: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e15823ef84
commit
61278ece28
7 changed files with 294 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
work/meshai/transport/__init__.py
Normal file
6
work/meshai/transport/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""MeshTransport abstraction layer."""
|
||||
|
||||
from .base import MeshTransport
|
||||
from .factory import build_transport
|
||||
|
||||
__all__ = ["MeshTransport", "build_transport"]
|
||||
86
work/meshai/transport/base.py
Normal file
86
work/meshai/transport/base.py
Normal 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."""
|
||||
38
work/meshai/transport/factory.py
Normal file
38
work/meshai/transport/factory.py
Normal 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'."
|
||||
)
|
||||
144
work/tests/test_transport_abstraction.py
Normal file
144
work/tests/test_transport_abstraction.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue