From ff3ded8ca23a4822bcf2ea1b7f7909ba677d756f Mon Sep 17 00:00:00 2001 From: malice Date: Thu, 2 Jul 2026 14:47:52 -0600 Subject: [PATCH] feat(routing): independent per-family MeshCore channel (#9) Add meshcore_channel (Optional, default None) to each notification family toggle, routed independently of the Meshtastic broadcast_channel. On a broadcast the Meshtastic child uses broadcast_channel and the MeshCore child uses meshcore_channel; an unset meshcore_channel means the family does NOT broadcast on MeshCore (no default, no parallel to Meshtastic). Additive; Meshtastic-only behavior unchanged. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- meshai/config.py | 6 + meshai/connector.py | 3 + meshai/notifications/channels.py | 13 +- meshai/notifications/pipeline/dispatcher.py | 2 + tests/test_meshcore_per_family_routing.py | 418 +++++++++++++++++++ work/meshai/connector.py | 2 + work/meshai/transport/base.py | 8 +- work/meshai/transport/composite_transport.py | 34 +- work/meshai/transport/meshcore_transport.py | 28 +- work/tests/test_composite_transport.py | 40 +- work/tests/test_meshcore_transport.py | 65 ++- 11 files changed, 580 insertions(+), 39 deletions(-) create mode 100644 tests/test_meshcore_per_family_routing.py diff --git a/meshai/config.py b/meshai/config.py index f1edfe7..754bb07 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -544,6 +544,9 @@ class NotificationRuleConfig: # Mesh broadcast fields broadcast_channel: int = 0 + # MeshCore channel index for this rule; independent of broadcast_channel. + # None = rule does NOT broadcast on MeshCore (no fallback, no default). + meshcore_channel: Optional[int] = None # Mesh DM fields node_ids: list = field(default_factory=list) @@ -585,6 +588,9 @@ 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 + # MeshCore channel index for this family; independent of broadcast_channel. + # None = family does NOT broadcast on MeshCore (no fallback, no default). + meshcore_channel: Optional[int] = None node_ids: list = field(default_factory=list) smtp_host: str = "" smtp_port: int = 587 diff --git a/meshai/connector.py b/meshai/connector.py index a7d3e19..2ef5649 100644 --- a/meshai/connector.py +++ b/meshai/connector.py @@ -250,6 +250,7 @@ class MeshConnector: text: str, destination: Optional[str] = None, channel: int = 0, + meshcore_channel: Optional[int] = None, # accepted and ignored; routing for MeshCoreTransport ) -> bool: """Send a text message. @@ -257,6 +258,8 @@ class MeshConnector: text: Message text to send destination: Node ID for DM, or None for broadcast channel: Channel index to send on + meshcore_channel: Per-family MeshCore channel index (ignored here; used by + MeshCoreTransport / CompositeTransport when transport=both). Returns: True if send was initiated successfully diff --git a/meshai/notifications/channels.py b/meshai/notifications/channels.py index 6ac39ff..9d1d557 100644 --- a/meshai/notifications/channels.py +++ b/meshai/notifications/channels.py @@ -60,9 +60,17 @@ 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[int] = None, + ): self._connector = connector self._channel = channel_index + # MeshCore channel index; None = do not broadcast on MeshCore for this family. + # The transport layer (CompositeTransport / MeshCoreTransport) enforces the skip. + self._meshcore_channel = meshcore_channel self._renderer = MeshRenderer() async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool: @@ -78,6 +86,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 @@ -89,6 +98,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 @@ -778,6 +788,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( diff --git a/meshai/notifications/pipeline/dispatcher.py b/meshai/notifications/pipeline/dispatcher.py index b93d7b3..a79db67 100644 --- a/meshai/notifications/pipeline/dispatcher.py +++ b/meshai/notifications/pipeline/dispatcher.py @@ -660,6 +660,8 @@ 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 is carried independently; None = skip MeshCore for this family. + 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/tests/test_meshcore_per_family_routing.py b/tests/test_meshcore_per_family_routing.py new file mode 100644 index 0000000..6bccd85 --- /dev/null +++ b/tests/test_meshcore_per_family_routing.py @@ -0,0 +1,418 @@ +"""Per-family MeshCore channel routing tests. + +Tests the independent meshcore_channel field on NotificationToggle / +NotificationRuleConfig and how it threads through the notification pipeline. + +Covers: + 1. Config round-trip: meshcore_channel serialises/deserialises correctly. + 2. _toggle_to_rule: carries meshcore_channel from toggle into rule. + 3. MeshBroadcastChannel: both channel and meshcore_channel passed to connector. + 4. meshcore_channel=None: connector receives meshcore_channel=None (transport + layer is responsible for the skip; channel-level just passes it through). + 5. transport=meshtastic (default): connector called with just channel; no + breakage from the new optional meshcore_channel kwarg. + 6. End-to-end via Dispatcher with mock children that track per-transport calls. +""" + +import asyncio +import time +from unittest.mock import MagicMock, call + +import pytest + +from meshai.config import ( + Config, + NotificationRuleConfig, + NotificationToggle, + _dataclass_to_dict, + _dict_to_dataclass, +) +from meshai.notifications.channels import MeshBroadcastChannel, create_channel +from meshai.notifications.events import NotificationPayload, make_event +from meshai.notifications.pipeline.dispatcher import Dispatcher + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_payload(text="test alert") -> NotificationPayload: + return NotificationPayload( + message=text, + category="weather_warning", + severity="priority", + timestamp=time.time(), + event_type="weather_warning", + ) + + +def _make_rule(broadcast_channel=1, meshcore_channel=None) -> NotificationRuleConfig: + return NotificationRuleConfig( + name="test-rule", + enabled=True, + trigger_type="condition", + delivery_type="mesh_broadcast", + broadcast_channel=broadcast_channel, + meshcore_channel=meshcore_channel, + ) + + +# --------------------------------------------------------------------------- +# 1. Config round-trip +# --------------------------------------------------------------------------- + +class TestConfigRoundTrip: + def test_toggle_meshcore_channel_serialises(self): + tog = NotificationToggle( + name="weather", + enabled=True, + broadcast_channel=1, + meshcore_channel=3, + ) + d = _dataclass_to_dict(tog) + assert d["meshcore_channel"] == 3 + + def test_toggle_meshcore_channel_deserialises(self): + data = { + "name": "weather", + "enabled": True, + "broadcast_channel": 1, + "meshcore_channel": 3, + } + tog = _dict_to_dataclass(NotificationToggle, data) + assert tog.meshcore_channel == 3 + + def test_toggle_meshcore_channel_none_round_trips(self): + tog = NotificationToggle(name="fire", enabled=False, meshcore_channel=None) + d = _dataclass_to_dict(tog) + rt = _dict_to_dataclass(NotificationToggle, d) + assert rt.meshcore_channel is None + + def test_toggle_meshcore_channel_zero_is_valid(self): + """Channel index 0 is a valid MeshCore channel (not falsy-ignored).""" + data = {"name": "weather", "enabled": True, "meshcore_channel": 0} + tog = _dict_to_dataclass(NotificationToggle, data) + assert tog.meshcore_channel == 0 + + def test_full_notifications_config_round_trips(self): + """NotificationToggle inside a full NotificationsConfig round-trips meshcore_channel.""" + cfg = Config() + cfg.notifications.cold_start_grace_seconds = 0 + cfg.notifications.toggles["weather"].meshcore_channel = 5 + + d = _dataclass_to_dict(cfg) + rt_cfg = _dict_to_dataclass(Config, d) + assert rt_cfg.notifications.toggles["weather"].meshcore_channel == 5 + + def test_rule_config_meshcore_channel_round_trips(self): + rule = NotificationRuleConfig( + name="r", + delivery_type="mesh_broadcast", + broadcast_channel=2, + meshcore_channel=7, + ) + d = _dataclass_to_dict(rule) + assert d["meshcore_channel"] == 7 + rt = _dict_to_dataclass(NotificationRuleConfig, d) + assert rt.meshcore_channel == 7 + + +# --------------------------------------------------------------------------- +# 2. _toggle_to_rule carries meshcore_channel +# --------------------------------------------------------------------------- + +class TestToggleToRule: + def _make_dispatcher(self): + """Dispatcher with no-op channel factory; returns (dispatcher, rec).""" + rec = [] + cfg = Config() + cfg.notifications.cold_start_grace_seconds = 0 + cfg.notifications.rules = [] + + class RecChannel: + def __init__(self, rec_): + self._rec = rec_ + + async def deliver(self, payload, rule): + self._rec.append({ + "broadcast_channel": rule.broadcast_channel, + "meshcore_channel": rule.meshcore_channel, + }) + return True + + d = Dispatcher( + cfg, + lambda rule, conn: RecChannel(rec), + connector=None, + ) + return d, rec, cfg + + def test_toggle_to_rule_carries_meshcore_channel_set(self): + d, rec, cfg = self._make_dispatcher() + tog = cfg.notifications.toggles["weather"] + tog.enabled = True + tog.min_severity = "routine" + tog.severity_channels = {"priority": ["mesh_broadcast"]} + tog.broadcast_channel = 1 + tog.meshcore_channel = 3 + + ev = make_event(source="nws", category="weather_warning", + severity="priority", title="t") + asyncio.run(d.dispatch(ev)) + + assert len(rec) == 1 + assert rec[0]["broadcast_channel"] == 1 + assert rec[0]["meshcore_channel"] == 3 + + def test_toggle_to_rule_carries_meshcore_channel_none(self): + d, rec, cfg = self._make_dispatcher() + tog = cfg.notifications.toggles["weather"] + tog.enabled = True + tog.min_severity = "routine" + tog.severity_channels = {"priority": ["mesh_broadcast"]} + tog.broadcast_channel = 1 + tog.meshcore_channel = None # explicit None + + ev = make_event(source="nws", category="weather_warning", + severity="priority", title="t") + asyncio.run(d.dispatch(ev)) + + assert len(rec) == 1 + assert rec[0]["broadcast_channel"] == 1 + assert rec[0]["meshcore_channel"] is None + + +# --------------------------------------------------------------------------- +# 3 & 4. MeshBroadcastChannel.deliver passes both channels to connector +# --------------------------------------------------------------------------- + +class TestMeshBroadcastChannelRouting: + def test_both_channels_passed_to_connector(self): + """connector.send_message receives channel=1 and meshcore_channel=3.""" + mock_connector = MagicMock() + channel = MeshBroadcastChannel( + connector=mock_connector, + channel_index=1, + meshcore_channel=3, + ) + asyncio.run(channel.deliver(_make_payload(), rule=None)) + + assert mock_connector.send_message.called + for call_ in mock_connector.send_message.call_args_list: + kw = call_.kwargs + assert kw.get("channel") == 1 + assert kw.get("meshcore_channel") == 3 + + def test_meshcore_channel_none_passed_to_connector(self): + """When meshcore_channel=None, connector receives meshcore_channel=None.""" + mock_connector = MagicMock() + channel = MeshBroadcastChannel( + connector=mock_connector, + channel_index=1, + meshcore_channel=None, + ) + asyncio.run(channel.deliver(_make_payload(), rule=None)) + + assert mock_connector.send_message.called + for call_ in mock_connector.send_message.call_args_list: + kw = call_.kwargs + assert kw.get("meshcore_channel") is None + + def test_pre_chunked_payload_also_passes_both_channels(self): + """Pre-chunked digest payloads also carry meshcore_channel.""" + mock_connector = MagicMock() + channel = MeshBroadcastChannel( + connector=mock_connector, + channel_index=2, + meshcore_channel=5, + ) + payload = NotificationPayload( + message="pre-chunked", + category="weather_warning", + severity="priority", + timestamp=time.time(), + event_type="weather_warning", + chunk_index=0, + ) + asyncio.run(channel.deliver(payload, rule=None)) + + mock_connector.send_message.assert_called_once_with( + text="pre-chunked", + destination=None, + channel=2, + meshcore_channel=5, + ) + + +# --------------------------------------------------------------------------- +# 5. create_channel propagates meshcore_channel from rule +# --------------------------------------------------------------------------- + +class TestCreateChannelPropagation: + def test_create_channel_propagates_meshcore_channel(self): + """create_channel passes rule.meshcore_channel to MeshBroadcastChannel.""" + mock_connector = MagicMock() + rule = _make_rule(broadcast_channel=1, meshcore_channel=3) + ch = create_channel(rule, connector=mock_connector) + + assert isinstance(ch, MeshBroadcastChannel) + assert ch._meshcore_channel == 3 + assert ch._channel == 1 + + def test_create_channel_meshcore_channel_none(self): + mock_connector = MagicMock() + rule = _make_rule(broadcast_channel=1, meshcore_channel=None) + ch = create_channel(rule, connector=mock_connector) + assert ch._meshcore_channel is None + + def test_create_channel_meshcore_channel_zero(self): + """meshcore_channel=0 is a valid channel index (not falsy-defaulted).""" + mock_connector = MagicMock() + rule = _make_rule(broadcast_channel=1, meshcore_channel=0) + ch = create_channel(rule, connector=mock_connector) + assert ch._meshcore_channel == 0 + + +# --------------------------------------------------------------------------- +# 6. transport=meshtastic: old connector API unchanged (meshcore_channel accepted) +# --------------------------------------------------------------------------- + +class TestMeshtasticOnlyUnchanged: + def test_connector_called_with_correct_channel(self): + """Meshtastic-only connector receives its channel; meshcore_channel=None is a no-op.""" + mock_connector = MagicMock() + channel = MeshBroadcastChannel( + connector=mock_connector, + channel_index=5, + # No meshcore_channel set → defaults to None + ) + asyncio.run(channel.deliver(_make_payload(), rule=None)) + + assert mock_connector.send_message.called + for call_ in mock_connector.send_message.call_args_list: + kw = call_.kwargs + assert kw.get("channel") == 5 + # meshcore_channel=None passed through; old MeshConnector accepts and ignores it. + assert kw.get("meshcore_channel") is None + + def test_dispatcher_meshtastic_family_no_meshcore_channel(self): + """End-to-end: toggle without meshcore_channel routes to channel=1, meshcore_channel=None.""" + rec = [] + + class RecChannel: + async def deliver(self, payload, rule): + rec.append({ + "broadcast_channel": rule.broadcast_channel, + "meshcore_channel": rule.meshcore_channel, + }) + return True + + cfg = Config() + cfg.notifications.cold_start_grace_seconds = 0 + cfg.notifications.rules = [] + tog = cfg.notifications.toggles["weather"] + tog.enabled = True + tog.min_severity = "routine" + tog.severity_channels = {"priority": ["mesh_broadcast"]} + tog.broadcast_channel = 1 + # meshcore_channel NOT set → remains None + + d = Dispatcher(cfg, lambda rule, conn: RecChannel(), connector=None) + ev = make_event(source="nws", category="weather_warning", + severity="priority", title="t") + asyncio.run(d.dispatch(ev)) + + assert len(rec) == 1 + assert rec[0]["broadcast_channel"] == 1 + assert rec[0]["meshcore_channel"] is None + + +# --------------------------------------------------------------------------- +# 7. Mock-composite end-to-end: both + None child routing +# --------------------------------------------------------------------------- + +class FakeMeshChild: + """Minimal connector mock that tracks send_message calls.""" + + def __init__(self, name: str): + self.name = name + self.calls: list[dict] = [] + + def send_message(self, text, destination=None, channel=0, meshcore_channel=None): + self.calls.append({ + "text": text, + "destination": destination, + "channel": channel, + "meshcore_channel": meshcore_channel, + }) + return True + + +class FakeCompositeConnector: + """Simulates CompositeTransport's per-family routing at the connector level. + + Replicates the exact logic in CompositeTransport._broadcast: + - Meshtastic child: receives ``channel`` + - MeshCore child: receives ``meshcore_channel``; skipped if None. + """ + + def __init__(self, mt_child: FakeMeshChild, mc_child: FakeMeshChild): + self.mt = mt_child + self.mc = mc_child + + def send_message(self, text, destination=None, channel=0, meshcore_channel=None): + # Meshtastic always gets the Meshtastic channel. + self.mt.send_message(text, destination=destination, channel=channel) + # MeshCore only gets a call when meshcore_channel is set. + if meshcore_channel is not None: + self.mc.send_message(text, destination=destination, channel=meshcore_channel) + return True + + +class TestMockCompositeBothTransport: + def _make_connector(self): + mt = FakeMeshChild("meshtastic") + mc = FakeMeshChild("meshcore") + return FakeCompositeConnector(mt, mc), mt, mc + + def test_broadcast_channel_1_meshcore_channel_3(self): + """meshcore_channel=3, broadcast_channel=1: MT on ch 1, MC on ch 3.""" + composite, mt, mc = self._make_connector() + channel = MeshBroadcastChannel( + connector=composite, + channel_index=1, + meshcore_channel=3, + ) + asyncio.run(channel.deliver(_make_payload("alert text"), rule=None)) + + assert len(mt.calls) >= 1 + assert all(c["channel"] == 1 for c in mt.calls) + + assert len(mc.calls) >= 1 + assert all(c["channel"] == 3 for c in mc.calls) + + def test_meshcore_channel_none_skips_meshcore_child(self): + """meshcore_channel=None: MT receives broadcast, MC child NOT called.""" + composite, mt, mc = self._make_connector() + channel = MeshBroadcastChannel( + connector=composite, + channel_index=1, + meshcore_channel=None, + ) + asyncio.run(channel.deliver(_make_payload("alert"), rule=None)) + + assert len(mt.calls) >= 1 # Meshtastic got it + assert len(mc.calls) == 0 # MeshCore was skipped + + def test_meshtastic_only_unchanged(self): + """transport=meshtastic (single connector): channel routing unchanged.""" + mt = FakeMeshChild("meshtastic") + channel = MeshBroadcastChannel( + connector=mt, # single connector, no composite + channel_index=5, + # No meshcore_channel → defaults to None + ) + asyncio.run(channel.deliver(_make_payload("single"), rule=None)) + + assert len(mt.calls) >= 1 + assert all(c["channel"] == 5 for c in mt.calls) diff --git a/work/meshai/connector.py b/work/meshai/connector.py index a908bf2..a474225 100644 --- a/work/meshai/connector.py +++ b/work/meshai/connector.py @@ -355,6 +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 ) -> bool: """Send a text message. @@ -363,6 +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. Returns: True if send was initiated successfully diff --git a/work/meshai/transport/base.py b/work/meshai/transport/base.py index 227be1b..77a0262 100644 --- a/work/meshai/transport/base.py +++ b/work/meshai/transport/base.py @@ -37,17 +37,23 @@ class MeshTransport(abc.ABC): destination: Optional[str] = None, channel: int = 0, transport: Optional[str] = None, + meshcore_channel: Optional[int] = None, ) -> bool: """Send a text message. Args: text: Message text to send. destination: Node ID for a DM, or None for broadcast. - channel: Channel index to send on. + channel: Channel index to send on (Meshtastic semantics). transport: Optional routing hint (used by CompositeTransport to 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. + CompositeTransport uses it to route each child correctly. + None = do not broadcast on MeshCore for this family. Returns: True if send was initiated successfully. diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index 4b407fb..63aaa30 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -196,14 +196,18 @@ class CompositeTransport(MeshTransport): destination: Optional[str] = None, channel: int = 0, transport: Optional[str] = None, + meshcore_channel: Optional[int] = None, ) -> bool: """Send a message, routing based on destination + hint. Routing rules ------------- 1. **Broadcast** (``destination is None``): - Fan out to ALL connected children. Return True if at least one - child succeeded; log per-child failures. + Fan out to connected children with per-transport channel selection: + - Meshtastic child receives ``channel`` (Meshtastic channel index). + - MeshCore child receives ``meshcore_channel``; if that is None the + MeshCore child is silently skipped (family not configured for MeshCore). + Return True if at least one child succeeded; log per-child failures. 2. **DM with routing hint** (``destination`` set AND ``transport`` given): Send ONLY via the child whose name == ``transport``. This is the @@ -220,7 +224,7 @@ class CompositeTransport(MeshTransport): """ if destination is None: # --- Rule 1: broadcast --- - return self._broadcast(text, channel) + return self._broadcast(text, channel, meshcore_channel=meshcore_channel) if transport is not None: # --- Rule 2: hinted DM --- @@ -229,8 +233,17 @@ class CompositeTransport(MeshTransport): # --- Rule 3: unhinted DM --- return self._send_unhinted(text, destination, channel) - def _broadcast(self, text: str, channel: int) -> bool: - """Fan text out to all connected children; return True if any succeed.""" + def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[int] = 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`` is None → skip the MeshCore child entirely + (family not configured for MeshCore; no fallback to global index). + + Returns True if at least one child succeeded. + """ any_ok = False for child in self._children: name = _child_name(child) @@ -239,8 +252,17 @@ class CompositeTransport(MeshTransport): "CompositeTransport: skipping broadcast to %r (not connected)", name ) continue + # Per-family MeshCore routing: skip MeshCore child when unset. + if name == "meshcore" and meshcore_channel is None: + logger.debug( + "CompositeTransport: skipping meshcore broadcast " + "(meshcore_channel=None for this family)" + ) + continue + # Route each child on its own channel semantics. + child_channel = meshcore_channel if name == "meshcore" else channel try: - ok = child.send_message(text, destination=None, channel=channel) + ok = child.send_message(text, destination=None, channel=child_channel) if ok: any_ok = True else: diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index c6f2cf0..3282cf8 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -194,6 +194,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, ) -> bool: """Send a message via MeshCore. @@ -201,11 +202,17 @@ class MeshCoreTransport(MeshTransport): text: Message text (caller is responsible for length limits; see ``mesh_max_chars`` config field). destination: hex pubkey string for a DM, or None for channel send. - channel: Channel index for channel sends (0 = default). + 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. + When None on a broadcast, the send is skipped (family not + configured for MeshCore — no fallback, no default). Returns: True if the send succeeded (not an error event). + True also for a no-op skip (meshcore_channel=None on broadcast) so + the caller (CompositeTransport) doesn't treat a silent skip as failure. """ if self._mc is None: logger.error("MeshCoreTransport: cannot send, not connected") @@ -213,20 +220,27 @@ class MeshCoreTransport(MeshTransport): try: if destination: + # DM: meshcore_channel is irrelevant; route by pubkey. result = self._run_coro( self._mc.commands.send_msg(destination, text) ) else: + # Channel broadcast. # Channel-index semantics do NOT cross transports: the passed # `channel` carries Meshtastic channel-index semantics (e.g. # index 8) that have no relationship to MeshCore's separate - # channel table. The configured MeshCore channel is therefore - # authoritative for broadcasts, so we ignore `channel` here - # (this also avoids an explicit channel=0 being treated as - # falsy). - chan_idx = getattr(self.config, "meshcore_channel_index", 0) + # channel table. + # + # Per-family routing: use meshcore_channel when provided. + # If meshcore_channel is None, this family is not configured + # for MeshCore → silent no-op (return True). + if meshcore_channel is None: + logger.debug( + "MeshCoreTransport: meshcore_channel=None, skipping broadcast" + ) + return True result = self._run_coro( - self._mc.commands.send_chan_msg(chan_idx, text) + self._mc.commands.send_chan_msg(meshcore_channel, text) ) success = not result.is_error() if not success: diff --git a/work/tests/test_composite_transport.py b/work/tests/test_composite_transport.py index d5c1c80..6bcf2ad 100644 --- a/work/tests/test_composite_transport.py +++ b/work/tests/test_composite_transport.py @@ -217,31 +217,65 @@ class TestLifecycle: class TestBroadcast: def test_broadcast_sends_to_all(self) -> None: + """With meshcore_channel set, broadcast fans to both children.""" mt = FakeChild("meshtastic") mc = FakeChild("meshcore") comp = CompositeTransport([mt, mc]) - result = comp.send_message("hello mesh") + result = comp.send_message("hello mesh", meshcore_channel=0) assert result is True assert len(mt.send_calls) == 1 assert len(mc.send_calls) == 1 assert mt.send_calls[0]["destination"] is None assert mc.send_calls[0]["destination"] is None + def test_broadcast_meshcore_skipped_when_channel_none(self) -> None: + """meshcore_channel=None → MeshCore child silently skipped; Meshtastic still gets it.""" + mt = FakeChild("meshtastic") + mc = FakeChild("meshcore") + comp = CompositeTransport([mt, mc]) + result = comp.send_message("hello mesh", meshcore_channel=None) + assert result is True # Meshtastic succeeded + assert len(mt.send_calls) == 1 + 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.""" + mt = FakeChild("meshtastic") + mc = FakeChild("meshcore") + comp = CompositeTransport([mt, mc]) + result = comp.send_message("hello", channel=1, meshcore_channel=3) + 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` + + 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) + assert result is True + assert len(mt.send_calls) == 1 + assert mt.send_calls[0]["channel"] == 2 + def test_broadcast_skips_disconnected_child(self) -> None: + """Disconnected Meshtastic child is skipped; MeshCore (with channel set) is sent.""" mt = FakeChild("meshtastic", connected_val=False) mc = FakeChild("meshcore") comp = CompositeTransport([mt, mc]) - result = comp.send_message("hi") + result = comp.send_message("hi", meshcore_channel=0) assert result is True assert len(mt.send_calls) == 0 assert len(mc.send_calls) == 1 def test_broadcast_true_if_at_least_one_ok(self) -> None: + """True if any child succeeds; Meshtastic failing + MeshCore succeeding → True.""" mt = FakeChild("meshtastic") mt.send_message = MagicMock(return_value=False) mc = FakeChild("meshcore") comp = CompositeTransport([mt, mc]) - result = comp.send_message("test") + result = comp.send_message("test", meshcore_channel=0) assert result is True diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py index 0f3eb48..54add8c 100644 --- a/work/tests/test_meshcore_transport.py +++ b/work/tests/test_meshcore_transport.py @@ -176,35 +176,59 @@ 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.""" 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") is True + assert t.send_message("hello", meshcore_channel=0) 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.""" 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") is False + assert t.send_message("hello", meshcore_channel=0) is False finally: _cleanup(t) def test_returns_false_when_not_connected(self): cfg = _mc_config() t = MeshCoreTransport(cfg) - # _mc is None, no loop started - assert t.send_message("test") is False + # _mc is None, no loop started — fails before channel check. + assert t.send_message("test", meshcore_channel=0) is False - def _transport_with_configured_index(self, index): - """Build a MeshCoreTransport whose config sets meshcore_channel_index.""" - cfg = _mc_config(meshcore_channel_index=index) + def test_meshcore_channel_none_skips_broadcast(self): + """meshcore_channel=None → silent no-op (True) without calling send_chan_msg.""" + t, mc, _ = _transport_with_mock_mc() + try: + mc.commands.send_chan_msg = AsyncMock() + result = t.send_message("hello", meshcore_channel=None) + assert result is True # no-op success + mc.commands.send_chan_msg.assert_not_awaited() + finally: + _cleanup(t) + + def test_meshcore_channel_default_skips_broadcast(self): + """Default meshcore_channel=None (no arg) → silent no-op.""" + t, mc, _ = _transport_with_mock_mc() + try: + mc.commands.send_chan_msg = AsyncMock() + result = t.send_message("hello") # no meshcore_channel arg + assert result is True + mc.commands.send_chan_msg.assert_not_awaited() + finally: + _cleanup(t) + + def _transport_with_mock_send_chan_msg(self): + """Build a MeshCoreTransport with a mock mc and async send_chan_msg.""" + cfg = _mc_config() t = MeshCoreTransport(cfg) ok = MagicMock() ok.is_error.return_value = False @@ -220,31 +244,30 @@ class TestSendMessageChannel: t._loop_thread = thread return t, mc - def test_uses_config_channel_index_when_channel_zero(self): - t, mc = self._transport_with_configured_index(3) + def test_uses_meshcore_channel_for_broadcast(self): + """meshcore_channel=3 → send_chan_msg(3, text).""" + t, mc = self._transport_with_mock_send_chan_msg() try: - t.send_message("hi", channel=0) - # channel=0 must not be treated as falsy-fallthrough: broadcasts - # always use the configured meshcore_channel_index=3. + t.send_message("hi", meshcore_channel=3) mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi") finally: _cleanup(t) - def test_uses_config_channel_index_when_channel_default(self): - t, mc = self._transport_with_configured_index(3) + def test_ignores_meshtastic_channel_uses_meshcore_channel(self): + """channel=8 (Meshtastic) is irrelevant; meshcore_channel=3 is authoritative.""" + t, mc = self._transport_with_mock_send_chan_msg() try: - t.send_message("hi") # default channel param + t.send_message("hi", channel=8, meshcore_channel=3) mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi") finally: _cleanup(t) - def test_ignores_meshtastic_channel_index(self): - # channel=8 carries Meshtastic channel-index semantics that do NOT map - # to MeshCore's channel table; the configured index (3) is authoritative. - t, mc = self._transport_with_configured_index(3) + def test_meshcore_channel_zero_is_valid(self): + """meshcore_channel=0 is a valid channel (not falsy-skipped).""" + t, mc = self._transport_with_mock_send_chan_msg() try: - t.send_message("hi", channel=8) - mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi") + t.send_message("hi", meshcore_channel=0) + mc.commands.send_chan_msg.assert_awaited_once_with(0, "hi") finally: _cleanup(t)