6b15571e14
* 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>
244 lines
8.4 KiB
Python
244 lines
8.4 KiB
Python
"""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)
|