From 58baf941811fb8573db10e858274d62e3bcfcc5a Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Thu, 2 Jul 2026 22:17:52 +0000 Subject: [PATCH] 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) --- work/meshai/notifications/channels.py | 10 ++- work/tests/test_channel_rendering.py | 108 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) 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/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"