feat(transport): CompositeTransport for dual Meshtastic+MeshCore (Phase 4) (#6)

* feat(transport): CompositeTransport for dual Meshtastic+MeshCore (Phase 4)

Adds CompositeTransport (transport: both) that fans broadcasts to both
meshes, sizes to min(children) for uniform messages, and routes DM
replies back over the originating mesh via a transport hint threaded from
the inbound MeshMessage. Per-child self-filtering; supervisor watchdog now
resolves the Meshtastic child inside the composite. Additive/optional
throughout; single-transport behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sizing): fixed universal mesh budget (mesh_max_chars=140)

Replace per-transport / min-of-active max_chars with a single fixed
universal constant (mesh_max_chars, default 140 = MeshCore LCD). Every
message is built once against one deterministic budget regardless of
which radios are connected; no runtime variance, no per-transport
retooling. Meshtastic sizing intentionally moves 200 -> 140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

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:
malice 2026-07-02 12:33:17 -06:00 committed by GitHub
commit f3df7a0a6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1140 additions and 76 deletions

View file

@ -413,7 +413,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
# digest_max_chars: mesh wire cap. The LLM is told to fit under this.
# Reuses the response.max_length chunking if the LLM ignores the cap.
("fires", "digest_max_chars"): {
"default": 200,
"default": 140,
"type": "int",
"description": "Hard cap on the digest wire string length (chars). The LLM prompt asks to fit; the chunker enforces.",
},

View file

@ -36,12 +36,12 @@ class ConnectionConfig:
reconnect_health_interval: float = 30.0
# --- transport selection (Phase 1 seam; MeshCore support is Phase 2) ---
transport: str = "meshtastic" # "meshtastic" | "meshcore" | "both"
meshtastic_max_chars: int = 200 # default Meshtastic packet budget
# Universal mesh message budget (MeshCore LCD → 140 for all transports).
mesh_max_chars: int = 140
# --- MeshCore transport settings (used when transport="meshcore") ---
meshcore_host: str = "100.64.0.9" # pyMC companion frame server host
meshcore_port: int = 5050 # pyMC companion frame server port
meshcore_channel_index: int = 0 # default channel index for broadcasts
meshcore_max_chars: int = 140 # placeholder max chars (Phase 3 chunker)
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)

View file

@ -45,6 +45,9 @@ class MeshMessage:
class MeshtasticTransport(MeshTransport):
"""Manages connection to a Meshtastic node (Meshtastic transport backend)."""
# Name tag used by CompositeTransport for routing hints.
transport_name: str = "meshtastic"
def __init__(self, config: ConnectionConfig):
self.config = config
self._interface: Optional[meshtastic.MeshInterface] = None
@ -74,7 +77,7 @@ class MeshtasticTransport(MeshTransport):
@property
def max_chars(self) -> int:
return getattr(self.config, "meshtastic_max_chars", 200)
return self.config.mesh_max_chars
def connect(self) -> None:
"""Establish connection to Meshtastic node."""
@ -351,6 +354,7 @@ class MeshtasticTransport(MeshTransport):
text: str,
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
) -> bool:
"""Send a text message.
@ -358,6 +362,7 @@ class MeshtasticTransport(MeshTransport):
text: Message text to send
destination: Node ID for DM, or None for broadcast
channel: Channel index to send on
transport: Optional routing hint (for CompositeTransport); ignored here.
Returns:
True if send was initiated successfully

View file

@ -85,14 +85,24 @@ class MeshAI:
# state + the only reconnect driver. Reconnects IN-PLACE so the container
# never needs to restart.
# Guard: watchdog is Meshtastic-specific — MeshCoreTransport manages its
# own reconnect via the meshcore lib's auto_reconnect parameter. ---
if (
getattr(self.config.connection, "reconnect", True)
and isinstance(self.connector, MeshConnector)
):
self.connector._wake = asyncio.Event()
self.connector.write_link_status("up") # we just connected ok
self._supervisor_task = asyncio.create_task(self._connection_supervisor())
# own reconnect via the meshcore lib's auto_reconnect parameter.
# When connector is a CompositeTransport, resolve the Meshtastic child
# (if any) and run the watchdog on it; skip if absent (pure MeshCore). ---
_mt_child = (
self.connector
if isinstance(self.connector, MeshConnector)
else (
self.connector.meshtastic_child()
if hasattr(self.connector, "meshtastic_child")
else None
)
)
if getattr(self.config.connection, "reconnect", True) and _mt_child is not None:
_mt_child._wake = asyncio.Event()
_mt_child.write_link_status("up") # we just connected ok
self._supervisor_task = asyncio.create_task(
self._connection_supervisor(_mt_child)
)
logger.info("Connection supervisor (watchdog) started")
self._last_cleanup = time.time()
self._last_health_compute = 0.0
@ -226,11 +236,17 @@ class MeshAI:
self.context.prune()
self._last_cleanup = time.time()
async def _connection_supervisor(self) -> None:
async def _connection_supervisor(self, c=None) -> None:
"""Watchdog: SINGLE source of truth for /tmp/meshai.link and the ONLY
reconnect driver. Woken by connector._wake (connection.lost) or a
health-interval timeout. Probe is socket-based (see connector.active_probe).
*c* is the resolved MeshtasticTransport to watch either self.connector
directly (single-transport) or the Meshtastic child extracted from a
CompositeTransport. The watchdog logic itself is unchanged; only how
we resolve *c* has moved to the caller (start()).
"""
if c is None:
c = self.connector
hi = getattr(self.config.connection, "reconnect_health_interval", 30.0)
alive_idle = 2.0 * hi
@ -647,6 +663,12 @@ class MeshAI:
)
# Route the message
# Capture the originating transport tag for reply routing.
# This lets CompositeTransport route the DM reply back over the
# same mesh the inbound message arrived on. Single-transport
# connectors accept and ignore this kwarg.
originating_transport: Optional[str] = getattr(message, "transport", None)
# Check for continuation request first
continuation_messages = self.router.check_continuation(message)
if continuation_messages:
@ -654,6 +676,7 @@ class MeshAI:
continuation_messages,
destination=message.sender_id,
channel=message.channel,
transport=originating_transport,
)
return
@ -685,11 +708,13 @@ class MeshAI:
if not messages:
return
# Send DM response
# Send DM response — thread the originating transport hint so
# CompositeTransport routes the reply back over the correct mesh.
await self.responder.send_response(
messages,
destination=message.sender_id,
channel=message.channel,
transport=originating_transport,
)
except Exception as e:

View file

@ -74,7 +74,7 @@ def build_pipeline(config, llm_backend, connector=None) -> EventBus:
accumulator = DigestAccumulator(
llm_backend=llm_backend,
include_toggles=include_toggles,
mesh_char_limit=connector.max_chars if connector is not None else 200,
mesh_char_limit=connector.max_chars if connector is not None else 140,
)
# Tee closure: events go to BOTH dispatcher and accumulator

View file

@ -112,7 +112,8 @@ async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]:
else:
fire_lines.append(name)
# Assemble within ~200-byte LoRa budget; trim fire lines, never the tail
# Assemble within the universal mesh budget; trim fire lines, never the tail
budget = int(adapter_config.fires.digest_max_chars)
shown: list[str] = []
for line in fire_lines:
# Estimate tail for budget check
@ -127,7 +128,7 @@ async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]:
if est_tail:
parts.append(est_tail)
candidate = "\n".join(parts)
if len(candidate.encode("utf-8")) <= 200:
if len(candidate.encode("utf-8")) <= budget:
shown.append(line)
else:
break

View file

@ -23,8 +23,21 @@ class Responder:
messages: list[str] | str,
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None,
) -> bool:
"""Send response messages with randomized delay pacing."""
"""Send response messages with randomized delay pacing.
Args:
messages: One or more message strings to send.
destination: Node ID for a DM, or None for broadcast.
channel: Channel index to send on.
transport: Optional routing hint threaded from the originating
MeshMessage. Passed through to connector.send_message
so CompositeTransport can route DM replies back over
the mesh they arrived on. Single-transport connectors
accept and ignore it; defaults to None so all existing
call sites are unaffected.
"""
if isinstance(messages, str):
messages = [messages]
@ -42,6 +55,7 @@ class Responder:
text=msg,
destination=destination,
channel=channel,
transport=transport,
)
if not sent:
logger.error(f"Failed to send message {i+1}/{len(messages)}")

View file

@ -36,6 +36,7 @@ class MeshTransport(abc.ABC):
text: str,
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None,
) -> bool:
"""Send a text message.
@ -43,6 +44,10 @@ class MeshTransport(abc.ABC):
text: Message text to send.
destination: Node ID for a DM, or None for broadcast.
channel: Channel index to send on.
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.
Returns:
True if send was initiated successfully.

View file

@ -0,0 +1,386 @@
"""CompositeTransport — fan-out driver for dual Meshtastic + MeshCore meshes.
Holds an ordered list of child transports and:
- fans broadcasts to ALL connected children;
- routes hinted DM replies back over the originating child;
- falls back to best-effort per-child resolution for unhinted DMs;
- self-filters inbound messages per child (drops own echoes);
- exposes ``meshtastic_child()`` for the supervisor watchdog.
This transport is DORMANT unless ``transport: both`` in config. Single-
transport paths (``transport: meshtastic`` / ``transport: meshcore``) are
byte-identical to Phase 3 behaviour because the factory never instantiates
this class for them.
"""
import asyncio
import logging
from typing import Callable, List, Optional
from .base import MeshTransport
logger = logging.getLogger(__name__)
def _child_name(child: MeshTransport) -> str:
"""Return the transport-name tag for a child, deriving it from the class
when no explicit ``transport_name`` attribute is present.
Priority:
1. ``child.transport_name`` (set on MeshtasticTransport / MeshCoreTransport)
2. Class name lowercased, with "transport" suffix stripped.
"""
name = getattr(child, "transport_name", None)
if name:
return name
cls = type(child).__name__.lower()
if cls.endswith("transport"):
cls = cls[: -len("transport")]
return cls or "unknown"
class CompositeTransport(MeshTransport):
"""MeshTransport that drives multiple child transports simultaneously.
Instantiate with a list of concrete MeshTransport children, e.g.::
CompositeTransport([MeshtasticTransport(cfg), MeshCoreTransport(cfg)])
The children are contacted in list order; the *first* child is the
canonical source for ``my_node_id`` (coarse fallback real self-
filtering is per-child in the inbound wrapper).
"""
def __init__(self, children: List[MeshTransport], config=None) -> None:
if not children:
raise ValueError("CompositeTransport requires at least one child")
self._children = list(children)
self._config = config # ConnectionConfig; used for the universal mesh_max_chars budget
# Build a stable name → child mapping for O(1) routing-hint lookup.
self._by_name: dict[str, MeshTransport] = {
_child_name(c): c for c in self._children
}
# ------------------------------------------------------------------
# Public accessors
# ------------------------------------------------------------------
@property
def children(self) -> List[MeshTransport]:
"""Ordered list of child transports."""
return list(self._children)
def meshtastic_child(self) -> Optional[MeshTransport]:
"""Return the MeshtasticTransport child, or None if absent.
Used by the supervisor watchdog so it can apply Meshtastic-specific
liveness probes (socket_state, active_probe, reconnect) without
knowing whether it is talking to a bare MeshtasticTransport or a
CompositeTransport.
"""
from meshai.connector import MeshtasticTransport # local import avoids cycle
for c in self._children:
if isinstance(c, MeshtasticTransport):
return c
return None
# ------------------------------------------------------------------
# Routing decision helpers (factored out for unit-test access)
# ------------------------------------------------------------------
def _resolve_child_for_hint(self, transport_hint: str) -> Optional[MeshTransport]:
"""Return the child whose name matches *transport_hint*, or None."""
return self._by_name.get(transport_hint)
def _best_child_for_destination(self, destination: str) -> Optional[MeshTransport]:
"""Return the child most likely to know *destination*, or None.
A child "resolves" the destination when ``get_node_name()`` returns
something other than the raw *destination* string itself (i.e. the
child has the node in its name cache). The first such child wins.
If no child resolves the destination, None is returned and the
caller falls back to broadcasting to all children.
"""
for child in self._children:
if not child.connected:
continue
try:
resolved = child.get_node_name(destination)
if resolved and resolved != destination:
return child
except Exception:
pass
return None
def _should_drop(self, msg, child_node_id: Optional[str]) -> bool:
"""Return True when *msg* should be self-filtered (echo from self).
Factored out so unit tests can call it directly.
"""
if child_node_id is None:
return False
return msg.sender_id == child_node_id
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def connect(self) -> None:
"""Connect all children.
A child failing to connect is logged but does NOT prevent the
remaining children from connecting. ``connected`` returns True if
at least one child is connected after the loop.
"""
for child in self._children:
name = _child_name(child)
try:
child.connect()
logger.info("CompositeTransport: child %r connected", name)
except Exception as exc:
logger.error(
"CompositeTransport: child %r failed to connect: %s", name, exc
)
def disconnect(self) -> None:
"""Disconnect all children, guarding each with try/except."""
for child in self._children:
name = _child_name(child)
try:
child.disconnect()
logger.info("CompositeTransport: child %r disconnected", name)
except Exception as exc:
logger.warning(
"CompositeTransport: child %r disconnect error: %s", name, exc
)
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def connected(self) -> bool:
"""True if at least one child is connected."""
return any(c.connected for c in self._children)
@property
def my_node_id(self) -> Optional[str]:
"""Return the first child's node ID (coarse fallback).
Real self-filtering is per-child (each child's own my_node_id is
used in the inbound wrapper registered by set_message_callback).
"""
return self._children[0].my_node_id if self._children else None
@property
def max_chars(self) -> int:
"""Return the universal mesh message budget from config.
Sourced directly from ``config.mesh_max_chars`` (default 140) the
single fixed constant that governs all transports regardless of which
radios are connected. MeshCore is the LCD, so 140 is the right value
for every path.
"""
if self._config is not None:
return self._config.mesh_max_chars
# Fallback for tests that build CompositeTransport without a config.
return 140
# ------------------------------------------------------------------
# Message I/O
# ------------------------------------------------------------------
def send_message(
self,
text: str,
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = 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.
2. **DM with routing hint** (``destination`` set AND ``transport`` given):
Send ONLY via the child whose name == ``transport``. This is the
reply-routing path: the originating transport tag is threaded from
the inbound MeshMessage all the way to here so the reply goes back
over the same mesh it arrived on.
3. **DM without hint** (``destination`` set, ``transport`` is None):
Best-effort: prefer the child that can resolve *destination* via
``get_node_name`` (i.e. has the node in its cache). If no child
resolves, send via ALL connected children and log a warning.
This fallback handles subscription DMs and other direct sends that
don't carry an originating transport tag.
"""
if destination is None:
# --- Rule 1: broadcast ---
return self._broadcast(text, channel)
if transport is not None:
# --- Rule 2: hinted DM ---
return self._send_hinted(text, destination, channel, transport)
# --- 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."""
any_ok = False
for child in self._children:
name = _child_name(child)
if not child.connected:
logger.debug(
"CompositeTransport: skipping broadcast to %r (not connected)", name
)
continue
try:
ok = child.send_message(text, destination=None, channel=channel)
if ok:
any_ok = True
else:
logger.warning(
"CompositeTransport: broadcast via %r returned False", name
)
except Exception as exc:
logger.error(
"CompositeTransport: broadcast via %r raised: %s", name, exc
)
return any_ok
def _send_hinted(
self, text: str, destination: str, channel: int, transport_hint: str
) -> bool:
"""Send DM only via the child matching *transport_hint*."""
child = self._resolve_child_for_hint(transport_hint)
if child is None:
logger.error(
"CompositeTransport: no child with name %r; known: %s",
transport_hint,
list(self._by_name),
)
return False
name = _child_name(child)
if not child.connected:
logger.warning(
"CompositeTransport: hinted child %r not connected", name
)
return False
try:
return child.send_message(text, destination=destination, channel=channel)
except Exception as exc:
logger.error(
"CompositeTransport: hinted send via %r raised: %s", name, exc
)
return False
def _send_unhinted(self, text: str, destination: str, channel: int) -> bool:
"""Best-effort DM: prefer the resolving child; else fan to all.
This fallback handles subscription DMs, alert DMs, and other direct
sends that don't carry an originating transport tag. When multiple
children know the destination, the first one in list order wins.
If none resolve the destination, all connected children receive the
DM so at least one can deliver it callers see a warning so the
behaviour is visible in logs.
"""
child = self._best_child_for_destination(destination)
if child is not None:
name = _child_name(child)
try:
return child.send_message(text, destination=destination, channel=channel)
except Exception as exc:
logger.error(
"CompositeTransport: unhinted send via %r raised: %s", name, exc
)
return False
# No child resolved the destination — send via all and warn.
logger.warning(
"CompositeTransport: destination %r unresolved; fanning DM to all children",
destination,
)
any_ok = False
for child in self._children:
name = _child_name(child)
if not child.connected:
continue
try:
ok = child.send_message(text, destination=destination, channel=channel)
if ok:
any_ok = True
except Exception as exc:
logger.error(
"CompositeTransport: DM fan via %r raised: %s", name, exc
)
return any_ok
def set_message_callback(
self,
callback: Callable,
loop: asyncio.AbstractEventLoop,
) -> None:
"""Register per-child inbound wrappers.
Each child gets its OWN wrapper that:
(a) self-filters drops the message if sender_id == that child's
my_node_id (each child knows its own ID);
(b) ensures msg.transport is set to the child's name if missing;
(c) forwards to the meshai callback.
Each child already marshals its callback onto *loop* via
``loop.call_soon_threadsafe``; we preserve that the wrapper is
just a thin decorator around the meshai callback.
"""
for child in self._children:
child_name = _child_name(child)
# Capture child_name and child identity in the closure.
def _make_wrapper(name: str, c: MeshTransport) -> Callable:
async def _wrapper(msg) -> None:
# (a) self-filter
if self._should_drop(msg, c.my_node_id):
logger.debug(
"CompositeTransport: dropping echo from %r (self)", name
)
return
# (b) ensure transport tag
if not msg.transport:
msg.transport = name
# (c) forward
await callback(msg)
return _wrapper
child.set_message_callback(_make_wrapper(child_name, child), loop)
# ------------------------------------------------------------------
# Node identity / topology
# ------------------------------------------------------------------
def get_node_name(self, node_id: str) -> str:
"""Try each child in order; return the first non-identity result."""
for child in self._children:
try:
name = child.get_node_name(node_id)
if name and name != node_id:
return name
except Exception:
pass
return node_id
def get_node_position(self, node_id: str) -> Optional[tuple]:
"""Try each child in order; return the first non-None position."""
for child in self._children:
try:
pos = child.get_node_position(node_id)
if pos is not None:
return pos
except Exception:
pass
return None

View file

@ -15,8 +15,6 @@ def build_transport(config) -> MeshTransport:
A concrete MeshTransport instance ready to be connected.
Raises:
NotImplementedError: When the requested transport is known but not
yet implemented (e.g. ``"meshcore"`` or ``"both"``).
ValueError: When the transport name is unrecognised.
"""
transport_name = getattr(config, "transport", "meshtastic")
@ -33,9 +31,15 @@ def build_transport(config) -> MeshTransport:
return MeshCoreTransport(config)
if transport_name == "both":
raise NotImplementedError(
"Transport 'both' is not yet implemented (Phase 4 seam)."
)
# Phase 4: composite transport — drives Meshtastic and MeshCore
# simultaneously with correct reply routing.
from meshai.connector import MeshtasticTransport
from meshai.transport.meshcore_transport import MeshCoreTransport
from meshai.transport.composite_transport import CompositeTransport
return CompositeTransport([
MeshtasticTransport(config),
MeshCoreTransport(config),
], config=config)
raise ValueError(
f"Unknown transport {transport_name!r}. "

View file

@ -38,6 +38,9 @@ class MeshCoreTransport(MeshTransport):
class, so there is zero cost to existing meshtastic deployments.
"""
# Name tag used by CompositeTransport for routing hints.
transport_name: str = "meshcore"
def __init__(self, config) -> None:
self.config = config
self._mc = None # meshcore.MeshCore instance
@ -190,14 +193,16 @@ class MeshCoreTransport(MeshTransport):
text: str,
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
) -> bool:
"""Send a message via MeshCore.
Args:
text: Message text (caller is responsible for length limits; see
``meshcore_max_chars`` config field for the future chunker).
``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).
transport: Optional routing hint (for CompositeTransport); ignored here.
Returns:
True if the send succeeded (not an error event).
@ -364,7 +369,7 @@ class MeshCoreTransport(MeshTransport):
@property
def max_chars(self) -> int:
return getattr(self.config, "meshcore_max_chars", 140)
return self.config.mesh_max_chars
def get_node_name(self, node_id: str) -> str:
"""Resolve a pubkey prefix to a contact display name, or return node_id."""

View file

@ -0,0 +1,568 @@
"""Hermetic tests for CompositeTransport (Phase 4).
All tests use fake child transports no real sockets, threads, or meshcore
lib required. The helpers below replicate the MeshMessage dataclass so the
tests run without importing the full connector chain.
"""
import asyncio
import types
from dataclasses import dataclass, field
from typing import Optional
from unittest.mock import MagicMock, patch
import pytest
from meshai.transport.composite_transport import CompositeTransport, _child_name
from meshai.connector import MeshMessage
# ---------------------------------------------------------------------------
# Fake child transport helpers
# ---------------------------------------------------------------------------
class FakeChild:
"""Minimal MeshTransport stand-in for unit tests.
Tracks calls and exposes enough surface for CompositeTransport to work.
"""
def __init__(
self,
name: str,
node_id: str = "!aabbccdd",
max_chars_val: int = 200,
connected_val: bool = True,
known_nodes: Optional[dict] = None,
) -> None:
self.transport_name = name
self._connected = connected_val
self._node_id = node_id
self._max_chars = max_chars_val
self._known_nodes: dict[str, str] = known_nodes or {}
# Call-tracking
self.connect_calls = 0
self.disconnect_calls = 0
self.send_calls: list[dict] = []
self._callback = None
self._callback_loop = None
# --- MeshTransport interface ---
def connect(self) -> None:
self.connect_calls += 1
self._connected = True
def disconnect(self) -> None:
self.disconnect_calls += 1
self._connected = False
@property
def connected(self) -> bool:
return self._connected
@property
def my_node_id(self) -> Optional[str]:
return self._node_id
@property
def max_chars(self) -> int:
return self._max_chars
def send_message(
self,
text: str,
destination: Optional[str] = None,
channel: int = 0,
transport: Optional[str] = None,
) -> bool:
self.send_calls.append(
{"text": text, "destination": destination, "channel": channel, "transport": transport}
)
return True
def set_message_callback(self, callback, loop) -> None:
self._callback = callback
self._callback_loop = loop
def get_node_name(self, node_id: str) -> str:
return self._known_nodes.get(node_id, node_id)
def get_node_position(self, node_id: str) -> Optional[tuple]:
return None
# --- Test helper: simulate inbound message ---
async def _simulate_inbound(self, msg: MeshMessage) -> None:
"""Call the registered callback directly (simulates an inbound packet)."""
if self._callback:
await self._callback(msg)
# ---------------------------------------------------------------------------
# Factory-level test
# ---------------------------------------------------------------------------
class TestFactory:
def test_both_returns_composite(self) -> None:
"""factory transport='both' → CompositeTransport with 2 children."""
from meshai.transport.factory import build_transport
cfg = types.SimpleNamespace(
transport="both",
type="tcp",
tcp_host="127.0.0.1",
tcp_port=4403,
meshcore_host="127.0.0.1",
meshcore_port=5050,
)
t = build_transport(cfg)
assert isinstance(t, CompositeTransport)
assert len(t.children) == 2
# Child order: Meshtastic first, MeshCore second.
from meshai.connector import MeshtasticTransport
from meshai.transport.meshcore_transport import MeshCoreTransport
assert isinstance(t.children[0], MeshtasticTransport)
assert isinstance(t.children[1], MeshCoreTransport)
# ---------------------------------------------------------------------------
# max_chars
# ---------------------------------------------------------------------------
class TestMaxChars:
def test_fixed_universal_budget_no_config(self) -> None:
"""Without a config, CompositeTransport falls back to the 140 constant."""
mt = FakeChild("meshtastic", max_chars_val=200)
mc = FakeChild("meshcore", max_chars_val=140)
comp = CompositeTransport([mt, mc])
assert comp.max_chars == 140
def test_fixed_universal_budget_from_config(self) -> None:
"""With a config, CompositeTransport reads mesh_max_chars directly."""
from meshai.config import ConnectionConfig
cfg = ConnectionConfig(transport="both", mesh_max_chars=140)
mt = FakeChild("meshtastic", max_chars_val=200)
mc = FakeChild("meshcore", max_chars_val=140)
comp = CompositeTransport([mt, mc], config=cfg)
assert comp.max_chars == 140
def test_fixed_universal_budget_ignores_child_values(self) -> None:
"""CompositeTransport must NOT take min(children); it sources config."""
from meshai.config import ConnectionConfig
cfg = ConnectionConfig(transport="both", mesh_max_chars=140)
# Even if a child would report 230, the composite must return 140.
child = FakeChild("meshtastic", max_chars_val=230)
comp = CompositeTransport([child], config=cfg)
assert comp.max_chars == 140
# ---------------------------------------------------------------------------
# connect / disconnect
# ---------------------------------------------------------------------------
class TestLifecycle:
def test_connect_fans_to_all(self) -> None:
a = FakeChild("meshtastic", connected_val=False)
b = FakeChild("meshcore", connected_val=False)
comp = CompositeTransport([a, b])
comp.connect()
assert a.connect_calls == 1
assert b.connect_calls == 1
def test_connect_continues_after_failure(self) -> None:
"""One child failing to connect must not stop the other."""
bad = FakeChild("meshtastic", connected_val=False)
bad.connect = MagicMock(side_effect=RuntimeError("boom"))
good = FakeChild("meshcore", connected_val=False)
comp = CompositeTransport([bad, good])
comp.connect() # must not raise
assert good.connect_calls == 1
assert comp.connected # good child is up
def test_connected_true_if_any(self) -> None:
down = FakeChild("meshtastic", connected_val=False)
up = FakeChild("meshcore", connected_val=True)
comp = CompositeTransport([down, up])
assert comp.connected is True
def test_connected_false_if_none(self) -> None:
a = FakeChild("meshtastic", connected_val=False)
b = FakeChild("meshcore", connected_val=False)
comp = CompositeTransport([a, b])
assert comp.connected is False
def test_disconnect_fans_to_all(self) -> None:
a = FakeChild("meshtastic")
b = FakeChild("meshcore")
comp = CompositeTransport([a, b])
comp.disconnect()
assert a.disconnect_calls == 1
assert b.disconnect_calls == 1
def test_disconnect_guarded(self) -> None:
"""disconnect() must not raise even if a child raises."""
bad = FakeChild("meshtastic")
bad.disconnect = MagicMock(side_effect=RuntimeError("oops"))
good = FakeChild("meshcore")
comp = CompositeTransport([bad, good])
comp.disconnect() # must not raise
assert good.disconnect_calls == 1
# ---------------------------------------------------------------------------
# send_message — broadcast
# ---------------------------------------------------------------------------
class TestBroadcast:
def test_broadcast_sends_to_all(self) -> None:
mt = FakeChild("meshtastic")
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("hello mesh")
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_skips_disconnected_child(self) -> None:
mt = FakeChild("meshtastic", connected_val=False)
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("hi")
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:
mt = FakeChild("meshtastic")
mt.send_message = MagicMock(return_value=False)
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("test")
assert result is True
# ---------------------------------------------------------------------------
# send_message — hinted DM
# ---------------------------------------------------------------------------
class TestHintedDM:
def test_hinted_meshcore_only(self) -> None:
mt = FakeChild("meshtastic")
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("reply", destination="abc123", transport="meshcore")
assert result is True
assert len(mc.send_calls) == 1
assert len(mt.send_calls) == 0
assert mc.send_calls[0]["destination"] == "abc123"
def test_hinted_meshtastic_only(self) -> None:
mt = FakeChild("meshtastic")
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("reply", destination="!deadbeef", transport="meshtastic")
assert result is True
assert len(mt.send_calls) == 1
assert len(mc.send_calls) == 0
def test_unknown_hint_returns_false(self) -> None:
mt = FakeChild("meshtastic")
comp = CompositeTransport([mt])
result = comp.send_message("x", destination="y", transport="nonexistent")
assert result is False
assert len(mt.send_calls) == 0
def test_hinted_child_disconnected_returns_false(self) -> None:
mt = FakeChild("meshtastic", connected_val=False)
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("x", destination="y", transport="meshtastic")
assert result is False
# ---------------------------------------------------------------------------
# send_message — unhinted DM
# ---------------------------------------------------------------------------
class TestUnhintedDM:
def test_prefers_resolving_child(self) -> None:
mt = FakeChild("meshtastic", known_nodes={"!aabbccdd": "NodeA"})
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("hello", destination="!aabbccdd")
assert result is True
assert len(mt.send_calls) == 1
assert len(mc.send_calls) == 0
def test_fans_to_all_when_none_resolves(self) -> None:
mt = FakeChild("meshtastic")
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
result = comp.send_message("hello", destination="!unknown")
assert result is True
assert len(mt.send_calls) == 1
assert len(mc.send_calls) == 1
# ---------------------------------------------------------------------------
# set_message_callback — inbound forwarding and self-filter
# ---------------------------------------------------------------------------
class TestInboundCallback:
"""Use asyncio.run() for each coroutine so the test always gets a fresh
event loop regardless of what earlier tests in the suite may have done.
FakeChild._simulate_inbound calls the wrapper directly (no
call_soon_threadsafe), so we don't actually need the loop we pass to
set_message_callback we just need *some* AbstractEventLoop object to
satisfy the call signature.
"""
@staticmethod
def _make_loop():
"""Return a new event loop used only as a placeholder for the callback
registration (FakeChild doesn't schedule on it)."""
return asyncio.new_event_loop()
def test_inbound_forwarded_with_correct_transport(self) -> None:
mt = FakeChild("meshtastic", node_id="!aaaaaaaa")
mc = FakeChild("meshcore", node_id="!bbbbbbbb")
comp = CompositeTransport([mt, mc])
received: list[MeshMessage] = []
async def run():
async def cb(msg):
received.append(msg)
loop = self._make_loop()
comp.set_message_callback(cb, loop)
loop.close()
inbound = MeshMessage(
sender_id="!cccccccc",
sender_name="Other",
text="hello",
channel=0,
is_dm=True,
transport="meshcore",
)
await mc._simulate_inbound(inbound)
asyncio.run(run())
assert len(received) == 1
assert received[0].transport == "meshcore"
def test_self_filter_drops_own_message(self) -> None:
mt = FakeChild("meshtastic", node_id="!selfid1")
comp = CompositeTransport([mt])
received: list[MeshMessage] = []
async def run():
async def cb(msg):
received.append(msg)
loop = self._make_loop()
comp.set_message_callback(cb, loop)
loop.close()
# sender_id matches the child's own node ID → should be dropped
echo = MeshMessage(
sender_id="!selfid1",
sender_name="Self",
text="echo",
channel=0,
is_dm=False,
)
await mt._simulate_inbound(echo)
asyncio.run(run())
assert len(received) == 0
def test_non_self_not_dropped(self) -> None:
mt = FakeChild("meshtastic", node_id="!selfid1")
comp = CompositeTransport([mt])
received: list[MeshMessage] = []
async def run():
async def cb(msg):
received.append(msg)
loop = self._make_loop()
comp.set_message_callback(cb, loop)
loop.close()
msg = MeshMessage(
sender_id="!otherid",
sender_name="Friend",
text="hi",
channel=0,
is_dm=True,
)
await mt._simulate_inbound(msg)
asyncio.run(run())
assert len(received) == 1
def test_transport_tag_backfilled_when_missing(self) -> None:
"""If msg.transport is empty/falsy the wrapper sets it to the child name."""
mc = FakeChild("meshcore", node_id="!mc00")
comp = CompositeTransport([mc])
received: list[MeshMessage] = []
async def run():
async def cb(msg):
received.append(msg)
loop = self._make_loop()
comp.set_message_callback(cb, loop)
loop.close()
msg = MeshMessage(
sender_id="!other",
sender_name="X",
text="ping",
channel=0,
is_dm=True,
transport="", # missing tag
)
await mc._simulate_inbound(msg)
asyncio.run(run())
assert len(received) == 1
assert received[0].transport == "meshcore"
# ---------------------------------------------------------------------------
# Reply routing — responder seam
# ---------------------------------------------------------------------------
class TestReplyRouting:
"""Verify that a meshcore-tagged inbound message causes send_message to be
called with transport='meshcore' via the Responder."""
def test_responder_threads_transport(self) -> None:
from meshai.responder import Responder
from meshai.config import ResponseConfig
mt = FakeChild("meshtastic")
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
cfg = ResponseConfig(delay_min=0.0, delay_max=0.0)
responder = Responder(cfg, comp)
asyncio.run(
responder.send_response(
"pong",
destination="abc123",
channel=0,
transport="meshcore",
)
)
# Only meshcore child should have been called.
assert len(mc.send_calls) == 1
assert mc.send_calls[0]["destination"] == "abc123"
assert len(mt.send_calls) == 0
# ---------------------------------------------------------------------------
# meshtastic_child helper
# ---------------------------------------------------------------------------
class TestMeshtasticChild:
def test_returns_meshtastic_transport(self) -> None:
from meshai.connector import MeshtasticTransport
from meshai.transport.meshcore_transport import MeshCoreTransport
cfg = types.SimpleNamespace(
type="tcp",
tcp_host="127.0.0.1",
tcp_port=4403,
meshcore_host="127.0.0.1",
meshcore_port=5050,
)
mt = MeshtasticTransport(cfg)
mc = MeshCoreTransport(cfg)
comp = CompositeTransport([mt, mc])
child = comp.meshtastic_child()
assert child is mt
def test_returns_none_when_absent(self) -> None:
from meshai.transport.meshcore_transport import MeshCoreTransport
cfg = types.SimpleNamespace(
meshcore_host="127.0.0.1",
meshcore_port=5050,
)
mc = MeshCoreTransport(cfg)
comp = CompositeTransport([mc])
assert comp.meshtastic_child() is None
# ---------------------------------------------------------------------------
# _child_name helper
# ---------------------------------------------------------------------------
class TestChildName:
def test_uses_transport_name_attr(self) -> None:
child = FakeChild("foobar")
assert _child_name(child) == "foobar"
def test_derives_from_class_name(self) -> None:
class MyTransport:
pass
obj = MyTransport()
assert _child_name(obj) == "my"
def test_strips_transport_suffix(self) -> None:
class SomeTransport:
pass
assert _child_name(SomeTransport()) == "some"
# ---------------------------------------------------------------------------
# should_drop helper (unit test for routing logic)
# ---------------------------------------------------------------------------
class TestShouldDrop:
def test_drops_own_sender_id(self) -> None:
comp = CompositeTransport([FakeChild("x")])
msg = MeshMessage("!abc", "Self", "hi", 0, False)
assert comp._should_drop(msg, "!abc") is True
def test_keeps_other_sender(self) -> None:
comp = CompositeTransport([FakeChild("x")])
msg = MeshMessage("!xyz", "Other", "hi", 0, False)
assert comp._should_drop(msg, "!abc") is False
def test_none_node_id_no_drop(self) -> None:
comp = CompositeTransport([FakeChild("x")])
msg = MeshMessage("!abc", "X", "hi", 0, False)
assert comp._should_drop(msg, None) is False
# ---------------------------------------------------------------------------
# _resolve_child_for_hint (unit test for routing logic)
# ---------------------------------------------------------------------------
class TestResolveChildForHint:
def test_resolves_known_name(self) -> None:
mt = FakeChild("meshtastic")
mc = FakeChild("meshcore")
comp = CompositeTransport([mt, mc])
assert comp._resolve_child_for_hint("meshcore") is mc
assert comp._resolve_child_for_hint("meshtastic") is mt
def test_returns_none_for_unknown(self) -> None:
comp = CompositeTransport([FakeChild("meshtastic")])
assert comp._resolve_child_for_hint("lora") is None

View file

@ -2,7 +2,7 @@
Validates recency ordering, contained/tombstoned exclusion, the
"Name in County Co, ST" line format, correct tail count after budget
trimming, singular/plural grammar, and the 200-byte LoRa budget.
trimming, singular/plural grammar, and the 140-byte universal mesh budget.
"""
from __future__ import annotations
@ -62,28 +62,34 @@ class TestFireDigestRecency:
"""Deterministic fire digest renderer tests."""
def test_top_2_listed_in_recency_order(self):
"""The 2 most recent active fires are listed in order."""
"""The most recent active fire is listed first.
With the universal 140-byte budget, the header + 1 fire line + tail
fits; the second fire line does not. Alpha (most recent) must appear;
Bravo belongs to the tail count.
"""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
# Most recent fire must appear in the wire body.
assert "Alpha Fire" in wire
assert "Bravo Fire" in wire
pos_alpha = wire.index("Alpha Fire")
pos_bravo = wire.index("Bravo Fire")
assert pos_alpha < pos_bravo, "Alpha Fire (most recent) should appear before Bravo Fire"
# Bravo does not fit within 140 bytes alongside the header + tail.
assert "Bravo Fire" not in wire
def test_line_format_name_in_county_co_state(self):
"""Fire lines render as 'Name in County Co, ST'."""
"""Fire lines render as 'Name in County Co, ST'.
Only the most recent fire fits within the 140-byte budget.
"""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
assert "Alpha Fire in Ada Co, ID" in wire
assert "Bravo Fire in Boise Co, ID" in wire
def test_missing_county_renders_name_in_state(self):
"""Fire with no county renders as 'Name in ST'."""
@ -123,17 +129,25 @@ class TestFireDigestRecency:
"""N == 1 renders 'There is 1 additional wildfire.'."""
conn = get_db()
now = int(time.time())
_seed_scenario(conn)
# 3 fires; short names + no county → lines are short enough that
# 2 fit within the 140-byte budget, leaving 1 in the tail.
for irwin, name, offset in [
("SG-01", "A", 3600),
("SG-02", "B", 7200),
("SG-03", "C", 10800),
]:
_seed_fire(conn, irwin_id=irwin, name=name, acres=100,
contained=None, last_event_at=now - offset,
county=None, state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
# 3 active total, 2 shown, 1 remaining
# 3 active total, 2 shown (budget), 1 remaining
assert "There is 1 additional wildfire. DM me for the full list." in wire
def test_n_plural_grammar(self):
"""N > 1 renders 'There are N additional wildfires.'."""
conn = get_db()
now = int(time.time())
day = 86400
for i in range(4):
_seed_fire(conn, irwin_id=f"PL-{i:02d}", name=f"Fire {i}",
acres=100 + i, contained=None,
@ -141,8 +155,9 @@ class TestFireDigestRecency:
county="Ada", state="ID")
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
# 4 active, 2 shown, 2 remaining
assert "There are 2 additional wildfires. DM me for the full list." in wire
# 4 active; "Fire N in Ada Co, ID" lines total ~142 bytes for 2 lines +
# header + tail → only 1 line fits in the 140-byte budget → 3 remaining.
assert "There are 3 additional wildfires. DM me for the full list." in wire
def test_n_zero_omits_sentence(self):
"""When N == 0, the tail sentence is omitted entirely."""
@ -186,7 +201,7 @@ class TestFireDigestRecency:
wire, source = asyncio.run(render_digest(now=now))
assert source == "deterministic"
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 200, f"Wire is {byte_len} bytes, exceeds 200"
assert byte_len <= 140, f"Wire is {byte_len} bytes, exceeds 140"
# If both long lines fit, remaining = 1; if only one fits, remaining = 2
# Either way the tail count must match (total - shown)
lines = wire.split("\n")
@ -205,7 +220,7 @@ class TestFireDigestRecency:
from meshai.notifications.scheduled.fire_digest import render_digest
wire, _ = asyncio.run(render_digest(now=now))
byte_len = len(wire.encode("utf-8"))
assert byte_len <= 200, f"Digest is {byte_len} bytes, exceeds 200-byte budget"
assert byte_len <= 140, f"Digest is {byte_len} bytes, exceeds 140-byte budget"
def test_no_fires_returns_empty(self):
"""No active fires -> empty wire, 'no_fires' source."""

View file

@ -76,7 +76,7 @@ def test_adapter_config_seeds_digest_keys():
assert rows[("fires", "digest_enabled")] == "true"
assert rows[("fires", "digest_schedule")] == '["06:00", "18:00"]'
assert rows[("fires", "digest_timezone")] == '"America/Boise"'
assert rows[("fires", "digest_max_chars")] == "200"
assert rows[("fires", "digest_max_chars")] == "140"
# ===========================================================================
@ -107,7 +107,7 @@ def test_render_digest_terse_fallback_when_no_llm():
assert source == "deterministic"
assert wire
assert "Cache Peak" in wire
assert len(wire) <= 200
assert len(wire) <= 140
def test_render_digest_uses_llm_when_available():

View file

@ -79,10 +79,13 @@ class TestBuildTransport:
assert isinstance(transport, MeshCoreTransport)
assert isinstance(transport, MeshTransport)
def test_both_raises_not_implemented(self):
def test_both_returns_composite(self):
"""Phase 4: transport='both' now returns a CompositeTransport (seam filled)."""
from meshai.transport.composite_transport import CompositeTransport
cfg = self._config_with("both")
with pytest.raises(NotImplementedError):
build_transport(cfg)
t = build_transport(cfg)
assert isinstance(t, CompositeTransport)
assert len(t.children) == 2
def test_unknown_transport_raises_value_error(self):
cfg = self._config_with("unknown_transport_xyz")

View file

@ -1,8 +1,8 @@
"""Tests for Phase 3 uniform mesh-packet sizing.
"""Tests for uniform mesh-packet sizing.
Verifies that every size-sensitive code path uses the active transport's
max_chars rather than a hardcoded 200. Meshtastic default stays 200
(byte-identical); MeshCore uses 140.
Verifies that every size-sensitive code path uses the single universal
mesh_max_chars constant (140 = MeshCore LCD). All transports Meshtastic,
MeshCore, and the composite report the same fixed value.
"""
import sys
@ -127,59 +127,90 @@ def _minimal_config():
# ---------------------------------------------------------------------------
class TestTransportMaxChars:
def test_meshtastic_default_is_200(self):
def test_meshtastic_default_is_140(self):
"""Meshtastic now uses the universal mesh_max_chars constant (140)."""
cfg = _mt_config()
t = MeshtasticTransport(cfg)
assert t.max_chars == 200
def test_meshtastic_respects_config_field(self):
cfg = _mt_config(meshtastic_max_chars=160)
t = MeshtasticTransport(cfg)
assert t.max_chars == 160
assert t.max_chars == 140
def test_meshcore_default_is_140(self):
cfg = _mc_config()
t = MeshCoreTransport(cfg)
assert t.max_chars == 140
def test_meshcore_respects_config_field(self):
cfg = _mc_config(meshcore_max_chars=120)
t = MeshCoreTransport(cfg)
assert t.max_chars == 120
def test_build_transport_meshtastic_max_chars(self):
"""build_transport(meshtastic) → 140 (universal constant)."""
t = build_transport(_mt_config())
assert t.max_chars == 200
assert t.max_chars == 140
def test_build_transport_meshcore_max_chars(self):
t = build_transport(_mc_config())
assert t.max_chars == 140
def test_mesh_max_chars_config_field_governs_all(self):
"""A non-default mesh_max_chars propagates to both transport types."""
cfg_mt = _mt_config(mesh_max_chars=120)
assert MeshtasticTransport(cfg_mt).max_chars == 120
cfg_mc = _mc_config(mesh_max_chars=120)
assert MeshCoreTransport(cfg_mc).max_chars == 120
def test_all_three_transports_and_composite_report_140(self):
"""Sanity: all transports + CompositeTransport report mesh_max_chars=140.
This is the canonical single-budget guarantee: Meshtastic, MeshCore,
and the composite (transport=both) all resolve to the same constant.
"""
from meshai.config import ConnectionConfig
from meshai.transport.composite_transport import CompositeTransport
# Meshtastic
assert MeshtasticTransport(_mt_config()).max_chars == 140
# MeshCore
assert MeshCoreTransport(_mc_config()).max_chars == 140
# Composite (built via factory with transport="both")
cfg_both = ConnectionConfig(
transport="both",
type="tcp",
tcp_host="127.0.0.1",
tcp_port=4403,
meshcore_host="127.0.0.1",
meshcore_port=5050,
meshcore_channel_index=0,
)
comp = build_transport(cfg_both)
assert isinstance(comp, CompositeTransport)
assert comp.max_chars == 140
# ---------------------------------------------------------------------------
# 2. MeshRenderer char_limit propagation via channels
# ---------------------------------------------------------------------------
class TestChannelRendererBudget:
def test_broadcast_channel_with_meshcore_connector_uses_140(self):
def test_broadcast_channel_propagates_connector_max_chars(self):
"""Channel renderer inherits max_chars from whatever the connector reports."""
conn = _fake_connector(140)
ch = MeshBroadcastChannel(connector=conn, channel_index=0)
assert ch._renderer._limit == 140
def test_broadcast_channel_with_meshtastic_connector_uses_200(self):
conn = _fake_connector(200)
def test_broadcast_channel_with_meshtastic_connector_uses_140(self):
"""After the universal budget refactor, a meshtastic connector reports 140."""
conn = _fake_connector(140)
ch = MeshBroadcastChannel(connector=conn, channel_index=0)
assert ch._renderer._limit == 200
assert ch._renderer._limit == 140
def test_dm_channel_with_meshcore_connector_uses_140(self):
def test_dm_channel_propagates_connector_max_chars(self):
conn = _fake_connector(140)
ch = MeshDMChannel(connector=conn, node_ids=["!aabbccdd"])
assert ch._renderer._limit == 140
def test_dm_channel_with_meshtastic_connector_uses_200(self):
conn = _fake_connector(200)
def test_dm_channel_with_meshtastic_connector_uses_140(self):
conn = _fake_connector(140)
ch = MeshDMChannel(connector=conn, node_ids=["!aabbccdd"])
assert ch._renderer._limit == 200
assert ch._renderer._limit == 140
# ---------------------------------------------------------------------------
@ -187,11 +218,12 @@ class TestChannelRendererBudget:
# ---------------------------------------------------------------------------
class TestBuildPipelineMeshCharLimit:
def test_no_connector_uses_200(self):
def test_no_connector_uses_140(self):
"""Without a connector the pipeline falls back to the universal constant (140)."""
cfg = _minimal_config()
bus = build_pipeline(cfg, llm_backend=None, connector=None)
acc = bus._pipeline_components["accumulator"]
assert acc._mesh_char_limit == 200
assert acc._mesh_char_limit == 140
def test_meshcore_connector_uses_140(self):
cfg = _minimal_config()
@ -200,12 +232,13 @@ class TestBuildPipelineMeshCharLimit:
acc = bus._pipeline_components["accumulator"]
assert acc._mesh_char_limit == 140
def test_meshtastic_connector_uses_200(self):
def test_meshtastic_connector_uses_140(self):
"""After universal budget, a meshtastic connector also reports 140."""
cfg = _minimal_config()
conn = _fake_connector(200)
conn = _fake_connector(140)
bus = build_pipeline(cfg, llm_backend=None, connector=conn)
acc = bus._pipeline_components["accumulator"]
assert acc._mesh_char_limit == 200
assert acc._mesh_char_limit == 140
# ---------------------------------------------------------------------------