feat(dashboard): MeshCore transport + per-family routing GUI controls (#10)

* feat(dashboard): MeshCore transport + per-family routing GUI controls

Add Transport mode selector (Meshtastic/MeshCore/Both) and MeshCore
host/port fields to the Config Connection section, and an independent
per-family "MeshCore channel" number input in Notifications (blank = not
broadcast on MeshCore, sends null). Extends the ConnectionConfig and
per-family toggle TS types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(routing): MeshCore routing by channel name, not index

MeshCore channels are {name,PSK} (up to 40+ slots, not Meshtastic's 0-7).
The send index is a fragile slot position, so store the channel NAME per
family and resolve name->slot against the companion's live channel table
at send time; never blind-send to an unresolved slot. GUI field becomes a
channel-name text box. meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routing): thread per-family meshcore_channel through the broadcast send path

MeshBroadcastChannel now carries the rule's meshcore_channel name and
passes it to send_message, so per-family MeshCore routing actually fires
end-to-end (dispatcher -> channel -> composite -> MeshCoreTransport).
Meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

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:
malice 2026-07-02 16:49:59 -06:00 committed by GitHub
commit 24cb6a31df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 358 additions and 45 deletions

View file

@ -23,6 +23,9 @@ interface ConnectionConfig {
serial_port: string
tcp_host: string
tcp_port: number
transport?: string
meshcore_host?: string
meshcore_port?: number
}
interface ResponseConfig {
@ -707,9 +710,23 @@ function BotSection({ data, onChange }: { data: BotConfig; onChange: (d: BotConf
}
function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) {
const transport = data.transport ?? 'meshtastic'
const showMeshCore = transport === 'meshcore' || transport === 'both'
return (
<div className="space-y-4">
<SectionDescription text={SECTION_DESCRIPTIONS.connection} />
<SelectInput
label="Transport Mode"
value={transport}
onChange={(v) => onChange({ ...data, transport: v })}
options={[
{ value: 'meshtastic', label: 'Meshtastic' },
{ value: 'meshcore', label: 'MeshCore' },
{ value: 'both', label: 'Both' },
]}
helper="Which radio transport(s) MeshAI uses"
info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation."
/>
<SelectInput
label="Connection Type"
value={data.type}
@ -749,6 +766,29 @@ function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChang
/>
</div>
)}
{showMeshCore && (
<div className="space-y-4 pt-2 border-t border-[#1e2a3a]">
<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={data.meshcore_host ?? ''}
onChange={(v) => onChange({ ...data, 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={data.meshcore_port ?? 5525}
onChange={(v) => onChange({ ...data, meshcore_port: v })}
min={1}
max={65535}
helper="MeshCore TCP port (default 5525)"
/>
</div>
</div>
)}
</div>
)
}

View file

@ -46,6 +46,7 @@ interface NotificationToggle {
regions: string[]
severity_channels: Record<string, string[]>
broadcast_channel: number | null
meshcore_channel?: string | null
node_ids: string[]
smtp_host: string
smtp_port: number
@ -1611,6 +1612,17 @@ function MasterToggles({ toggles, onChange }: {
</table>
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." /> <div className="text-xs text-slate-500 pt-1">Channel config</div>
<NumberInput label="Broadcast channel" value={t.broadcast_channel ?? 0} onChange={(v) => upd(key, { broadcast_channel: v })} />
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">MeshCore channel</label>
<input
type="text"
value={t.meshcore_channel != null ? t.meshcore_channel : ''}
onChange={(e) => upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value })}
placeholder=""
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
/>
<p className="text-xs text-slate-600">MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.</p>
</div>
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />

View file

@ -41,7 +41,6 @@ class ConnectionConfig:
# --- MeshCore transport settings (used when transport="meshcore") ---
meshcore_host: str = "100.64.0.9" # pyMC companion frame server host
meshcore_port: int = 5050 # pyMC companion frame server port
meshcore_channel_index: int = 0 # default channel index for broadcasts
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
@ -555,6 +554,8 @@ class NotificationRuleConfig:
# Mesh broadcast fields
broadcast_channel: int = 0
# Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore.
meshcore_channel: Optional[str] = None
# Mesh DM fields
node_ids: list = field(default_factory=list)
@ -596,6 +597,8 @@ class NotificationToggle:
cooldown_seconds: int = 0 # per (toggle, category, region) throttle window; 0 = disabled
# per-channel delivery config (mirrors NotificationRuleConfig channel fields)
broadcast_channel: Optional[int] = None
# Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore.
meshcore_channel: Optional[str] = None
node_ids: list = field(default_factory=list)
smtp_host: str = ""
smtp_port: int = 587

View file

@ -355,7 +355,7 @@ class MeshtasticTransport(MeshTransport):
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
meshcore_channel: Optional[int] = None, # per-family MeshCore channel — accepted and IGNORED here
meshcore_channel: Optional[str] = None, # per-family MeshCore channel — accepted and IGNORED here
) -> bool:
"""Send a text message.
@ -364,7 +364,7 @@ class MeshtasticTransport(MeshTransport):
destination: Node ID for DM, or None for broadcast
channel: Channel index to send on
transport: Optional routing hint (for CompositeTransport); ignored here.
meshcore_channel: Per-family MeshCore channel index; ignored by Meshtastic.
meshcore_channel: Per-family MeshCore channel name; ignored by Meshtastic.
Returns:
True if send was initiated successfully

View file

@ -60,9 +60,13 @@ class MeshBroadcastChannel(NotificationChannel):
channel_type = "mesh_broadcast"
def __init__(self, connector: "MeshConnector", channel_index: int = 0):
def __init__(self, connector: "MeshConnector", channel_index: int = 0,
meshcore_channel: Optional[str] = None):
self._connector = connector
self._channel = channel_index
# Per-family MeshCore channel NAME (None = MeshCore child skipped
# downstream). Ignored by Meshtastic; behavior-preserving there.
self._meshcore_channel = meshcore_channel
_mc = getattr(connector, "max_chars", 200)
self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200)
@ -79,6 +83,7 @@ class MeshBroadcastChannel(NotificationChannel):
text=alert.message or "",
destination=None,
channel=self._channel,
meshcore_channel=self._meshcore_channel,
)
logger.info("Broadcast pre-chunked alert to channel %d", self._channel)
return True
@ -90,6 +95,7 @@ class MeshBroadcastChannel(NotificationChannel):
text=chunk,
destination=None,
channel=self._channel,
meshcore_channel=self._meshcore_channel,
)
logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel)
return True
@ -780,6 +786,7 @@ def create_channel(rule: "NotificationRuleConfig", connector=None) -> Notificati
return MeshBroadcastChannel(
connector=connector,
channel_index=rule.broadcast_channel,
meshcore_channel=getattr(rule, "meshcore_channel", None),
)
elif delivery_type == "mesh_dm":
return MeshDMChannel(
@ -816,6 +823,7 @@ def create_channel_from_dict(config: dict, connector=None) -> NotificationChanne
return MeshBroadcastChannel(
connector=connector,
channel_index=config.get("channel_index", 0),
meshcore_channel=config.get("meshcore_channel"),
)
elif channel_type == "mesh_dm":
return MeshDMChannel(

View file

@ -660,6 +660,7 @@ class Dispatcher:
name=f"toggle:{getattr(tog, 'name', '')}",
enabled=True, trigger_type="condition", delivery_type=ch_type,
broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0),
meshcore_channel=getattr(tog, "meshcore_channel", None),
node_ids=list(getattr(tog, "node_ids", []) or []),
smtp_host=getattr(tog, "smtp_host", ""), smtp_port=getattr(tog, "smtp_port", 587),
smtp_user=getattr(tog, "smtp_user", ""), smtp_password=getattr(tog, "smtp_password", ""),

View file

@ -37,7 +37,7 @@ class MeshTransport(abc.ABC):
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None,
meshcore_channel: Optional[int] = None,
meshcore_channel: Optional[str] = None,
) -> bool:
"""Send a text message.
@ -49,9 +49,9 @@ class MeshTransport(abc.ABC):
select the child mesh that originated an inbound DM).
Single-transport implementations accept and IGNORE this
parameter; it is always None in non-composite callers.
meshcore_channel: Per-family MeshCore channel index for broadcasts.
MeshtasticTransport ignores this; MeshCoreTransport uses
it in place of the global meshcore_channel_index when set.
meshcore_channel: Per-family MeshCore channel NAME for broadcasts.
MeshtasticTransport ignores this; MeshCoreTransport
resolves the name to a companion slot at send time.
CompositeTransport uses it to route each child correctly.
None = do not broadcast on MeshCore for this family.

View file

@ -196,7 +196,7 @@ class CompositeTransport(MeshTransport):
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None,
meshcore_channel: Optional[int] = None,
meshcore_channel: Optional[str] = None,
) -> bool:
"""Send a message, routing based on destination + hint.
@ -233,14 +233,15 @@ class CompositeTransport(MeshTransport):
# --- Rule 3: unhinted DM ---
return self._send_unhinted(text, destination, channel)
def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[int] = None) -> bool:
def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[str] = None) -> bool:
"""Fan text out to connected children with per-transport channel routing.
For the Meshtastic child, ``channel`` (Meshtastic channel index) is used.
For the MeshCore child:
- ``meshcore_channel`` set use that channel index on MeshCore.
- ``meshcore_channel`` set route that channel NAME to MeshCore,
which resolves it to a companion slot at send time.
- ``meshcore_channel`` is None skip the MeshCore child entirely
(family not configured for MeshCore; no fallback to global index).
(family not configured for MeshCore; no fallback to a default).
Returns True if at least one child succeeded.
"""

View file

@ -51,6 +51,9 @@ class MeshCoreTransport(MeshTransport):
self._message_callback: Optional[Callable] = None
self._callback_loop: Optional[asyncio.AbstractEventLoop] = None
self._loop_ready: threading.Event = threading.Event()
# Companion channel table: channel NAME -> slot index, built at
# connect time by _enumerate_channels(). Empty until connected.
self._chan_name_to_idx: dict[str, int] = {}
# ------------------------------------------------------------------
# Internal helpers
@ -78,6 +81,63 @@ class MeshCoreTransport(MeshTransport):
self._loop = None
self._loop_thread = None
# ------------------------------------------------------------------
# Channel table enumeration
# ------------------------------------------------------------------
def _enumerate_channels(self) -> None:
"""Build ``self._chan_name_to_idx`` from the companion's channel table.
MeshCore channels are {name, PSK} pairs living in numbered slots (up
to 40+, unlike Meshtastic's 0-7). We ask the companion for each slot
in turn via ``get_channel(idx)`` and record NAME slot for every
named (non-empty) slot, so send_message can resolve a per-family
channel NAME to the right slot at send time.
Robustness:
- Any error yields an empty (or partial) map never raises out.
- Enumeration stops on the first error/None result (end of table)
or after 3 consecutive empty slots (contiguous provisioning),
with a hard cap of 40 slots.
"""
self._chan_name_to_idx = {}
try:
empty_run = 0
for idx in range(40):
try:
event = self._run_coro(self._mc.commands.get_channel(idx))
except Exception as exc:
logger.debug(
"MeshCore: get_channel(%d) failed, ending enumeration: %s",
idx, exc,
)
break
# Falsy / None / ERROR event → end of enumeration.
if not event:
break
is_err = getattr(event, "is_error", None)
if callable(is_err) and event.is_error():
break
payload = event.payload or {}
name = payload.get("channel_name", "")
slot = payload.get("channel_idx", idx)
if not name:
# Empty/unset slot; stop after a contiguous run of empties.
empty_run += 1
if empty_run >= 3:
break
continue
# Named slot: exact, case-sensitive (firmware name is already
# null-truncated / utf-8-decoded — do NOT trim or lowercase).
self._chan_name_to_idx[name] = slot
empty_run = 0
except Exception as exc:
logger.warning("MeshCore: channel enumeration error: %s", exc)
self._chan_name_to_idx = {}
logger.info(
"MeshCore: enumerated %d named channel(s)", len(self._chan_name_to_idx)
)
# ------------------------------------------------------------------
# Internal coroutines (run on the dedicated loop)
# ------------------------------------------------------------------
@ -166,6 +226,10 @@ class MeshCoreTransport(MeshTransport):
# Subscribe to inbound events on the dedicated loop.
self._run_coro(self._setup_subscriptions())
# Build the channel NAME → slot map from the live companion table so
# per-family broadcasts can resolve their channel name to a slot.
self._enumerate_channels()
logger.info(
"MeshCoreTransport: connected as %s (pubkey %s)",
self._self_info.get("name", "unknown"),
@ -194,7 +258,7 @@ class MeshCoreTransport(MeshTransport):
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
meshcore_channel: Optional[int] = None,
meshcore_channel: Optional[str] = None,
) -> bool:
"""Send a message via MeshCore.
@ -204,10 +268,11 @@ class MeshCoreTransport(MeshTransport):
destination: hex pubkey string for a DM, or None for channel send.
channel: Channel index for channel sends (Meshtastic semantics; ignored here).
transport: Optional routing hint (for CompositeTransport); ignored here.
meshcore_channel: Per-family MeshCore channel index for broadcasts.
When provided, overrides the global meshcore_channel_index.
meshcore_channel: Per-family MeshCore channel NAME for broadcasts.
Resolved to a companion slot via the live channel table.
When None on a broadcast, the send is skipped (family not
configured for MeshCore no fallback, no default).
An unknown name is never blind-sent: it warns and returns False.
Returns:
True if the send succeeded (not an error event).
@ -231,7 +296,7 @@ class MeshCoreTransport(MeshTransport):
# index 8) that have no relationship to MeshCore's separate
# channel table.
#
# Per-family routing: use meshcore_channel when provided.
# Per-family routing: meshcore_channel is a channel NAME.
# If meshcore_channel is None, this family is not configured
# for MeshCore → silent no-op (return True).
if meshcore_channel is None:
@ -239,8 +304,22 @@ class MeshCoreTransport(MeshTransport):
"MeshCoreTransport: meshcore_channel=None, skipping broadcast"
)
return True
# Resolve NAME → slot against the live companion channel table.
idx = self._chan_name_to_idx.get(meshcore_channel)
if idx is None:
# One lazy re-enumeration in case the table changed since
# connect (e.g. a channel was provisioned after startup).
self._enumerate_channels()
idx = self._chan_name_to_idx.get(meshcore_channel)
if idx is None:
# Never blind-send to a guessed slot.
logger.warning(
"MeshCore channel '%s' not on companion; skipping",
meshcore_channel,
)
return False
result = self._run_coro(
self._mc.commands.send_chan_msg(meshcore_channel, text)
self._mc.commands.send_chan_msg(idx, text)
)
success = not result.is_error()
if not success:

View file

@ -212,3 +212,111 @@ def test_webhook_channel_uses_webhook_renderer():
assert "schema_version" in json_payload
assert json_payload["schema_version"] == "1.0"
assert json_payload["message"] == "Test webhook message"
# ============================================================
# PER-FAMILY MESHCORE ROUTING — end-to-end threading guard
# (regression guard for the broadcast send-path gap)
# ============================================================
def test_broadcast_threads_meshcore_channel_through_factory():
"""create_channel(rule) -> MeshBroadcastChannel.deliver must pass BOTH
channel=<broadcast_channel> AND meshcore_channel=<name> to send_message.
This is the regression guard for the gap where the rule's
meshcore_channel never reached connector.send_message.
"""
from meshai.config import NotificationRuleConfig
from meshai.notifications.channels import create_channel
mock_connector = MagicMock()
rule = NotificationRuleConfig(
name="toggle:fire",
delivery_type="mesh_broadcast",
broadcast_channel=1,
meshcore_channel="AIDA",
)
channel = create_channel(rule, mock_connector)
# Pre-chunked payload => exactly one deterministic send_message call.
payload = NotificationPayload(
message="fire alert",
category="fire",
severity="immediate",
timestamp=time.time(),
event_type="fire",
chunk_index=0,
)
assert asyncio.run(channel.deliver(payload, rule)) is True
mock_connector.send_message.assert_called_once()
kwargs = mock_connector.send_message.call_args.kwargs
assert kwargs.get("channel") == 1
# The load-bearing assertion: the name was NOT dropped.
assert kwargs.get("meshcore_channel") == "AIDA"
def test_broadcast_meshcore_channel_none_passed_through():
"""meshcore_channel=None (family not on MeshCore) => send_message still
receives meshcore_channel=None (MeshCore child skipped downstream)."""
from meshai.config import NotificationRuleConfig
from meshai.notifications.channels import create_channel
mock_connector = MagicMock()
rule = NotificationRuleConfig(
name="toggle:weather",
delivery_type="mesh_broadcast",
broadcast_channel=0,
meshcore_channel=None,
)
channel = create_channel(rule, mock_connector)
payload = NotificationPayload(
message="weather alert",
category="weather_warning",
severity="priority",
timestamp=time.time(),
event_type="weather_warning",
chunk_index=0,
)
assert asyncio.run(channel.deliver(payload, rule)) is True
kwargs = mock_connector.send_message.call_args.kwargs
assert kwargs.get("channel") == 0
assert "meshcore_channel" in kwargs
assert kwargs.get("meshcore_channel") is None
def test_broadcast_render_loop_threads_meshcore_channel():
"""Non-prechunked path (renderer loop) also threads meshcore_channel
on every chunk send."""
from meshai.config import NotificationRuleConfig
from meshai.notifications.channels import create_channel
mock_connector = MagicMock()
rule = NotificationRuleConfig(
name="toggle:fire",
delivery_type="mesh_broadcast",
broadcast_channel=2,
meshcore_channel="AIDA",
)
channel = create_channel(rule, mock_connector)
long_message = "This is a very long alert message that exceeds the limit. " * 5
payload = NotificationPayload(
message=long_message,
category="fire",
severity="immediate",
timestamp=time.time(),
event_type="fire",
)
assert asyncio.run(channel.deliver(payload, rule)) is True
assert mock_connector.send_message.call_count >= 2
for call in mock_connector.send_message.call_args_list:
assert call.kwargs.get("channel") == 2
assert call.kwargs.get("meshcore_channel") == "AIDA"

View file

@ -221,7 +221,7 @@ class TestBroadcast:
mt = FakeChild("meshtastic")
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("hello mesh", meshcore_channel=0)
result = comp.send_message("hello mesh", meshcore_channel="AIDA")
assert result is True
assert len(mt.send_calls) == 1
assert len(mc.send_calls) == 1
@ -239,22 +239,22 @@ class TestBroadcast:
assert len(mc.send_calls) == 0 # MeshCore was skipped
def test_broadcast_meshcore_uses_meshcore_channel(self) -> None:
"""MeshCore child receives meshcore_channel, Meshtastic receives channel."""
"""MeshCore child receives meshcore_channel NAME, Meshtastic receives channel."""
mt = FakeChild("meshtastic")
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("hello", channel=1, meshcore_channel=3)
result = comp.send_message("hello", channel=1, meshcore_channel="Fire")
assert result is True
assert len(mt.send_calls) == 1
assert len(mc.send_calls) == 1
assert mt.send_calls[0]["channel"] == 1 # Meshtastic gets `channel`
assert mc.send_calls[0]["channel"] == 3 # MeshCore gets `meshcore_channel`
assert mt.send_calls[0]["channel"] == 1 # Meshtastic gets `channel`
assert mc.send_calls[0]["channel"] == "Fire" # MeshCore gets `meshcore_channel` name
def test_broadcast_meshtastic_only_when_no_meshcore_child(self) -> None:
"""When there is no MeshCore child, meshcore_channel is irrelevant."""
mt = FakeChild("meshtastic")
comp = CompositeTransport([mt])
result = comp.send_message("hello", channel=2, meshcore_channel=5)
result = comp.send_message("hello", channel=2, meshcore_channel="Fire")
assert result is True
assert len(mt.send_calls) == 1
assert mt.send_calls[0]["channel"] == 2
@ -264,7 +264,7 @@ class TestBroadcast:
mt = FakeChild("meshtastic", connected_val=False)
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("hi", meshcore_channel=0)
result = comp.send_message("hi", meshcore_channel="AIDA")
assert result is True
assert len(mt.send_calls) == 0
assert len(mc.send_calls) == 1
@ -275,7 +275,7 @@ class TestBroadcast:
mt.send_message = MagicMock(return_value=False)
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("test", meshcore_channel=0)
result = comp.send_message("test", meshcore_channel="AIDA")
assert result is True

View file

@ -6,7 +6,13 @@ cfg.notifications.rules as raw dicts (which crashed Dispatcher._matching_rules
on rule.enabled). config_loader.load_config uses this same _dict_to_dataclass.
"""
from meshai.config import Config, NotificationRuleConfig, _dict_to_dataclass
from meshai.config import (
Config,
NotificationRuleConfig,
NotificationToggle,
_dataclass_to_dict,
_dict_to_dataclass,
)
def test_multifile_load_coerces_notification_rules():
@ -61,3 +67,20 @@ def test_rules_attribute_access_does_not_raise():
_ = r.trigger_type
_ = r.categories
_ = r.min_severity
def test_toggle_meshcore_channel_name_round_trips():
"""A NotificationToggle's meshcore_channel NAME survives dict round-trip.
_dict_to_dataclass drops unknown keys, so this guards that the new
meshcore_channel field is a real dataclass field and persists as a str.
"""
tog = NotificationToggle(name="fire", enabled=True, meshcore_channel="AIDA")
d = _dataclass_to_dict(tog)
assert d["meshcore_channel"] == "AIDA"
restored = _dict_to_dataclass(NotificationToggle, d)
assert restored.meshcore_channel == "AIDA"
# Default stays None when unset.
default = _dict_to_dataclass(NotificationToggle, {"name": "weather"})
assert default.meshcore_channel is None

View file

@ -99,7 +99,6 @@ def _mc_config(**overrides):
transport="meshcore",
meshcore_host="127.0.0.1",
meshcore_port=5050,
meshcore_channel_index=0,
)
for k, v in overrides.items():
setattr(cfg, k, v)
@ -117,6 +116,7 @@ def _transport_with_mock_mc(mc_overrides=None):
mc = MagicMock()
mc.get_contact_by_key_prefix.return_value = None
_install_channel_table(mc)
if mc_overrides:
for k, v in mc_overrides.items():
setattr(mc, k, v)
@ -156,6 +156,31 @@ def _make_channel_event(text="chan msg", channel_idx=2, **extra):
return e
# Default fake companion channel table: NAME -> slot. Slots not present here
# report an empty channel_name, so _enumerate_channels stops after the empty run.
_FAKE_CHANNEL_TABLE = {2: "AIDA", 3: "Fire"}
def _install_channel_table(mc, table=None):
"""Wire ``mc.commands.get_channel`` to enumerate a fake channel table.
``table`` maps slot index -> channel name. get_channel(idx) returns a
non-error event whose payload carries ``channel_name``/``channel_idx``
exactly like the real firmware; unlisted slots report an empty name so
_enumerate_channels ends on the contiguous-empty run.
"""
if table is None:
table = _FAKE_CHANNEL_TABLE
async def _get_channel(idx):
e = MagicMock()
e.is_error.return_value = False
e.payload = {"channel_name": table.get(idx, ""), "channel_idx": idx}
return e
mc.commands.get_channel = _get_channel
# ---------------------------------------------------------------------------
# 1. Factory / subclass tests
# ---------------------------------------------------------------------------
@ -176,25 +201,25 @@ class TestBuildTransport:
class TestSendMessageChannel:
def test_returns_true_on_non_error_event(self):
"""With meshcore_channel set, send_chan_msg is called and True returned."""
"""With a resolvable channel name, send_chan_msg is called and True returned."""
t, mc, _ = _transport_with_mock_mc()
try:
ok = MagicMock()
ok.is_error.return_value = False
mc.commands.send_chan_msg = AsyncMock(return_value=ok)
assert t.send_message("hello", meshcore_channel=0) is True
assert t.send_message("hello", meshcore_channel="AIDA") is True
mc.commands.send_chan_msg.assert_awaited_once()
finally:
_cleanup(t)
def test_returns_false_on_error_event(self):
"""With meshcore_channel set, an error result returns False."""
"""With a resolvable channel name, an error result returns False."""
t, mc, _ = _transport_with_mock_mc()
try:
err = MagicMock()
err.is_error.return_value = True
mc.commands.send_chan_msg = AsyncMock(return_value=err)
assert t.send_message("hello", meshcore_channel=0) is False
assert t.send_message("hello", meshcore_channel="AIDA") is False
finally:
_cleanup(t)
@ -202,7 +227,7 @@ class TestSendMessageChannel:
cfg = _mc_config()
t = MeshCoreTransport(cfg)
# _mc is None, no loop started — fails before channel check.
assert t.send_message("test", meshcore_channel=0) is False
assert t.send_message("test", meshcore_channel="AIDA") is False
def test_meshcore_channel_none_skips_broadcast(self):
"""meshcore_channel=None → silent no-op (True) without calling send_chan_msg."""
@ -227,7 +252,8 @@ class TestSendMessageChannel:
_cleanup(t)
def _transport_with_mock_send_chan_msg(self):
"""Build a MeshCoreTransport with a mock mc and async send_chan_msg."""
"""Build a MeshCoreTransport with a mock mc, a fake channel table, and
an async send_chan_msg recorder."""
cfg = _mc_config()
t = MeshCoreTransport(cfg)
ok = MagicMock()
@ -235,6 +261,7 @@ class TestSendMessageChannel:
loop = asyncio.new_event_loop()
mc = MagicMock()
_install_channel_table(mc) # {"AIDA": 2, "Fire": 3}
mc.commands.send_chan_msg = AsyncMock(return_value=ok)
t._mc = mc
t._connected = True
@ -244,30 +271,43 @@ class TestSendMessageChannel:
t._loop_thread = thread
return t, mc
def test_uses_meshcore_channel_for_broadcast(self):
"""meshcore_channel=3 → send_chan_msg(3, text)."""
def test_resolves_name_to_slot_for_broadcast(self):
"""meshcore_channel='AIDA' → resolves to slot 2 → send_chan_msg(2, text)."""
t, mc = self._transport_with_mock_send_chan_msg()
try:
t.send_message("hi", meshcore_channel=3)
assert t.send_message("hi", meshcore_channel="AIDA") is True
mc.commands.send_chan_msg.assert_awaited_once_with(2, "hi")
finally:
_cleanup(t)
def test_resolves_second_named_channel(self):
"""meshcore_channel='Fire' → resolves to slot 3 → send_chan_msg(3, text)."""
t, mc = self._transport_with_mock_send_chan_msg()
try:
assert t.send_message("hi", meshcore_channel="Fire") is True
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
finally:
_cleanup(t)
def test_ignores_meshtastic_channel_uses_meshcore_channel(self):
"""channel=8 (Meshtastic) is irrelevant; meshcore_channel=3 is authoritative."""
def test_ignores_meshtastic_channel_uses_meshcore_name(self):
"""channel=8 (Meshtastic) is irrelevant; the MeshCore NAME is authoritative."""
t, mc = self._transport_with_mock_send_chan_msg()
try:
t.send_message("hi", channel=8, meshcore_channel=3)
t.send_message("hi", channel=8, meshcore_channel="Fire")
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
finally:
_cleanup(t)
def test_meshcore_channel_zero_is_valid(self):
"""meshcore_channel=0 is a valid channel (not falsy-skipped)."""
def test_unknown_name_warns_and_never_sends(self, caplog):
"""An unresolved channel name → no send_chan_msg, returns False, warns."""
import logging
t, mc = self._transport_with_mock_send_chan_msg()
try:
t.send_message("hi", meshcore_channel=0)
mc.commands.send_chan_msg.assert_awaited_once_with(0, "hi")
with caplog.at_level(logging.WARNING):
result = t.send_message("hi", meshcore_channel="Nonexistent")
assert result is False
mc.commands.send_chan_msg.assert_not_awaited()
assert any("Nonexistent" in r.getMessage() for r in caplog.records)
finally:
_cleanup(t)

View file

@ -102,7 +102,6 @@ def _mc_config(**overrides):
transport="meshcore",
meshcore_host="127.0.0.1",
meshcore_port=5050,
meshcore_channel_index=0,
)
for k, v in overrides.items():
setattr(cfg, k, v)
@ -178,7 +177,6 @@ class TestTransportMaxChars:
tcp_port=4403,
meshcore_host="127.0.0.1",
meshcore_port=5050,
meshcore_channel_index=0,
)
comp = build_transport(cfg_both)
assert isinstance(comp, CompositeTransport)