Add MCP server for interacting with meshtastic devices and testing framework / TUI (#10194)
* Start of MCP server and test suite * Add MCP server for interacting with meshtastic devices and testing framework / TUI * Update mcp-server/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix mcp-server review feedback from thread Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control * Semgrep fixes * Trunk and semgrep fixes * optimize pio streaming tee file writes Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * chore: remove redundant log handle assignment Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Consolidate type imports and remove placeholder test files * Add tests for config persistence and more exchange messages * Refactor position test to validate on-demand request/reply behavior * Remove position request/reply test and update README for telemetry behavior * Fix transmit history file to get removed on factory reset --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent
8fd0a7f283
commit
6b15571e14
@@ -0,0 +1,220 @@
|
||||
"""Shared helper for mesh receive tests.
|
||||
|
||||
`pio device monitor` captures firmware log output, which does NOT include
|
||||
decoded text message contents or telemetry payloads — those are only
|
||||
accessible through `meshtastic.SerialInterface`'s pubsub mechanism.
|
||||
|
||||
`ReceiveCollector` opens a long-lived SerialInterface on a port, subscribes
|
||||
to the pubsub topic of interest, and exposes an atomic `wait_for(predicate)`
|
||||
that mesh tests use to verify end-to-end delivery.
|
||||
|
||||
This module also exposes two module-level helpers for forcing a device to
|
||||
broadcast a fresh NodeInfo — the on-demand path that sidesteps the
|
||||
firmware's 10-minute NodeInfo rate-limit. Tests doing directed PKI-encrypted
|
||||
sends need BOTH endpoints to hold current pubkeys for each other:
|
||||
|
||||
nudge_nodeinfo(iface) # nudge an already-open SerialInterface
|
||||
nudge_nodeinfo_port(port) # open briefly, nudge, close
|
||||
|
||||
See `ReceiveCollector.broadcast_nodeinfo_ping` for the firmware-side
|
||||
rationale (PKI staleness → directed sends NAK with Routing.Error=35
|
||||
PKI_UNKNOWN_PUBKEY or 39 PKI_SEND_FAIL_PUBLIC_KEY).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def nudge_nodeinfo(iface: Any) -> None:
|
||||
"""Force the device behind ``iface`` to broadcast a fresh NodeInfo.
|
||||
|
||||
Sends a ``ToRadio.Heartbeat(nonce=1)`` — the firmware's documented
|
||||
on-demand NodeInfo trigger (see `src/mesh/api/PacketAPI.cpp:74-79`
|
||||
for TCP/UDP and `src/mesh/PhoneAPI.cpp::handleToRadio` for serial,
|
||||
both routed to `NodeInfoModule::sendOurNodeInfo(..., shorterTimeout=true)`
|
||||
with the 60-s window rather than the 10-min rate-limit).
|
||||
|
||||
Call on BOTH TX and RX ifaces before a directed PKI-encrypted send.
|
||||
Nudging only one side leaves the other with a stale pubkey cache and
|
||||
makes the directed send NAK with PKI_UNKNOWN_PUBKEY.
|
||||
"""
|
||||
from meshtastic.protobuf import mesh_pb2 # type: ignore[import-untyped]
|
||||
|
||||
tr = mesh_pb2.ToRadio()
|
||||
tr.heartbeat.nonce = 1
|
||||
iface._sendToRadio(tr)
|
||||
|
||||
|
||||
def nudge_nodeinfo_port(port: str) -> None:
|
||||
"""Open ``port`` briefly, nudge, close — for when no iface is open yet.
|
||||
|
||||
Uses the meshtastic_mcp port-lock-aware `connect()` context manager
|
||||
so we don't race ReceiveCollector or other long-lived handles on
|
||||
the same port.
|
||||
"""
|
||||
from meshtastic_mcp.connection import connect
|
||||
|
||||
with connect(port=port) as iface:
|
||||
nudge_nodeinfo(iface)
|
||||
|
||||
|
||||
class ReceiveCollector:
|
||||
"""Listen for meshtastic packets on `port` and let tests wait for a match.
|
||||
|
||||
Must be used as a context manager so the underlying SerialInterface is
|
||||
always closed (leaked interfaces hold the CDC port open and break
|
||||
subsequent tool calls).
|
||||
|
||||
Usage:
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
# ... send from TX ...
|
||||
assert rx.wait_for(
|
||||
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
|
||||
timeout=60,
|
||||
), f"packet not received; got {rx.snapshot()!r}"
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
topic: str = "meshtastic.receive",
|
||||
capture_logs: bool = False,
|
||||
) -> None:
|
||||
self._port = port
|
||||
self._topic = topic
|
||||
self._capture_logs = capture_logs
|
||||
self._packets: list[dict[str, Any]] = []
|
||||
self._log_lines: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
self._iface = None
|
||||
self._handler_ref = None # keep strong ref so pubsub doesn't GC it
|
||||
self._log_handler_ref = None
|
||||
|
||||
def __enter__(self) -> "ReceiveCollector":
|
||||
from meshtastic.serial_interface import (
|
||||
SerialInterface, # type: ignore[import-untyped]
|
||||
)
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
# pubsub uses weak refs by default — we stash a strong ref so the
|
||||
# handler doesn't disappear between subscribe and wait_for.
|
||||
def handler(packet: dict, interface: Any) -> None:
|
||||
with self._lock:
|
||||
self._packets.append(packet)
|
||||
|
||||
self._handler_ref = handler
|
||||
pub.subscribe(handler, self._topic)
|
||||
|
||||
# Firmware-side logs come through the SAME SerialInterface when
|
||||
# `config.security.debug_log_api_enabled = True`. Subscribing here
|
||||
# captures them for failure-artifact attachment without needing a
|
||||
# separate pio monitor session that would fight our port lock.
|
||||
if self._capture_logs:
|
||||
|
||||
def log_handler(line: str, interface: Any) -> None:
|
||||
with self._lock:
|
||||
self._log_lines.append(line)
|
||||
|
||||
self._log_handler_ref = log_handler
|
||||
pub.subscribe(log_handler, "meshtastic.log.line")
|
||||
|
||||
self._iface = SerialInterface(devPath=self._port, connectNow=True)
|
||||
# Let the config bootstrap complete so we don't miss early arrivals.
|
||||
time.sleep(1.0)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
if self._handler_ref is not None:
|
||||
try:
|
||||
pub.unsubscribe(self._handler_ref, self._topic)
|
||||
except Exception:
|
||||
pass
|
||||
if self._log_handler_ref is not None:
|
||||
try:
|
||||
pub.unsubscribe(self._log_handler_ref, "meshtastic.log.line")
|
||||
except Exception:
|
||||
pass
|
||||
if self._iface is not None:
|
||||
try:
|
||||
self._iface.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def snapshot(self) -> list[dict[str, Any]]:
|
||||
"""Return a thread-safe copy of the list of collected packets."""
|
||||
with self._lock:
|
||||
return list(self._packets)
|
||||
|
||||
def log_snapshot(self) -> list[str]:
|
||||
"""Return captured firmware log lines.
|
||||
|
||||
Only populated if `capture_logs=True` AND the device has
|
||||
`security.debug_log_api_enabled=True`.
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._log_lines)
|
||||
|
||||
def send_text(
|
||||
self,
|
||||
text: str,
|
||||
destination_id: Any = "^all",
|
||||
want_ack: bool = False,
|
||||
channel_index: int = 0,
|
||||
) -> Any:
|
||||
"""Send a text packet through the already-open SerialInterface.
|
||||
|
||||
Use this when a test also has a ReceiveCollector open on the same port
|
||||
— `admin.send_text(port=...)` would try to open a second SerialInterface
|
||||
and fail the port lock.
|
||||
"""
|
||||
if self._iface is None:
|
||||
raise RuntimeError("ReceiveCollector not started; use as context manager")
|
||||
return self._iface.sendText(
|
||||
text,
|
||||
destinationId=destination_id,
|
||||
wantAck=want_ack,
|
||||
channelIndex=channel_index,
|
||||
)
|
||||
|
||||
def broadcast_nodeinfo_ping(self) -> None:
|
||||
"""Force the firmware on `port` to broadcast a fresh NodeInfo.
|
||||
|
||||
Thin wrapper around the module-level :func:`nudge_nodeinfo` that
|
||||
also validates the context-manager invariant. Delegates so tests
|
||||
that need to nudge BOTH sides (bilateral PKI warmup) share one
|
||||
implementation — the caller just passes each iface in turn.
|
||||
|
||||
Firmware-side details (rate-limit bypass, nonce==1 trigger path,
|
||||
shorterTimeout=true window) are documented on the module-level
|
||||
helper.
|
||||
"""
|
||||
if self._iface is None:
|
||||
raise RuntimeError("ReceiveCollector not started; use as context manager")
|
||||
nudge_nodeinfo(self._iface)
|
||||
|
||||
def wait_for(
|
||||
self,
|
||||
predicate: Callable[[dict[str, Any]], bool],
|
||||
timeout: float = 60.0,
|
||||
poll_interval: float = 0.5,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Block until a received packet matches `predicate` or timeout.
|
||||
|
||||
Returns the matching packet (truthy) or None (falsy).
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with self._lock:
|
||||
for pkt in self._packets:
|
||||
try:
|
||||
if predicate(pkt):
|
||||
return pkt
|
||||
except Exception:
|
||||
continue
|
||||
time.sleep(poll_interval)
|
||||
return None
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Mesh: explicit two-way communication, single pass/fail.
|
||||
|
||||
Opens a ReceiveCollector on EVERY role, sends a uniquely-tagged broadcast
|
||||
from each role in turn, and asserts every OTHER role saw it. One atomic
|
||||
test that answers "is the mesh actually working both directions?".
|
||||
|
||||
Not parametrized — it inherently involves the full hub.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from ._receive import ReceiveCollector
|
||||
|
||||
|
||||
@pytest.mark.timeout(300)
|
||||
def test_bidirectional_mesh_communication(
|
||||
baked_mesh: dict[str, Any],
|
||||
) -> None:
|
||||
"""Requires ≥2 baked roles.
|
||||
|
||||
For each role, broadcast a unique tag. Assert every other role's
|
||||
ReceiveCollector saw that tag within a 120s window per direction.
|
||||
"""
|
||||
roles = sorted(baked_mesh.keys())
|
||||
if len(roles) < 2:
|
||||
pytest.skip(f"need ≥2 roles; have {roles!r}")
|
||||
|
||||
# Open receive collectors on every role BEFORE sending anything.
|
||||
collectors: dict[str, ReceiveCollector] = {}
|
||||
try:
|
||||
for role in roles:
|
||||
rx = ReceiveCollector(
|
||||
baked_mesh[role]["port"], topic="meshtastic.receive.text"
|
||||
)
|
||||
rx.__enter__()
|
||||
collectors[role] = rx
|
||||
|
||||
# Let the meshtastic interfaces stabilize before the first send
|
||||
time.sleep(2.0)
|
||||
|
||||
# From each role, send a uniquely-tagged broadcast. We MUST send through
|
||||
# the already-open collector — opening a new SerialInterface here would
|
||||
# race the collector's exclusive lock on the port.
|
||||
tags: dict[str, str] = {}
|
||||
for sender in roles:
|
||||
tag = f"bidi-{sender}-{int(time.time() * 1000) % 100_000}"
|
||||
tags[sender] = tag
|
||||
collectors[sender].send_text(tag)
|
||||
# Small gap so airtime doesn't overlap
|
||||
time.sleep(4.0)
|
||||
|
||||
# Every OTHER role must see every sender's tag within 120s each
|
||||
missing: list[str] = []
|
||||
for sender, tag in tags.items():
|
||||
for receiver in roles:
|
||||
if receiver == sender:
|
||||
continue
|
||||
got = collectors[receiver].wait_for(
|
||||
lambda pkt, t=tag: pkt.get("decoded", {}).get("text") == t,
|
||||
timeout=120,
|
||||
)
|
||||
if got is None:
|
||||
observed = [
|
||||
p.get("decoded", {}).get("text")
|
||||
for p in collectors[receiver].snapshot()
|
||||
]
|
||||
missing.append(
|
||||
f"{sender}->{receiver}: tag {tag!r} not seen; "
|
||||
f"receiver got {observed!r}"
|
||||
)
|
||||
|
||||
assert not missing, "bidirectional comms incomplete:\n " + "\n ".join(missing)
|
||||
finally:
|
||||
for rx in collectors.values():
|
||||
try:
|
||||
rx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Mesh: broadcast text from TX arrives at RX.
|
||||
|
||||
Uses `meshtastic.SerialInterface` pubsub on RX to detect the decoded text
|
||||
packet — `pio device monitor` output doesn't include message bodies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import admin
|
||||
|
||||
from ._receive import ReceiveCollector
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_broadcast_delivers(
|
||||
mesh_pair: dict[str, Any],
|
||||
) -> None:
|
||||
"""Runs for every directed role pair. TX sends a unique broadcast text;
|
||||
RX must receive the decoded text via the meshtastic pubsub receive topic
|
||||
within 120s.
|
||||
"""
|
||||
tx_port = mesh_pair["tx"]["port"]
|
||||
rx_port = mesh_pair["rx"]["port"]
|
||||
tx_role = mesh_pair["tx_role"]
|
||||
rx_role = mesh_pair["rx_role"]
|
||||
|
||||
unique = f"mcp-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
admin.send_text(text=unique, port=tx_port)
|
||||
|
||||
got = rx.wait_for(
|
||||
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
assert got is not None, (
|
||||
f"broadcast {unique!r} from {tx_role} not received at {rx_role} within 120s. "
|
||||
f"RX saw {len(rx.snapshot())} text packet(s): "
|
||||
f"{[p.get('decoded', {}).get('text') for p in rx.snapshot()]!r}"
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Mesh: direct text addressed to RX's node_num arrives at RX.
|
||||
|
||||
Uses the same pubsub receive pattern as `test_broadcast_delivers`, but sends
|
||||
with `destinationId=<rx_node_num>` and `wantAck=True`. The assertion is that
|
||||
the RX firmware accepted and decoded the text; the ACK is handled by the
|
||||
firmware transparently (and fires automatically when wantAck is set + the
|
||||
destination is the local node).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.connection import connect
|
||||
|
||||
from ._receive import ReceiveCollector, nudge_nodeinfo
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_direct_with_ack_roundtrip(
|
||||
mesh_pair: dict[str, Any],
|
||||
) -> None:
|
||||
"""Runs for every directed pair. Addressed send from TX to RX's node_num
|
||||
with want_ack=True; RX must receive the decoded text via pubsub.
|
||||
|
||||
Why this proves ACK: setting want_ack on a directed send causes the
|
||||
firmware to retry until an ACK is received. If RX's decoded.text fires
|
||||
once, both the outbound text AND the inbound ACK happened.
|
||||
"""
|
||||
tx_port = mesh_pair["tx"]["port"]
|
||||
rx_port = mesh_pair["rx"]["port"]
|
||||
rx_node_num = mesh_pair["rx"]["my_node_num"]
|
||||
tx_role = mesh_pair["tx_role"]
|
||||
rx_role = mesh_pair["rx_role"]
|
||||
assert rx_node_num is not None, f"{rx_role} my_node_num missing"
|
||||
|
||||
unique = f"mcp-ack-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
|
||||
# TX iface stays open across the RX wait — sendText+wantAck relies on
|
||||
# the firmware's retransmit loop, which races the SerialInterface close.
|
||||
# Bilateral NodeInfo nudge: directed packets are PKI-encrypted, so BOTH
|
||||
# sides need current pubkeys (err=35/39 otherwise). See
|
||||
# `tests/mesh/_receive.py::nudge_nodeinfo` for the heartbeat-nonce=1
|
||||
# firmware path.
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
|
||||
pk_deadline = time.monotonic() + 45.0
|
||||
last_nudge = time.monotonic()
|
||||
last_rec: dict[str, Any] = {}
|
||||
while time.monotonic() < pk_deadline:
|
||||
last_rec = (tx_iface.nodesByNum or {}).get(rx_node_num, {})
|
||||
user = last_rec.get("user", {})
|
||||
if user.get("publicKey"):
|
||||
break
|
||||
# Re-nudge both sides every 15 s in case a broadcast was
|
||||
# lost to a LoRa collision.
|
||||
if time.monotonic() - last_nudge > 15.0:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
last_nudge = time.monotonic()
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
pytest.fail(
|
||||
f"TX ({tx_role}) never saw RX ({rx_role}) public key "
|
||||
f"within 45s; nodesByNum entry={last_rec!r}"
|
||||
)
|
||||
|
||||
# Retry covers LoRa collisions. Re-nudge both sides between
|
||||
# attempts — if RX's cached TX pubkey is stale, just re-sending
|
||||
# the text doesn't heal it; re-broadcasting NodeInfo does.
|
||||
got = None
|
||||
for _attempt in range(2):
|
||||
packet = tx_iface.sendText(
|
||||
unique,
|
||||
destinationId=rx_node_num,
|
||||
wantAck=True,
|
||||
)
|
||||
assert packet is not None, "sendText returned None"
|
||||
got = rx.wait_for(
|
||||
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
|
||||
timeout=30,
|
||||
)
|
||||
if got is not None:
|
||||
break
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(5.0)
|
||||
|
||||
assert got is not None, (
|
||||
f"directed send {unique!r} from {tx_role} to {rx_role} "
|
||||
f"(node_num 0x{rx_node_num:08x}) not received within 120s. "
|
||||
f"RX saw {len(rx.snapshot())} text packet(s): "
|
||||
f"{[p.get('decoded', {}).get('text') for p in rx.snapshot()]!r}"
|
||||
)
|
||||
# Additional: confirm the destination matches (not leaked broadcast)
|
||||
assert got.get("to") == rx_node_num, (
|
||||
f"received packet destination mismatch: to={got.get('to')}, "
|
||||
f"expected 0x{rx_node_num:08x}"
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Mesh: two devices baked with the same session profile discover each other.
|
||||
|
||||
The fundamental "does my mesh work" test. If both devices share a PSK, LoRa
|
||||
region, modem preset, and channel slot, they should hear each other's
|
||||
NodeInfo packets within ~60s of boot and appear in each other's `nodesByNum`
|
||||
DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.connection import connect
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_mesh_formation_within_60s(mesh_pair: dict[str, Any], wait_until) -> None:
|
||||
"""Runs for every directed role pair — so we prove `A sees B in its node
|
||||
DB` AND `B sees A in its node DB` independently. A one-sided pass can
|
||||
mask a real problem (e.g. device A's RX works but its TX is dead).
|
||||
"""
|
||||
observer_port = mesh_pair["tx"]["port"]
|
||||
target_node_num = mesh_pair["rx"]["my_node_num"]
|
||||
assert (
|
||||
target_node_num is not None
|
||||
), f"{mesh_pair['rx']['role']} my_node_num not populated"
|
||||
|
||||
def target_visible_from_observer() -> bool:
|
||||
with connect(port=observer_port) as iface:
|
||||
nodes = iface.nodesByNum or {}
|
||||
return target_node_num in nodes
|
||||
|
||||
wait_until(
|
||||
target_visible_from_observer,
|
||||
timeout=120,
|
||||
backoff_start=2.0,
|
||||
backoff_max=10.0,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Mesh: traceroute from TX to RX round-trips with no intermediate hops.
|
||||
|
||||
TX sends a `TRACEROUTE_APP` request (RouteDiscovery with `want_response=True`)
|
||||
addressed to RX's node_num. RX's firmware (`modules/TraceRouteModule.cpp`)
|
||||
replies with a RouteDiscovery payload whose `route` / `route_back` lists
|
||||
contain any intermediate relays and `snr_towards` / `snr_back` carry per-hop
|
||||
SNRs. In a 2-device direct mesh there are no relays between TX and RX, so
|
||||
both route lists must be empty and each SNR list carries exactly one entry
|
||||
for the direct TX↔RX link.
|
||||
|
||||
Validates the full TRACEROUTE_APP portnum round-trip: request encoding, RX
|
||||
firmware dispatch, RouteDiscovery payload construction, wire response, and
|
||||
client-side decode through `meshtastic.__init__.py::protocols[TRACEROUTE_APP]`
|
||||
(which is what publishes the `meshtastic.receive.traceroute` pubsub topic).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic.mesh_interface import MeshInterface
|
||||
|
||||
from ._receive import ReceiveCollector, nudge_nodeinfo_port
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
"""Runs for every directed pair. Asserts TX sends + RX responds, then
|
||||
inspects the captured RouteDiscovery to confirm the path is direct.
|
||||
|
||||
Why the listener is on TX (not RX):
|
||||
The traceroute RESPONSE is addressed to TX (the original requester).
|
||||
The meshtastic Python client publishes `meshtastic.receive.traceroute`
|
||||
on the interface that received that response — which is TX's iface.
|
||||
A listener on RX would only see the inbound REQUEST, which lacks
|
||||
the SNR-towards / SNR-back fields the firmware only fills on reply.
|
||||
|
||||
Why we ping RX's NodeInfo before sending:
|
||||
Traceroute requests are directed sends (wantResponse=True, specific
|
||||
destinationId) — subject to the same PKI_SEND_FAIL_PUBLIC_KEY trap
|
||||
as `test_direct_with_ack`. We open RX briefly to trigger the
|
||||
on-demand NodeInfo broadcast, then wait for TX's nodesByNum to
|
||||
populate RX's publicKey before calling sendTraceRoute.
|
||||
"""
|
||||
tx_port = mesh_pair["tx"]["port"]
|
||||
rx_port = mesh_pair["rx"]["port"]
|
||||
rx_node_num = mesh_pair["rx"]["my_node_num"]
|
||||
tx_role = mesh_pair["tx_role"]
|
||||
rx_role = mesh_pair["rx_role"]
|
||||
assert rx_node_num is not None, f"{rx_role} my_node_num missing"
|
||||
|
||||
with ReceiveCollector(
|
||||
tx_port, topic="meshtastic.receive.traceroute"
|
||||
) as tx_listener:
|
||||
# Bilateral PKI warmup — traceroute requests are directed and
|
||||
# PKI-encrypted, so both sides need current pubkeys. See
|
||||
# `_receive.py::nudge_nodeinfo` and the test_direct_with_ack
|
||||
# comment for the full rationale (one-sided nudge lets err=35
|
||||
# PKI_UNKNOWN_PUBKEY slip through in whichever direction had
|
||||
# stale RX-side cache).
|
||||
nudge_nodeinfo_port(rx_port) # RX via brief side-connection
|
||||
tx_listener.broadcast_nodeinfo_ping() # TX via already-open iface
|
||||
|
||||
# Poll TX's view of RX until the publicKey propagates. 45 s matches
|
||||
# the cap used in `test_direct_with_ack`; the re-nudge at 15 s
|
||||
# covers a LoRa collision on the first NodeInfo broadcast.
|
||||
pk_deadline = time.monotonic() + 45.0
|
||||
last_nudge = time.monotonic()
|
||||
last_rec: dict[str, Any] = {}
|
||||
while time.monotonic() < pk_deadline:
|
||||
last_rec = (tx_listener._iface.nodesByNum or {}).get(rx_node_num, {})
|
||||
if last_rec.get("user", {}).get("publicKey"):
|
||||
break
|
||||
if time.monotonic() - last_nudge > 15.0:
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
tx_listener.broadcast_nodeinfo_ping()
|
||||
last_nudge = time.monotonic()
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
pytest.fail(
|
||||
f"TX ({tx_role}) never saw RX ({rx_role}) public key within "
|
||||
f"45s; nodesByNum entry={last_rec!r}"
|
||||
)
|
||||
|
||||
# sendTraceRoute blocks internally on `waitForTraceRoute` and raises
|
||||
# `MeshInterface.MeshInterfaceError` on timeout. One retry covers a
|
||||
# transient LoRa collision on either the request or the reply.
|
||||
ok = False
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
tx_listener._iface.sendTraceRoute(
|
||||
dest=rx_node_num,
|
||||
hopLimit=3,
|
||||
)
|
||||
ok = True
|
||||
break
|
||||
except MeshInterface.MeshInterfaceError:
|
||||
time.sleep(5.0)
|
||||
assert ok, (
|
||||
f"sendTraceRoute {tx_role}→{rx_role} timed out twice; the mesh "
|
||||
f"may be saturated or RX's TraceRouteModule is misrouting the "
|
||||
f"reply"
|
||||
)
|
||||
|
||||
# sendTraceRoute already waited for the response internally, but
|
||||
# pubsub dispatch runs on the meshtastic-python reader thread —
|
||||
# give it a short grace window to queue the packet.
|
||||
packet = tx_listener.wait_for(
|
||||
lambda p: p.get("from") == rx_node_num,
|
||||
timeout=5.0,
|
||||
)
|
||||
assert packet is not None, (
|
||||
f"sendTraceRoute returned OK but no `receive.traceroute` packet "
|
||||
f"from RX (0x{rx_node_num:08x}) arrived via pubsub. Captured: "
|
||||
f"{tx_listener.snapshot()!r}"
|
||||
)
|
||||
|
||||
# Inspect the decoded RouteDiscovery. The meshtastic client stores
|
||||
# the parsed protobuf (as a plain dict via MessageToDict) under
|
||||
# `decoded.traceroute` for this portnum; keys are camelCase because
|
||||
# protobuf JSON conversion uses `preserving_proto_field_name=False`
|
||||
# by default.
|
||||
decoded = packet.get("decoded", {})
|
||||
route_info = decoded.get("traceroute") or {}
|
||||
|
||||
forward_hops = route_info.get("route") or []
|
||||
back_hops = route_info.get("routeBack") or []
|
||||
snr_towards = route_info.get("snrTowards") or []
|
||||
|
||||
assert forward_hops == [], (
|
||||
f"traceroute forward `route` should be empty on a 2-device direct "
|
||||
f"mesh (no intermediaries between {tx_role} and {rx_role}); got "
|
||||
f"{forward_hops!r}"
|
||||
)
|
||||
assert back_hops == [], (
|
||||
f"traceroute `routeBack` should be empty on a 2-device direct "
|
||||
f"mesh; got {back_hops!r}"
|
||||
)
|
||||
# `snr_towards` has len(route) + 1 entries — one per hop plus a final
|
||||
# entry for the destination's receive SNR. Direct mesh → len(route)
|
||||
# is 0 → exactly 1 SNR entry.
|
||||
assert len(snr_towards) == 1, (
|
||||
f"traceroute `snrTowards` should carry exactly 1 entry (direct "
|
||||
f"link SNR) on a 2-device mesh; got {snr_towards!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user