"""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 `. Returns exit_code, duration, artifact paths under .pio/build//, 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_: 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=` 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 -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 -t upload --upload-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_: value` baked into this build via userPrefs.jsonc injection; restored after upload. `build_flags` (optional): dict of `-D=` 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_: 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_: 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_` 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[_]`. 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_` + `_PORT_` 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//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_.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, )