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,