- name
- meshtastic-device-ops
- license
- GPL-3.0-only
- description
- Discover, connect to, drive, configure, observe, and flash Meshtastic devices through the Meshtastic MCP server. Use when an agent needs to bring up a radio (serial or TCP), read or change device/channel config, send messages, inspect the node DB, watch live packets/telemetry/logs via the recorder, recover a bricked device, or build/flash firmware. Covers the non-e2e MCP tool surface; for cross-plane device↔app testing use the `meshtastic-e2e` skill.
# Meshtastic device operations (via the MCP server)
The Meshtastic MCP server exposes a large tool surface, plus resources and prompts. This
skill maps common operator intents to the right tools and the safe order to use them.
## First: know your environment
- **`doctor`** (tool) or the **`meshtastic://doctor`** resource — what's installed, what's
missing, and the exact command to acquire it. Call this first if any tool fails with a
missing-prerequisite error, or before an e2e/flash run.
- **`meshtastic://capabilities`** resource — one line: which capability groups are active
(`core`, `firmware`, `android`, `apple`).
- Prereq binaries are gated: `firmware` tools (build/flash/boards/userprefs) register only
when `MESHTASTIC_FIRMWARE_ROOT` + `pio` are present. Core (admin/recorder/transport) is always on.
## Connect
Two transports, same admin surface:
- **Serial:** a USB port from `list_devices` (e.g. `/dev/cu.usbmodem101`).
- **TCP:** `tcp://host:port` (a networked node, or a virtual `meshtasticd` at `127.0.0.1:4403`).
**Prefer the CLI subcommands for read-only discovery** — they cost no MCP schema tokens
and work without the server running:
```bash
meshtastic-mcp devices # find ports (bash, ~0 schema tokens)
meshtastic-mcp devices --all # include non-Meshtastic serial ports
meshtastic-mcp info <port> # firmware version, region, node identity
meshtastic-mcp nodes <port> # mesh peers (long/short name, SNR, last-heard)
meshtastic-mcp boards # list all PlatformIO board envs
meshtastic-mcp boards --arch esp32s3 # filter by architecture
meshtastic-mcp boards get heltec-v3 # full metadata for one board
# All accept --json for structured output.
```
Fall back to MCP tools when already in an MCP session or when you need board metadata
for a follow-on `build_start`:
```
list_devices # find candidates (include_unknown=true to see every port)
device_info <port> # firmware version, node identity, channel summary
list_nodes <port> # the node DB (long/short name, SNR, last-heard, position)
```
One MCP call per serial port at a time — the port lock is **exclusive and non-blocking**:
contention fails fast with "busy … Retry shortly" (it does not queue). Open → act → close.
## Configure (mutating — confirm-gated)
```
get_config <port> <section> # lora | device | position | power | network | display | ...
set_config <port> <section> <field>=<value> ...
get_channel_url <port> # the shareable channel URL (keys)
set_channel_url <port> <url>
set_owner <port> --long ... --short ...
```
After a write, **reboot then re-read** to prove it persisted to NVS, not just RAM
(`reboot <port>` → `get_config`). Region (`lora.region`) and `network.enabled_protocols`
are the two that bite — see `meshtastic-e2e` `topology.md`.
## Vanity identity (chosen node id / app colour)
A node's number is `crc32(x25519_public_key)` and every app paints it with the low 24 bits
read as RGB, so both are chosen by grinding keys, not by setting a field.
```
vanity_grind_start color=crimson tol=6 # or pattern=dc80, or both; returns job_id
vanity_grind_poll <job_id> # hits[]; NEVER use one with verified=false
vanity_preview <private_key> # what any key gives you — no device, no GPU
vanity_apply <private_key> <port> confirm=true
```
- `tol` is free and finds a hit orders of magnitude sooner — always offer it.
- Grinding needs `mvgrind` (`doctor` prints how to build it); preview/apply do not.
- `vanity_apply` **replaces the identity**, drops the old NodeNum from the node's own DB, and
reboots the board. It needs `lora.region` set (keygen is skipped while UNSET) and refuses an
unclamped key. Keep the previous key if a way back matters.
- Hits are private keys. Don't echo them further than needed; see `docs/vanity.md`.
## Message + observe
```
send_text <port> <text> [--dest <nodeId>] # broadcast (^all) or directed
```
The **recorder** is always capturing to JSONL; query windows instead of tailing:
```
packets_window # recent RX/TX packets (portnum, from/to, payload) — wire truth
telemetry_timeline # device/environment metrics over time
logs_window # firmware log lines
events_window # recorder-marked events
mark_event # drop a labeled marker to anchor a later query
recorder_status / recorder_pause / recorder_resume / recorder_export
```
For app-visible delivery vs wire truth (broadcast shows an error icon in a flat mesh even when
delivered) see `meshtastic-e2e` `references/loop-outbound.md`.
## Recover + flash (firmware capability)
```bash
# Board lookup — use the CLI (no schema overhead):
meshtastic-mcp boards --query <slug> # find the env name
meshtastic-mcp boards get <env> # confirm arch + upload_speed
```
```
# Build + flash via MCP (async to avoid 60 s client timeout):
build_start <env> # returns build_id immediately
build_poll <build_id> # poll until status=done
pio_flash <env> <port> / erase_and_flash <env> <port> / update_flash <env> <port>
touch_1200bps <port> # bounce into the bootloader (nRF/RP2040)
```
Chip-specific escape hatches when pio can't help: `esptool_*`, `nrfutil_*`, `picotool_*`
(raw passthroughs; destructive ones are confirm-gated). For a wedged USB device, power-cycle
the hub port with `uhubctl_list` / `uhubctl_power` / `uhubctl_cycle`.
> **Linux:** `uhubctl` requires udev rules to work without root. Run
> `meshtastic-mcp doctor` — it will detect the permission issue and print
> the exact `sudo curl … && sudo udevadm trigger` command to fix it.
### BLE OTA DFU (manual — no MCP tool covers this yet)
Every `flash`/`pio_flash`/`nrfutil_dfu` path above is USB (serial or UF2). There is
no MCP tool for the *wireless* leg — the Nordic legacy BLE DFU that the Meshtastic
Android app uses for its in-app bootloader/firmware upgrade, and that an
Adafruit/OTAFIX-family nRF52 bootloader (e.g. `meshtastic/Adafruit_nRF52_Bootloader_OTAFIX`)
speaks natively. To validate that path from a dev machine instead of a phone:
1. **Buttonless jump from app mode is one of two GATT services, chosen at compile
time — check which before assuming a UUID.** `NRF52Bluetooth.cpp` picks
`BLEDfuSecure` (Nordic Secure DFU, service `0xFE59`, control characteristic
`8ec90003-f315-4f60-9fb8-838830daea50`) only when the board's `variant.h`
defines `BLE_DFU_SECURE` — today that's `wio-t1000-s` alone. Every other
nRF52 board, **including RAK4631**, falls through to plain `BLEDfu`
(Adafruit's Bluefruit library): legacy service `00001530-...`, control
characteristic `00001531-...`. Either way: enable notifications/indications
on the control characteristic, write `0x01` to it, and the node disconnects
and reboots into the bootloader, advertising under a new BLE name (OTAFIX
boards use `<BOARD>_DFU`, e.g. `4631_DFU` for RAK4631 — see that repo's
README "BLE advertising names" table).
2. **Legacy DFU transfer in bootloader mode.** The bootloader's own GATT
service is always the older Nordic Legacy DFU (`00001530-...`, control
point `...1531`, data `...1532`) regardless of which service the app used
to jump there. [`recrof/nrf_dfu_py`](https://github.com/recrof/nrf_dfu_py)
(pure Python + `bleak`) speaks **only** this legacy service (its
`DFU_SERVICE_UUID` constant is the `00001530-...` one, full stop) — it has
no path for a `BLE_DFU_SECURE` board like `wio-t1000-s`, so it's a match
for RAK4631 and most other nRF52 targets, not a universal tool. Clone it,
`pip install bleak`, then from that checkout:
`python3 dfu_cli.py --scan <firmware-or-bootloader.zip> <device-name-or-addr>`.
Use the `*-ota.zip` release asset (not the `.uf2`/`.hex`) — that's the format
this DFU protocol expects. One call does both legs unassisted — its
`jump_to_bootloader()` sends the exact same 2-byte legacy opcode write
described in step 1, then it rescans and transfers — so giving it the
**app-mode** name/address up front is usually enough; you don't need a
separate manual jump. Do the jump as its own step only when you need to
debug the jump in isolation (its post-jump bootloader rescan matches by
substring against a literal `"DFU"`/MAC-increment heuristic, not the
board's exact advertised name, so once already in bootloader mode,
re-running against the bootloader's own `<BOARD>_DFU` name is the more
reliable retry).
3. **Finding the device's BLE name is a scan-and-match, and a name prefix can be
ambiguous — resolve it to exactly one device before acting.** The app's
advertised name is `<short_name>_<hex><hex>` where the hex suffix is the
last two bytes of the nRF52's FICR `DEVICEADDR` (`getDeviceName()` in
`firmware/src/main.cpp`) — **not** derived from `my_node_num`/`device_info`'s
node id in any way you can compute offline. Scan (`BleakScanner.discover`)
and match by the known `short_name` prefix, but confirm the scan turned up
exactly one match before connecting: `nrf_dfu_py` (and most such tools)
connects to the first match among the names/addresses you give it, so an
ambiguous prefix on a mesh with more than one device sharing it can jump or
flash the wrong node.
4. **`bluetooth.mode = RANDOM_PIN` needs a human (or the app) watching for the
passkey — an unattended agent session can't complete pairing, screen or no
screen.** The firmware sends the 6-digit passkey to `BluetoothStatus` (the
app's pairing UI reads it from there) and additionally shows it on-screen
`#if HAS_SCREEN` — some RAK4631 builds do have one (e.g. `rak_wismesh_pocket`
is explicitly "rak4631 pin map + OLED" in `platformio.ini`; don't assume
`hw_model: RAK4631` alone tells you whether the running build has a
display). `capture_screen` (see *Hardware UI checks* above) can read an
OLED when one's present — worth reaching for on a future attempt, since
neither it nor a live debug log was actually checked in real time here;
the passkey/display behavior below is reasoned from firmware source
after the fact, not confirmed against what this device actually showed.
Whether the passkey is visible in a live debug log (`set_debug_log_api`)
depends on the exact firmware build — `onPairingPasskey`'s `LOG_INFO`
included the passkey digits in
`v2.7.26.54e0d8d` (what this was tested against) and still does on
`develop`, but a firmware security fix logged only `match_request` for a
stretch of the 2.7.x line in between (redacting pairing secrets from
logs) — don't assume the log line carries it on an arbitrary build; the
app's pairing UI is the one path guaranteed to receive it regardless.
Neither was being watched in a scripted `bleak` session here, so the OS
pairing prompt sat with nothing to type in and the connection was
dropped. This is
expected `RANDOM_PIN` behavior, not a bug — switch to `FIXED_PIN` (a value
you already know) for scripted/headless testing instead of chasing it.
`get_config`-read the current `bluetooth.mode`/`fixed_pin` *before* changing
anything, and restore them (`set_config` + `reboot` + `get_config` to
confirm) once testing is done — a device left in `FIXED_PIN` carries a
known, reusable pairing credential indefinitely otherwise.
Separately, firmware `develop` (2.8) does carry two real nRF52 BLE-pairing
fixes not yet in 2.7.x that are worth knowing about if pairing looks
flaky on a 2.7.x build: a passkey callback that wasn't restored after a
BT disable/re-enable cycle without a reboot (#11027), and a BLE-task
stack overflow that could crash the device mid-pairing on nrf52840
targets (#11190).
**macOS-specific friction**, all one-time per machine/device pair, not per session:
- Bluetooth must be explicitly on (Control Center) — `bleak`/CoreBluetooth error
clearly (`BleakBluetoothNotAvailableError: POWERED_OFF`) when it isn't, so this
fails fast rather than silently.
- The terminal app driving the script needs Bluetooth permission granted
(System Settings → Privacy & Security → Bluetooth) — without it, scanning
either errors or (confusingly) just finds nothing.
- A newly-enumerated USB device can trigger a silent macOS "accessory" permission
popup that hides the port from `list_devices`/`ls /dev/cu.*` until approved —
if a device that was just flashed or reset seems to vanish from USB entirely,
check for that popup before assuming a bad flash.
## Hardware UI (OLED) checks
`send_input_event` drives the device's buttons; `capture_screen` grabs the OLED (camera/OCR
optional — see `doctor` for the `[ui]` extra). This is device-only; for app UI use `meshtastic-e2e`.
## Grounded answers
- **`android_docs_search` / `android_docs_fetch`** — Android/Compose/API questions answered from
the official Knowledge Base (no guessing) when working with the Android app.
## Prompts (slash workflows)
- **`bringup_device`** — connect + verify a healthy baseline (firmware, region, node count).
- **`inbound_loop`** / **`triage_e2e_failure`** — e2e workflows (see the `meshtastic-e2e` skill).
## Hard rules
1. One call per serial port at a time (exclusive non-blocking lock).
2. Mutations are confirm-gated and reversible-by-reboot only for RAM writes — re-read after reboot.
3. `factory_reset(full=true)` wipes BLE bonds + the identity key; `full=false` keeps them.
4. Prefer the recorder windows over ad-hoc reads — they're timestamped and align with app snapshots.
5. Never apply a ground key whose `verified` is false, and never apply one without telling the
operator the old node id first — the change is not reversible without the previous key.
Ver no GitHub