mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 09:21:33 +00:00
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:
parent
b260dcbae0
commit
47b56adab5
12 changed files with 128 additions and 144 deletions
|
|
@ -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"
|
||||
>
|
||||
→ MeshCore transport & connection
|
||||
→ MeshCore connection
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<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>
|
||||
<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."
|
||||
/>
|
||||
<NumberInput
|
||||
label="MeshCore Port"
|
||||
value={config.meshcore_port ?? 5525}
|
||||
onChange={(v) => upd({ meshcore_port: v })}
|
||||
min={1}
|
||||
max={65535}
|
||||
helper="MeshCore TCP port (default 5525)"
|
||||
/>
|
||||
</div>
|
||||
<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 — leave blank to disable MeshCore"
|
||||
info="MeshCore is active when this field is non-empty."
|
||||
/>
|
||||
<NumberInput
|
||||
label="MeshCore Port"
|
||||
value={config.meshcore_port ?? 5525}
|
||||
onChange={(v) => upd({ meshcore_port: v })}
|
||||
min={1}
|
||||
max={65535}
|
||||
helper="MeshCore TCP port (default 5525)"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue