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
thebentern
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent
aab4cd086f
commit
c8dac10348
@@ -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