Files
22072c5f4b Pr1.5 tmm nexthop (#10745)
* TrafficManagement: flat unified cache + persistent next-hop overflow store

Reworks the TrafficManagementModule cache layer (policing behaviour unchanged
from upstream) and adds a routing-hint overflow store:

- Flatten the ring: replace the cuckoo-hashed unified cache and the bucketed
  PSRAM NodeInfo index with plain flat arrays + linear scan (same idiom as
  WarmNodeStore). At LoRa packet rates an O(n) scan of the cache is negligible,
  and it removes a large amount of hashing/displacement complexity. The cache
  entry is 11 B; timestamps use a uniform +1 presence-offset so a 0 byte always
  means "empty" across every sub-store. Adds rebaseEpoch() so cached state
  survives the ~19 h relative-timestamp horizon instead of being flushed.

- Next-hop overflow cache: setNextHop/getNextHopHint store a confirmed last-byte
  relay for a destination, written only from NextHopRouter's ACK-confirmed
  decision (and mirrored from TraceRoute). NextHopRouter::getNextHop falls back
  to this cache when the hot NodeDB has no hint, so DMs/relays to long-tail
  nodes keep routing after the node ages out of NodeInfoLite.

- Persistence: preloadNextHopsFromNodeDB warm-starts the cache from persisted
  NodeInfoLite hints on first maintenance pass; next_hop entries are kept alive
  across the maintenance sweep (no TTL) and never clobbered by a stale preload.

All packet-policing logic (rate limit, position dedup, unknown-packet drop,
NodeInfo direct response, hop exhaustion) is the existing upstream behaviour,
untouched. HAS_TRAFFIC_MANAGEMENT defaults on so the module is compiled in. (see note).

Tests: upstream policing suite now actually runs (adds the MeshTypes.h include
that gates HAS_TRAFFIC_MANAGEMENT) plus 4 next-hop tests. Role-aware throttles,
politeness, precision clamp, port-interval and mesh-radius gating — and the
rate-limit >255 saturation fix — are deferred to the advanced-TMM branch.

Note: default dedup movement grid moves to ~91m, which also means 1.5km required to end up with the same signature position - coarser and therefore further than before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* TrafficManagement: fix cppcheck constVariablePointer warning

`node` in preloadNextHopsFromNodeDB() is never written through — mark
it const to satisfy cppcheck's constVariablePointer check in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add multi-hop NextHop recovery tests and unit tests for routing reliability

- Introduced a new test suite for multi-hop NextHop directed-message delivery and relay recovery in `test_nexthop_multihop_recovery.py`. This includes tests for end-to-end delivery and recovery after relay drop.
- Implemented unit tests in `test_main.cpp` for NextHop routing reliability mitigations, covering:
  - M1: Ambiguity-aware last-byte resolution.
  - M2: NextHopRouter's strict-neighbor gate and hop limit checks.
  - M3: Route-health freshness and failure decay.
- Enhanced mock classes to facilitate controlled testing of node behaviors and routing logic.

* grafting fixed

* Address Copilot review for PR #10735 (NextHop improvements)

- docs/nexthop-routing-reliability.md: update status from "no code
  changes yet" to reflect that mitigations and tests are implemented

RAM pressure and MIGRATION_VERBOSE concerns addressed upstream in
PR2.5 (per-platform TRAFFIC_MANAGEMENT_CACHE_SIZE) and PR2 (verbose
default=0) respectively; (0,0) sentinel fixed in PR2.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* CI: fix cppcheck constVariablePointer and test include path

- NextHopRouter.cpp: qualify two RouteHealth *h locals as const — only
  read for stale-route checks, never mutated through the pointer
- Router.cpp: qualify meshtastic_NodeInfoLite *node as const in
  shouldDecrementHopLimit — only read for favorite/role predicate
- test_position_module/test_main.cpp: change bare PositionModule.h to
  modules/PositionModule.h — build_flags sets -Isrc, not -Isrc/modules,
  so the bare form fails to resolve in the native PlatformIO test env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WarmStore: cache device role + protected category in last_heard low bits

Steal the low 6 bits of WarmNodeEntry.last_heard to carry an evicted node's
device role (4 bits) and a protected category (2 bits) for the hop-trim path,
at zero record-size cost (entry stays 40 B; no RAM/flash growth). The high bits
remain a real unix-seconds timestamp, quantised to 64 s — ample for warm LRU
ordering of long-tail nodes.

- absorb() packs role/protectedCat; place()/ring replay store the raw word so
  metadata round-trips through flash. LRU compares masked time (warmTimeOf).
- take() rehydration masks the metadata bits and restores the cached role so a
  re-admitted node isn't stuck at CLIENT until its next NodeInfo.
- NodeDB classifies the category (favorite/ignored/verified -> Flag;
  tracker/sensor/tak_tracker -> Role) at each eviction site.
- WarmNodeStore::lookupMeta() exposes role/category to consumers.
- Bump WARM_RING_MAGIC (WRNG->WRN2): old rings read as erased and rebuild;
  warm data is a non-critical evictee cache, so discard-on-upgrade is safe.

Tests: test_warm_store 11/11 (new meta round-trip + quantisation-aware ordering);
NodeDB compiles (test_nodedb_blocked 4/4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: migrate v1 rings/files by discarding last_heard, not the data

Previously the WRNG->WRN2 magic bump treated old rings as erased, discarding all
warm entries — including the PKI public keys that let evicted nodes keep
decrypting DMs. Instead, read v1 (WRNG / WRM1) records and keep each node's
identity + public key, discarding only last_heard (its low bits would otherwise
be misread as the new role/protected metadata). Records re-rank and re-learn
their role on next contact.

- Ring backend (nRF52840): ringReadHeader accepts both magics and reports v1 via
  an out-param; replay zeroes last_heard for v1 records. If the active head page
  is v1, force a rotation so new v2 records never land in a v1-headered page
  (which would discard their freshly-set role on the next load). Legacy pages
  convert to v2 as the ring rotates.
- File backend (warm.dat): bump WARM_STORE_MAGIC WRM1->WRM2; accept WRM1, verify
  CRC against the stored bytes, then discard last_heard and mark dirty so the
  next save rewrites as v2.

Tests: test_warm_store 12/12 (adds test_ws_v1_migration_discardsLastHeard:
key survives, role/protected reset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: guard role bit-width + test eviction carries role/protected

- static_assert that the device role enum still fits the 4-bit warm metadata
  field (WARM_ROLE_MASK); fails the build loudly if a new role is added past 15
  rather than silently truncating role on eviction. (Max role today = 12.)
- Add test_migration_carriesRoleAndProtectedIntoWarm: a demoted TRACKER lands in
  the warm tier with its key, role=TRACKER and protected category=Role; a demoted
  CLIENT carries role=CLIENT/None. Exercises the NodeDB eviction path +
  warmProtectedCategory classification (the warm-store unit tests only cover
  absorb() directly).

Tests: test_nodedb_blocked 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix copilot comments

* fix(test): restore #if HAS_TRAFFIC_MANAGEMENT guard in TMM test

The rebase onto PR1.5 lost the top-level HAS_TRAFFIC_MANAGEMENT guard
that PR1.5 introduced, leaving the #else/#endif tail orphaned and
causing compile errors on non-TMM builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-19 19:52:58 -05:00
..
2026-06-19 19:52:58 -05:00

Meshtastic MCP Server

An MCP server for working with the Meshtastic firmware repo and connected devices. Lets Claude Code / Claude Desktop:

  • Discover USB-connected Meshtastic devices
  • Enumerate PlatformIO board variants (166+) with Meshtastic metadata
  • Build, clean, flash, erase-and-flash (factory), and OTA-update firmware
  • Read serial logs via pio device monitor (with board-specific exception decoders)
  • Trigger 1200bps touch-reset for bootloader entry (nRF52, ESP32-S3, RP2040)
  • Query and administer a running node via the meshtastic Python API: owner name, config (LocalConfig + ModuleConfig), channels, messaging, reboot/shutdown/factory-reset
  • Call esptool, nrfutil, picotool directly when PlatformIO doesn't cover the operation

Design principle

PlatformIO first. Its pio run -t upload knows the correct protocol, offsets, and post-build chain for every variant in variants/. Direct vendor-tool wrappers (esptool_*, nrfutil_*, picotool_*) exist as escape hatches for operations pio doesn't cover (blank-chip erase, DFU .zip packages, BOOTSEL-mode inspection).

Prerequisites

  • Python ≥ 3.11
  • PlatformIO Corepio on $PATH or at ~/.platformio/penv/bin/pio
  • The Meshtastic firmware repo checked out somewhere (set via MESHTASTIC_FIRMWARE_ROOT)
  • Optional: esptool, nrfutil, picotool on $PATH (or under the firmware venv at .venv/bin/) if you want to use the direct-tool wrappers

Install

cd <firmware-repo>/mcp-server
python3 -m venv .venv
.venv/bin/pip install -e .

Verify:

MESHTASTIC_FIRMWARE_ROOT=<firmware-repo> .venv/bin/python -m meshtastic_mcp

The server blocks on stdin (that's correct — it speaks MCP over stdio). Ctrl-C to exit.

Register with Claude Code

Edit ~/.claude/settings.json (global) or <firmware-repo>/.claude/settings.local.json (project-only):

{
  "mcpServers": {
    "meshtastic": {
      "command": "<firmware-repo>/mcp-server/.venv/bin/python",
      "args": ["-m", "meshtastic_mcp"],
      "env": {
        "MESHTASTIC_FIRMWARE_ROOT": "<firmware-repo>"
      }
    }
  }
}

Replace <firmware-repo> with the absolute path, e.g. /Users/you/GitHub/firmware. Restart Claude Code after editing.

Register with Claude Desktop

Same mcpServers block, but in ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows).

Tools (43)

Discovery & metadata

Tool What it does
list_devices USB/serial port listing, flags likely-Meshtastic candidates
list_boards PlatformIO envs with custom_meshtastic_* metadata; filters by arch/supported/query/level
get_board Full env dict incl. raw pio config

Build & flash

Tool What it does
build pio run -e <env> (+ mtjson target)
clean pio run -e <env> -t clean
pio_flash pio run -e <env> -t upload --upload-port <port> — any architecture
erase_and_flash ESP32 full factory flash via bin/device-install.sh
update_flash ESP32 OTA app-partition update via bin/device-update.sh
touch_1200bps 1200-baud open/close to trigger USB CDC bootloader entry

Serial log sessions

Backed by long-running pio device monitor subprocesses with a 10k-line ring buffer per session and board-specific filters (esp32_exception_decoder auto-selected when you pass env=).

Tool What it does
serial_open Start a monitor session; returns session_id
serial_read Cursor-based pull; reports dropped if lines aged out of the ring
serial_list All active sessions
serial_close Terminate a session

Device reads

Tool What it does
device_info my_node_num, long/short name, firmware version, region, channel, node count
list_nodes Full node database with position, SNR, RSSI, last_heard, battery

The tool tables below document 38 currently registered MCP server tools.

Device writes

Tool What it does
set_owner Long name + optional short name (≤4 chars)
get_config One section or all (LocalConfig + ModuleConfig)
set_config Dot-path field write: lora.region="US", device.role="ROUTER", etc.
get_channel_url Primary-only or include_all=admin URL
set_channel_url Import channels from a Meshtastic URL
set_debug_log_api Enable or disable debug logging for the Meshtastic Python API client
send_text Broadcast or direct text message
reboot localNode.reboot(secs) — requires confirm=True
shutdown localNode.shutdown(secs) — requires confirm=True
factory_reset localNode.factoryReset(full?) — requires confirm=True

Direct hardware tools (escape hatches)

Tool What it does
esptool_chip_info Read chip, MAC, crystal, flash size
esptool_erase_flash Full-chip erase (destructive)
esptool_raw Pass-through; confirm=True required for write/erase/merge
nrfutil_dfu DFU-flash a .zip package
nrfutil_raw Pass-through
picotool_info Read Pico BOOTSEL-mode info
picotool_load Load a UF2
picotool_raw Pass-through

USB power control (uhubctl)

Tool What it does
uhubctl_list Enumerate USB hubs + attached-device VID/PID (read-only)
uhubctl_power Drive a hub port on or off; off requires confirm=True
uhubctl_cycle Off → wait delay_s → on; confirm=True required

Target a port by explicit (location, port) (raw uhubctl syntax like location="1-1.3", port=2) or by role ("nrf52", "esp32s3"). Role lookup checks MESHTASTIC_UHUBCTL_LOCATION_<ROLE> + MESHTASTIC_UHUBCTL_PORT_<ROLE> env vars first, then auto-detects via VID against uhubctl's output.

Requires uhubctl on PATH:

brew install uhubctl        # macOS
apt install uhubctl         # Debian/Ubuntu

Modern macOS + PPPS-capable hubs generally work without root. On Linux without udev rules, or on old macOS with driver quirks, you may need sudo. If uhubctl returns a permission error the MCP tool raises a clear UhubctlError pointing at the udev-rules / sudo fallback rather than auto-sudo'ing mid-run.

Safety

  • All destructive flash/admin tools require confirm=True as a tool-level gate, on top of any permission prompt from Claude.
  • Serial port is exclusive. If a serial_* session is active on a port, device_info/admin tools on the same port will fail fast with a pointer at the active session_id. Close the session first.
  • Flash confirmation by architecture: erase_and_flash / update_flash error if the env's architecture isn't ESP32 — use pio_flash for nRF52/RP2040/STM32.

Environment variables

Var Default Purpose
MESHTASTIC_FIRMWARE_ROOT walks up from cwd for platformio.ini Pin the firmware repo
MESHTASTIC_PIO_BIN ~/.platformio/penv/bin/pio$PATH pioplatformio Override pio location
MESHTASTIC_ESPTOOL_BIN <firmware>/.venv/bin/esptool$PATH Override esptool
MESHTASTIC_NRFUTIL_BIN $PATH Override nrfutil
MESHTASTIC_PICOTOOL_BIN $PATH Override picotool
MESHTASTIC_MCP_SEED mcp-<user>-<host> PSK seed for test-harness session (CI override)
MESHTASTIC_MCP_FLASH_LOG <mcp-server>/tests/flash.log Tee target for pio/esptool/nrfutil subprocess output (TUI tails it)
MESHTASTIC_MCP_TCP_HOST unset host or host:port of a meshtasticd daemon to surface as a TCP device (see "TCP / native-host nodes" below)

TCP / native-host nodes

The native-macos and native PlatformIO envs build a headless meshtasticd binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino). The daemon exposes the meshtastic TCP API on port 4403 rather than a USB serial endpoint — point the MCP server at it via MESHTASTIC_MCP_TCP_HOST:

# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini
#    for full Homebrew prereqs and CH341 LoRa-adapter setup).
pio run -e native-macos
~/.meshtasticd/meshtasticd

# 2. Point the MCP server at it.
export MESHTASTIC_MCP_TCP_HOST=localhost     # or host:port, default port 4403

First-run gotcha — MAC address. meshtasticd derives its MAC from the USB adapter's serial-number / product strings. Many cheap CH341 dongles (MeshStick included — VID 0x1A86 / PID 0x5512) ship with iSerialNumber=0 and iProduct=0, so the daemon aborts on boot with *** Blank MAC Address not allowed!. Set the MAC explicitly in config.yaml:

# Under General:
MACAddress: 02:CA:FE:BA:BE:01

Use a locally-administered address (first byte's second-LSB set, e.g. 02:* / 06:* / 0A:* / 0E:*) to avoid colliding with a real OUI.

There is also a --hwid AA:BB:CC:DD:EE:FF CLI flag visible in meshtasticd --help, but it is currently broken in MAC_from_string() (src/platform/portduino/PortduinoGlue.cpp): the function strips colons from its parameter but then reads bytes from the global portduino_config.mac_address, so --hwid is silently overridden when MACAddress: is also set, and crashes the daemon (uncaught std::invalid_argument: stoi: no conversion) when it isn't. Use the YAML form until that's fixed upstream.

list_devices will surface the daemon as tcp://localhost:4403 with likely_meshtastic=True, so device_info, list_nodes, get_config, set_config, set_owner, send_text, userprefs_*, and the admin RPCs auto-select it when no port is passed. Pass port="tcp://other-host:9999" explicitly to target a different daemon.

Tools that don't apply to a TCP/native node (no USB hardware to operate on) raise a clear ConnectionError rather than failing mysteriously: pio_flash, erase_and_flash, update_flash, touch_1200bps, serial_open (use info/admin tools directly), and the vendor escape hatches esptool_*, nrfutil_*, picotool_*. pio_flash against a native* env similarly raises — there's no upload step; use build and run the binary directly.

The pytest harness in tests/ still assumes USB-attached devices per role — TCP-aware fixtures are not part of this surface yet.

Hardware Test Suite

mcp-server/tests/ holds a pytest-based integration suite that exercises real USB-connected Meshtastic devices against the MCP server surface. Separate from the native C++ unit tests in the firmware repo's top-level test/ directory — this one validates the device-facing behavior end-to-end.

Invocation

./mcp-server/run-tests.sh                               # full suite (auto-detect + auto-bake-if-needed)
./mcp-server/run-tests.sh --force-bake                  # reflash devices before testing
./mcp-server/run-tests.sh --assume-baked                # skip the bake step (caller vouches for state)
./mcp-server/run-tests.sh tests/mesh                    # one tier
./mcp-server/run-tests.sh tests/mesh/test_traceroute.py # one file
./mcp-server/run-tests.sh -k telemetry                  # pytest name filter

The wrapper auto-detects connected devices (VID 0x239Anrf52 → env rak4631; 0x303A or 0x10C4esp32s3 → env heltec-v3), exports MESHTASTIC_MCP_ENV_<ROLE> env vars, and invokes pytest. Overrides via per-role env vars: MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114 ./run-tests.sh.

No hardware connected? The wrapper narrows to tests/unit/ only and says so in the pre-flight header.

Tiers (run in this order)

  • bake (tests/test_00_bake.py) — flashes both hub roles with the session's test profile. Has a skip-if-already-baked check (region + channel match); --force-bake overrides.
  • unit — pure Python, no hardware. boards / PIO wrapper / userPrefs-parse / testing-profile fixtures.
  • mesh — 2-device mesh: formation, broadcast delivery, direct+ACK, traceroute, bidirectional. Parametrized over both directions. Includes test_peer_offline_recovery which uses uhubctl to power-cycle one peer mid-conversation and verifies the mesh recovers (skips without uhubctl).
  • telemetry — periodic telemetry broadcast + on-demand request/reply (TELEMETRY_APP with wantResponse=True).
  • monitor — boot log has no panic markers within 60 s of reboot.
  • recoveryuhubctl power-cycle round-trip: verifies the hub port can be toggled off/on, the device re-enumerates with the same my_node_num, and NVS-resident config (region, channel, modem preset) survives a hard reset. Requires uhubctl on PATH; skips cleanly otherwise.
  • ui — input-broker-driven screen navigation (AdminMessage.send_input_event injection → Screen::handleInputEvent → frame transition). Parametrized on the screen-bearing role (heltec-v3 OLED). Captures images via USB webcam + OCRs them for HTML-report evidence. Requires pip install -e '.[ui]' and MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=<index>; tier is auto-deselected if cv2 isn't importable.
  • fleet — PSK-seed isolation: two labs with different seeds never overlap.
  • admin — owner persistence across reboot, channel URL round-trip, lora.hop_limit persistence.
  • provisioning — region/channel baking, userPrefs survive factory_reset(full=False).

UI tier setup

The tests/ui/ tier drives the on-device OLED via the firmware's existing AdminMessage.send_input_event RPC (no firmware changes required) and verifies transitions via a macro-gated log line + camera + OCR. Summary:

  1. Install extras: pip install -e 'mcp-server/.[ui]' — pulls in opencv-python-headless, numpy, easyocr, Pillow. First easyocr run downloads ~100 MB of models to ~/.EasyOCR/; an autouse session fixture pre-warms the reader so per-test OCR is <100 ms after that.
  2. Point a USB webcam at the heltec-v3 OLED. Discover its index:
    .venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"
    
  3. Export the per-role device env var:
    export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0
    
  4. Run:
    ./run-tests.sh tests/ui -v
    
    Captures land under tests/ui_captures/<session_seed>/<test_id>/, one PNG + .ocr.txt per frame_capture() call, with a per-test transcript.md stepping through event → frame → OCR. The HTML report embeds the full image strip inline (pass or fail).

On macOS, cv2.VideoCapture(0) triggers the TCC Camera permission prompt on first use. Pre-grant Terminal (or your IDE's terminal) before running. The OpenCVBackend fails fast on 10 consecutive black frames so a silent permission denial surfaces as a clear error, not an empty PNG strip.

No camera? Set MESHTASTIC_UI_CAMERA_BACKEND=null (or leave the device var unset). Tests still exercise the event-injection path and log assertions; captures just become 1×1 black PNGs.

Artifacts (regenerated every run, under tests/)

  • report.html — self-contained pytest-html report. Each test gets a Meshtastic debug section attached on failure with a 200-line firmware log tail + device-state dump. Open this first on failures.
  • junit.xml — CI-parseable.
  • reportlog.jsonlpytest-reportlog event stream; consumed by the TUI.
  • fwlog.jsonl — firmware log mirror (meshtastic.log.line pubsub → JSONL).
  • flash.log — tee of all pio / esptool / nrfutil / picotool subprocess output during the run (driven by MESHTASTIC_MCP_FLASH_LOG).

Live TUI

.venv/bin/meshtastic-mcp-test-tui
.venv/bin/meshtastic-mcp-test-tui tests/mesh    # pytest args pass through

Textual-based wrapper over run-tests.sh with a live test tree, tier counters, pytest output pane, firmware-log pane, and a device-status strip. Key bindings: r re-run focused, f filter, d failure detail, g open report.html, x export reproducer bundle, l cycle fw-log filter, q quit (SIGINT → SIGTERM → SIGKILL escalation).

Set MESHTASTIC_UI_TUI_CAMERA=1 to mount a bottom-of-screen UI camera panel. Left side: the latest capture PNG rendered as Unicode half-blocks (via rich-pixels, works in any terminal — no kitty/sixel required). Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes — OCR: Nodes 2/2") so you can see every event-injection and its result as each UI test runs. Requires the [ui] extras for image rendering; the transcript alone works without them.

Slash commands

Three AI-assisted workflows are wired up for Claude Code operators (.claude/commands/) and Copilot operators (.github/prompts/): /test (run + interpret), /diagnose (read-only health report), /repro (flake triage, N-times re-run with log diff).

House rules (for human + agent contributors)

  • Session-scoped fixtures in tests/conftest.py snapshot + restore userPrefs.jsonc; never edit userPrefs.jsonc from inside a test. Use the test_profile / no_region_profile fixtures for ephemeral overrides.
  • SerialInterface holds an exclusive port lock; sequence calls open → mutate → close, then next device. No parallel calls to the same port.
  • Directed PKI-encrypted sends need bilateral NodeInfo warmup — both sides must hold the other's current pubkey. See tests/mesh/_receive.py::nudge_nodeinfo_port and the three directed- send tests (test_direct_with_ack, test_traceroute, test_telemetry_request_reply) for the canonical pattern.

Layout

mcp-server/
├── pyproject.toml
├── README.md
└── src/meshtastic_mcp/
    ├── __main__.py         # entry: python -m meshtastic_mcp
    ├── server.py           # FastMCP app + @app.tool() registrations (thin)
    ├── config.py           # firmware_root, pio_bin, esptool_bin, etc.
    ├── pio.py              # subprocess wrapper (timeouts, JSON, tail_lines)
    ├── devices.py          # list_devices (findPorts + comports)
    ├── boards.py           # list_boards / get_board (pio project config parse + cache)
    ├── flash.py            # build, clean, flash, erase_and_flash, update_flash, touch_1200bps
    ├── serial_session.py   # SerialSession + reader thread + ring buffer
    ├── registry.py         # session registry + per-port locks
    ├── connection.py       # connect(port) ctx mgr — SerialInterface + port lock
    ├── info.py             # device_info, list_nodes
    ├── admin.py            # set_owner, get/set_config, channels, send_text, reboot/shutdown/factory_reset
    └── hw_tools.py         # esptool / nrfutil / picotool wrappers

Troubleshooting

  • "Could not locate Meshtastic firmware root" — set MESHTASTIC_FIRMWARE_ROOT.
  • "Could not find pio" — install PlatformIO or set MESHTASTIC_PIO_BIN.
  • "Port is held by serial session ..." — call serial_close(session_id) or serial_list to find it.
  • factory.bin not found after build — the env may not be ESP32; only ESP32 envs produce a .factory.bin.
  • touch_1200bps reported new_port: null — the device may not have 1200bps-reset stdio, or the bootloader re-uses the same port name. Check list_devices manually.