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>
This commit is contained in:
Matt Johnson 2026-07-02 22:17:52 +00:00
commit 58baf94181
2 changed files with 117 additions and 1 deletions

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

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