mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
b6f9e0ea6c
commit
58baf94181
2 changed files with 117 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue