Files
firmware/mcp-server/src/meshtastic_mcp/server.py
T
Ben Meadors eead467ce6 Added NodeDB fixtures and refactored to use std maps for better memory efficiency (#10464)
* Added NodeDB fixtures and refactored to use std maps for better efficiency

* Defer NodeDB save during xmodem transfer to prevent mid-transfer fsFormat
2026-05-12 17:23:29 -05:00

1009 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastMCP server wiring — 43 tools across 9 categories (adds uhubctl power control).
Each tool handler is a thin delegation to a named module (pio.py, admin.py,
etc.). Business logic does not live here.
"""
from __future__ import annotations
import logging
from typing import Any
from mcp.server.fastmcp import FastMCP
from . import (
admin,
boards,
devices,
fixtures,
flash,
hw_tools,
info,
log_query,
registry,
serial_session,
)
from . import userprefs as userprefs_mod
from .recorder import get_recorder
log = logging.getLogger(__name__)
app = FastMCP("meshtastic-mcp")
def _start_recorder() -> None:
# Persistent device-log capture. Starts on first import — pubsub fan-out
# is process-global, so subscribing here captures every active interface
# (whether opened by an MCP tool, a pytest fixture, or a serial_session).
# Files land in mcp-server/.mtlog/ (gitignored). See recorder/recorder.py
# for the full design. Recorder startup is best-effort: an unwritable
# log dir or pubsub mismatch should not take the MCP server down.
try:
get_recorder().start()
except Exception as exc:
log.warning("Failed to start persistent recorder: %s", exc)
_start_recorder()
# ---------- Discovery & metadata ------------------------------------------
@app.tool()
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
"""List USB/serial ports, flagging those likely to be Meshtastic devices.
With include_unknown=True, returns every serial port the OS knows about
(useful for debugging when a device isn't detected). Otherwise returns
only likely-Meshtastic candidates.
"""
return devices.list_devices(include_unknown=include_unknown)
@app.tool()
def list_boards(
architecture: str | None = None,
actively_supported_only: bool = False,
query: str | None = None,
board_level: str | None = None,
) -> list[dict[str, Any]]:
"""Enumerate PlatformIO envs (boards) with Meshtastic metadata.
architecture: filter to one arch ("esp32", "esp32s3", "nrf52840", "rp2040", "stm32", "native").
actively_supported_only: filter to boards marked custom_meshtastic_actively_supported=true.
query: substring match on display_name, env name, or hw_model_slug (case-insensitive).
board_level: "release" (default-track release boards), "pr" (PR CI), or "extra" (opt-in extras).
"""
return boards.list_boards(
architecture=architecture,
actively_supported_only=actively_supported_only,
query=query,
board_level=board_level,
)
@app.tool()
def get_board(env: str) -> dict[str, Any]:
"""Full metadata for one PlatformIO env, including raw pio config fields."""
return boards.get_board(env)
# ---------- Build & flash -------------------------------------------------
@app.tool()
def build(
env: str,
with_manifest: bool = True,
userprefs: dict[str, Any] | None = None,
build_flags: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build firmware for one env via `pio run -e <env>`.
Returns exit_code, duration, artifact paths under .pio/build/<env>/, and
tails of stdout/stderr (last 200 lines each). with_manifest=True adds the
mtjson target which produces an .mt.json manifest alongside the firmware.
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
build via userPrefs.jsonc injection. The file is restored after the build
completes. Use `userprefs_manifest` to discover available keys. Use
`userprefs_set` for persistent changes.
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for this build
only, injected via `PLATFORMIO_BUILD_FLAGS`. Common pattern:
`build_flags={"DEBUG_HEAP": 1}` enables per-thread leak detection + a
`[heap N]` prefix on every log line. The recorder picks the prefix up
automatically and synthesizes a high-resolution heap timeline that
`telemetry_timeline(field="free_heap")` can read alongside the normal
~60 s LocalStats packets. Pair with `/leakhunt` for classification.
"""
return flash.build(
env,
with_manifest=with_manifest,
userprefs_overrides=userprefs,
build_flags=build_flags,
)
@app.tool()
def clean(env: str) -> dict[str, Any]:
"""Clean one env's build output via `pio run -e <env> -t clean`.
Useful when switching branches or debugging a stale-cache build failure.
"""
return flash.clean(env)
@app.tool()
def pio_flash(
env: str,
port: str,
confirm: bool = False,
userprefs: dict[str, Any] | None = None,
build_flags: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
Works for any architecture (ESP32/nRF52/RP2040/STM32). Requires confirm=True.
For first-time flashing a blank ESP32 board (erase + bootloader + app + fs),
prefer `erase_and_flash`. For ESP32 OTA updates, prefer `update_flash`.
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
build via userPrefs.jsonc injection; restored after upload.
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for the
rebuild-before-upload, e.g. `{"DEBUG_HEAP": 1}`. Required for the flags
to actually land in the uploaded firmware — without it, the implicit
rebuild relinks without the env var and silently drops them.
"""
return flash.flash(
env,
port,
confirm=confirm,
userprefs_overrides=userprefs,
build_flags=build_flags,
)
@app.tool()
def erase_and_flash(
env: str,
port: str,
confirm: bool = False,
skip_build: bool = False,
userprefs: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""ESP32-only: full erase + factory flash via bin/device-install.sh.
Wipes the entire flash and writes bootloader, app, OTA, and LittleFS
partitions from the factory.bin. Requires confirm=True. Runs `build` first
if no factory.bin is present (set skip_build=True to require a prior build).
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into the
factory.bin via userPrefs.jsonc injection. When provided, forces a rebuild
(skip_build=True is incompatible). File is restored after upload.
"""
return flash.erase_and_flash(
env, port, confirm=confirm, skip_build=skip_build, userprefs_overrides=userprefs
)
@app.tool()
def update_flash(
env: str,
port: str,
confirm: bool = False,
skip_build: bool = False,
userprefs: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""ESP32-only: OTA app-partition update via bin/device-update.sh.
Updates only the application partition, preserving device config and node
database. Faster than erase_and_flash but won't recover a broken bootloader.
Requires confirm=True. Builds first if needed.
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into the
firmware.bin via userPrefs.jsonc injection. When provided, forces a rebuild.
"""
return flash.update_flash(
env, port, confirm=confirm, skip_build=skip_build, userprefs_overrides=userprefs
)
# ---------- USERPREFS discovery & persistence -----------------------------
@app.tool()
def userprefs_manifest() -> dict[str, Any]:
"""Full manifest of USERPREFS_* keys the firmware knows about.
Combines `userPrefs.jsonc` (active + commented examples) with a scan of
`src/**` for `USERPREFS_<KEY>` references — so every key the firmware
actually consumes shows up, even if undocumented in the jsonc.
Each entry has: key, active (is it uncommented), value (current), example
(jsonc commented default), declared_in_jsonc, consumed_by (list of src
files), inferred_type (brace|number|bool|enum|string|unknown).
`inferred_type` mirrors how platformio-custom.py wraps values at build
time: `brace` = byte array `{ 0x01, ... }`, `number` = decimal, `bool` =
true/false, `enum` = `meshtastic_*` constant, `string` = wrapped in quotes
via StringifyMacro.
"""
return userprefs_mod.build_manifest()
@app.tool()
def userprefs_get() -> dict[str, Any]:
"""Return the current userPrefs.jsonc state.
`active` is the dict of uncommented `USERPREFS_*` → value that will be
baked into the next build. `commented` is the dict of commented example
defaults (shown for reference).
"""
state = userprefs_mod.read_state()
# Drop `order` (internal for round-trip rendering) from the public payload.
return {
"path": state["path"],
"active": state["active"],
"commented": state["commented"],
}
@app.tool()
def userprefs_set(prefs: dict[str, Any]) -> dict[str, Any]:
"""Merge `prefs` into userPrefs.jsonc persistently (uncommenting keys).
Existing active values not in `prefs` are kept. To remove a key from the
active set, call `userprefs_reset` (restores the MCP backup if present)
or edit the jsonc manually. Values are stringified the way
platformio-custom.py expects (bool → "true"/"false", int → "42", etc.).
"""
return userprefs_mod.merge_active(prefs)
@app.tool()
def userprefs_reset() -> dict[str, Any]:
"""Restore userPrefs.jsonc from the most recent MCP backup (if any).
The backup is only created by the legacy `userprefs_set` workflow (not
currently written automatically). Returns `{restored: bool, ...}` — false
when no backup is present, in which case the caller should edit the
jsonc directly.
"""
return userprefs_mod.reset()
@app.tool()
def userprefs_testing_profile(
psk_seed: str | None = None,
channel_name: str = "McpTest",
channel_num: int = 88,
region: str = "US",
modem_preset: str = "LONG_FAST",
short_name: str | None = None,
long_name: str | None = None,
disable_mqtt: bool = True,
disable_position: bool = False,
) -> dict[str, Any]:
"""Generate a USERPREFS dict for provisioning an isolated test-mesh device.
Baking this into firmware produces devices that:
- Run on a deterministic non-default LoRa slot (default 88 on US LONG_FAST,
well off the `hash("LongFast")` slot a stock production device uses)
- Join a private channel with a name and PSK that differ from public
defaults — so no accidental mesh-with-production-devices
- Have MQTT disabled (no uplink/downlink bridge), so test traffic never
leaks to a public broker
- Optionally disable GPS for bench-test conditions
For a multi-device test cluster, pass the same `psk_seed` to every call so
every device shares the same PSK and lands on the same isolated mesh.
Returned dict is ready to pass straight to `build`, `pio_flash`,
`erase_and_flash`, or `update_flash` via their `userprefs` parameter.
Example:
profile = userprefs_testing_profile(psk_seed="ci-run-2026-04-16")
erase_and_flash(env="tbeam", port="/dev/cu.usbmodem...", confirm=True,
userprefs=profile)
Args:
psk_seed: seed for deterministic 32-byte PSK via SHA-256. None = random
(fine one-off, useless for multi-device clusters).
channel_name: primary channel name (≤11 chars). Default "McpTest".
channel_num: 1-indexed LoRa slot (0 = fall back to name-hash). Default
88 — mid-upper US band, unlikely to collide with production slots.
region: short code — one of US, EU_433, EU_868, CN, JP, ANZ, KR, TW,
RU, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, LORA_24.
modem_preset: one of LONG_FAST, LONG_SLOW, LONG_MODERATE, VERY_LONG_SLOW,
MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, SHORT_TURBO.
short_name: optional owner short name (≤4 chars) stamped into the build.
long_name: optional owner long name stamped into the build.
disable_mqtt: disable MQTT module + uplink/downlink (default True).
disable_position: disable GPS + smart-position broadcasts (default False).
"""
return userprefs_mod.build_testing_profile(
psk_seed=psk_seed,
channel_name=channel_name,
channel_num=channel_num,
region=region,
modem_preset=modem_preset,
short_name=short_name,
long_name=long_name,
disable_mqtt=disable_mqtt,
disable_position=disable_position,
)
@app.tool()
def touch_1200bps(port: str, settle_ms: int = 250) -> dict[str, Any]:
"""Open `port` at 1200 baud and immediately close, triggering USB CDC
bootloader entry on nRF52840, ESP32-S3 (native USB), RP2040, etc.
After the touch, polls serial devices for up to 3 seconds and reports any
new port that appeared (the bootloader often enumerates as a different
device). Not destructive — this is just a reset signal.
"""
return flash.touch_1200bps(port, settle_ms=settle_ms)
# ---------- Serial log sessions -------------------------------------------
@app.tool()
def serial_open(
port: str,
baud: int = 115200,
env: str | None = None,
filters: list[str] | None = None,
) -> dict[str, Any]:
"""Open a `pio device monitor` session reading from `port`.
If `env` is set, pio picks up monitor_speed and monitor_filters from
platformio.ini — recommended for firmware debugging since it enables
esp32_exception_decoder / esp32_c3_exception_decoder for ESP32 envs.
Without `env`, uses the supplied baud and filters (default ["direct"]).
Common filters: direct, time, hexlify, esp32_exception_decoder,
esp32_c3_exception_decoder, log2file.
Returns a session_id for use with serial_read / serial_close, plus the
resolved baud and filters so callers can confirm what pio selected.
"""
session = serial_session.open_session(
port=port, baud=baud, env=env, filters=filters
)
registry.register_session(session)
return {
"session_id": session.id,
"resolved_baud": session.baud,
"resolved_filters": session.filters,
"env": session.env,
}
@app.tool()
def serial_read(
session_id: str,
max_lines: int = 200,
since_cursor: int | None = None,
) -> dict[str, Any]:
"""Read buffered lines from a serial monitor session.
Default: returns everything since your last call to serial_read (uses an
advancing cursor). Pass `since_cursor=N` to re-read from a specific point,
or `since_cursor=0` to read from the start of the in-memory buffer.
Returns `dropped` = count of lines that aged out of the 10k-line ring
buffer between reads — so a value > 0 means you missed data.
"""
session = registry.get_session(session_id)
return serial_session.read_session(
session, max_lines=max_lines, since_cursor=since_cursor
)
@app.tool()
def serial_list() -> list[dict[str, Any]]:
"""List all active serial monitor sessions."""
return [serial_session.session_summary(s) for s in registry.all_sessions()]
@app.tool()
def serial_close(session_id: str) -> dict[str, Any]:
"""Terminate a serial monitor session and free its port."""
session = registry.remove_session(session_id)
if session is None:
return {"ok": False, "reason": f"Unknown session_id {session_id!r}"}
serial_session.close_session(session)
return {"ok": True}
# ---------- Device interaction: reads -------------------------------------
@app.tool()
def device_info(port: str | None = None, timeout_s: float = 8.0) -> dict[str, Any]:
"""Connect via meshtastic.SerialInterface and return a summary of the node.
If `port` is omitted and exactly one likely-Meshtastic device is connected,
it's auto-selected; otherwise the tool errors with the candidate list.
"""
return info.device_info(port=port, timeout_s=timeout_s)
@app.tool()
def list_nodes(port: str | None = None, timeout_s: float = 8.0) -> list[dict[str, Any]]:
"""Return the device's current node database (local node + all known peers)."""
return info.list_nodes(port=port, timeout_s=timeout_s)
# ---------- Device interaction: writes ------------------------------------
@app.tool()
def set_owner(
long_name: str, short_name: str | None = None, port: str | None = None
) -> dict[str, Any]:
"""Set the device's owner long name and (optional) short name (≤4 chars)."""
return admin.set_owner(long_name=long_name, short_name=short_name, port=port)
@app.tool()
def get_config(section: str | None = None, port: str | None = None) -> dict[str, Any]:
"""Read one or all config sections.
`section` may be any LocalConfig section (device, position, power, network,
display, lora, bluetooth, security) or LocalModuleConfig section (mqtt,
serial, telemetry, external_notification, canned_message, range_test,
store_forward, neighbor_info, ambient_lighting, detection_sensor,
paxcounter, audio, remote_hardware, statusmessage, traffic_management).
Omit or pass "all" for every section.
"""
return admin.get_config(section=section, port=port)
@app.tool()
def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]:
"""Set one config field via dot-path and write it to the device.
Examples: "lora.region"="US", "lora.modem_preset"="LONG_FAST",
"device.role"="ROUTER", "mqtt.enabled"=True, "mqtt.address"="host".
Enum fields accept their name (case-insensitive) or int.
"""
return admin.set_config(path=path, value=value, port=port)
@app.tool()
def get_channel_url(
include_all: bool = False, port: str | None = None
) -> dict[str, Any]:
"""Get the shareable channel URL (QR-code content).
include_all=True returns the admin URL including all secondary channels;
False returns only the primary channel (what users typically share).
"""
return admin.get_channel_url(include_all=include_all, port=port)
@app.tool()
def set_channel_url(url: str, port: str | None = None) -> dict[str, Any]:
"""Import channels from a Meshtastic channel URL."""
return admin.set_channel_url(url=url, port=port)
@app.tool()
def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
"""Toggle security.debug_log_api_enabled on the local node.
When true, firmware streams log lines as protobuf `LogRecord` messages
over the StreamAPI (topic `meshtastic.log.line` in meshtastic-python)
instead of raw text. Lets diagnostic clients capture firmware-side logs
through the SAME SerialInterface used for admin/info calls — no
separate `pio device monitor` session needed, no exclusive-port-lock
conflict. Persists across reboot via NVS; wiped by factory_reset
unless re-applied.
The earlier emitLogRecord race (shared tx buffer) is fixed at the
firmware level — the log path has a dedicated scratch + txBuf and
both emission paths serialize via a mutex. Safe to leave on under
traffic.
"""
return admin.set_debug_log_api(enabled=enabled, port=port)
@app.tool()
def send_text(
text: str,
to: str | int | None = None,
channel_index: int = 0,
want_ack: bool = False,
port: str | None = None,
) -> dict[str, Any]:
"""Send a text message over the mesh.
`to` defaults to broadcast ("^all"). Pass a node ID (hex string like
"!abcdef01") or node number (int) to direct-message a specific node.
channel_index picks which configured channel to send on.
"""
return admin.send_text(
text=text, to=to, channel_index=channel_index, want_ack=want_ack, port=port
)
@app.tool()
def reboot(
port: str | None = None, confirm: bool = False, seconds: int = 10
) -> dict[str, Any]:
"""Reboot the connected node in `seconds` seconds. Requires confirm=True."""
return admin.reboot(port=port, confirm=confirm, seconds=seconds)
@app.tool()
def shutdown(
port: str | None = None, confirm: bool = False, seconds: int = 10
) -> dict[str, Any]:
"""Shut down the connected node in `seconds` seconds. Requires confirm=True."""
return admin.shutdown(port=port, confirm=confirm, seconds=seconds)
@app.tool()
def factory_reset(
port: str | None = None, confirm: bool = False, full: bool = False
) -> dict[str, Any]:
"""Factory-reset the connected node. Requires confirm=True.
`full=True` also wipes device identity/keys (not just config).
"""
return admin.factory_reset(port=port, confirm=confirm, full=full)
@app.tool()
def send_input_event(
event_code: int | str,
kb_char: int = 0,
touch_x: int = 0,
touch_y: int = 0,
port: str | None = None,
) -> dict[str, Any]:
"""Inject an InputBroker event (button / key / gesture) into the device UI.
Drives the same code path as a physical button press. Accepts a numeric
event code (0..255) or a name like `"RIGHT"`, `"SELECT"`, `"FN_F1"`.
Common codes: SELECT=10, UP=17, DOWN=18, LEFT=19, RIGHT=20, CANCEL=24,
BACK=27, FN_F1..F5=241..245.
"""
return admin.send_input_event(
event_code=event_code,
kb_char=kb_char,
touch_x=touch_x,
touch_y=touch_y,
port=port,
)
@app.tool()
def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]:
"""Grab a frame from the USB webcam pointed at the device screen.
Returns PNG bytes (base64), optional OCR text, and backend metadata.
Requires the `[ui]` extras (opencv-python-headless) and a camera
configured via `MESHTASTIC_UI_CAMERA_DEVICE[_<ROLE>]`. Falls back to a
1×1 black PNG from the null backend when no camera is configured.
"""
import base64
from . import camera as camera_mod
cam = camera_mod.get_camera(role)
try:
png = cam.capture()
finally:
cam.close()
result: dict[str, Any] = {
"backend": cam.name,
"bytes": len(png),
"image_base64": base64.b64encode(png).decode("ascii"),
}
if ocr:
from . import ocr as ocr_mod
result["ocr_backend"] = ocr_mod.backend_name()
result["ocr_text"] = ocr_mod.ocr_text(png)
return result
# ---------- USB power control (uhubctl) -----------------------------------
@app.tool()
def uhubctl_list() -> list[dict[str, Any]]:
"""List every USB hub + per-port device attachment as seen by `uhubctl`.
Read-only — no confirm required. Each hub entry includes its location
(`1-1.3`), descriptor, whether it supports Per-Port Power Switching,
and a list of populated ports with VID:PID of attached devices.
Useful for pre-flight checks before a destructive power-cycle call.
"""
from . import uhubctl as uhubctl_mod
return uhubctl_mod.list_hubs()
@app.tool()
def uhubctl_power(
action: str,
location: str | None = None,
port: int | None = None,
role: str | None = None,
confirm: bool = False,
) -> dict[str, Any]:
"""Power a USB hub port on or off via `uhubctl -a on|off`.
Target the port by either (`location`, `port`) — raw uhubctl syntax,
e.g. `location="1-1.3", port=2` — OR by `role` ("nrf52", "esp32s3").
Role lookup honors `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
`_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
`action="off"` requires `confirm=True` (destructive — the attached
device will immediately disappear from the OS).
"""
from . import uhubctl as uhubctl_mod
action_lower = action.lower()
if action_lower not in {"on", "off"}:
raise ValueError(f"action must be 'on' or 'off', got {action!r}")
if action_lower == "off" and not confirm:
raise uhubctl_mod.UhubctlError(
"uhubctl_power action='off' requires confirm=True"
)
loc, p = _resolve_uhubctl_target(location, port, role)
if action_lower == "on":
return uhubctl_mod.power_on(loc, p)
return uhubctl_mod.power_off(loc, p)
@app.tool()
def uhubctl_cycle(
location: str | None = None,
port: int | None = None,
role: str | None = None,
delay_s: int = 2,
confirm: bool = False,
) -> dict[str, Any]:
"""Power a USB hub port off, wait `delay_s` seconds, then on.
The typical hard-reset sequence — shorter than off+on as two RPCs
because uhubctl handles the timing in-process. Target by (location,
port) or by role (see `uhubctl_power`). Requires `confirm=True`.
"""
from . import uhubctl as uhubctl_mod
if not confirm:
raise uhubctl_mod.UhubctlError("uhubctl_cycle requires confirm=True")
if delay_s < 0 or delay_s > 60:
raise ValueError(f"delay_s must be 0..60, got {delay_s}")
loc, p = _resolve_uhubctl_target(location, port, role)
return uhubctl_mod.cycle(loc, p, delay_s=delay_s)
def _resolve_uhubctl_target(
location: str | None, port: int | None, role: str | None
) -> tuple[str, int]:
"""Shared arg-resolution for uhubctl_power + uhubctl_cycle."""
from . import uhubctl as uhubctl_mod
if role is not None:
if location is not None or port is not None:
raise ValueError("pass either `role` OR (`location` + `port`), not both")
return uhubctl_mod.resolve_target(role)
if location is None or port is None:
raise ValueError("must pass `role` or both `location` and `port`")
return (location, int(port))
# ---------- Direct hardware tools -----------------------------------------
@app.tool()
def esptool_chip_info(port: str) -> dict[str, Any]:
"""Run `esptool flash_id` and return chip, MAC, crystal, and flash size.
Read-only — no confirm required. Prefer this over parsing pio upload logs
when you just want to identify the chip.
"""
return hw_tools.esptool_chip_info(port)
@app.tool()
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
"""Full-chip erase via `esptool erase_flash`. Leaves the device unbootable.
Prefer `erase_and_flash` which also writes firmware. Use this only for
recovery when a device is in a weird state. Requires confirm=True.
"""
return hw_tools.esptool_erase_flash(port, confirm=confirm)
@app.tool()
def esptool_raw(
args: list[str], port: str | None = None, confirm: bool = False
) -> dict[str, Any]:
"""Pass-through to `esptool`. Destructive subcommands (write_flash,
erase_flash, erase_region, merge_bin) require confirm=True.
Prefer the high-level `pio_flash` / `erase_and_flash` / `update_flash`
tools where possible — they know board-specific offsets and protocols.
"""
return hw_tools.esptool_raw(args, port=port, confirm=confirm)
@app.tool()
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
"""DFU-flash a .zip package to an nRF52840 via `nrfutil dfu serial`.
Prefer `pio_flash` for flashing firmware built from this repo — pio handles
the DFU invocation automatically. Use this tool when flashing a pre-built
release zip or a custom bootloader. Requires confirm=True.
"""
return hw_tools.nrfutil_dfu(port, package_path, confirm=confirm)
@app.tool()
def nrfutil_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
"""Pass-through to `nrfutil`. dfu/settings subcommands require confirm=True."""
return hw_tools.nrfutil_raw(args, confirm=confirm)
@app.tool()
def picotool_info(port: str | None = None) -> dict[str, Any]:
"""Run `picotool info -a`. Requires the RP2040 to be in BOOTSEL mode
(hold BOOTSEL button while plugging in, or call `touch_1200bps` if the
firmware supports 1200bps-reset)."""
return hw_tools.picotool_info(port=port)
@app.tool()
def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]:
"""Load a UF2 to a Pico in BOOTSEL mode via `picotool load -x -t uf2`.
Prefer `pio_flash` for flashing firmware built from this repo.
Requires confirm=True.
"""
return hw_tools.picotool_load(uf2_path, confirm=confirm)
@app.tool()
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
"""Pass-through to `picotool`. load/reboot/save/erase require confirm=True."""
return hw_tools.picotool_raw(args, confirm=confirm)
# ---------- Persistent device-log capture (recorder) ----------------------
#
# The recorder is autouse — it starts at server import and continuously
# writes every meshtastic pubsub event to JSONL files under .mtlog/. These
# tools are query-only over those files, plus a few lifecycle controls.
@app.tool()
def logs_window(
start: str = "-15m",
end: str = "now",
grep: str | None = None,
level: str | None = None,
tag: str | None = None,
port: str | None = None,
max_lines: int = 200,
) -> dict[str, Any]:
"""Recent firmware log lines from the persistent recorder.
Filters by time window, regex over the line, level (single or
pipe-separated set like "WARN|ERROR|CRIT"), thread-name tag, and
interface port. Returns up to max_lines most-recent matches.
Time strings: "-15m", "-2h", "-3d", "now", or ISO 8601.
Note: lines arriving via the LogRecord protobuf path (when
set_debug_log_api(True) is on) come without level prefix — the
meshtastic Python lib drops record.level before fan-out. For those,
`level` filter won't match; use `grep` instead.
"""
return log_query.logs_window(
start=start,
end=end,
grep=grep,
level=level,
tag=tag,
port=port,
max_lines=max_lines,
)
@app.tool()
def telemetry_timeline(
window: str = "1h",
variant: str = "local",
field: str = "free_heap",
port: str | None = None,
max_points: int = 200,
) -> dict[str, Any]:
"""Time series of one telemetry field, downsampled to <= max_points.
`variant` ∈ device, local, environment, power, airQuality, health, host.
`field` accepts snake_case or camelCase; common aliases (free_heap ↔
heap_free_bytes) are normalized.
Returns slope_per_min (linear-regression slope, units/minute) so a
leak detector can read one number — negative slope on free_heap over
a long window indicates a real leak.
LocalStats variant ("local") cadence is ~60 s (whatever the device's
`device_update_interval` is set to), so a 1 h window gives ~60 raw
points. Bucket-mean downsampling preserves shape.
"""
return log_query.telemetry_timeline(
window=window,
variant=variant,
field=field,
port=port,
max_points=max_points,
)
@app.tool()
def packets_window(
start: str = "-5m",
end: str = "now",
portnum: str | None = None,
from_node: str | None = None,
to_node: str | None = None,
max: int = 200,
) -> dict[str, Any]:
"""Recent mesh packets recorded by the recorder.
Each row is a summary (portnum, from/to, hop_limit, RSSI/SNR, payload
size + first 64 bytes hex) — full payload bytes are not stored.
`portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP".
"""
return log_query.packets_window(
start=start,
end=end,
portnum=portnum,
from_node=from_node,
to_node=to_node,
max=max,
)
@app.tool()
def events_window(
start: str = "-1h",
end: str = "now",
kind: str | None = None,
max: int = 200,
) -> dict[str, Any]:
"""Return recorder events: connection lifecycle, node updates, and `mark_event` markers.
`kind` ∈ recorder_start, recorder_pause, recorder_resume,
connection_established, connection_lost, node_updated, mark.
Pipe-separated sets ("connection_lost|connection_established") work.
"""
return log_query.events_window(start=start, end=end, kind=kind, max=max)
@app.tool()
def mark_event(
label: str,
note: str | None = None,
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Drop a named marker into events.jsonl AND logs.jsonl.
Useful for aligning a timeline around a known stimulus: call before
and after a stress workload, then query telemetry_timeline /
logs_window with the markers' timestamps as bounds.
The marker also lands in logs.jsonl with level=MARK so a single
grep over logs picks it up.
"""
return get_recorder().mark_event(label=label, note=note, data=data)
@app.tool()
def recorder_status() -> dict[str, Any]:
"""Return recorder runtime info: running, paused, file sizes, last_ts per stream.
Use this to sanity-check that capture is working before you trust a
`logs_window` / `telemetry_timeline` result.
"""
return get_recorder().status()
@app.tool()
def recorder_pause(reason: str | None = None) -> dict[str, Any]:
"""Pause writes to all four streams. Pubsub subscriptions stay active —
we just drop events on the floor while paused. Resume with `recorder_resume`.
Use when capturing a known-good baseline that you don't want to
pollute with pre-test noise. Default state is recording; this is
rarely needed.
"""
get_recorder().pause(reason=reason)
return {"ok": True, "paused": True, "reason": reason}
@app.tool()
def recorder_resume() -> dict[str, Any]:
"""Resume writes after `recorder_pause`. No-op if already running."""
get_recorder().resume()
return {"ok": True, "paused": False}
@app.tool()
def recorder_export(
start: str,
end: str,
dest_dir: str,
streams: list[str] | None = None,
) -> dict[str, Any]:
"""Bundle a slice of the recorder's streams into `dest_dir`.
Writes one uncompressed JSONL per requested stream (logs / telemetry /
packets / events). Useful for: attaching to a bug report, feeding a
notebook, or backfilling Datadog after the fact.
"""
return log_query.export(
start=start,
end=end,
dest_dir=dest_dir,
streams=streams,
)
# ---------- Fixture / test-data push --------------------------------------
@app.tool()
def push_fake_nodedb(
size: int,
target: str = "portduino",
port: str | None = None,
portduino_config: str = "default",
backup_existing: bool = True,
confirm: bool = False,
reboot_after: bool = True,
custom_seed_jsonl: str | None = None,
) -> dict[str, Any]:
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
Two transports:
target="portduino" — file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
Fast, no device connection needed.
target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto.
Requires `port` + `confirm=True`. Triggers a reboot
so loadFromDisk picks up the new file at next boot.
Compiles a fresh-timestamp proto from the committed JSONL seed under
test/fixtures/nodedb/seed_v25_<N>.jsonl each invocation, so the loaded
NodeDB always looks "recent" to the connecting phone. Structural data
(names, IDs, positions, telemetries) is deterministic per the seed.
Override the JSONL via `custom_seed_jsonl` to push a hand-edited scenario.
"""
return fixtures.push_fake_nodedb(
size=size,
target=target, # type: ignore[arg-type]
port=port,
portduino_config=portduino_config,
backup_existing=backup_existing,
confirm=confirm,
reboot_after=reboot_after,
custom_seed_jsonl=custom_seed_jsonl,
)