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
GitHub
Copilot
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent
aab4cd086f
commit
c8dac10348
@@ -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
|
||||
Reference in New Issue
Block a user