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:
Ben Meadors
2026-04-18 08:17:44 -05:00
committed by GitHub
co-authored by GitHub Copilot copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent aab4cd086f
commit c8dac10348
77 changed files with 10701 additions and 13 deletions
View File
@@ -0,0 +1,57 @@
"""Admin: channel URL export and re-import round-trip.
Real operator workflow: "I have two fleets, I want them to share a channel
config. Export URL from fleet A's bootstrap device, paste into fleet B's
onboarding tool, expect identical channels." Proves `getURL` + `setURL`
round-trip without data loss.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(60)
def test_channel_url_roundtrip(
baked_single: dict[str, Any],
test_profile: dict[str, Any],
) -> None:
"""Runs once per connected role. Verify:
1. `get_channel_url()` on a baked device returns a non-empty URL.
2. The URL parses — `set_channel_url(url)` accepts it without error.
3. After set, `get_channel_url()` returns the same (canonicalized) URL.
4. Primary channel name survives round-trip.
"""
port = baked_single["port"]
url_before = admin.get_channel_url(include_all=False, port=port)["url"]
assert url_before, "device returned empty channel URL"
assert (
"meshtastic" in url_before.lower() or "#" in url_before
), f"URL does not look like a Meshtastic channel URL: {url_before!r}"
# Re-apply the same URL — no-op in content but exercises the setURL path.
applied = admin.set_channel_url(url=url_before, port=port)
assert applied["ok"] is True
assert applied["channels_imported"] >= 1
time.sleep(2.0)
# Confirm the primary channel name survived
live = info.device_info(port=port, timeout_s=8.0)
assert live["primary_channel"] == test_profile["USERPREFS_CHANNEL_0_NAME"]
url_after = admin.get_channel_url(include_all=False, port=port)["url"]
# Canonicalization is tricky: the firmware may re-serialize the protobuf
# with fields in a different order, producing a visually-different URL
# that encodes the same content. Accept that as a success when the
# primary channel name survived the round-trip (already asserted above)
# and the URL is still a parseable Meshtastic URL. Bit-equality is a
# nice-to-have, not a correctness guarantee.
assert url_after, "URL went blank after setURL"
assert (
"meshtastic" in url_after.lower() or "#" in url_after
), f"URL after setURL no longer looks like a channel URL: {url_after!r}"
@@ -0,0 +1,106 @@
"""Admin: a config mutation survives a reboot.
This is the most-critical admin behavior not tested elsewhere. If
config persistence breaks in a firmware release, every deployed device
gets bricked on its next reboot (channels lost, region lost, owner lost,
everything back to Meshtastic stock). The fleet blast radius is "every
unit on every shelf" — easily worth one explicit test per release.
Pattern: single-device (``baked_single``, one test per role). Mutate a
benign, easy-to-observe LoRa field (``lora.hop_limit``), confirm
pre-reboot, reboot, rediscover port (nRF52 may re-enumerate), verify
the value survived, restore original for downstream tests.
Why ``lora.hop_limit`` specifically:
* Non-destructive — doesn't change region, channel, or PSK, so
downstream mesh tests still work regardless of the flipped value.
* Bounded small-integer (1..7) — easy to flip to a definitively
different value and read back.
* Persisted via ``writeConfig("lora")`` which is the same path
every other LoRa config mutation uses, so we're really testing
the whole lora-config persistence pipeline end-to-end.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
from .._port_discovery import resolve_port_by_role
def _get_hop_limit(port: str) -> int:
"""Read `lora.hop_limit` from the device's current config."""
lora = admin.get_config("lora", port=port).get("config", {}).get("lora", {})
hl = lora.get("hop_limit")
assert isinstance(hl, int), (
f"lora.hop_limit missing or non-int in get_config response: " f"{lora!r}"
)
return hl
@pytest.mark.timeout(180)
def test_lora_hop_limit_survives_reboot(
baked_single: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role. Mutates `lora.hop_limit`, reboots,
verifies the new value is still there after the device comes back.
"""
role = baked_single["role"]
port = baked_single["port"]
original = _get_hop_limit(port)
# Flip to a definitively different value within the protocol's
# valid range (1..7 per LoRaConfig.hop_limit comment). Pick 5 if
# current is != 5, else 4.
new_value = 5 if original != 5 else 4
try:
admin.set_config("lora.hop_limit", new_value, port=port)
# Pre-reboot sanity: the write reached the device and
# get_config reflects it in-memory. If this fails, the persist
# test below is moot — something's wrong with the write path
# itself, not with persistence.
assert _get_hop_limit(port) == new_value, (
f"pre-reboot readback failed: set {new_value}, got "
f"{_get_hop_limit(port)}"
)
# Reboot. `seconds=3` gives the Python client time to
# disconnect cleanly; sleep long enough for the boot to start
# before we begin polling.
admin.reboot(port=port, confirm=True, seconds=3)
time.sleep(8.0)
# nRF52 re-enumerates on reboot → rediscover.
port = resolve_port_by_role(role, timeout_s=60.0)
wait_until(
lambda: info.device_info(port=port, timeout_s=5.0).get("my_node_num")
is not None,
timeout=60,
backoff_start=1.0,
)
# The assertion this test exists for: the mutation persisted
# across the reboot cycle through NVS / LittleFS / UICR.
post = _get_hop_limit(port)
assert post == new_value, (
f"lora.hop_limit did not survive reboot: set to {new_value} "
f"pre-reboot, read back {post} post-reboot. Config persistence "
f"is broken — downstream fleet impact would be total."
)
finally:
# Restore so downstream tests see the original hop_limit.
# Wrapped in its own try to avoid masking the real assertion
# if the restore itself races the reboot — the worst case
# there is a non-default hop_limit sticks around, which is
# benign (mesh still works at hop_limit 3 or 5).
try:
admin.set_config("lora.hop_limit", original, port=port)
except Exception:
pass
@@ -0,0 +1,59 @@
"""Admin: owner name persists across a reboot.
The single most common "did my admin change stick?" test. Proves
`localNode.setOwner()` + `writeConfig("device")` commits to non-volatile
storage before the reboot.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(120)
def test_owner_survives_reboot(
baked_single: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role — proves the reboot-persistence
round-trip works on each device independently, not just one."""
port = baked_single["port"]
pre = info.device_info(port=port, timeout_s=8.0)
original = pre.get("long_name") or ""
marker = "RebootSurvive"
try:
admin.set_owner(long_name=marker, short_name="RS", port=port)
time.sleep(1.5)
# Confirm pre-reboot
confirmed = info.device_info(port=port, timeout_s=8.0)
assert confirmed["long_name"] == marker
# Reboot (short delay)
admin.reboot(port=port, confirm=True, seconds=3)
# Wait for device to come back
time.sleep(8.0)
wait_until(
lambda: info.device_info(port=port, timeout_s=5.0).get("my_node_num")
is not None,
timeout=60,
backoff_start=1.0,
)
post = info.device_info(port=port, timeout_s=8.0)
assert post["long_name"] == marker, (
f"owner name did not persist across reboot: "
f"expected {marker!r}, got {post['long_name']!r}"
)
finally:
# Restore original (best-effort)
try:
admin.set_owner(long_name=original or "TestNode", port=port)
except Exception:
pass