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,72 @@
|
||||
"""`boards.py` filter and enumeration correctness.
|
||||
|
||||
Runs against the real `pio project config` output of this firmware repo —
|
||||
validates that filter predicates match expected envs and don't drift if
|
||||
variants get reorganized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import boards
|
||||
|
||||
|
||||
def test_list_boards_returns_many() -> None:
|
||||
all_boards = boards.list_boards()
|
||||
assert len(all_boards) >= 50, "expected at least 50 PlatformIO envs"
|
||||
|
||||
|
||||
def test_tbeam_is_canonical_esp32() -> None:
|
||||
"""The default env in platformio.ini is `tbeam`; it must always be present
|
||||
and flagged as esp32."""
|
||||
rec = boards.get_board("tbeam")
|
||||
assert rec["architecture"] == "esp32"
|
||||
assert rec["hw_model_slug"] == "TBEAM"
|
||||
assert rec["actively_supported"] is True
|
||||
assert rec["board"] == "ttgo-tbeam"
|
||||
|
||||
|
||||
def test_filter_by_architecture() -> None:
|
||||
esp32s3 = boards.list_boards(architecture="esp32s3")
|
||||
assert len(esp32s3) >= 1
|
||||
assert all(b["architecture"] == "esp32s3" for b in esp32s3)
|
||||
|
||||
|
||||
def test_filter_by_actively_supported() -> None:
|
||||
supported = boards.list_boards(actively_supported_only=True)
|
||||
unsupported = [b for b in boards.list_boards() if not b["actively_supported"]]
|
||||
assert supported, "at least one board should be actively supported"
|
||||
assert all(b["actively_supported"] for b in supported)
|
||||
# Quick sanity: the set difference is non-empty in this repo (there are
|
||||
# boards marked actively_supported=false).
|
||||
assert unsupported, "expected at least one actively_supported=false board"
|
||||
|
||||
|
||||
def test_filter_by_query_substring_matches_display_name() -> None:
|
||||
heltec = boards.list_boards(query="heltec")
|
||||
assert heltec, "expected at least one Heltec env"
|
||||
# Case-insensitive across display_name, env name, or hw_model_slug
|
||||
for b in heltec:
|
||||
blob = " ".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
b.get("display_name") or "",
|
||||
b["env"],
|
||||
b.get("hw_model_slug") or "",
|
||||
],
|
||||
)
|
||||
).lower()
|
||||
assert "heltec" in blob
|
||||
|
||||
|
||||
def test_get_board_unknown_env_raises() -> None:
|
||||
with pytest.raises(KeyError, match="Unknown env"):
|
||||
boards.get_board("definitely-not-a-real-env")
|
||||
|
||||
|
||||
def test_get_board_surfaces_raw_config() -> None:
|
||||
rec = boards.get_board("tbeam")
|
||||
assert "raw_config" in rec
|
||||
assert "custom_meshtastic_architecture" in rec["raw_config"]
|
||||
assert rec["raw_config"]["custom_meshtastic_architecture"] == "esp32"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""`pio.py` subprocess wrapper: error paths, tailing, JSON parsing.
|
||||
|
||||
Uses a real `pio` install only for the happy-path `--version`; error paths are
|
||||
exercised with a deliberately-broken `MESHTASTIC_PIO_BIN` override.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import pio
|
||||
|
||||
|
||||
def test_tail_lines_keeps_last_n() -> None:
|
||||
text = "\n".join(f"line-{i}" for i in range(1, 11))
|
||||
assert pio.tail_lines(text, 3) == "line-8\nline-9\nline-10"
|
||||
assert pio.tail_lines(text, 100) == text # more lines requested than exist
|
||||
assert pio.tail_lines("", 5) == ""
|
||||
|
||||
|
||||
def test_tail_lines_handles_trailing_newline() -> None:
|
||||
assert pio.tail_lines("a\nb\nc\n", 2) == "b\nc"
|
||||
|
||||
|
||||
def test_pio_version_runs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Happy path: `pio --version` exits 0 and prints a version string.
|
||||
|
||||
This exercises subprocess spawn, timeout default, and the PioResult shape.
|
||||
Skipped if pio isn't installed (CI would need pio preinstalled).
|
||||
"""
|
||||
try:
|
||||
result = pio.run(["--version"], timeout=30)
|
||||
except pio.PioError:
|
||||
pytest.skip("pio not available in this environment")
|
||||
assert result.returncode == 0
|
||||
assert "PlatformIO" in result.stdout or "platformio" in result.stdout.lower()
|
||||
assert result.duration_s > 0
|
||||
|
||||
|
||||
def test_pio_bad_command_raises_pio_error() -> None:
|
||||
"""`pio` returning non-zero must surface as PioError with stderr captured."""
|
||||
with pytest.raises(pio.PioError) as excinfo:
|
||||
pio.run(["this-subcommand-does-not-exist"], timeout=10)
|
||||
# PioError includes returncode + a tail of stderr/stdout.
|
||||
assert excinfo.value.returncode != 0
|
||||
|
||||
|
||||
def test_pio_timeout_raises_pio_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Extremely short timeout on a command that takes longer must raise PioTimeout."""
|
||||
# `pio` startup alone typically takes ~200-500ms; a 1ms timeout reliably trips.
|
||||
with pytest.raises(pio.PioTimeout):
|
||||
pio.run(["--help"], timeout=0.001)
|
||||
|
||||
|
||||
def test_run_json_parses_device_list() -> None:
|
||||
"""`pio device list --json-output` is a known-valid JSON producer."""
|
||||
try:
|
||||
data = pio.run_json(["device", "list"], timeout=15)
|
||||
except pio.PioError:
|
||||
pytest.skip("pio not available in this environment")
|
||||
# Always a list; may be empty if nothing is plugged in.
|
||||
assert isinstance(data, list)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""`userprefs.build_testing_profile` / `generate_psk` correctness.
|
||||
|
||||
The testing-profile generator is the critical primitive for automated test
|
||||
labs: it must produce deterministic PSKs for a given seed (so every device
|
||||
baked in a CI run joins the same mesh) and different PSKs for different seeds
|
||||
(so concurrent labs don't collide).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import userprefs
|
||||
|
||||
|
||||
def test_generate_psk_is_32_bytes_formatted() -> None:
|
||||
psk = userprefs.generate_psk(seed="deterministic")
|
||||
# Format: "{ 0x.., 0x.., ... }" with 32 comma-separated hex bytes.
|
||||
assert psk.startswith("{ ") and psk.endswith(" }")
|
||||
bytes_part = psk.removeprefix("{ ").removesuffix(" }")
|
||||
hex_bytes = [b.strip() for b in bytes_part.split(",")]
|
||||
assert len(hex_bytes) == 32
|
||||
for b in hex_bytes:
|
||||
assert b.startswith("0x")
|
||||
int(b, 16) # raises if not valid hex
|
||||
|
||||
|
||||
def test_generate_psk_deterministic_under_same_seed() -> None:
|
||||
a = userprefs.generate_psk(seed="pytest-session-123")
|
||||
b = userprefs.generate_psk(seed="pytest-session-123")
|
||||
assert a == b, "same seed must produce same PSK"
|
||||
|
||||
|
||||
def test_generate_psk_varies_with_seed() -> None:
|
||||
seeds = ["a", "b", "pytest-1", "pytest-2", "prod-fleet-alpha"]
|
||||
psks = {userprefs.generate_psk(seed=s) for s in seeds}
|
||||
assert len(psks) == len(seeds), "seed → PSK map must be injective"
|
||||
|
||||
|
||||
def test_generate_psk_random_when_seedless() -> None:
|
||||
a = userprefs.generate_psk(seed=None)
|
||||
b = userprefs.generate_psk(seed=None)
|
||||
# Not strictly guaranteed (birthday paradox), but 256-bit randomness makes
|
||||
# a collision astronomically unlikely.
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_testing_profile_contains_expected_keys() -> None:
|
||||
profile = userprefs.build_testing_profile(psk_seed="ci-run-1")
|
||||
|
||||
required = {
|
||||
"USERPREFS_CONFIG_LORA_REGION",
|
||||
"USERPREFS_LORACONFIG_MODEM_PRESET",
|
||||
"USERPREFS_LORACONFIG_CHANNEL_NUM",
|
||||
"USERPREFS_CHANNELS_TO_WRITE",
|
||||
"USERPREFS_CHANNEL_0_NAME",
|
||||
"USERPREFS_CHANNEL_0_PSK",
|
||||
"USERPREFS_CHANNEL_0_PRECISION",
|
||||
"USERPREFS_CONFIG_LORA_IGNORE_MQTT",
|
||||
"USERPREFS_MQTT_ENABLED",
|
||||
"USERPREFS_CHANNEL_0_UPLINK_ENABLED",
|
||||
"USERPREFS_CHANNEL_0_DOWNLINK_ENABLED",
|
||||
}
|
||||
assert required <= set(profile.keys())
|
||||
|
||||
# Defaults from the plan
|
||||
assert profile["USERPREFS_CONFIG_LORA_REGION"].endswith("_US")
|
||||
assert profile["USERPREFS_LORACONFIG_MODEM_PRESET"].endswith("_LONG_FAST")
|
||||
assert profile["USERPREFS_LORACONFIG_CHANNEL_NUM"] == 88
|
||||
assert profile["USERPREFS_CHANNEL_0_NAME"] == "McpTest"
|
||||
|
||||
|
||||
def test_testing_profile_rejects_unknown_region() -> None:
|
||||
with pytest.raises(ValueError, match="Unknown region"):
|
||||
userprefs.build_testing_profile(region="ATLANTIS")
|
||||
|
||||
|
||||
def test_testing_profile_rejects_unknown_modem_preset() -> None:
|
||||
with pytest.raises(ValueError, match="Unknown modem_preset"):
|
||||
userprefs.build_testing_profile(modem_preset="WARP_9")
|
||||
|
||||
|
||||
def test_testing_profile_rejects_oversized_channel_name() -> None:
|
||||
with pytest.raises(ValueError, match="11-char max"):
|
||||
userprefs.build_testing_profile(channel_name="WayTooLongChannelName")
|
||||
|
||||
|
||||
def test_testing_profile_rejects_oversized_short_name() -> None:
|
||||
with pytest.raises(ValueError, match="≤4 chars"):
|
||||
userprefs.build_testing_profile(short_name="TOOLONG")
|
||||
|
||||
|
||||
def test_disable_mqtt_false_drops_mqtt_keys() -> None:
|
||||
profile = userprefs.build_testing_profile(psk_seed="x", disable_mqtt=False)
|
||||
# When disable_mqtt is False, the MQTT-gating keys should NOT be in the
|
||||
# profile (device uses firmware defaults, whatever those are).
|
||||
assert "USERPREFS_MQTT_ENABLED" not in profile
|
||||
assert "USERPREFS_CHANNEL_0_UPLINK_ENABLED" not in profile
|
||||
|
||||
|
||||
def test_disable_position_adds_gps_disabled() -> None:
|
||||
profile = userprefs.build_testing_profile(psk_seed="x", disable_position=True)
|
||||
assert profile["USERPREFS_CONFIG_GPS_MODE"].endswith("_DISABLED")
|
||||
assert profile["USERPREFS_CONFIG_SMART_POSITION_ENABLED"] is False
|
||||
|
||||
|
||||
def test_owner_names_included_when_provided() -> None:
|
||||
profile = userprefs.build_testing_profile(
|
||||
psk_seed="x", long_name="Lab Bench 1", short_name="LB1"
|
||||
)
|
||||
assert profile["USERPREFS_CONFIG_OWNER_LONG_NAME"] == "Lab Bench 1"
|
||||
assert profile["USERPREFS_CONFIG_OWNER_SHORT_NAME"] == "LB1"
|
||||
|
||||
|
||||
def test_psk_seed_isolation_across_ci_runs() -> None:
|
||||
"""The core claim: two test labs running concurrently with different
|
||||
session seeds produce different PSKs — their meshes cannot decode each
|
||||
other's traffic."""
|
||||
lab_a = userprefs.build_testing_profile(psk_seed="lab-A-nightly")
|
||||
lab_b = userprefs.build_testing_profile(psk_seed="lab-B-nightly")
|
||||
assert lab_a["USERPREFS_CHANNEL_0_PSK"] != lab_b["USERPREFS_CHANNEL_0_PSK"]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Unit tests for `userprefs.py`: jsonc parse, type inference, round-trip
|
||||
write, and the `temporary_overrides` context manager's byte-for-byte restore.
|
||||
|
||||
None of these require hardware. They validate the contract that the flash/
|
||||
testing-profile tools rely on — if these fail, the provisioning tier will
|
||||
produce confusing mismatches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import userprefs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_jsonc(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Write a minimal userPrefs.jsonc into tmp_path and point config at it."""
|
||||
content = """{
|
||||
"USERPREFS_CONFIG_LORA_REGION": "meshtastic_Config_LoRaConfig_RegionCode_US",
|
||||
"USERPREFS_LORACONFIG_CHANNEL_NUM": "88",
|
||||
// "USERPREFS_CHANNEL_0_NAME": "McpTest",
|
||||
"USERPREFS_CHANNEL_0_PSK": "{ 0x01, 0x02, 0x03 }",
|
||||
// "USERPREFS_MQTT_ENABLED": "0",
|
||||
"USERPREFS_CONFIG_LORA_IGNORE_MQTT": "true"
|
||||
}
|
||||
"""
|
||||
# Fake firmware root with a userPrefs.jsonc + platformio.ini (needed for
|
||||
# `config.firmware_root()`'s walk-up detection).
|
||||
(tmp_path / "platformio.ini").write_text("[platformio]\n", encoding="utf-8")
|
||||
jsonc = tmp_path / "userPrefs.jsonc"
|
||||
jsonc.write_text(content, encoding="utf-8")
|
||||
monkeypatch.setenv("MESHTASTIC_FIRMWARE_ROOT", str(tmp_path))
|
||||
return jsonc
|
||||
|
||||
|
||||
def test_read_state_separates_active_and_commented(sample_jsonc: Path) -> None:
|
||||
state = userprefs.read_state()
|
||||
assert set(state["active"]) == {
|
||||
"USERPREFS_CONFIG_LORA_REGION",
|
||||
"USERPREFS_LORACONFIG_CHANNEL_NUM",
|
||||
"USERPREFS_CHANNEL_0_PSK",
|
||||
"USERPREFS_CONFIG_LORA_IGNORE_MQTT",
|
||||
}
|
||||
assert set(state["commented"]) == {
|
||||
"USERPREFS_CHANNEL_0_NAME",
|
||||
"USERPREFS_MQTT_ENABLED",
|
||||
}
|
||||
|
||||
|
||||
def test_infer_type_matches_platformio_custom_py() -> None:
|
||||
# Mirrors the branch order in `bin/platformio-custom.py:222-235`.
|
||||
assert userprefs.infer_type("{ 0x01, 0x02 }") == "brace"
|
||||
assert userprefs.infer_type("88") == "number"
|
||||
assert userprefs.infer_type("-1.5") == "number"
|
||||
assert userprefs.infer_type("true") == "bool"
|
||||
assert userprefs.infer_type("false") == "bool"
|
||||
assert userprefs.infer_type("meshtastic_Config_DeviceConfig_Role_ROUTER") == "enum"
|
||||
assert userprefs.infer_type("plain string value") == "string"
|
||||
assert userprefs.infer_type(None) == "unknown"
|
||||
|
||||
|
||||
def test_temporary_overrides_restores_byte_for_byte(sample_jsonc: Path) -> None:
|
||||
"""The context manager MUST leave the file bit-identical on exit, even on
|
||||
exception — this is the safety guarantee build/flash tools rely on."""
|
||||
original = sample_jsonc.read_bytes()
|
||||
|
||||
with userprefs.temporary_overrides({"USERPREFS_CHANNEL_0_NAME": "OverrideTest"}):
|
||||
# During the context, the override is written.
|
||||
during = userprefs.read_state()
|
||||
assert "USERPREFS_CHANNEL_0_NAME" in during["active"]
|
||||
assert during["active"]["USERPREFS_CHANNEL_0_NAME"] == "OverrideTest"
|
||||
|
||||
# After: byte-identical restore.
|
||||
assert sample_jsonc.read_bytes() == original
|
||||
|
||||
|
||||
def test_temporary_overrides_restores_after_exception(sample_jsonc: Path) -> None:
|
||||
original = sample_jsonc.read_bytes()
|
||||
|
||||
with pytest.raises(RuntimeError, match="simulated"):
|
||||
with userprefs.temporary_overrides({"USERPREFS_CHANNEL_0_NAME": "Failing"}):
|
||||
raise RuntimeError("simulated mid-build failure")
|
||||
|
||||
assert sample_jsonc.read_bytes() == original
|
||||
|
||||
|
||||
def test_temporary_overrides_none_is_noop(sample_jsonc: Path) -> None:
|
||||
original = sample_jsonc.read_bytes()
|
||||
with userprefs.temporary_overrides(None) as effective:
|
||||
# No file write, and `effective` still reflects the active set.
|
||||
assert "USERPREFS_CONFIG_LORA_REGION" in effective
|
||||
assert sample_jsonc.read_bytes() == original
|
||||
|
||||
|
||||
def test_temporary_overrides_rejects_non_userprefs_keys(sample_jsonc: Path) -> None:
|
||||
with pytest.raises(ValueError, match="USERPREFS_"):
|
||||
with userprefs.temporary_overrides({"RANDOM_KEY": "value"}):
|
||||
pass
|
||||
|
||||
|
||||
def test_build_manifest_surfaces_all_keys(sample_jsonc: Path) -> None:
|
||||
"""Manifest should union the jsonc set with firmware-src consumers.
|
||||
|
||||
In the sample tmpdir there's no `src/` so `consumed_by` is empty for all
|
||||
entries; that's fine — the manifest still lists every jsonc key.
|
||||
"""
|
||||
manifest = userprefs.build_manifest()
|
||||
keys = {e["key"] for e in manifest["entries"]}
|
||||
# All 6 keys from sample_jsonc should be present.
|
||||
assert "USERPREFS_CONFIG_LORA_REGION" in keys
|
||||
assert "USERPREFS_CHANNEL_0_NAME" in keys # commented but still listed
|
||||
assert manifest["active_count"] == 4
|
||||
assert manifest["commented_count"] == 2
|
||||
Reference in New Issue
Block a user