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
8fd0a7f283
commit
6b15571e14
@@ -0,0 +1,243 @@
|
||||
"""Direct wrappers around vendor flashing tools: esptool, nrfutil, picotool.
|
||||
|
||||
These are escape hatches. Prefer the pio-based tools in flash.py when they
|
||||
cover the operation — pio knows the correct offsets, protocols, and filters
|
||||
for every supported board. Use these when pio doesn't: to erase a bricked
|
||||
ESP32, DFU-flash an nRF52 zip package, or inspect an RP2040's bootloader.
|
||||
|
||||
Every destructive `*_raw` subcommand is gated by `confirm=True` so callers
|
||||
can't accidentally `--write-flash` from freeform args.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from . import config, pio
|
||||
|
||||
_TIMEOUT_SHORT = 30
|
||||
_TIMEOUT_LONG = 600
|
||||
|
||||
|
||||
class ToolError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _run(
|
||||
binary: Path,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
timeout: float = _TIMEOUT_LONG,
|
||||
cwd: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# Shared with pio.run(): if `MESHTASTIC_MCP_FLASH_LOG` is set, each line
|
||||
# of output is tee'd to that file as it arrives so the TUI can show live
|
||||
# esptool/nrfutil/picotool progress instead of 3 minutes of silence.
|
||||
full = [str(binary), *args]
|
||||
try:
|
||||
rc, stdout, stderr, duration = pio._run_capturing(
|
||||
full,
|
||||
cwd=cwd,
|
||||
timeout=timeout,
|
||||
tee_header=f"{binary.name} {' '.join(args)}",
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ToolError(
|
||||
f"{binary.name} {' '.join(args)} timed out after {timeout}s"
|
||||
) from exc
|
||||
return {
|
||||
"exit_code": rc,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"stdout_tail": pio.tail_lines(stdout, 200),
|
||||
"stderr_tail": pio.tail_lines(stderr, 200),
|
||||
"duration_s": round(duration, 2),
|
||||
}
|
||||
|
||||
|
||||
def _require_confirm(confirm: bool, what: str) -> None:
|
||||
if not confirm:
|
||||
raise ToolError(f"{what} is destructive and requires confirm=True.")
|
||||
|
||||
|
||||
# ---------- esptool --------------------------------------------------------
|
||||
|
||||
ESPTOOL_DESTRUCTIVE = {
|
||||
"write_flash",
|
||||
"write-flash",
|
||||
"erase_flash",
|
||||
"erase-flash",
|
||||
"erase_region",
|
||||
"erase-region",
|
||||
"merge_bin",
|
||||
"merge-bin",
|
||||
}
|
||||
|
||||
|
||||
def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]:
|
||||
"""Parse `esptool chip_id` / `flash_id` output into structured fields."""
|
||||
result: dict[str, Any] = {
|
||||
"chip": None,
|
||||
"mac": None,
|
||||
"crystal_mhz": None,
|
||||
"flash_size": None,
|
||||
"features": [],
|
||||
}
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if m := re.match(r"Chip is (.+)", line):
|
||||
result["chip"] = m.group(1).strip()
|
||||
elif m := re.match(r"MAC: ([0-9a-fA-F:]+)", line):
|
||||
result["mac"] = m.group(1)
|
||||
elif m := re.match(r"Crystal is (\d+)MHz", line):
|
||||
result["crystal_mhz"] = int(m.group(1))
|
||||
elif m := re.match(r"Detected flash size: (\S+)", line):
|
||||
result["flash_size"] = m.group(1)
|
||||
elif m := re.match(r"Features: (.+)", line):
|
||||
result["features"] = [f.strip() for f in m.group(1).split(",") if f.strip()]
|
||||
return result
|
||||
|
||||
|
||||
def esptool_chip_info(port: str) -> dict[str, Any]:
|
||||
binary = config.esptool_bin()
|
||||
# `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash.
|
||||
combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT)
|
||||
if combined["exit_code"] != 0:
|
||||
raise ToolError(
|
||||
f"esptool failed (exit {combined['exit_code']}):\n{combined['stderr_tail']}"
|
||||
)
|
||||
parsed = _parse_esptool_chip_info(combined["stdout"])
|
||||
return {**parsed, "raw_stdout_tail": combined["stdout_tail"]}
|
||||
|
||||
|
||||
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
|
||||
"""Full-chip erase. Leaves the device unbootable until reflashed."""
|
||||
_require_confirm(confirm, "esptool_erase_flash")
|
||||
binary = config.esptool_bin()
|
||||
# esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name
|
||||
# first; if it fails with unknown command, retry old.
|
||||
res = _run(binary, ["--port", port, "erase-flash"], timeout=_TIMEOUT_LONG)
|
||||
if (
|
||||
res["exit_code"] != 0
|
||||
and "unrecognized" in (res["stderr"] or res["stdout"]).lower()
|
||||
):
|
||||
res = _run(binary, ["--port", port, "erase_flash"], timeout=_TIMEOUT_LONG)
|
||||
return res
|
||||
|
||||
|
||||
def esptool_raw(
|
||||
args: list[str], port: str | None = None, confirm: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Raw esptool passthrough. Destructive subcommands require confirm=True."""
|
||||
if not args:
|
||||
raise ToolError("args must not be empty")
|
||||
# Find the first non-flag arg (the subcommand).
|
||||
subcommand = next((a for a in args if not a.startswith("-")), None)
|
||||
if subcommand and subcommand.replace("-", "_") in {
|
||||
s.replace("-", "_") for s in ESPTOOL_DESTRUCTIVE
|
||||
}:
|
||||
_require_confirm(confirm, f"esptool {subcommand}")
|
||||
|
||||
binary = config.esptool_bin()
|
||||
full_args: list[str] = []
|
||||
if port:
|
||||
full_args.extend(["--port", port])
|
||||
full_args.extend(args)
|
||||
return _run(binary, full_args, timeout=_TIMEOUT_LONG)
|
||||
|
||||
|
||||
# ---------- nrfutil --------------------------------------------------------
|
||||
|
||||
NRFUTIL_DESTRUCTIVE = {"dfu", "settings"}
|
||||
|
||||
|
||||
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
|
||||
_require_confirm(confirm, "nrfutil_dfu")
|
||||
pkg = Path(package_path).expanduser()
|
||||
if not pkg.is_file():
|
||||
raise ToolError(f"Package not found: {pkg}")
|
||||
binary = config.nrfutil_bin()
|
||||
return _run(
|
||||
binary,
|
||||
["dfu", "serial", "--package", str(pkg), "--port", port, "-b", "115200"],
|
||||
timeout=_TIMEOUT_LONG,
|
||||
)
|
||||
|
||||
|
||||
def nrfutil_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
||||
if not args:
|
||||
raise ToolError("args must not be empty")
|
||||
subcommand = next((a for a in args if not a.startswith("-")), None)
|
||||
if subcommand in NRFUTIL_DESTRUCTIVE:
|
||||
_require_confirm(confirm, f"nrfutil {subcommand}")
|
||||
binary = config.nrfutil_bin()
|
||||
return _run(binary, args, timeout=_TIMEOUT_LONG)
|
||||
|
||||
|
||||
# ---------- picotool -------------------------------------------------------
|
||||
|
||||
PICOTOOL_DESTRUCTIVE = {"load", "reboot", "save", "erase"}
|
||||
|
||||
|
||||
def _parse_picotool_info(stdout: str) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"vendor": None,
|
||||
"product": None,
|
||||
"serial": None,
|
||||
"flash_size": None,
|
||||
"program_name": None,
|
||||
"program_version": None,
|
||||
}
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if m := re.match(r"Program information:", line):
|
||||
continue
|
||||
if m := re.match(r"name:\s*(.+)", line):
|
||||
result["program_name"] = m.group(1).strip()
|
||||
elif m := re.match(r"version:\s*(.+)", line):
|
||||
result["program_version"] = m.group(1).strip()
|
||||
elif m := re.match(r"vendor:\s*(.+)", line):
|
||||
result["vendor"] = m.group(1).strip()
|
||||
elif m := re.match(r"product:\s*(.+)", line):
|
||||
result["product"] = m.group(1).strip()
|
||||
elif m := re.match(r"serial number:\s*(.+)", line):
|
||||
result["serial"] = m.group(1).strip()
|
||||
elif m := re.match(r"flash size:\s*(.+)", line):
|
||||
result["flash_size"] = m.group(1).strip()
|
||||
return result
|
||||
|
||||
|
||||
def picotool_info(port: str | None = None) -> dict[str, Any]:
|
||||
"""Read device info from a Pico in BOOTSEL mode. `port` is informational
|
||||
only — picotool auto-detects."""
|
||||
binary = config.picotool_bin()
|
||||
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
|
||||
if res["exit_code"] != 0:
|
||||
raise ToolError(
|
||||
f"picotool info failed (exit {res['exit_code']}): "
|
||||
"is the Pico in BOOTSEL mode?\n" + res["stderr_tail"]
|
||||
)
|
||||
parsed = _parse_picotool_info(res["stdout"])
|
||||
return {**parsed, "raw_stdout_tail": res["stdout_tail"]}
|
||||
|
||||
|
||||
def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]:
|
||||
_require_confirm(confirm, "picotool_load")
|
||||
uf2 = Path(uf2_path).expanduser()
|
||||
if not uf2.is_file():
|
||||
raise ToolError(f"UF2 not found: {uf2}")
|
||||
binary = config.picotool_bin()
|
||||
return _run(binary, ["load", "-x", "-t", "uf2", str(uf2)], timeout=_TIMEOUT_LONG)
|
||||
|
||||
|
||||
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
||||
if not args:
|
||||
raise ToolError("args must not be empty")
|
||||
subcommand = next((a for a in args if not a.startswith("-")), None)
|
||||
if subcommand in PICOTOOL_DESTRUCTIVE:
|
||||
_require_confirm(confirm, f"picotool {subcommand}")
|
||||
binary = config.picotool_bin()
|
||||
return _run(binary, args, timeout=_TIMEOUT_LONG)
|
||||
Reference in New Issue
Block a user