refactor(transport): derive active transports from config, drop transport setting (#13)

* refactor(transport): derive active transports from config, drop transport setting

A mesh is active when its connection is configured: Meshtastic is the
always-on base; MeshCore runs whenever meshcore_host is set (blank = off);
both configured = both. Removes the transport mode field/toggle entirely
so there's no separate flag to miss.

* docs: fix stale transport comment after field removal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-03 00:18:53 -06:00 committed by GitHub
commit 47b56adab5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 128 additions and 144 deletions

View file

@ -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;
/>
</div>
)}
{/* 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. */}
<div className="pt-2">
<Link
to="/meshcore/connection"
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-accent transition-colors"
>
&rarr; MeshCore transport &amp; connection
&rarr; MeshCore connection
</Link>
</div>
</div>

View file

@ -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,44 +158,19 @@ export default function MeshCoreConnection() {
{/* Form */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-slate-300">Enable MeshCore</span>
<p className="text-xs text-slate-600">
Meshtastic is always on; enabling adds MeshCore (Both).
</p>
</div>
<button
type="button"
onClick={() => {
const checked = !(config.transport === 'both' || config.transport === 'meshcore')
upd({ transport: checked ? 'both' : 'meshtastic' })
}}
className={`relative w-11 h-6 rounded-full transition-colors ${
config.transport === 'both' || config.transport === 'meshcore'
? 'bg-accent'
: 'bg-[#1e2a3a]'
}`}
>
<span
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
config.transport === 'both' || config.transport === 'meshcore'
? 'translate-x-5'
: ''
}`}
/>
</button>
</div>
<div className="pt-2 border-t border-[#1e2a3a] space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<p className="text-xs text-slate-500">
Set the host and port to enable MeshCore; leave host blank to disable.
Meshtastic is always active.
</p>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="MeshCore Host"
value={config.meshcore_host ?? ''}
onChange={(v) => 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."
helper="IP or hostname — leave blank to disable MeshCore"
info="MeshCore is active when this field is non-empty."
/>
<NumberInput
label="MeshCore Port"
@ -207,7 +181,6 @@ export default function MeshCoreConnection() {
helper="MeshCore TCP port (default 5525)"
/>
</div>
</div>
<div className="pt-2">
<Link
to="/meshtastic/connection"

View file

@ -34,12 +34,11 @@ 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 Phase 2) ---
transport: str = "meshtastic" # "meshtastic" | "meshcore" | "both"
# Universal mesh message budget (MeshCore LCD → 140 for all transports).
mesh_max_chars: int = 140
# --- MeshCore transport settings (used when transport="meshcore") ---
meshcore_host: str = "100.64.0.9" # pyMC companion frame server host
# --- MeshCore transport settings ---
# MeshCore is active when meshcore_host is a non-empty string; blank = off.
meshcore_host: str = "" # pyMC companion frame server host
meshcore_port: int = 5050 # pyMC companion frame server port
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)

View file

@ -394,7 +394,7 @@ class MeshAI:
# Load persisted summaries into memory cache
await self._load_summaries()
# Transport connector (factory selects backend from config.connection.transport)
# Transport connector (factory derives backend from config.connection.meshcore_host)
self.connector = build_transport(self.config.connection)
# Fit every broadcast handler's one-packet formatter to the active mesh

View file

@ -250,7 +250,7 @@ class MeshCoreBroadcastChannel(NotificationChannel):
return {
"success": False,
"message": "No MeshCore transport available",
"error": "Set connection.transport to 'meshcore' or 'both'",
"error": "Set connection.meshcore_host to enable MeshCore",
"details": {"meshcore_channel": self._meshcore_channel},
}
return {
@ -469,7 +469,7 @@ class MeshCoreDMChannel(NotificationChannel):
return {
"success": False,
"message": "No MeshCore transport available",
"error": "Set connection.transport to 'meshcore' or 'both'",
"error": "Set connection.meshcore_host to enable MeshCore",
"details": {"contacts": self._contacts},
}
if not self._contacts:

View file

@ -7,10 +7,9 @@ Holds an ordered list of child transports and:
- self-filters inbound messages per child (drops own echoes);
- exposes ``meshtastic_child()`` for the supervisor watchdog.
This transport is DORMANT unless ``transport: both`` in config. Single-
transport paths (``transport: meshtastic`` / ``transport: meshcore``) are
byte-identical to Phase 3 behaviour because the factory never instantiates
this class for them.
This transport is active when ``meshcore_host`` is configured (non-empty).
The factory instantiates it only when MeshCore is configured; Meshtastic-only
configs receive a bare MeshtasticTransport instead.
"""
import asyncio

View file

@ -4,44 +4,29 @@ from .base import MeshTransport
def build_transport(config) -> 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")
if transport_name == "meshtastic":
# Import here to avoid a circular import at module load time.
from meshai.connector import MeshtasticTransport
return MeshtasticTransport(config)
meshtastic = 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

View file

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

View file

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

View file

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

View file

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

View file

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