mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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 <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f3df7a0a6d
commit
ff3ded8ca2
11 changed files with 580 additions and 39 deletions
|
|
@ -544,6 +544,9 @@ class NotificationRuleConfig:
|
||||||
|
|
||||||
# Mesh broadcast fields
|
# Mesh broadcast fields
|
||||||
broadcast_channel: int = 0
|
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
|
# Mesh DM fields
|
||||||
node_ids: list = field(default_factory=list)
|
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
|
cooldown_seconds: int = 0 # per (toggle, category, region) throttle window; 0 = disabled
|
||||||
# per-channel delivery config (mirrors NotificationRuleConfig channel fields)
|
# per-channel delivery config (mirrors NotificationRuleConfig channel fields)
|
||||||
broadcast_channel: Optional[int] = None
|
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)
|
node_ids: list = field(default_factory=list)
|
||||||
smtp_host: str = ""
|
smtp_host: str = ""
|
||||||
smtp_port: int = 587
|
smtp_port: int = 587
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,7 @@ class MeshConnector:
|
||||||
text: str,
|
text: str,
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
|
meshcore_channel: Optional[int] = None, # accepted and ignored; routing for MeshCoreTransport
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a text message.
|
"""Send a text message.
|
||||||
|
|
||||||
|
|
@ -257,6 +258,8 @@ class MeshConnector:
|
||||||
text: Message text to send
|
text: Message text to send
|
||||||
destination: Node ID for DM, or None for broadcast
|
destination: Node ID for DM, or None for broadcast
|
||||||
channel: Channel index to send on
|
channel: Channel index to send on
|
||||||
|
meshcore_channel: Per-family MeshCore channel index (ignored here; used by
|
||||||
|
MeshCoreTransport / CompositeTransport when transport=both).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if send was initiated successfully
|
True if send was initiated successfully
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,17 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
|
|
||||||
channel_type = "mesh_broadcast"
|
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._connector = connector
|
||||||
self._channel = channel_index
|
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()
|
self._renderer = MeshRenderer()
|
||||||
|
|
||||||
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
||||||
|
|
@ -78,6 +86,7 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
text=alert.message or "",
|
text=alert.message or "",
|
||||||
destination=None,
|
destination=None,
|
||||||
channel=self._channel,
|
channel=self._channel,
|
||||||
|
meshcore_channel=self._meshcore_channel,
|
||||||
)
|
)
|
||||||
logger.info("Broadcast pre-chunked alert to channel %d", self._channel)
|
logger.info("Broadcast pre-chunked alert to channel %d", self._channel)
|
||||||
return True
|
return True
|
||||||
|
|
@ -89,6 +98,7 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
text=chunk,
|
text=chunk,
|
||||||
destination=None,
|
destination=None,
|
||||||
channel=self._channel,
|
channel=self._channel,
|
||||||
|
meshcore_channel=self._meshcore_channel,
|
||||||
)
|
)
|
||||||
logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel)
|
logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel)
|
||||||
return True
|
return True
|
||||||
|
|
@ -778,6 +788,7 @@ def create_channel(rule: "NotificationRuleConfig", connector=None) -> Notificati
|
||||||
return MeshBroadcastChannel(
|
return MeshBroadcastChannel(
|
||||||
connector=connector,
|
connector=connector,
|
||||||
channel_index=rule.broadcast_channel,
|
channel_index=rule.broadcast_channel,
|
||||||
|
meshcore_channel=getattr(rule, "meshcore_channel", None),
|
||||||
)
|
)
|
||||||
elif delivery_type == "mesh_dm":
|
elif delivery_type == "mesh_dm":
|
||||||
return MeshDMChannel(
|
return MeshDMChannel(
|
||||||
|
|
|
||||||
|
|
@ -660,6 +660,8 @@ class Dispatcher:
|
||||||
name=f"toggle:{getattr(tog, 'name', '')}",
|
name=f"toggle:{getattr(tog, 'name', '')}",
|
||||||
enabled=True, trigger_type="condition", delivery_type=ch_type,
|
enabled=True, trigger_type="condition", delivery_type=ch_type,
|
||||||
broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0),
|
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 []),
|
node_ids=list(getattr(tog, "node_ids", []) or []),
|
||||||
smtp_host=getattr(tog, "smtp_host", ""), smtp_port=getattr(tog, "smtp_port", 587),
|
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", ""),
|
smtp_user=getattr(tog, "smtp_user", ""), smtp_password=getattr(tog, "smtp_password", ""),
|
||||||
|
|
|
||||||
418
tests/test_meshcore_per_family_routing.py
Normal file
418
tests/test_meshcore_per_family_routing.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -355,6 +355,7 @@ class MeshtasticTransport(MeshTransport):
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
|
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:
|
) -> bool:
|
||||||
"""Send a text message.
|
"""Send a text message.
|
||||||
|
|
||||||
|
|
@ -363,6 +364,7 @@ class MeshtasticTransport(MeshTransport):
|
||||||
destination: Node ID for DM, or None for broadcast
|
destination: Node ID for DM, or None for broadcast
|
||||||
channel: Channel index to send on
|
channel: Channel index to send on
|
||||||
transport: Optional routing hint (for CompositeTransport); ignored here.
|
transport: Optional routing hint (for CompositeTransport); ignored here.
|
||||||
|
meshcore_channel: Per-family MeshCore channel index; ignored by Meshtastic.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if send was initiated successfully
|
True if send was initiated successfully
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,23 @@ class MeshTransport(abc.ABC):
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
transport: Optional[str] = None,
|
transport: Optional[str] = None,
|
||||||
|
meshcore_channel: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a text message.
|
"""Send a text message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Message text to send.
|
text: Message text to send.
|
||||||
destination: Node ID for a DM, or None for broadcast.
|
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
|
transport: Optional routing hint (used by CompositeTransport to
|
||||||
select the child mesh that originated an inbound DM).
|
select the child mesh that originated an inbound DM).
|
||||||
Single-transport implementations accept and IGNORE this
|
Single-transport implementations accept and IGNORE this
|
||||||
parameter; it is always None in non-composite callers.
|
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:
|
Returns:
|
||||||
True if send was initiated successfully.
|
True if send was initiated successfully.
|
||||||
|
|
|
||||||
|
|
@ -196,14 +196,18 @@ class CompositeTransport(MeshTransport):
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
transport: Optional[str] = None,
|
transport: Optional[str] = None,
|
||||||
|
meshcore_channel: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a message, routing based on destination + hint.
|
"""Send a message, routing based on destination + hint.
|
||||||
|
|
||||||
Routing rules
|
Routing rules
|
||||||
-------------
|
-------------
|
||||||
1. **Broadcast** (``destination is None``):
|
1. **Broadcast** (``destination is None``):
|
||||||
Fan out to ALL connected children. Return True if at least one
|
Fan out to connected children with per-transport channel selection:
|
||||||
child succeeded; log per-child failures.
|
- 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):
|
2. **DM with routing hint** (``destination`` set AND ``transport`` given):
|
||||||
Send ONLY via the child whose name == ``transport``. This is the
|
Send ONLY via the child whose name == ``transport``. This is the
|
||||||
|
|
@ -220,7 +224,7 @@ class CompositeTransport(MeshTransport):
|
||||||
"""
|
"""
|
||||||
if destination is None:
|
if destination is None:
|
||||||
# --- Rule 1: broadcast ---
|
# --- Rule 1: broadcast ---
|
||||||
return self._broadcast(text, channel)
|
return self._broadcast(text, channel, meshcore_channel=meshcore_channel)
|
||||||
|
|
||||||
if transport is not None:
|
if transport is not None:
|
||||||
# --- Rule 2: hinted DM ---
|
# --- Rule 2: hinted DM ---
|
||||||
|
|
@ -229,8 +233,17 @@ class CompositeTransport(MeshTransport):
|
||||||
# --- Rule 3: unhinted DM ---
|
# --- Rule 3: unhinted DM ---
|
||||||
return self._send_unhinted(text, destination, channel)
|
return self._send_unhinted(text, destination, channel)
|
||||||
|
|
||||||
def _broadcast(self, text: str, channel: int) -> bool:
|
def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[int] = None) -> bool:
|
||||||
"""Fan text out to all connected children; return True if any succeed."""
|
"""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
|
any_ok = False
|
||||||
for child in self._children:
|
for child in self._children:
|
||||||
name = _child_name(child)
|
name = _child_name(child)
|
||||||
|
|
@ -239,8 +252,17 @@ class CompositeTransport(MeshTransport):
|
||||||
"CompositeTransport: skipping broadcast to %r (not connected)", name
|
"CompositeTransport: skipping broadcast to %r (not connected)", name
|
||||||
)
|
)
|
||||||
continue
|
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:
|
try:
|
||||||
ok = child.send_message(text, destination=None, channel=channel)
|
ok = child.send_message(text, destination=None, channel=child_channel)
|
||||||
if ok:
|
if ok:
|
||||||
any_ok = True
|
any_ok = True
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,7 @@ class MeshCoreTransport(MeshTransport):
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
|
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
|
||||||
|
meshcore_channel: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a message via MeshCore.
|
"""Send a message via MeshCore.
|
||||||
|
|
||||||
|
|
@ -201,11 +202,17 @@ class MeshCoreTransport(MeshTransport):
|
||||||
text: Message text (caller is responsible for length limits; see
|
text: Message text (caller is responsible for length limits; see
|
||||||
``mesh_max_chars`` config field).
|
``mesh_max_chars`` config field).
|
||||||
destination: hex pubkey string for a DM, or None for channel send.
|
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.
|
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:
|
Returns:
|
||||||
True if the send succeeded (not an error event).
|
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:
|
if self._mc is None:
|
||||||
logger.error("MeshCoreTransport: cannot send, not connected")
|
logger.error("MeshCoreTransport: cannot send, not connected")
|
||||||
|
|
@ -213,20 +220,27 @@ class MeshCoreTransport(MeshTransport):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if destination:
|
if destination:
|
||||||
|
# DM: meshcore_channel is irrelevant; route by pubkey.
|
||||||
result = self._run_coro(
|
result = self._run_coro(
|
||||||
self._mc.commands.send_msg(destination, text)
|
self._mc.commands.send_msg(destination, text)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Channel broadcast.
|
||||||
# Channel-index semantics do NOT cross transports: the passed
|
# Channel-index semantics do NOT cross transports: the passed
|
||||||
# `channel` carries Meshtastic channel-index semantics (e.g.
|
# `channel` carries Meshtastic channel-index semantics (e.g.
|
||||||
# index 8) that have no relationship to MeshCore's separate
|
# index 8) that have no relationship to MeshCore's separate
|
||||||
# channel table. The configured MeshCore channel is therefore
|
# channel table.
|
||||||
# authoritative for broadcasts, so we ignore `channel` here
|
#
|
||||||
# (this also avoids an explicit channel=0 being treated as
|
# Per-family routing: use meshcore_channel when provided.
|
||||||
# falsy).
|
# If meshcore_channel is None, this family is not configured
|
||||||
chan_idx = getattr(self.config, "meshcore_channel_index", 0)
|
# 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(
|
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()
|
success = not result.is_error()
|
||||||
if not success:
|
if not success:
|
||||||
|
|
|
||||||
|
|
@ -217,31 +217,65 @@ class TestLifecycle:
|
||||||
|
|
||||||
class TestBroadcast:
|
class TestBroadcast:
|
||||||
def test_broadcast_sends_to_all(self) -> None:
|
def test_broadcast_sends_to_all(self) -> None:
|
||||||
|
"""With meshcore_channel set, broadcast fans to both children."""
|
||||||
mt = FakeChild("meshtastic")
|
mt = FakeChild("meshtastic")
|
||||||
mc = FakeChild("meshcore")
|
mc = FakeChild("meshcore")
|
||||||
comp = CompositeTransport([mt, mc])
|
comp = CompositeTransport([mt, mc])
|
||||||
result = comp.send_message("hello mesh")
|
result = comp.send_message("hello mesh", meshcore_channel=0)
|
||||||
assert result is True
|
assert result is True
|
||||||
assert len(mt.send_calls) == 1
|
assert len(mt.send_calls) == 1
|
||||||
assert len(mc.send_calls) == 1
|
assert len(mc.send_calls) == 1
|
||||||
assert mt.send_calls[0]["destination"] is None
|
assert mt.send_calls[0]["destination"] is None
|
||||||
assert mc.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:
|
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)
|
mt = FakeChild("meshtastic", connected_val=False)
|
||||||
mc = FakeChild("meshcore")
|
mc = FakeChild("meshcore")
|
||||||
comp = CompositeTransport([mt, mc])
|
comp = CompositeTransport([mt, mc])
|
||||||
result = comp.send_message("hi")
|
result = comp.send_message("hi", meshcore_channel=0)
|
||||||
assert result is True
|
assert result is True
|
||||||
assert len(mt.send_calls) == 0
|
assert len(mt.send_calls) == 0
|
||||||
assert len(mc.send_calls) == 1
|
assert len(mc.send_calls) == 1
|
||||||
|
|
||||||
def test_broadcast_true_if_at_least_one_ok(self) -> None:
|
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 = FakeChild("meshtastic")
|
||||||
mt.send_message = MagicMock(return_value=False)
|
mt.send_message = MagicMock(return_value=False)
|
||||||
mc = FakeChild("meshcore")
|
mc = FakeChild("meshcore")
|
||||||
comp = CompositeTransport([mt, mc])
|
comp = CompositeTransport([mt, mc])
|
||||||
result = comp.send_message("test")
|
result = comp.send_message("test", meshcore_channel=0)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,35 +176,59 @@ class TestBuildTransport:
|
||||||
|
|
||||||
class TestSendMessageChannel:
|
class TestSendMessageChannel:
|
||||||
def test_returns_true_on_non_error_event(self):
|
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()
|
t, mc, _ = _transport_with_mock_mc()
|
||||||
try:
|
try:
|
||||||
ok = MagicMock()
|
ok = MagicMock()
|
||||||
ok.is_error.return_value = False
|
ok.is_error.return_value = False
|
||||||
mc.commands.send_chan_msg = AsyncMock(return_value=ok)
|
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()
|
mc.commands.send_chan_msg.assert_awaited_once()
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
def test_returns_false_on_error_event(self):
|
def test_returns_false_on_error_event(self):
|
||||||
|
"""With meshcore_channel set, an error result returns False."""
|
||||||
t, mc, _ = _transport_with_mock_mc()
|
t, mc, _ = _transport_with_mock_mc()
|
||||||
try:
|
try:
|
||||||
err = MagicMock()
|
err = MagicMock()
|
||||||
err.is_error.return_value = True
|
err.is_error.return_value = True
|
||||||
mc.commands.send_chan_msg = AsyncMock(return_value=err)
|
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:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
def test_returns_false_when_not_connected(self):
|
def test_returns_false_when_not_connected(self):
|
||||||
cfg = _mc_config()
|
cfg = _mc_config()
|
||||||
t = MeshCoreTransport(cfg)
|
t = MeshCoreTransport(cfg)
|
||||||
# _mc is None, no loop started
|
# _mc is None, no loop started — fails before channel check.
|
||||||
assert t.send_message("test") is False
|
assert t.send_message("test", meshcore_channel=0) is False
|
||||||
|
|
||||||
def _transport_with_configured_index(self, index):
|
def test_meshcore_channel_none_skips_broadcast(self):
|
||||||
"""Build a MeshCoreTransport whose config sets meshcore_channel_index."""
|
"""meshcore_channel=None → silent no-op (True) without calling send_chan_msg."""
|
||||||
cfg = _mc_config(meshcore_channel_index=index)
|
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)
|
t = MeshCoreTransport(cfg)
|
||||||
ok = MagicMock()
|
ok = MagicMock()
|
||||||
ok.is_error.return_value = False
|
ok.is_error.return_value = False
|
||||||
|
|
@ -220,31 +244,30 @@ class TestSendMessageChannel:
|
||||||
t._loop_thread = thread
|
t._loop_thread = thread
|
||||||
return t, mc
|
return t, mc
|
||||||
|
|
||||||
def test_uses_config_channel_index_when_channel_zero(self):
|
def test_uses_meshcore_channel_for_broadcast(self):
|
||||||
t, mc = self._transport_with_configured_index(3)
|
"""meshcore_channel=3 → send_chan_msg(3, text)."""
|
||||||
|
t, mc = self._transport_with_mock_send_chan_msg()
|
||||||
try:
|
try:
|
||||||
t.send_message("hi", channel=0)
|
t.send_message("hi", meshcore_channel=3)
|
||||||
# channel=0 must not be treated as falsy-fallthrough: broadcasts
|
|
||||||
# always use the configured meshcore_channel_index=3.
|
|
||||||
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
|
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
def test_uses_config_channel_index_when_channel_default(self):
|
def test_ignores_meshtastic_channel_uses_meshcore_channel(self):
|
||||||
t, mc = self._transport_with_configured_index(3)
|
"""channel=8 (Meshtastic) is irrelevant; meshcore_channel=3 is authoritative."""
|
||||||
|
t, mc = self._transport_with_mock_send_chan_msg()
|
||||||
try:
|
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")
|
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
def test_ignores_meshtastic_channel_index(self):
|
def test_meshcore_channel_zero_is_valid(self):
|
||||||
# channel=8 carries Meshtastic channel-index semantics that do NOT map
|
"""meshcore_channel=0 is a valid channel (not falsy-skipped)."""
|
||||||
# to MeshCore's channel table; the configured index (3) is authoritative.
|
t, mc = self._transport_with_mock_send_chan_msg()
|
||||||
t, mc = self._transport_with_configured_index(3)
|
|
||||||
try:
|
try:
|
||||||
t.send_message("hi", channel=8)
|
t.send_message("hi", meshcore_channel=0)
|
||||||
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
|
mc.commands.send_chan_msg.assert_awaited_once_with(0, "hi")
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue