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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue