diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx
index 1b2a455..b8da989 100644
--- a/work/dashboard-frontend/src/pages/Config.tsx
+++ b/work/dashboard-frontend/src/pages/Config.tsx
@@ -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 (
+ 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."
+ />
)}
+ {showMeshCore && (
+
+
MeshCore Connection
+
+ 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."
+ />
+ onChange({ ...data, meshcore_port: v })}
+ min={1}
+ max={65535}
+ helper="MeshCore TCP port (default 5525)"
+ />
+
+
+ )}
)
}
diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx
index 5e49212..e5fe133 100644
--- a/work/dashboard-frontend/src/pages/Notifications.tsx
+++ b/work/dashboard-frontend/src/pages/Notifications.tsx
@@ -46,6 +46,7 @@ interface NotificationToggle {
regions: string[]
severity_channels: Record
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 }: {
upd(key, { regions: v })} placeholder="Add region..." /> Channel config
upd(key, { broadcast_channel: v })} />
+
+
+
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"
+ />
+
MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.
+
upd(key, { node_ids: v })} placeholder="!nodeid" />
upd(key, { recipients: v })} placeholder="ops@example.com" />
upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
diff --git a/work/meshai/config.py b/work/meshai/config.py
index 1b1491c..6c496bd 100644
--- a/work/meshai/config.py
+++ b/work/meshai/config.py
@@ -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
diff --git a/work/meshai/connector.py b/work/meshai/connector.py
index a474225..cbed018 100644
--- a/work/meshai/connector.py
+++ b/work/meshai/connector.py
@@ -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
diff --git a/work/meshai/notifications/channels.py b/work/meshai/notifications/channels.py
index 9b1a3c2..555d5d3 100644
--- a/work/meshai/notifications/channels.py
+++ b/work/meshai/notifications/channels.py
@@ -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(
diff --git a/work/meshai/notifications/pipeline/dispatcher.py b/work/meshai/notifications/pipeline/dispatcher.py
index b93d7b3..a93bfa1 100644
--- a/work/meshai/notifications/pipeline/dispatcher.py
+++ b/work/meshai/notifications/pipeline/dispatcher.py
@@ -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", ""),
diff --git a/work/meshai/transport/base.py b/work/meshai/transport/base.py
index 77a0262..2d69d07 100644
--- a/work/meshai/transport/base.py
+++ b/work/meshai/transport/base.py
@@ -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.
diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py
index 63aaa30..572b95e 100644
--- a/work/meshai/transport/composite_transport.py
+++ b/work/meshai/transport/composite_transport.py
@@ -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.
"""
diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py
index 3282cf8..d432345 100644
--- a/work/meshai/transport/meshcore_transport.py
+++ b/work/meshai/transport/meshcore_transport.py
@@ -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:
diff --git a/work/tests/test_channel_rendering.py b/work/tests/test_channel_rendering.py
index c69c91a..945127d 100644
--- a/work/tests/test_channel_rendering.py
+++ b/work/tests/test_channel_rendering.py
@@ -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= AND meshcore_channel= 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"
diff --git a/work/tests/test_composite_transport.py b/work/tests/test_composite_transport.py
index 6bcf2ad..6ddd6f5 100644
--- a/work/tests/test_composite_transport.py
+++ b/work/tests/test_composite_transport.py
@@ -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
diff --git a/work/tests/test_config_loader.py b/work/tests/test_config_loader.py
index 727247f..9ea9d84 100644
--- a/work/tests/test_config_loader.py
+++ b/work/tests/test_config_loader.py
@@ -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
diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py
index 54add8c..d68c8d8 100644
--- a/work/tests/test_meshcore_transport.py
+++ b/work/tests/test_meshcore_transport.py
@@ -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)
diff --git a/work/tests/test_uniform_sizing.py b/work/tests/test_uniform_sizing.py
index 7a48414..16769da 100644
--- a/work/tests/test_uniform_sizing.py
+++ b/work/tests/test_uniform_sizing.py
@@ -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)