diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx
index 13d67f5..075baa6 100644
--- a/work/dashboard-frontend/src/pages/Config.tsx
+++ b/work/dashboard-frontend/src/pages/Config.tsx
@@ -25,7 +25,6 @@ export interface ConnectionConfig {
serial_port: string
tcp_host: string
tcp_port: number
- transport?: string
meshcore_host?: string
meshcore_port?: number
}
@@ -751,14 +750,14 @@ export function ConnectionSection({ data, onChange }: { data: ConnectionConfig;
/>
)}
- {/* MeshCore transport + host/port live on their own first-class page
+ {/* MeshCore host/port live on their own first-class page
(/meshcore/connection). Subtle cross-link only — no editable fields here. */}
- → MeshCore transport & connection
+ → MeshCore connection
diff --git a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
index e957f37..859b1f6 100644
--- a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
+++ b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
@@ -14,7 +14,6 @@ interface ConnectionConfig {
serial_port?: string
tcp_host?: string
tcp_port?: number
- transport?: string
meshcore_host?: string
meshcore_port?: number
[key: string]: unknown
@@ -159,54 +158,28 @@ export default function MeshCoreConnection() {
{/* Form */}
-
-
- Enable MeshCore
-
- Meshtastic is always on; enabling adds MeshCore (Both).
-
-
-
-
-
-
MeshCore Connection
-
- upd({ meshcore_host: v })}
- placeholder="192.168.1.100"
- helper="IP or hostname of the MeshCore node"
- info="Address of the MeshCore node to connect to."
- />
- upd({ meshcore_port: v })}
- min={1}
- max={65535}
- helper="MeshCore TCP port (default 5525)"
- />
-
+
MeshCore Connection
+
+ Set the host and port to enable MeshCore; leave host blank to disable.
+ Meshtastic is always active.
+
+
+ upd({ meshcore_host: v })}
+ placeholder="192.168.1.100"
+ helper="IP or hostname — leave blank to disable MeshCore"
+ info="MeshCore is active when this field is non-empty."
+ />
+ upd({ meshcore_port: v })}
+ min={1}
+ max={65535}
+ helper="MeshCore TCP port (default 5525)"
+ />
MeshTransport:
- """Instantiate and return the configured MeshTransport.
+ """Instantiate and return the active MeshTransport derived from config.
+
+ The active transports are derived from the connection config, not a
+ separate mode flag:
+
+ - Meshtastic is always the base transport.
+ - MeshCore is active when ``config.meshcore_host`` is a non-empty string.
+ - Both configured → CompositeTransport wrapping both.
+ - MeshCore host blank (default) → Meshtastic only.
Args:
- config: A ConnectionConfig (or duck-compatible object). The
- ``transport`` field selects the backend; it defaults to
- ``"meshtastic"`` so existing configs require no changes.
+ config: A ConnectionConfig (or duck-compatible object).
Returns:
A concrete MeshTransport instance ready to be connected.
-
- Raises:
- ValueError: When the transport name is unrecognised.
"""
- transport_name = getattr(config, "transport", "meshtastic")
+ from meshai.connector import MeshtasticTransport
+ meshtastic = MeshtasticTransport(config)
- 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 == "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":
- # Phase 4: composite transport — drives Meshtastic and MeshCore
- # simultaneously with correct reply routing.
- from meshai.connector import MeshtasticTransport
+ meshcore_host = getattr(config, "meshcore_host", "") or ""
+ if meshcore_host.strip():
from meshai.transport.meshcore_transport import MeshCoreTransport
from meshai.transport.composite_transport import CompositeTransport
- return CompositeTransport([
- MeshtasticTransport(config),
- MeshCoreTransport(config),
- ], config=config)
+ return CompositeTransport([meshtastic, MeshCoreTransport(config)], config=config)
- raise ValueError(
- f"Unknown transport {transport_name!r}. "
- "Expected one of: 'meshtastic', 'meshcore', 'both'."
- )
+ return meshtastic
diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py
index d432345..2d7b66d 100644
--- a/work/meshai/transport/meshcore_transport.py
+++ b/work/meshai/transport/meshcore_transport.py
@@ -33,9 +33,9 @@ class MeshCoreTransport(MeshTransport):
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.
+ The transport is dormant until ``connect()`` is called. The factory
+ only instantiates this class when ``meshcore_host`` is non-empty, so
+ there is zero cost to Meshtastic-only deployments.
"""
# Name tag used by CompositeTransport for routing hints.
diff --git a/work/tests/test_composite_transport.py b/work/tests/test_composite_transport.py
index 6ddd6f5..5878203 100644
--- a/work/tests/test_composite_transport.py
+++ b/work/tests/test_composite_transport.py
@@ -142,7 +142,7 @@ class TestMaxChars:
def test_fixed_universal_budget_from_config(self) -> None:
"""With a config, CompositeTransport reads mesh_max_chars directly."""
from meshai.config import ConnectionConfig
- cfg = ConnectionConfig(transport="both", mesh_max_chars=140)
+ cfg = ConnectionConfig(mesh_max_chars=140)
mt = FakeChild("meshtastic", max_chars_val=200)
mc = FakeChild("meshcore", max_chars_val=140)
comp = CompositeTransport([mt, mc], config=cfg)
@@ -151,7 +151,7 @@ class TestMaxChars:
def test_fixed_universal_budget_ignores_child_values(self) -> None:
"""CompositeTransport must NOT take min(children); it sources config."""
from meshai.config import ConnectionConfig
- cfg = ConnectionConfig(transport="both", mesh_max_chars=140)
+ cfg = ConnectionConfig(mesh_max_chars=140)
# Even if a child would report 230, the composite must return 140.
child = FakeChild("meshtastic", max_chars_val=230)
comp = CompositeTransport([child], config=cfg)
diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py
index d68c8d8..857486b 100644
--- a/work/tests/test_meshcore_transport.py
+++ b/work/tests/test_meshcore_transport.py
@@ -94,9 +94,8 @@ from meshai.transport.meshcore_transport import MeshCoreTransport # noqa: E402
# ---------------------------------------------------------------------------
def _mc_config(**overrides):
- """Return a ConnectionConfig wired for meshcore."""
+ """Return a ConnectionConfig with meshcore_host set (activates MeshCore)."""
cfg = ConnectionConfig(
- transport="meshcore",
meshcore_host="127.0.0.1",
meshcore_port=5050,
)
@@ -186,11 +185,16 @@ def _install_channel_table(mc, table=None):
# ---------------------------------------------------------------------------
class TestBuildTransport:
- def test_returns_meshcore_transport(self):
+ def test_returns_composite_when_meshcore_host_set(self):
+ """meshcore_host set → build_transport returns CompositeTransport."""
+ from meshai.transport.composite_transport import CompositeTransport
t = build_transport(_mc_config())
- assert isinstance(t, MeshCoreTransport)
+ assert isinstance(t, CompositeTransport)
+ # MeshCoreTransport must be the second child.
+ assert isinstance(t.children[1], MeshCoreTransport)
def test_is_mesh_transport_subclass(self):
+ """CompositeTransport (returned when meshcore_host is set) is a MeshTransport."""
t = build_transport(_mc_config())
assert isinstance(t, MeshTransport)
diff --git a/work/tests/test_transport_abstraction.py b/work/tests/test_transport_abstraction.py
index 962d5f8..1f576a2 100644
--- a/work/tests/test_transport_abstraction.py
+++ b/work/tests/test_transport_abstraction.py
@@ -1,10 +1,9 @@
-"""Lightweight tests for the Phase-1 MeshTransport abstraction.
+"""Tests for the MeshTransport abstraction and derive-from-config factory.
-These tests exercise the structural contracts introduced by the refactor:
+These tests exercise:
- 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
+ - build_transport derives active transports from meshcore_host, not a flag
+ - MeshMessage has the expected transport-routing fields with correct defaults
No real radio, socket, or asyncio loop is required.
"""
@@ -47,50 +46,78 @@ class TestMeshtasticTransportABC:
# ---------------------------------------------------------------------------
-# Factory
+# Factory — transports derived from meshcore_host, not a transport flag
# ---------------------------------------------------------------------------
class TestBuildTransport:
- def _default_config(self):
- return ConnectionConfig() # transport defaults to "meshtastic"
+ def _config_no_meshcore(self):
+ """Default config: meshcore_host is blank → Meshtastic only."""
+ return ConnectionConfig()
- def _config_with(self, transport_name):
+ def _config_with_meshcore(self, host="1.2.3.4"):
+ """Config with a non-empty meshcore_host → composite."""
cfg = ConnectionConfig()
- cfg.transport = transport_name
+ cfg.meshcore_host = host
return cfg
- def test_default_config_returns_meshtastic_transport(self):
- cfg = self._default_config()
+ def test_blank_host_returns_meshtastic_only(self):
+ """Empty meshcore_host → MeshtasticTransport, no composite."""
+ cfg = self._config_no_meshcore()
+ assert cfg.meshcore_host == ""
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_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")
- transport = build_transport(cfg)
- assert isinstance(transport, MeshCoreTransport)
- assert isinstance(transport, MeshTransport)
-
- def test_both_returns_composite(self):
- """Phase 4: transport='both' now returns a CompositeTransport (seam filled)."""
+ # Must NOT be a composite when MeshCore is not configured.
from meshai.transport.composite_transport import CompositeTransport
- cfg = self._config_with("both")
+ assert not isinstance(transport, CompositeTransport)
+
+ def test_meshcore_host_set_returns_composite(self):
+ """Non-empty meshcore_host → CompositeTransport with both children."""
+ from meshai.transport.composite_transport import CompositeTransport
+ from meshai.transport.meshcore_transport import MeshCoreTransport
+ cfg = self._config_with_meshcore("1.2.3.4")
t = build_transport(cfg)
assert isinstance(t, CompositeTransport)
assert len(t.children) == 2
+ assert isinstance(t.children[0], MeshtasticTransport)
+ assert isinstance(t.children[1], MeshCoreTransport)
- def test_unknown_transport_raises_value_error(self):
- cfg = self._config_with("unknown_transport_xyz")
- with pytest.raises(ValueError):
- build_transport(cfg)
+ def test_whitespace_only_host_is_treated_as_blank(self):
+ """Whitespace-only meshcore_host is treated as blank → Meshtastic only."""
+ cfg = ConnectionConfig()
+ cfg.meshcore_host = " "
+ transport = build_transport(cfg)
+ assert isinstance(transport, MeshtasticTransport)
+ from meshai.transport.composite_transport import CompositeTransport
+ assert not isinstance(transport, CompositeTransport)
+
+ def test_config_has_no_transport_field(self):
+ """ConnectionConfig must not have a transport attribute after the refactor."""
+ cfg = ConnectionConfig()
+ assert not hasattr(cfg, "transport"), (
+ "ConnectionConfig.transport was not removed; the refactor is incomplete."
+ )
+
+ def test_stray_transport_key_in_yaml_loads_without_error(self):
+ """A config dict with a stale 'transport' key must load cleanly."""
+ from meshai.config import _dict_to_dataclass, ConnectionConfig as CC
+ raw = {
+ "type": "tcp",
+ "tcp_host": "10.0.0.1",
+ "transport": "both", # stale key — must be ignored
+ "meshcore_host": "192.168.1.253",
+ }
+ cfg = _dict_to_dataclass(CC, raw)
+ # The stale key must be silently dropped; no AttributeError.
+ assert not hasattr(cfg, "transport")
+ assert cfg.meshcore_host == "192.168.1.253"
+
+ def test_serialized_config_has_no_transport_field(self):
+ """_dataclass_to_dict must not emit a 'transport' key."""
+ from meshai.config import _dataclass_to_dict
+ cfg = ConnectionConfig()
+ d = _dataclass_to_dict(cfg)
+ assert "transport" not in d
# ---------------------------------------------------------------------------
diff --git a/work/tests/test_uniform_sizing.py b/work/tests/test_uniform_sizing.py
index 16769da..03074b9 100644
--- a/work/tests/test_uniform_sizing.py
+++ b/work/tests/test_uniform_sizing.py
@@ -89,17 +89,16 @@ from meshai.notifications.pipeline import build_pipeline # noqa: E402
# ---------------------------------------------------------------------------
def _mt_config(**overrides):
- """Return a ConnectionConfig wired for meshtastic (default)."""
- cfg = ConnectionConfig(transport="meshtastic")
+ """Return a ConnectionConfig with no MeshCore host (Meshtastic-only)."""
+ cfg = ConnectionConfig() # meshcore_host defaults to "" → Meshtastic only
for k, v in overrides.items():
setattr(cfg, k, v)
return cfg
def _mc_config(**overrides):
- """Return a ConnectionConfig wired for meshcore."""
+ """Return a ConnectionConfig with meshcore_host set (activates MeshCore)."""
cfg = ConnectionConfig(
- transport="meshcore",
meshcore_host="127.0.0.1",
meshcore_port=5050,
)
@@ -158,7 +157,7 @@ class TestTransportMaxChars:
"""Sanity: all transports + CompositeTransport report mesh_max_chars=140.
This is the canonical single-budget guarantee: Meshtastic, MeshCore,
- and the composite (transport=both) all resolve to the same constant.
+ and the composite (meshcore_host set) all resolve to the same constant.
"""
from meshai.config import ConnectionConfig
from meshai.transport.composite_transport import CompositeTransport
@@ -169,9 +168,8 @@ class TestTransportMaxChars:
# MeshCore
assert MeshCoreTransport(_mc_config()).max_chars == 140
- # Composite (built via factory with transport="both")
+ # Composite — derived from meshcore_host being non-empty
cfg_both = ConnectionConfig(
- transport="both",
type="tcp",
tcp_host="127.0.0.1",
tcp_port=4403,