From 510e9796f9868be7b97f36390291af049c88c49a Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 2 Jul 2026 14:33:04 -0500 Subject: [PATCH] Extract mcp-server to its own repo (meshtastic/meshtastic-mcp) (#10861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python MCP server + hardware test harness that lived under mcp-server/ now has its own home at https://github.com/meshtastic/meshtastic-mcp (published, versioned independently). Remove the in-tree copy and wire the firmware repo to the standalone server externally. - Delete mcp-server/ (96 files) and the 8 harness-coupled AI workflow files under .claude/commands/ and .github/prompts/ that drove ./mcp-server/ run-tests.sh — those workflows now ship with meshtastic-mcp as skills. - .mcp.json: register the server via `uvx --from git+https://github.com/meshtastic/meshtastic-mcp meshtastic-mcp` instead of a local ./mcp-server/.venv, keeping MESHTASTIC_FIRMWARE_ROOT="." so the MCP tools still work from this checkout with no local build. - Repoint the remaining references (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md, bin/regen-*.sh, docs, Screen.h, userPrefs.jsonc, test/fixtures/nodedb/README.md, .trunk/configs/.bandit) at the standalone repo. The MCP tool surface is unchanged — only the pytest harness moves out; run it from a meshtastic-mcp checkout with MESHTASTIC_FIRMWARE_ROOT pointed here. No build/CI/platformio coupling existed, so nothing in the firmware build changes. Co-authored-by: Claude Opus 4.8 --- .claude/commands/README.md | 49 - .claude/commands/diagnose.md | 68 - .claude/commands/leakhunt.md | 103 - .claude/commands/repro.md | 70 - .claude/commands/test.md | 47 - .github/copilot-instructions.md | 59 +- .github/prompts/mcp-diagnose.prompt.md | 64 - .github/prompts/mcp-repro.prompt.md | 68 - .github/prompts/mcp-test.prompt.md | 57 - .mcp.json | 8 +- .trunk/configs/.bandit | 8 +- AGENTS.md | 66 +- CLAUDE.md | 12 +- bin/_rewrite_proto_namespace.py | 2 +- bin/regen-fake-nodedbs.sh | 8 +- bin/regen-py-protos.sh | 2 +- docs/nexthop-routing-reliability.md | 8 +- mcp-server/.gitignore | 35 - mcp-server/README.md | 413 ---- mcp-server/pyproject.toml | 54 - mcp-server/run-tests.sh | 274 --- mcp-server/scripts/datadog-dashboard.json | 217 -- mcp-server/scripts/mtlog_to_datadog.py | 389 ---- mcp-server/src/meshtastic_mcp/__init__.py | 3 - mcp-server/src/meshtastic_mcp/__main__.py | 11 - mcp-server/src/meshtastic_mcp/admin.py | 417 ---- mcp-server/src/meshtastic_mcp/boards.py | 159 -- mcp-server/src/meshtastic_mcp/camera.py | 286 --- mcp-server/src/meshtastic_mcp/cli/__init__.py | 6 - .../src/meshtastic_mcp/cli/_flashlog.py | 73 - mcp-server/src/meshtastic_mcp/cli/_fwlog.py | 96 - mcp-server/src/meshtastic_mcp/cli/_history.py | 127 -- .../src/meshtastic_mcp/cli/_reproducer.py | 214 -- mcp-server/src/meshtastic_mcp/cli/_uicap.py | 83 - mcp-server/src/meshtastic_mcp/cli/test_tui.py | 1911 ----------------- mcp-server/src/meshtastic_mcp/config.py | 148 -- mcp-server/src/meshtastic_mcp/connection.py | 229 -- mcp-server/src/meshtastic_mcp/devices.py | 135 -- mcp-server/src/meshtastic_mcp/fixtures.py | 382 ---- mcp-server/src/meshtastic_mcp/flash.py | 507 ----- mcp-server/src/meshtastic_mcp/hw_tools.py | 248 --- mcp-server/src/meshtastic_mcp/info.py | 103 - mcp-server/src/meshtastic_mcp/input_events.py | 67 - mcp-server/src/meshtastic_mcp/log_query.py | 410 ---- mcp-server/src/meshtastic_mcp/ocr.py | 147 -- mcp-server/src/meshtastic_mcp/pio.py | 310 --- .../src/meshtastic_mcp/recorder/__init__.py | 19 - .../src/meshtastic_mcp/recorder/parsers.py | 309 --- .../src/meshtastic_mcp/recorder/recorder.py | 467 ---- .../src/meshtastic_mcp/recorder/rotating.py | 163 -- mcp-server/src/meshtastic_mcp/registry.py | 98 - .../src/meshtastic_mcp/serial_session.py | 246 --- mcp-server/src/meshtastic_mcp/server.py | 1008 --------- mcp-server/src/meshtastic_mcp/uhubctl.py | 321 --- mcp-server/src/meshtastic_mcp/userprefs.py | 540 ----- mcp-server/tests/README.md | 116 - mcp-server/tests/__init__.py | 0 mcp-server/tests/_port_discovery.py | 118 - mcp-server/tests/_power.py | 112 - mcp-server/tests/admin/__init__.py | 0 .../tests/admin/test_channel_url_roundtrip.py | 57 - .../tests/admin/test_config_roundtrip.py | 106 - .../tests/admin/test_owner_survives_reboot.py | 59 - mcp-server/tests/conftest.py | 1172 ---------- mcp-server/tests/fleet/__init__.py | 0 .../fleet/test_psk_seed_isolates_runs.py | 43 - mcp-server/tests/mesh/__init__.py | 0 mcp-server/tests/mesh/_receive.py | 220 -- mcp-server/tests/mesh/test_bidirectional.py | 83 - .../tests/mesh/test_broadcast_delivers.py | 45 - mcp-server/tests/mesh/test_direct_with_ack.py | 105 - mcp-server/tests/mesh/test_mesh_formation.py | 39 - .../mesh/test_nexthop_multihop_recovery.py | 347 --- .../tests/mesh/test_peer_offline_recovery.py | 155 -- mcp-server/tests/mesh/test_traceroute.py | 147 -- mcp-server/tests/monitor/__init__.py | 0 .../tests/monitor/test_boot_log_no_panic.py | 63 - mcp-server/tests/provisioning/__init__.py | 0 .../provisioning/test_admin_key_baked.py | 83 - .../test_bake_region_modem_slot.py | 60 - .../test_unset_region_blocks_tx.py | 108 - .../test_userprefs_survive_factory_reset.py | 90 - mcp-server/tests/recovery/__init__.py | 6 - mcp-server/tests/recovery/conftest.py | 44 - mcp-server/tests/recovery/test_list_hubs.py | 43 - .../test_power_cycle_preserves_userprefs.py | 60 - .../recovery/test_power_cycle_roundtrip.py | 61 - mcp-server/tests/telemetry/__init__.py | 0 .../test_device_telemetry_broadcast.py | 77 - .../telemetry/test_telemetry_request_reply.py | 187 -- mcp-server/tests/test_00_bake.py | 291 --- mcp-server/tests/tool_coverage.py | 152 -- mcp-server/tests/ui/__init__.py | 7 - mcp-server/tests/ui/_screen_log.py | 176 -- mcp-server/tests/ui/conftest.py | 381 ---- mcp-server/tests/ui/test_input_fn_jump.py | 61 - mcp-server/tests/ui/test_input_fn_oob.py | 61 - mcp-server/tests/ui/test_input_menu.py | 68 - .../tests/ui/test_input_message_scroll.py | 60 - mcp-server/tests/ui/test_input_navigation.py | 93 - mcp-server/tests/ui/test_input_node_scroll.py | 51 - mcp-server/tests/unit/__init__.py | 0 mcp-server/tests/unit/test_boards.py | 72 - mcp-server/tests/unit/test_build_flags.py | 88 - mcp-server/tests/unit/test_connection_tcp.py | 383 ---- .../tests/unit/test_fake_nodedb_generator.py | 364 ---- .../tests/unit/test_input_event_codes.py | 90 - mcp-server/tests/unit/test_pio_wrapper.py | 61 - mcp-server/tests/unit/test_recorder.py | 548 ----- mcp-server/tests/unit/test_testing_profile.py | 120 -- mcp-server/tests/unit/test_uhubctl_parser.py | 148 -- mcp-server/tests/unit/test_ui_screen_log.py | 80 - mcp-server/tests/unit/test_userprefs_parse.py | 115 - src/graphics/Screen.h | 3 +- test/fixtures/nodedb/README.md | 7 +- userPrefs.jsonc | 2 +- 116 files changed, 93 insertions(+), 18519 deletions(-) delete mode 100644 .claude/commands/README.md delete mode 100644 .claude/commands/diagnose.md delete mode 100644 .claude/commands/leakhunt.md delete mode 100644 .claude/commands/repro.md delete mode 100644 .claude/commands/test.md delete mode 100644 .github/prompts/mcp-diagnose.prompt.md delete mode 100644 .github/prompts/mcp-repro.prompt.md delete mode 100644 .github/prompts/mcp-test.prompt.md delete mode 100644 mcp-server/.gitignore delete mode 100644 mcp-server/README.md delete mode 100644 mcp-server/pyproject.toml delete mode 100755 mcp-server/run-tests.sh delete mode 100644 mcp-server/scripts/datadog-dashboard.json delete mode 100755 mcp-server/scripts/mtlog_to_datadog.py delete mode 100644 mcp-server/src/meshtastic_mcp/__init__.py delete mode 100644 mcp-server/src/meshtastic_mcp/__main__.py delete mode 100644 mcp-server/src/meshtastic_mcp/admin.py delete mode 100644 mcp-server/src/meshtastic_mcp/boards.py delete mode 100644 mcp-server/src/meshtastic_mcp/camera.py delete mode 100644 mcp-server/src/meshtastic_mcp/cli/__init__.py delete mode 100644 mcp-server/src/meshtastic_mcp/cli/_flashlog.py delete mode 100644 mcp-server/src/meshtastic_mcp/cli/_fwlog.py delete mode 100644 mcp-server/src/meshtastic_mcp/cli/_history.py delete mode 100644 mcp-server/src/meshtastic_mcp/cli/_reproducer.py delete mode 100644 mcp-server/src/meshtastic_mcp/cli/_uicap.py delete mode 100644 mcp-server/src/meshtastic_mcp/cli/test_tui.py delete mode 100644 mcp-server/src/meshtastic_mcp/config.py delete mode 100644 mcp-server/src/meshtastic_mcp/connection.py delete mode 100644 mcp-server/src/meshtastic_mcp/devices.py delete mode 100644 mcp-server/src/meshtastic_mcp/fixtures.py delete mode 100644 mcp-server/src/meshtastic_mcp/flash.py delete mode 100644 mcp-server/src/meshtastic_mcp/hw_tools.py delete mode 100644 mcp-server/src/meshtastic_mcp/info.py delete mode 100644 mcp-server/src/meshtastic_mcp/input_events.py delete mode 100644 mcp-server/src/meshtastic_mcp/log_query.py delete mode 100644 mcp-server/src/meshtastic_mcp/ocr.py delete mode 100644 mcp-server/src/meshtastic_mcp/pio.py delete mode 100644 mcp-server/src/meshtastic_mcp/recorder/__init__.py delete mode 100644 mcp-server/src/meshtastic_mcp/recorder/parsers.py delete mode 100644 mcp-server/src/meshtastic_mcp/recorder/recorder.py delete mode 100644 mcp-server/src/meshtastic_mcp/recorder/rotating.py delete mode 100644 mcp-server/src/meshtastic_mcp/registry.py delete mode 100644 mcp-server/src/meshtastic_mcp/serial_session.py delete mode 100644 mcp-server/src/meshtastic_mcp/server.py delete mode 100644 mcp-server/src/meshtastic_mcp/uhubctl.py delete mode 100644 mcp-server/src/meshtastic_mcp/userprefs.py delete mode 100644 mcp-server/tests/README.md delete mode 100644 mcp-server/tests/__init__.py delete mode 100644 mcp-server/tests/_port_discovery.py delete mode 100644 mcp-server/tests/_power.py delete mode 100644 mcp-server/tests/admin/__init__.py delete mode 100644 mcp-server/tests/admin/test_channel_url_roundtrip.py delete mode 100644 mcp-server/tests/admin/test_config_roundtrip.py delete mode 100644 mcp-server/tests/admin/test_owner_survives_reboot.py delete mode 100644 mcp-server/tests/conftest.py delete mode 100644 mcp-server/tests/fleet/__init__.py delete mode 100644 mcp-server/tests/fleet/test_psk_seed_isolates_runs.py delete mode 100644 mcp-server/tests/mesh/__init__.py delete mode 100644 mcp-server/tests/mesh/_receive.py delete mode 100644 mcp-server/tests/mesh/test_bidirectional.py delete mode 100644 mcp-server/tests/mesh/test_broadcast_delivers.py delete mode 100644 mcp-server/tests/mesh/test_direct_with_ack.py delete mode 100644 mcp-server/tests/mesh/test_mesh_formation.py delete mode 100644 mcp-server/tests/mesh/test_nexthop_multihop_recovery.py delete mode 100644 mcp-server/tests/mesh/test_peer_offline_recovery.py delete mode 100644 mcp-server/tests/mesh/test_traceroute.py delete mode 100644 mcp-server/tests/monitor/__init__.py delete mode 100644 mcp-server/tests/monitor/test_boot_log_no_panic.py delete mode 100644 mcp-server/tests/provisioning/__init__.py delete mode 100644 mcp-server/tests/provisioning/test_admin_key_baked.py delete mode 100644 mcp-server/tests/provisioning/test_bake_region_modem_slot.py delete mode 100644 mcp-server/tests/provisioning/test_unset_region_blocks_tx.py delete mode 100644 mcp-server/tests/provisioning/test_userprefs_survive_factory_reset.py delete mode 100644 mcp-server/tests/recovery/__init__.py delete mode 100644 mcp-server/tests/recovery/conftest.py delete mode 100644 mcp-server/tests/recovery/test_list_hubs.py delete mode 100644 mcp-server/tests/recovery/test_power_cycle_preserves_userprefs.py delete mode 100644 mcp-server/tests/recovery/test_power_cycle_roundtrip.py delete mode 100644 mcp-server/tests/telemetry/__init__.py delete mode 100644 mcp-server/tests/telemetry/test_device_telemetry_broadcast.py delete mode 100644 mcp-server/tests/telemetry/test_telemetry_request_reply.py delete mode 100644 mcp-server/tests/test_00_bake.py delete mode 100644 mcp-server/tests/tool_coverage.py delete mode 100644 mcp-server/tests/ui/__init__.py delete mode 100644 mcp-server/tests/ui/_screen_log.py delete mode 100644 mcp-server/tests/ui/conftest.py delete mode 100644 mcp-server/tests/ui/test_input_fn_jump.py delete mode 100644 mcp-server/tests/ui/test_input_fn_oob.py delete mode 100644 mcp-server/tests/ui/test_input_menu.py delete mode 100644 mcp-server/tests/ui/test_input_message_scroll.py delete mode 100644 mcp-server/tests/ui/test_input_navigation.py delete mode 100644 mcp-server/tests/ui/test_input_node_scroll.py delete mode 100644 mcp-server/tests/unit/__init__.py delete mode 100644 mcp-server/tests/unit/test_boards.py delete mode 100644 mcp-server/tests/unit/test_build_flags.py delete mode 100644 mcp-server/tests/unit/test_connection_tcp.py delete mode 100644 mcp-server/tests/unit/test_fake_nodedb_generator.py delete mode 100644 mcp-server/tests/unit/test_input_event_codes.py delete mode 100644 mcp-server/tests/unit/test_pio_wrapper.py delete mode 100644 mcp-server/tests/unit/test_recorder.py delete mode 100644 mcp-server/tests/unit/test_testing_profile.py delete mode 100644 mcp-server/tests/unit/test_uhubctl_parser.py delete mode 100644 mcp-server/tests/unit/test_ui_screen_log.py delete mode 100644 mcp-server/tests/unit/test_userprefs_parse.py diff --git a/.claude/commands/README.md b/.claude/commands/README.md deleted file mode 100644 index 87ad24aed..000000000 --- a/.claude/commands/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Claude Code slash commands for the mcp-server test suite - -Three AI-assisted workflows wrapping `mcp-server/run-tests.sh` and the meshtastic MCP tools. Each one has a twin in `.github/prompts/` for Copilot users. - -| Slash command | What it does | Copilot equivalent | -| --------------------- | ------------------------------------------------------------------------- | ---------------------------------------- | -| `/test [args]` | Runs the test suite (auto-detects hardware) and interprets failures | `.github/prompts/mcp-test.prompt.md` | -| `/diagnose [role]` | Read-only device health report via the meshtastic MCP tools | `.github/prompts/mcp-diagnose.prompt.md` | -| `/repro [n=5]` | Re-runs one test N times, diffs firmware logs between passes and failures | `.github/prompts/mcp-repro.prompt.md` | - -## Why two surfaces - -The Claude Code commands and Copilot prompts cover the same three workflows but each speaks its host's idiom: - -- **Claude Code** (`/test`) uses `$ARGUMENTS` for pass-through, has direct access to Bash + all MCP tools registered in the user's settings, and runs in the terminal context. -- **Copilot** (`/mcp-test`) runs in VS Code's agent mode; it has terminal + MCP access too but typically asks the operator to confirm inputs interactively. - -A contributor using either IDE gets equivalent assistance. Keep the two in sync when behavior changes - the diff of intent should be minimal. - -## House rules - -- **No destructive writes without explicit operator approval.** Skills that could reflash, factory-reset, or reboot a device must describe the action and stop - the operator authorizes. -- **Interpret failures, don't just echo them.** The skill body should pull firmware log lines from `mcp-server/tests/report.html` (the `Meshtastic debug` section, attached by `tests/conftest.py::pytest_runtest_makereport`) and classify the failure. -- **Keep MCP tool calls sequential per port.** SerialInterface holds an exclusive port lock; two parallel tool calls on the same port deadlock. -- **Never speculate about root cause.** If the evidence doesn't support a classification, say "unknown" and list what you'd need to disambiguate. - -## Adding a new command - -1. Write the Claude Code version at `.claude/commands/.md` with YAML frontmatter: - - ```yaml - --- - description: one-line purpose (used for auto-invocation by the model) - argument-hint: [optional-hint] - --- - ``` - -2. Write the Copilot equivalent at `.github/prompts/mcp-.prompt.md` with: - - ```yaml - --- - mode: agent - description: ... - --- - ``` - -3. Add the row to the table above. Cross-link in both bodies. - -4. Smoke-test on Claude Code first (`/` should appear in autocomplete), then in VS Code Copilot (`/mcp-` in Chat). diff --git a/.claude/commands/diagnose.md b/.claude/commands/diagnose.md deleted file mode 100644 index d8db2e48f..000000000 --- a/.claude/commands/diagnose.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -description: Produce a device health report using the meshtastic MCP tools (device_info, list_nodes, get_config, short serial log capture) -argument-hint: [role=all|nrf52|esp32s3|] ---- - -# `/diagnose` - device health report - -Call the meshtastic MCP tool bundle and format a structured health report for one or all detected devices. Zero guesswork for the operator. - -## What to do - -1. **Enumerate hardware.** Call `mcp__meshtastic__list_devices(include_unknown=True)`. For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`. - -2. **Filter by `$ARGUMENTS`**: - - No args, `all` → every likely-meshtastic device. - - `nrf52` → only devices with `vid == 0x239a`. - - `esp32s3` → only devices with `vid == 0x303a` or `vid == 0x10c4`. - - A `/dev/cu.*` path → only that one port. - - Anything else → treat as a substring match against the `port` string. - -3. **For each selected device, in sequence (NOT parallel - SerialInterface holds an exclusive port lock):** - - `mcp__meshtastic__device_info(port=

)` - captures `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`. - - `mcp__meshtastic__list_nodes(port=

)` - count of peers, which ones have `publicKey` set, SNR/RSSI distribution. - - `mcp__meshtastic__get_config(section="lora", port=

)` - region, preset, channel_num, tx_power, hop_limit. - - Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=

, env=)`, wait 3s, `serial_read(session_id=, max_lines=100)`, `serial_close(session_id=)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*` is set. - -4. **Hub health** (call once, not per-device): `mcp__meshtastic__uhubctl_list()` - enumerates every USB hub the host can see. Note which hubs advertise `ppps=true` and which hub hosts each Meshtastic device (cross-reference by VID). Flag it in the report if: - - No hub advertises PPPS → `tests/recovery/` can't run on this setup; hard-recovery via `uhubctl_cycle` isn't available. - - A Meshtastic device is on a non-PPPS hub → note it; operator may want to move the device to a PPPS hub to unlock auto-recovery. - - `uhubctl_list` raises `ConfigError: uhubctl not found` → just say `uhubctl not installed` in the report; don't treat as a fault. - -5. **Render per-device report** as: - - ```text - [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 - owner : Meshtastic 40eb / 40eb - region/band : US, channel 88, LONG_FAST - tx_power : 30 dBm, hop_limit=3 - peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) - primary ch : McpTest - hub : 1-1.3 port 2 (PPPS, uhubctl-controllable) - firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts - ``` - - Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub, device on non-PPPS hub), flag it inline with a short `⚠︎ `. - -6. **Cross-device correlation** (only when >1 device is inspected): - - Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo - flag it. - - Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh) - - Do the primary channel NAMES match? Mismatch = different PSK = no decode. - -7. **Recorder slice (cheap, always available).** The mcp-server runs an autouse log recorder that's been collecting from every connected device. Pull two short slices to surface anything weird that's already happened: - - `mcp__meshtastic__logs_window(start="-2m", level="WARN|ERROR|CRIT", max_lines=20)` - recent firmware errors. If empty, say "no recent errors"; don't manufacture concern. - - `mcp__meshtastic__telemetry_timeline(window="1h", field="free_heap", max_points=60)` - heap trend. If `slope_per_min < -50`, flag it and recommend `/leakhunt window=6h` for a deeper read; otherwise just note the current free heap. - - If `recorder_status` shows `running:false` or `files.telemetry.last_ts` is null, note "recorder has no telemetry yet - enable `set_debug_log_api(True)` to populate" and skip this step gracefully. - -8. **Suggest next actions only for specific, recognisable failure modes**: - - Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` - the retry + nodeinfo-ping heals this in the test path." - - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." - - Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`. - - CP2102-wedged-driver on macOS → see the note in `run-tests.sh`. - - Heap slope strongly negative → "run `/leakhunt window=6h` for a full timeline + classification." - -## What NOT to do - -- No writes. No `set_config`, no `reboot`, no `factory_reset`. This is a read-only diagnostic skill - if the operator wants to change state, they'll ask explicitly. -- No `flash` / `erase_and_flash`. Those are separate escalations. -- No holding SerialInterface across tool calls - open, query, close; next device. The port lock is exclusive. diff --git a/.claude/commands/leakhunt.md b/.claude/commands/leakhunt.md deleted file mode 100644 index 3950af8c6..000000000 --- a/.claude/commands/leakhunt.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -description: Hunt for memory leaks (and other slow degradations) by reading the persistent recorder's heap timeline + log slice over a window -argument-hint: [window=1h] [field=free_heap] [variant=local] ---- - - - -# `/leakhunt` - read the recorder, classify a memory leak - -Use the always-on recorder (`mcp-server/.mtlog/`) to read a heap timeline plus the matching log slice and produce a one-page verdict: **steady / slow leak / fragmentation / OOM-imminent**. No firmware changes, no special build flags - the LocalStats telemetry packet that the firmware already broadcasts every ~60 s carries `heap_free_bytes` and `heap_total_bytes`. - -## Two signal paths - pick the right one - -| Path | Build flag | Cadence | Per-thread attribution | Cost | -| --------------------- | ---------------- | -------------- | ---------------------- | ------------------------- | -| LocalStats packet | (default) | ~60 s | No | Free - always on | -| `[heap N]` log prefix | `-DDEBUG_HEAP=1` | every log line | Yes (Thread X leaked) | Bigger flash + log volume | - -Both feed the same `telemetry_timeline(field="free_heap")` query - when DEBUG_HEAP is on, the recorder synthesizes telemetry rows from log prefixes (tagged `source: debug_heap`), so a single timeline call gets whichever signal is available. **For a slow leak diagnosis, the default path is plenty** (60 s cadence over 6 h = 360 points; linear regression over that nails sub-100-byte/min slopes). **DEBUG_HEAP is for attribution** - when the slope is real and you need to know which thread is leaking. - -## What to do - -1. **Parse `$ARGUMENTS`**: optional `window` (default `1h`, accepts `30m`/`6h`/`-3d`/etc.), optional `field` (default `free_heap`; alternates: `total_heap`, `battery_level`, anything in the LocalStats variant), optional `variant` (default `local`; alternates: `device`, `environment`, `power`, `airQuality`, `health`). - -2. **Verify the recorder is alive** - call `mcp__meshtastic__recorder_status`. Check: - - `running == True` - - `files.telemetry.lines > 0` (at least one telemetry packet recorded - if zero, the device hasn't broadcast LocalStats yet OR `set_debug_log_api` has never been on; tell the operator to run `mcp__meshtastic__set_debug_log_api(enabled=True)` and wait one device-update interval) - - `files.telemetry.last_ts` within the last 5 minutes (if older, the device is silent - log that, not "leak detected") - -3. **Detect whether DEBUG_HEAP is active** - `mcp__meshtastic__logs_window(start="-2m", grep=r"\\[heap \\d+\\]", max_lines=3)`. If any line matches, the firmware has the prefix → DEBUG_HEAP is on, expect higher-cadence data and `heap_event` rows. If zero matches over the last 2 minutes, you're on the LocalStats-only path. - -4. **Pull the timeline** - `mcp__meshtastic__telemetry_timeline(window=$window, variant=$variant, field=$field, max_points=200)`. Read: - - `samples` - how many raw points contributed - - `min`, `max` - total swing - - `slope_per_min` - units per minute (linear regression over the whole window) - -5. **Pull the log context for the same window** - `mcp__meshtastic__logs_window(start="-${window}", grep="Heap status|leaked heap|freed heap|out of memory|Alloc an err|panic|abort", max_lines=200)`. These are the strings the firmware emits when something memory-related happens (`DEBUG_HEAP` builds emit `"Heap status:"` and `"leaked heap"` lines; production builds emit `"Alloc an err"` on failure and `"out of memory"` on OOM). - -6. **Pull marker events** so we know if the operator labeled phases - `mcp__meshtastic__events_window(start="-${window}", kind="mark|connection_lost|connection_established")`. If a `connection_lost` overlaps a sharp drop, that's not a leak; that's a reboot. - -6a. **(DEBUG_HEAP only) Per-thread attribution** - `mcp__meshtastic__logs_window(start="-${window}", grep="leaked heap", max_lines=200)`. Each row has a structured `heap_event` field with `{kind, thread, before, after, delta}`. Aggregate by thread: sum the `delta` over the window per thread name. The thread with the largest cumulative negative delta is your suspect. Note the count too - a thread with 50× small leaks is different from 1× big leak. - -7. **Classify** based on what the data says, NOT on what you wish it said. Use these rules in order: - - **Insufficient data** (< 5 samples): say so. Suggest a longer window or longer wait. Stop. - - **Reboot mid-window**: if any `connection_lost` event is present AND `free_heap` jumped UP at that timestamp, the device rebooted. Note it; pre-reboot trend may be a leak but you only have part of the curve. - - **OOM-imminent**: any `Alloc an err=` or `out of memory` line in the log slice. This trumps everything; flag urgently. - - **Slow leak**: `slope_per_min < -50` AND `max - min > 1000` AND no reboot. The heap is monotonically (or near-monotonically) declining. Estimate time-to-zero: `min / -slope_per_min` minutes. Surface it. - - **Fragmentation suspect**: `slope_per_min` close to zero (|x| < 50) BUT min trends down across the window AND the log slice shows `Alloc an err` warnings WITHOUT total OOM. Means free total is OK but largest contiguous block is shrinking. Recommend a `DEBUG_HEAP` build to confirm. - - **Steady**: |slope_per_min| < 50, no error lines. Heap is fine. - - **Recovery curve**: slope is POSITIVE - heap recovered. Either a workload completed or GC fired. Note it; not a leak. - -8. **Report**: - - ```text - /leakhunt window=6h field=free_heap variant=local - ──────────────────────────────────────────────────── - recorder : running, telem last_ts 8s ago - build : DEBUG_HEAP=ON (per-line prefix detected) - samples : 14,200 over 6h (cadence ~1.5s, log-line synth) - free_heap : min 92,344 / max 124,008 / range 31,664 - slope : -82 bytes/min (negative - heap declining) - reboots : none in window - OOM events : none - error lines : 3× "Alloc an err=ESP_ERR_NO_MEM" at +4h12m, +5h08m, +5h44m - thread leaks : (DEBUG_HEAP) MeshPacket -3,124 B over 18 events - Router -1,408 B over 4 events - others -240 B - verdict : SLOW LEAK - primary suspect MeshPacket thread - est. time-to-OOM: ~1,127 min (~18.8 h) at current slope - evidence : (3 log line citations with uptimes) - ``` - - Then: **what to do next.** - - SLOW LEAK, **DEBUG_HEAP off** → recommend rebuilding with the flag and re-running this skill. Concrete one-liner the operator can copy: - ```text - mcp__meshtastic__build(env="", build_flags={"DEBUG_HEAP": 1}) - mcp__meshtastic__pio_flash(env="", port="", confirm=True) - ``` - After flash, set debug_log_api back on and wait one window; re-run `/leakhunt`. - - SLOW LEAK, **DEBUG_HEAP on** → cite the top-leaking thread name from step 6a. Point at the corresponding source file (`grep -rn "ThreadName(\"\")" src/`); the operator decides what to fix. - - FRAGMENTATION SUSPECT → propose pre-allocating any per-packet buffers; or rebuilding with `CONFIG_HEAP_TASK_TRACKING=y` on ESP32 to see who's holding the largest blocks. - - OOM-IMMINENT → flag for immediate attention; don't wait for the next telemetry interval. - - STEADY → say so; stop. Don't invent problems. - -## What NOT to do - -- Don't assume a leak from a single dip. LocalStats fires every ~60 s and the firmware naturally allocates+frees on each broadcast cycle; one packet sees the trough. Look at the slope, not the deltas. -- Don't recommend code changes. This skill diagnoses; the operator decides what to fix. -- Don't enable `set_debug_log_api` automatically - if it's off, telemetry isn't reaching pubsub anyway, and the recorder will be empty. Tell the operator to flip it on and wait, then re-run. -- Don't run heavy workloads to "trigger the leak." The recorder is passive; we read what's there. - -## Companion: `mark_event` for stress runs - -If the operator wants to test under stimulus (e.g. blast 50 broadcasts and see what the heap does), they can frame the experiment with markers: - -```text -mark_event("burst-start") -… run the workload … -mark_event("burst-end") -/leakhunt window=15m -``` - -The markers land in both `events.jsonl` and `logs.jsonl`, so the report can show "free_heap dipped 8 KB during the burst window, recovered to baseline within 2 LocalStats cycles" → not a leak. diff --git a/.claude/commands/repro.md b/.claude/commands/repro.md deleted file mode 100644 index d5728dec3..000000000 --- a/.claude/commands/repro.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -description: Re-run a specific test N times in isolation to triage flakes, diff firmware logs between passes and failures -argument-hint: [count=5] ---- - - - -# `/repro` - flakiness triage for one test - -Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y." - -## What to do - -1. **Parse `$ARGUMENTS`**: first token is the pytest node id (e.g. `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[nrf52->esp32s3]`); second token is an integer count (default `5`, cap at `20`). If the first token doesn't look like a test path (no `::` and no `tests/` prefix), treat the whole `$ARGUMENTS` as a `-k` filter instead. - -2. **Sanity-check the hub first** (so we're not measuring "nothing plugged in" N times): call `mcp__meshtastic__list_devices`. If the test name contains `nrf52` or `esp32s3` and the matching VID isn't present, stop and report - re-running won't help. - -3. **Loop N times**. For each iteration: - - ```bash - ./mcp-server/run-tests.sh --tb=short -p no:cacheprovider - ``` - - Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware log section from `mcp-server/tests/report.html`. `-p no:cacheprovider` suppresses pytest's `.pytest_cache` writes so iterations don't influence each other. - -4. **Track a small structured tally**: - - ```text - attempt 1: PASS (42s) - attempt 2: FAIL (128s) ← firmware log 200-line tail captured - attempt 3: PASS (39s) - attempt 4: FAIL (121s) - attempt 5: PASS (41s) - -------------------------------------- - pass rate: 3/5 (60%) | mean duration: 74s - ``` - -5. **On mixed outcomes**: diff the firmware log tails between a representative passing attempt and a representative failing attempt. Focus on: - - Error-level lines only present in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`) - - Timing around the assertion event - did a broadcast go out, was there an ACK, did NAK fire? - - Device state fields that changed (nodesByNum entries, region/preset, channel_num) - - Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs - pull specific lines with uptime timestamps. - -5a. **Archive recorder slices per attempt** (no extra device interaction; the recorder runs autouse). Right after each attempt finishes, capture its `(start_ts, end_ts)` and call `mcp__meshtastic__recorder_export(start=, end=, dest_dir="mcp-server/tests/repro_artifacts//attempt_/")`. This drops a `logs.jsonl`, `telemetry.jsonl`, `packets.jsonl`, and `events.jsonl` snapshot scoped to the attempt window. Use these for cross-attempt diffs in step 5: `jq '.line' logs.jsonl` is faster than re-running the test, and the telemetry slice lets you compare heap behavior across attempts. - -6. **Classify the flake** into one of: - - **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body. - - **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this. - - **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup. - - **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. For a device that's wedged past `touch_1200bps`, the next escalation is `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed). - - **Device went dark mid-run** → fails from some attempt onward, never recovers, firmware log stops arriving. Almost always hardware: a Guru crash + frozen CDC. Hard-power-cycle via `uhubctl_cycle(role=..., confirm=True)` before the next iteration; if that also fails, escalate to replug. - - **Genuinely unknown** → say so; don't invent a root cause. - -7. **Report back** with: - - Pass rate and mean duration. - - Classification + evidence (the specific log lines that support it). - - A suggested next step (re-run with specific args, open `/diagnose`, edit a specific test file, nothing). - -## Examples - -- `/repro tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[esp32s3->nrf52] 10` - runs 10 times, diffs firmware logs. -- `/repro broadcast_delivers` - no `::`, no `tests/`, so interpreted as `-k broadcast_delivers`; runs every matching test the default 5 times. -- `/repro tests/telemetry/test_device_telemetry_broadcast.py 3` - shorter run for a slow test. - -## Constraints - -- Don't exceed `count=20` per invocation - airtime and USB wear add up. If the user asks for 50, negotiate down. -- Don't rebuild firmware as part of triage; flakes that only reproduce under different firmware belong in a separate session. -- If the FIRST attempt fails AND the rest all pass, that's a classic "state leak from a prior test" → say so and suggest running with `--force-bake` or starting from a clean state rather than chasing the first failure. diff --git a/.claude/commands/test.md b/.claude/commands/test.md deleted file mode 100644 index 5609b3d33..000000000 --- a/.claude/commands/test.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -description: Run the mcp-server test suite (auto-detects devices) and interpret the results -argument-hint: [pytest-args] ---- - -# `/test` - mcp-server test runner with interpretation - -Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn't have to. - -## What to do - -1. **Invoke the wrapper.** From the firmware repo root, run: - - ```bash - ./mcp-server/run-tests.sh $ARGUMENTS - ``` - - The wrapper auto-detects connected Meshtastic devices, maps each to its PlatformIO env, exports the required `MESHTASTIC_MCP_ENV_*` env vars, and invokes pytest. If the user passed no arguments, the wrapper supplies a sensible default set (`tests/ --html=tests/report.html --self-contained-html --junitxml=tests/junit.xml -v --tb=short`). A `--report-log=tests/reportlog.jsonl` arg is always appended (unless the operator passed their own). `--assume-baked` is deliberately NOT in the defaults - `test_00_bake.py` has its own skip-if-already-baked check and runs the ~8 s verification by default. Operators can opt into the fast path with `--assume-baked`, or force a reflash with `--force-bake`. - -2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only - say so explicitly in your summary so the operator knows hardware tiers were skipped. - -3. **On pass**: one-line summary of the form `N passed, M skipped in `. Don't enumerate the test names - the user can read those. Do mention any SKIPPED tests and name the cause: - - `"role not present on hub"` → device unplugged; operator knows to reconnect. - - `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped because the macro isn't in firmware yet; suggest `--force-bake`. - - `"uhubctl not installed"` → tests/recovery + peer-offline skipped; suggest `brew install uhubctl` / `apt install uhubctl`. - - `"no PPPS-capable hubs detected"` → tests/recovery skipped because the hub doesn't support per-port power; the tier will never run on that setup. - - `"opencv-python-headless is not installed"` → tests/ui auto-deselected by run-tests.sh; suggest `pip install -e 'mcp-server/.[ui]'`. - -4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). For UI-tier failures also glance at `mcp-server/tests/ui_captures///transcript.md` - it records each step's frame + OCR. - -5. **Classify the failure** as one of: - - **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro ` to confirm. - - **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery in escalation order: (a) replug USB, (b) `touch_1200bps(port=...)` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role="nrf52", confirm=True)` when a device is fully wedged past DFU (needs `uhubctl` installed - `baked_single`'s auto-recovery hook does this once automatically). Also check `git status userPrefs.jsonc`. - - **Regression**: same assertion fails repeatedly, firmware log shows a new/unusual error. Surface the diff between expected and observed, identify the module likely responsible. - -6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or USB replug, \_describe what to do* - don't execute. The operator decides. - -## Arguments handling - -- No args → wrapper's defaults (full suite). -- `$ARGUMENTS` passed verbatim to the wrapper, which passes them to pytest. -- Common operator invocations: `/test tests/mesh`, `/test tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip`, `/test --force-bake`, `/test -k telemetry`. - -## Side-effects to mention in summary - -- The session fixture snapshots `userPrefs.jsonc` at session start and restores at teardown (plus on `atexit`). After a clean run, `git status userPrefs.jsonc` should be empty. If the wrapper's pre-flight printed a warning about a stale sidecar, call that out - means a prior session crashed. -- `mcp-server/tests/report.html` and `junit.xml` are regenerated on every run; the HTML is self-contained (shareable). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 37773d3b1..2782ff918 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,12 +2,12 @@ > **TL;DR** > -> | | | -> | -------------- | -------------------------------------------------------------------------------------------- | -> | Local tests | `./bin/run-tests.sh` (exit 0 GREEN · 1 RED · 2 AMBER · 3 FILTERED) | -> | Hardware tests | `./mcp-server/run-tests.sh` | -> | Format | `trunk fmt` | -> | Mirror docs | `AGENTS.md` (short pointer for agents that don't read this file) · `CLAUDE.md` (Claude Code) | +> | | | +> | -------------- | ---------------------------------------------------------------------------------------------------------------------- | +> | Local tests | `./bin/run-tests.sh` (exit 0 GREEN · 1 RED · 2 AMBER · 3 FILTERED) | +> | Hardware tests | [meshtastic/meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) (`MESHTASTIC_FIRMWARE_ROOT` → this checkout) | +> | Format | `trunk fmt` | +> | Mirror docs | `AGENTS.md` (short pointer for agents that don't read this file) · `CLAUDE.md` (Claude Code) | > > **Need this? It's here.** > @@ -737,15 +737,15 @@ Simulation testing: `bin/test-simulator.sh` Quick entry point for new test modules: `test/README.md` (native unit-test authoring guide, skeleton, pitfalls, and setup checklist). -### Hardware-in-the-loop tests (`mcp-server/tests/`) +### Hardware-in-the-loop tests ([meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp)) -Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules. +Separate pytest suite that exercises real USB-connected Meshtastic devices. It now lives in the standalone [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) repo, run against a firmware checkout via `MESHTASTIC_FIRMWARE_ROOT`. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules. ## MCP Server & Hardware Test Harness -The `mcp-server/` directory houses a firmware-aware [MCP](https://modelcontextprotocol.io/) server plus a pytest-based integration suite. AI agents that speak MCP get a well-defined tool surface for flashing, configuring, and inspecting physical Meshtastic devices - use it instead of hand-rolling `pio` or `meshtastic --port` calls where possible. `mcp-server/README.md` is the operator-facing setup doc; this section is the agent-facing usage contract. +The firmware-aware [MCP](https://modelcontextprotocol.io/) server plus its pytest-based integration suite now live in the standalone [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) repo. AI agents that speak MCP get a well-defined tool surface for flashing, configuring, and inspecting physical Meshtastic devices - use it instead of hand-rolling `pio` or `meshtastic --port` calls where possible. The meshtastic-mcp repo's README is the operator-facing setup doc; this section is the agent-facing usage contract. -The repo registers the server via `.mcp.json` at the repo root - Claude Code picks it up automatically once `mcp-server/.venv/` is built (`cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'`). +The repo registers the server via `.mcp.json` at the repo root - Claude Code / Copilot pick it up automatically and run it through `uvx --from git+https://github.com/meshtastic/meshtastic-mcp meshtastic-mcp`, so the MCP tools work with no local build. To run the pytest hardware harness instead, clone [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) and point `MESHTASTIC_FIRMWARE_ROOT` at this firmware checkout. ### When to use which surface @@ -757,7 +757,7 @@ The repo registers the server via `.mcp.json` at the repo root - Claude Code pic | Flash firmware to a variant | `pio_flash` (any arch) or `erase_and_flash` (ESP32 factory install) | | Stream serial logs while debugging | `serial_open` → `serial_read` loop → `serial_close` | | Administer `userPrefs.jsonc` build-time constants | `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest` | -| Run the regression suite | `./mcp-server/run-tests.sh` (or `/test` slash command) | +| Run the regression suite | `./run-tests.sh` from a meshtastic-mcp checkout (or `/test` slash command) | | Diagnose a specific device | `/diagnose [role]` slash command (read-only) | | Triage a flaky test | `/repro [count]` slash command | @@ -765,7 +765,7 @@ The repo registers the server via `.mcp.json` at the repo root - Claude Code pic ### MCP tool surface (43 tools) -Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here. +Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a few high-value signatures are called out here. - **Discovery & metadata**: `list_devices`, `list_boards`, `get_board` - **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps` @@ -775,13 +775,13 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v - **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw` - **USB power control** (via `uhubctl`, per-port PPPS toggle): `uhubctl_list` (read-only), `uhubctl_power(action='on'|'off', confirm=True)`, `uhubctl_cycle(delay_s, confirm=True)`. Target by raw `(location, port)` or by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_` + `_PORT_` env vars first, falls back to VID auto-detection. -- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` - grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured. +- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` - grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `meshtastic-mcp[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured. `confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve - it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`. -**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step - use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes". +**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step - use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See the meshtastic-mcp repo's README § "TCP / native-host nodes". -### Hardware test suite (`mcp-server/run-tests.sh`) +### Hardware test suite (`run-tests.sh`, from a meshtastic-mcp checkout) The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. @@ -801,22 +801,23 @@ Suite tiers (collected + run in this order via `pytest_collection_modifyitems`): Invocation patterns: ```bash -./mcp-server/run-tests.sh # full suite (auto-bake-if-needed) -./mcp-server/run-tests.sh --force-bake # reflash before testing -./mcp-server/run-tests.sh --assume-baked # skip bake (caller vouches for device state) -./mcp-server/run-tests.sh tests/mesh # one tier -./mcp-server/run-tests.sh tests/mesh/test_direct_with_ack.py # one file -./mcp-server/run-tests.sh -k telemetry # name filter +# run from a meshtastic-mcp checkout, with MESHTASTIC_FIRMWARE_ROOT=/path/to/firmware +./run-tests.sh # full suite (auto-bake-if-needed) +./run-tests.sh --force-bake # reflash before testing +./run-tests.sh --assume-baked # skip bake (caller vouches for device state) +./run-tests.sh tests/mesh # one tier +./run-tests.sh tests/mesh/test_direct_with_ack.py # one file +./run-tests.sh -k telemetry # name filter ``` **No hardware detected?** The wrapper auto-narrows to `tests/unit/` only and prints `detected hub : (none)` in the pre-flight header. Agents interpreting the output should call this out explicitly - a 52-test green run without hardware is qualitatively different from a 12-unit-test green run. **Artifacts every run produces:** -- `mcp-server/tests/report.html` - self-contained pytest-html. Each test gets a `Meshtastic debug` section with the tail of firmware log + device state dump. **Open this first** on failures; it's the canonical evidence source. -- `mcp-server/tests/junit.xml` - CI-parseable. -- `mcp-server/tests/reportlog.jsonl` - pytest-reportlog stream (`$report_type` keyed JSONL). Consumed by the live TUI. -- `mcp-server/tests/fwlog.jsonl` - firmware log mirror from the `meshtastic.log.line` pubsub topic. Populated by the `_firmware_log_stream` autouse session fixture. +- `tests/report.html` - self-contained pytest-html. Each test gets a `Meshtastic debug` section with the tail of firmware log + device state dump. **Open this first** on failures; it's the canonical evidence source. +- `tests/junit.xml` - CI-parseable. +- `tests/reportlog.jsonl` - pytest-reportlog stream (`$report_type` keyed JSONL). Consumed by the live TUI. +- `tests/fwlog.jsonl` - firmware log mirror from the `meshtastic.log.line` pubsub topic. Populated by the `_firmware_log_stream` autouse session fixture. ### Live TUI (`meshtastic-mcp-test-tui`) @@ -836,7 +837,7 @@ A Textual-based live view that wraps `run-tests.sh`. Tails reportlog for per-tes Launch: ```bash -cd mcp-server +# from a meshtastic-mcp checkout (MESHTASTIC_FIRMWARE_ROOT set) .venv/bin/meshtastic-mcp-test-tui # full suite .venv/bin/meshtastic-mcp-test-tui tests/mesh # args pass through to pytest ``` @@ -861,7 +862,7 @@ House rules for agents running these prompts: ### Key fixtures (test authors + agents debugging) -`mcp-server/tests/conftest.py` provides: +`tests/conftest.py` (in the meshtastic-mcp checkout) provides: - **`_session_userprefs`** (autouse session) - snapshots `userPrefs.jsonc` at session start, merges the session test profile via `userprefs.merge_active(test_profile)`, restores at teardown. Four layers of safety: pytest teardown + `atexit` + sidecar file (`userPrefs.jsonc.mcp-session-bak`) + startup self-heal in `run-tests.sh`. **Do not edit `userPrefs.jsonc` from inside a test.** - **`_firmware_log_stream`** (autouse session) - subscribes to `meshtastic.log.line` pubsub on every connected `SerialInterface` and mirrors lines to `tests/fwlog.jsonl`. Drives the TUI firmware-log pane. @@ -877,13 +878,13 @@ Two firmware changes exist specifically so the test harness works reliably. **Ke - **`src/mesh/StreamAPI.cpp` + `StreamAPI.h`** - `emitLogRecord` uses a dedicated `fromRadioScratchLog` + `txBufLog` pair and a `concurrency::Lock streamLock`. Before this fix, `debug_log_api_enabled=true` would tear `FromRadio` protobufs on the serial transport because `emitTxBuffer` and `emitLogRecord` shared a single scratch buffer. The conftest enables the log stream session-wide; without this fix the device would corrupt its own FromRadio replies mid-session. - **`src/mesh/PhoneAPI.cpp`** - `ToRadio` `Heartbeat(nonce=1)` triggers `nodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, true, 0, true)` for serial clients, mirroring the pre-existing behavior for TCP/UDP clients in `PacketAPI.cpp`. The mesh tests rely on this to force a NodeInfo broadcast right after connect so the peer discovers them before the test's first assertion. -If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` flow, run `./mcp-server/run-tests.sh` at minimum before asking for review. +If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` flow, run `./run-tests.sh` (from a meshtastic-mcp checkout, with `MESHTASTIC_FIRMWARE_ROOT` pointed here) at minimum before asking for review. ### Recovery playbooks | Symptom | First check | Fix | | --------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once - the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. | +| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./run-tests.sh` (from a meshtastic-mcp checkout) once - the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. | | Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. | | nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. | | Device fully wedged (Guru Meditation, frozen CDC, no DFU) | `list_devices` shows the VID but every admin call times out | `uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles the port via USB hub PPPS. `baked_single`'s auto-recovery hook does this once automatically if uhubctl is installed. Falls back to physical replug if no PPPS hub. | diff --git a/.github/prompts/mcp-diagnose.prompt.md b/.github/prompts/mcp-diagnose.prompt.md deleted file mode 100644 index 3ef6bf22d..000000000 --- a/.github/prompts/mcp-diagnose.prompt.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -mode: agent -description: Device health report via the meshtastic MCP tools (Copilot equivalent of the Claude Code /diagnose slash command) ---- - -# `/mcp-diagnose` - device health report - -Equivalent of `.claude/commands/diagnose.md`. Use when the operator asks to "check the devices", "what's the mesh looking like", "is nrf52 alive", etc. - -This prompt assumes the meshtastic MCP server is registered with your VS Code Copilot agent. If it isn't, fall back to running `./mcp-server/run-tests.sh tests/unit` plus a short `device_info` script via the terminal. - -## What to do - -1. **Enumerate hardware** via the `list_devices` MCP tool (with `include_unknown=True`). For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`. - -2. **Apply the operator's filter** (if any): - - No filter → every likely-meshtastic device. - - `nrf52` → `vid == 0x239a` - - `esp32s3` → `vid == 0x303a` or `vid == 0x10c4` - - A `/dev/cu.*` path → only that port. - - Anything else → substring match on port. - -3. **For each selected device, in sequence (don't parallelize - SerialInterface holds an exclusive port lock):** - - `device_info(port=

)` → `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel` - - `list_nodes(port=

)` → peer count, which peers have `publicKey`, SNR/RSSI distribution - - `get_config(section="lora", port=

)` → region, preset, channel_num, tx_power, hop_limit - - If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=

, env=)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_` env var overrides it. - -4. **Hub health** (call once, not per-device): `uhubctl_list()` - enumerates every USB hub the host sees. Cross-reference each Meshtastic device's VID to find which hub + port it's on. Flag in the report if: - - No hub advertises `ppps=true` → `tests/recovery/` can't run; hard-recovery via `uhubctl_cycle` isn't available. - - A Meshtastic device is on a non-PPPS hub → note it; moving to a PPPS hub unlocks auto-recovery. - - `uhubctl_list` raises `ConfigError: uhubctl not found` → report as "uhubctl not installed"; don't treat as a device fault. - -5. **Render per-device report** as a compact block: - - ```text - [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 - owner : Meshtastic 40eb / 40eb - region/band : US, channel 88, LONG_FAST - tx_power : 30 dBm, hop_limit=3 - peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) - primary ch : McpTest - hub : 1-1.3 port 2 (PPPS, uhubctl-controllable) - firmware : no panics in last 3s - ``` - - Flag abnormalities inline with `⚠︎ ` - missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc. - -6. **Cross-device correlation** (when >1 device selected): - - Do both see each other in `nodesByNum`? - - Do `region`, `channel_num`, `modem_preset` match across devices? - - Do the primary channel names match? (Different name → different PSK → no decode.) - -7. **Suggest next steps only for recognizable failure modes**, never speculatively: - - Stale PKI one-way → "`/mcp-test tests/mesh/test_direct_with_ack.py` - the test's retry+nodeinfo-ping heals this." - - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." - - Device unreachable, DFU reachable → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds and the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`. - - CP2102-wedged-driver on macOS → see `run-tests.sh` notes. - -## Hard constraints - -- **Read-only.** No `set_config`, no `reboot`, no `factory_reset`, no `flash`. If the operator wants mutation, they'll escalate explicitly. -- **Open/query/close per device.** Never hold multiple SerialInterfaces to the same port. The port lock is exclusive. -- **Don't infer env beyond the VID map** - if the operator has an unusual board, ask them which env to use rather than guessing. diff --git a/.github/prompts/mcp-repro.prompt.md b/.github/prompts/mcp-repro.prompt.md deleted file mode 100644 index e260bdd19..000000000 --- a/.github/prompts/mcp-repro.prompt.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -mode: agent -description: Re-run a specific test N times to triage flakes; diff firmware logs between passes and failures (Copilot equivalent of the Claude Code /repro slash command) ---- - -# `/mcp-repro` - flakiness triage for one test - -Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one test is flaky - dig in", "repro the direct_with_ack failure", "why does X sometimes fail?". - -## What to do - -1. **Parse the operator's input** into two pieces: - - **Test identifier** - either a pytest node id (has `::` or starts with `tests/`) or a `-k`-style filter (plain substring like `direct_with_ack`). - - **Count** - integer, default `5`, cap at `20`. If the operator asks for 50, negotiate down and explain (airtime + USB wear). - -2. **Sanity-check the hub** via the `list_devices` MCP tool. If the test name references `nrf52` or `esp32s3` and the matching VID isn't present, stop and report - re-running won't help. - -3. **Loop** N times. Each iteration: - - ```bash - ./mcp-server/run-tests.sh --tb=short -p no:cacheprovider - ``` - - `-p no:cacheprovider` keeps pytest from caching anything between iterations. Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware-log section from `mcp-server/tests/report.html`. - -4. **Tally** results as you go: - - ```text - attempt 1: PASS (42s) - attempt 2: FAIL (128s) ← fw log captured - attempt 3: PASS (39s) - attempt 4: FAIL (121s) - attempt 5: PASS (41s) - -------------------------------------------------- - pass rate: 3/5 (60%) | mean duration: 74s - ``` - -5. **On mixed outcomes, diff the firmware logs** between one representative pass and one representative fail. Focus on: - - Error-level lines present only in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`, `NAK`) - - Timing around the assertion point (broadcast sent? ACK received? retry fired?) - - Device-state fields that changed between attempts - - Surface the top 3 differences as a compact "passes when / fails when" table with uptime timestamps. Don't dump full logs. - -6. **Classify** the flake into one of: - - **LoRa airtime collision** - pass rate improves with fewer concurrent transmitters. Suggest a `time.sleep` gap or retry bump in the test body. - - **PKI key staleness** - first attempt fails, subsequent ones pass; existing retry-loop pattern in `test_direct_with_ack.py` is the fix. - - **NodeInfo cooldown** - `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs a `broadcast_nodeinfo_ping()` warmup. - - **Hardware-specific** - one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. For a device wedged past `touch_1200bps`, recommend `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed). - - **Device went dark mid-run** - fails from some iteration onward and never recovers; firmware log stops arriving. Almost always a Guru crash with frozen CDC. Recommend `uhubctl_cycle` before the next iteration; escalate to replug if that also fails. - - **Unknown** - say so. Don't invent a root cause. - -7. **Report back** with: - - Pass rate + mean duration. - - Classification + the specific log evidence for it. - - A concrete next step (tighter assertion, more retries, open `/mcp-diagnose`, file a bug, nothing). - -## Examples - -- `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[esp32s3->nrf52] 10` - 10 runs of that parametrized case. -- `broadcast_delivers` - no `::`, no `tests/`; treat as `-k broadcast_delivers`; runs every match 5 times. -- `tests/telemetry/test_device_telemetry_broadcast.py 3` - shorter count for a slow test. - -## Notes - -- If the FIRST attempt fails and the rest pass, that's a state-leak signature - suggest starting from `--force-bake` or a clean device state rather than chasing the first-failure firmware logs. -- If ALL N fail, this isn't a flake - it's a regression. Say so, stop iterating, escalate to `/mcp-test` for full-suite context. -- Don't rebuild firmware during triage. Flakes that only reproduce under different firmware belong in a separate session with a plan. diff --git a/.github/prompts/mcp-test.prompt.md b/.github/prompts/mcp-test.prompt.md deleted file mode 100644 index 89d691624..000000000 --- a/.github/prompts/mcp-test.prompt.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -mode: agent -description: Run the mcp-server test suite and interpret results (Copilot equivalent of the Claude Code /test slash command) ---- - -# `/mcp-test` - mcp-server test runner with interpretation - -Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md`. Use this when the operator asks you to "run the tests", "check the mcp test suite", "run the mesh tests", etc. - -## What to do - -1. **Invoke the wrapper** from the firmware repo root: - - ```bash - ./mcp-server/run-tests.sh [pytest-args] - ``` - - If the operator specified a subset (e.g. "just the mesh tests"), pass it through as `tests/mesh` or a pytest `-k filter`. If they said nothing, use the wrapper's defaults (full suite with pytest-html report). - - The wrapper auto-detects connected Meshtastic devices, maps each to its PlatformIO env, exports the required env vars, and invokes pytest. Zero pre-flight config needed from the operator. - -2. **Read the pre-flight header** (first few lines of wrapper output). The `detected hub :` line lists role → port → env mappings. If it reads `(none)`, the wrapper narrowed to `tests/unit` only - call that out explicitly so the operator knows hardware tiers were skipped. - -3. **On pass**: one-line summary like `N passed, M skipped in `. Don't enumerate test names. DO mention any non-placeholder SKIPs and name the cause: - - `"role not present on hub"` → device unplugged; operator should reconnect. - - `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped; the UI-log compile macro isn't in the baked firmware. Suggest `--force-bake`. - - `"uhubctl not installed"` → tests/recovery + `test_peer_offline_recovery` skipped. Suggest `brew install uhubctl` / `apt install uhubctl`. - - `"no PPPS-capable hubs detected"` → tests/recovery skipped because the attached hub doesn't support per-port power switching; won't run on that setup. - - `"opencv-python-headless is not installed"` → tests/ui auto-deselected by `run-tests.sh`. Suggest `pip install -e 'mcp-server/.[ui]'`. - -4. **On failure**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise: - - test name - - one-line assertion message - - the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`) - - for UI-tier failures also check `mcp-server/tests/ui_captures///transcript.md` (per-step frame + OCR) - -5. **Classify each failure** as one of: - - **Transient flake** - LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro ` to confirm. - - **Environmental** - device unreachable, port busy, CP2102 driver wedged on macOS. Suggest recovery in escalation order: (a) replug USB, (b) `touch_1200bps` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role=..., confirm=True)` for a device wedged past DFU (needs `uhubctl` installed; `baked_single` does this once automatically when available). Also check `git status userPrefs.jsonc`. - - **Regression** - same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible. - -6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or replug - \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval. - -## Arguments convention - -Operators generally invoke this prompt either with no arguments (full suite) or with a specific subset. Examples: - -- `tests/mesh` - one tier -- `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip` - one test -- `--force-bake` - reflash devices first -- `-k telemetry` - name-filter - -## Side-effects to confirm in your summary - -- `userPrefs.jsonc` should be clean after a successful run. The session fixture in `mcp-server/tests/conftest.py` (`_session_userprefs`) snapshots and restores. Check `git status --porcelain userPrefs.jsonc` and report if it's non-empty. -- `mcp-server/tests/report.html` and `junit.xml` regenerate on every run. -- The wrapper prints a warning if a `.mcp-session-bak` sidecar was left over from a crashed prior session and auto-restores from it - mention that if it happened. diff --git a/.mcp.json b/.mcp.json index c5cf2e55e..822166664 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,12 @@ { "mcpServers": { "meshtastic": { - "command": "./mcp-server/.venv/bin/python", - "args": ["-m", "meshtastic_mcp"], + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/meshtastic/meshtastic-mcp", + "meshtastic-mcp" + ], "env": { "MESHTASTIC_FIRMWARE_ROOT": "." } diff --git a/.trunk/configs/.bandit b/.trunk/configs/.bandit index c70e7743b..cca791bb4 100644 --- a/.trunk/configs/.bandit +++ b/.trunk/configs/.bandit @@ -12,10 +12,10 @@ # defensive loops over flaky sources (pubsub handlers, device # re-enumeration polls). One failed iteration shouldn't abort the loop. # B404 import_subprocess -# mcp-server wraps PlatformIO, esptool, nrfutil, picotool, and the -# pytest test-runner — subprocess is a load-bearing import here, not -# a smell. The "consider possible security implications" advisory is -# redundant given the file-level review already applied. +# the bin/ proto + fixture tooling (regen-py-protos, gen-fake-nodedb-seed, +# seed-json-to-proto) shells out to protoc and helper scripts — subprocess +# is a load-bearing import here, not a smell. The "consider possible security +# implications" advisory is redundant given the file-level review already applied. # B603 subprocess_without_shell_equals_true # all subprocess calls use a static argv list; `shell=False` is the # default and we never string-interpolate user input into the command. diff --git a/AGENTS.md b/AGENTS.md index b1949cb7c..7c25bdc2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ > **TL;DR** > -> | | | -> | -------------- | ------------------------------------------------------------------------- | -> | Local tests | `./bin/run-tests.sh` (exit 0 GREEN · 1 RED · 2 AMBER · 3 FILTERED) | -> | Hardware tests | `./mcp-server/run-tests.sh` | -> | Format | `trunk fmt` | -> | Mirror docs | `.github/copilot-instructions.md` (canonical) · `CLAUDE.md` (Claude Code) | +> | | | +> | -------------- | ---------------------------------------------------------------------------------------------------------------------- | +> | Local tests | `./bin/run-tests.sh` (exit 0 GREEN · 1 RED · 2 AMBER · 3 FILTERED) | +> | Hardware tests | [meshtastic/meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) (`MESHTASTIC_FIRMWARE_ROOT` → this checkout) | +> | Format | `trunk fmt` | +> | Mirror docs | `.github/copilot-instructions.md` (canonical) · `CLAUDE.md` (Claude Code) | > > **Need this? It's here.** > @@ -18,7 +18,7 @@ > | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | > | Observer / event wiring | `src/Observer.h` | -This repository is the [Meshtastic](https://meshtastic.org) firmware - a C++17 embedded codebase targeting ESP32 / nRF52 / RP2040 / STM32WL / Linux-Portduino LoRa mesh radios - plus a Python MCP server in `mcp-server/` that AI agents use to flash, configure, and test connected devices. +This repository is the [Meshtastic](https://meshtastic.org) firmware - a C++17 embedded codebase targeting ESP32 / nRF52 / RP2040 / STM32WL / Linux-Portduino LoRa mesh radios. The Python MCP server that AI agents use to flash, configure, and test connected devices now lives in its own repo, [meshtastic/meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp); this repo registers it via `.mcp.json` (run through `uvx`) so its tools are available automatically. ## Primary instruction file @@ -35,15 +35,15 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don | Clean + rebuild | `pio run -e -t clean && pio run -e ` | | Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | | Run firmware unit tests (native) | `./bin/run-tests.sh` (preferred - ASan/LSan + RED/AMBER/GREEN verdict); or raw: `~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1` | -| Run MCP hardware tests | `./mcp-server/run-tests.sh` | -| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | +| Run MCP hardware tests | From a [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) checkout: `MESHTASTIC_FIRMWARE_ROOT=/path/to/firmware ./run-tests.sh` | +| Live TUI test runner | `uvx --from git+https://github.com/meshtastic/meshtastic-mcp meshtastic-mcp-test-tui` | | Format before commit | `trunk fmt` | | Regenerate protobuf bindings | `bin/regen-protos.sh` | | Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | ## MCP server (device + test automation) -The `mcp-server/` package exposes ~32 MCP tools for device discovery, building, flashing, serial monitoring, and live-node administration. Tools are grouped as: +The [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) server exposes ~32 MCP tools for device discovery, building, flashing, serial monitoring, and live-node administration. Tools are grouped as: - **Discovery**: `list_devices`, `list_boards`, `get_board` - **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 factory), `update_flash` (ESP32 OTA), `touch_1200bps` @@ -53,19 +53,13 @@ The `mcp-server/` package exposes ~32 MCP tools for device discovery, building, - **userPrefs admin**: `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*` -Setup: `cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'`. The repo registers the server via `.mcp.json` - Claude Code picks it up automatically. +Setup: nothing to build - `.mcp.json` runs the server via `uvx --from git+https://github.com/meshtastic/meshtastic-mcp meshtastic-mcp`, so Claude Code picks it up automatically. To run the pytest hardware harness, clone [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) and set `MESHTASTIC_FIRMWARE_ROOT` to this firmware checkout. -See `mcp-server/README.md` for argument shapes and the **MCP Server & Hardware Test Harness** section of `.github/copilot-instructions.md` for agent usage rules (tool surface, fixture contract, firmware integration points, recovery playbooks). +See the meshtastic-mcp repo's README for argument shapes and the **MCP Server & Hardware Test Harness** section of `.github/copilot-instructions.md` for agent usage rules (tool surface, fixture contract, firmware integration points, recovery playbooks). ## Slash commands (AI-assisted workflows) -Three test-and-diagnose workflows exist as slash commands: - -- **`/test` (Claude Code) / `/mcp-test` (Copilot)** - run the hardware test suite and interpret failures -- **`/diagnose` / `/mcp-diagnose`** - read-only device health report -- **`/repro` / `/mcp-repro`** - flakiness triage: re-run one test N times, diff firmware logs between passes and failures - -Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index. +The test-and-diagnose workflows (`/test`, `/diagnose`, `/repro`, `/leakhunt`) now ship with the [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) repo as skills - run them from a checkout of that repo (with `MESHTASTIC_FIRMWARE_ROOT` pointed here). The MCP tools they build on are still available in this repo via `.mcp.json`. ## Encryption at a glance @@ -108,8 +102,8 @@ Sequence these; don't parallelize on the same port. 1. Build locally: `pio run -e ` 2. Flash the test device: `pio_flash(env=..., port=..., confirm=True)` -3. Run the suite: `./mcp-server/run-tests.sh tests/` or `/test tests/` -4. On failure, open `mcp-server/tests/report.html` → `Meshtastic debug` section for the firmware log tail + device state dump +3. Run the suite from a meshtastic-mcp checkout: `MESHTASTIC_FIRMWARE_ROOT=/path/to/firmware ./run-tests.sh tests/` (or the `/test` skill) +4. On failure, open the run's `tests/report.html` → `Meshtastic debug` section for the firmware log tail + device state dump 5. Iterate ### Debugging a flaky test @@ -120,25 +114,23 @@ Sequence these; don't parallelize on the same port. ## Where to look -| Path | What's there | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) | -| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI | -| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | -| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | -| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | -| `test/` | Firmware unit tests (19 suites; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) | -| `mcp-server/` | Python MCP server + pytest hardware integration tests | -| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` | -| `.claude/commands/` | Claude Code slash command bodies | -| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) | -| `.github/copilot-instructions.md` | **Primary agent instructions - read this** | -| `.github/workflows/` | CI pipelines | -| `.mcp.json` | MCP server registration for Claude Code | +| Path | What's there | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) | +| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI | +| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | +| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | +| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | +| `test/` | Firmware unit tests (19 suites; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) | +| [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) | Standalone MCP server + tiered pytest hardware harness (`unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/`) - registered here via `.mcp.json` | +| `.github/prompts/` | Copilot prompt bodies (firmware scaffolding: new module / sensor / variant) | +| `.github/copilot-instructions.md` | **Primary agent instructions - read this** | +| `.github/workflows/` | CI pipelines | +| `.mcp.json` | MCP server registration for Claude Code | ## Recovery one-liners -- **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`. +- **`userPrefs.jsonc` dirty after a test run?** Re-run the meshtastic-mcp harness once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`. - **nRF52 not responding?** `mcp__meshtastic__touch_1200bps(port=...)` drops it into the DFU bootloader, then `pio_flash` re-installs. - **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access. - **Port busy?** `lsof ` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it. diff --git a/CLAUDE.md b/CLAUDE.md index f4cac6e16..c7150cd2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,12 +2,12 @@ > **TL;DR** > -> | | | -> | -------------- | ------------------------------------------------------------------ | -> | Local tests | `./bin/run-tests.sh` (exit 0 GREEN · 1 RED · 2 AMBER · 3 FILTERED) | -> | Hardware tests | `./mcp-server/run-tests.sh` | -> | Format | `trunk fmt` | -> | Mirror docs | `.github/copilot-instructions.md` (canonical) · `AGENTS.md` | +> | | | +> | -------------- | ---------------------------------------------------------------------------------------------------------------------- | +> | Local tests | `./bin/run-tests.sh` (exit 0 GREEN · 1 RED · 2 AMBER · 3 FILTERED) | +> | Hardware tests | [meshtastic/meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) (`MESHTASTIC_FIRMWARE_ROOT` → this checkout) | +> | Format | `trunk fmt` | +> | Mirror docs | `.github/copilot-instructions.md` (canonical) · `AGENTS.md` | > > **Need this? It's here.** > diff --git a/bin/_rewrite_proto_namespace.py b/bin/_rewrite_proto_namespace.py index 53c011595..9c269f543 100755 --- a/bin/_rewrite_proto_namespace.py +++ b/bin/_rewrite_proto_namespace.py @@ -7,7 +7,7 @@ attribute access) to use the new namespace (e.g., `meshtastic_v25`). Why: the .proto files declare `package meshtastic;`, so protoc emits `from meshtastic import mesh_pb2 as ...` lines. That would shadow the PyPI -`meshtastic` package which other parts of the mcp-server depend on. Renaming +`meshtastic` package which the meshtastic-mcp tooling depends on. Renaming to a local namespace keeps both available. Usage: diff --git a/bin/regen-fake-nodedbs.sh b/bin/regen-fake-nodedbs.sh index fd92daa02..b9d6d4775 100755 --- a/bin/regen-fake-nodedbs.sh +++ b/bin/regen-fake-nodedbs.sh @@ -22,9 +22,10 @@ if [[ ! -d bin/_generated/meshtastic ]]; then fi # 2) Pick a Python interpreter that has the meshtastic deps installed. -# Prefer the mcp-server venv (most likely to be set up by the operator). +# Prefer a local venv; otherwise fall back to system python3 (install the +# `meshtastic` package there, or run this under `uv run --with meshtastic`). PY="python3" -for cand in mcp-server/.venv/bin/python3 .venv/bin/python3; do +for cand in .venv/bin/python3; do if [[ -x "$cand" ]]; then PY="$cand" break @@ -70,4 +71,5 @@ echo "Done. To load on Portduino native:" echo " cp build/fixtures/nodedb/nodes_v25_1000.proto ~/.portduino/default/prefs/nodes.proto" echo "" echo "To push to a hardware device:" -echo " Use the mcp-server tool: push_fake_nodedb(size=1000, target=\"hardware\", port=\"/dev/cu.usbmodemXXXX\", confirm=True)" +echo " Use the meshtastic-mcp tool: push_fake_nodedb(size=1000, target=\"hardware\", port=\"/dev/cu.usbmodemXXXX\", confirm=True)" +echo " (MCP server: https://github.com/meshtastic/meshtastic-mcp)" diff --git a/bin/regen-py-protos.sh b/bin/regen-py-protos.sh index 5edad2325..c5e73f0fc 100755 --- a/bin/regen-py-protos.sh +++ b/bin/regen-py-protos.sh @@ -8,7 +8,7 @@ # Namespace rewrite: # The .proto files declare `package meshtastic;`, which makes protoc emit # imports like `from meshtastic import mesh_pb2`. That conflicts with the -# PyPI `meshtastic` package (which the mcp-server relies on for its +# PyPI `meshtastic` package (which the meshtastic-mcp tooling relies on for its # SerialInterface/BLEInterface transport). We post-process the generated # files to live under `meshtastic_v25` instead — both the directory layout # and all internal imports — so they coexist cleanly with the PyPI package. diff --git a/docs/nexthop-routing-reliability.md b/docs/nexthop-routing-reliability.md index 27abb860a..42a08d077 100644 --- a/docs/nexthop-routing-reliability.md +++ b/docs/nexthop-routing-reliability.md @@ -374,15 +374,15 @@ via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this ### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop) -- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` - **the multi-hop validator +- `meshtastic-mcp/tests/mesh/test_nexthop_multihop_recovery.py` - **the multi-hop validator for this work** (added on this branch). Self-discovers an A - relay - C line, asserts a directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range). -- `mcp-server/tests/mesh/test_direct_with_ack.py` - happy-path regression: a fresh/unique +- `meshtastic-mcp/tests/mesh/test_direct_with_ack.py` - happy-path regression: a fresh/unique route still delivers a want_ack DM on the first/second try (M4's gate must keep this green). -- `mcp-server/tests/mesh/test_peer_offline_recovery.py` - 2-device recovery validator: peer +- `meshtastic-mcp/tests/mesh/test_peer_offline_recovery.py` - 2-device recovery validator: peer off mid-conversation then back. Must stay green and ideally recover in fewer attempts. ### 4. Build / format sanity @@ -411,7 +411,7 @@ production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B without it. The intermediate-node failure-count path and the M4 A/B therefore have unit coverage of their logic but no end-to-end multi-node run yet. - **Hardware / multi-hop tier** - a committable bench test now exists: - `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real + `meshtastic-mcp/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real multi-hop pair (A - relay - C), asserts a directed DM is delivered across the relay, and asserts delivery recovers after the relay is power-cycled (the M3 path). It `pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore deleted file mode 100644 index 744a4401d..000000000 --- a/mcp-server/.gitignore +++ /dev/null @@ -1,35 +0,0 @@ -.venv/ -__pycache__/ -*.py[cod] -*.egg-info/ -.pytest_cache/ -.mypy_cache/ -dist/ -build/ - -# Persistent device-log capture (recorder + Datadog cursor). -# Cross-session JSONL streams written by the autouse Recorder singleton -# (see src/meshtastic_mcp/recorder/). Lives outside tests/ so the pytest -# fixture truncate doesn't touch it. -.mtlog/ - -# Test harness artifacts -tests/report.html -tests/junit.xml -tests/reportlog.jsonl -tests/fwlog.jsonl -# Subprocess-output tee from pio/esptool/nrfutil/picotool (live flash -# progress for the TUI; also a post-run diagnostic for plain CLI runs). -tests/flash.log -tests/tool_coverage.json -tests/.coverage -htmlcov/ -# Persistent run counter for meshtastic-mcp-test-tui header. -tests/.tui-runs -# Cross-run history (TUI duration sparkline). -tests/.history/ -# Reproducer bundles (TUI `x` export on failed tests). -tests/reproducers/ -# UI-tier camera captures + per-test transcripts. Regenerated every run; -# left on disk for human review between runs. -tests/ui_captures/ diff --git a/mcp-server/README.md b/mcp-server/README.md deleted file mode 100644 index 44bebff9a..000000000 --- a/mcp-server/README.md +++ /dev/null @@ -1,413 +0,0 @@ -# Meshtastic MCP Server - -An [MCP](https://modelcontextprotocol.io) 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](https://github.com/meshtastic/python): 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 Core](https://platformio.org/install/cli) - `pio` 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 - -```bash -cd /mcp-server -python3 -m venv .venv -.venv/bin/pip install -e . -``` - -Verify: - -```bash -MESHTASTIC_FIRMWARE_ROOT= .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 `/.claude/settings.local.json` (project-only): - -```json -{ - "mcpServers": { - "meshtastic": { - "command": "/mcp-server/.venv/bin/python", - "args": ["-m", "meshtastic_mcp"], - "env": { - "MESHTASTIC_FIRMWARE_ROOT": "" - } - } - } -} -``` - -Replace `` 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 ` (+ mtjson target) | -| `clean` | `pio run -e -t clean` | -| `pio_flash` | `pio run -e -t upload --upload-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_` + -`MESHTASTIC_UHUBCTL_PORT_` env vars first, then auto-detects via VID -against `uhubctl`'s output. - -Requires [`uhubctl`](https://github.com/mvp/uhubctl) on PATH: - -```bash -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](https://github.com/mvp/uhubctl#linux-usb-permissions) -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` `pio` → `platformio` | Override `pio` location | -| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | -| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | -| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | -| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | -| `MESHTASTIC_MCP_FLASH_LOG` | `/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`: - -```bash -# 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`: - -```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 - -```bash -./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 `0x239A` → `nrf52` → env -`rak4631`; `0x303A` or `0x10C4` → `esp32s3` → env `heltec-v3`), exports -`MESHTASTIC_MCP_ENV_` 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. -- **`recovery`** - `uhubctl` 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=`; 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: - ```bash - .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: - ```bash - export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0 - ``` -4. Run: - ```bash - ./run-tests.sh tests/ui -v - ``` - Captures land under `tests/ui_captures///`, 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.jsonl` - `pytest-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 - -```bash -.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 - -```text -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. diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml deleted file mode 100644 index d2bab7841..000000000 --- a/mcp-server/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[project] -name = "meshtastic-mcp" -version = "0.1.0" -description = "MCP server for Meshtastic firmware development: device discovery, PlatformIO tooling, flashing, serial monitoring, and device administration via the meshtastic Python API." -readme = "README.md" -requires-python = ">=3.11" -license = { text = "GPL-3.0-only" } -authors = [{ name = "thebentern" }] -dependencies = ["mcp>=1.2", "pyserial>=3.5", "meshtastic>=2.7.8"] - -[project.optional-dependencies] -dev = ["pytest>=7"] -test = [ - "pytest>=8", - "pytest-html>=4", - "pytest-reportlog>=0.4", - "pytest-timeout>=2.3", - "coverage[toml]>=7", - "pyyaml>=6", - # textual is required by the `meshtastic-mcp-test-tui` script (see - # `src/meshtastic_mcp/cli/test_tui.py`). Bundled into `test` rather than a - # separate `[tui]` extra because v1 expects test operators are the only - # consumers; revisit if install cost pushes back. - "textual>=0.50", -] -# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR -# model alone is ~100 MB and camera hardware is user-supplied. -# pip install -e '.[ui]' - full (OpenCV + easyocr) -# pip install -e '.[ui-min]' - image capture only, no OCR -ui = [ - "opencv-python-headless>=4.9", - "numpy>=1.26", - "easyocr>=1.7", - "Pillow>=10.0", - # Renders the latest camera capture as Unicode half-blocks in the TUI - # (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic - no kitty / sixel - # dependency. Pure Python, tiny. - "rich-pixels>=3.0", -] -ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"] - -[project.scripts] -meshtastic-mcp = "meshtastic_mcp.__main__:main" -# Live TUI wrapping run-tests.sh - shells out to the same script the plain -# CLI uses, tails pytest-reportlog for per-test state, and polls the device -# list at startup + post-run (port lock forces it to stay idle during the run). -meshtastic-mcp-test-tui = "meshtastic_mcp.cli.test_tui:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/meshtastic_mcp"] diff --git a/mcp-server/run-tests.sh b/mcp-server/run-tests.sh deleted file mode 100755 index c34fcc71c..000000000 --- a/mcp-server/run-tests.sh +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env bash -# mcp-server hardware test runner. -# -# Auto-detects connected Meshtastic devices, maps each to its PlatformIO env -# via the same role table the pytest fixtures use, exports the right -# MESHTASTIC_MCP_ENV_* env vars, and invokes pytest. -# -# Usage: -# ./run-tests.sh # full suite, default pytest args -# ./run-tests.sh tests/mesh # subset (any pytest args pass through) -# ./run-tests.sh --force-bake # override one default with another -# MESHTASTIC_MCP_ENV_NRF52=foo ./run-tests.sh # override env per role -# MESHTASTIC_MCP_SEED=ci-run-42 ./run-tests.sh # override PSK seed -# -# If zero supported devices are detected, only the unit tier runs. -# -# Also restores `userPrefs.jsonc` from the session-backup sidecar if a prior -# run exited abnormally (belt to conftest.py's atexit suspenders). - -set -euo pipefail - -# cd to the script's directory so relative paths resolve consistently no -# matter where the user invoked from. -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$SCRIPT_DIR" - -VENV_PY="$SCRIPT_DIR/.venv/bin/python" -if [[ ! -x $VENV_PY ]]; then - echo "error: $VENV_PY not found or not executable." >&2 - echo " Bootstrap the venv first:" >&2 - echo " cd $SCRIPT_DIR && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'" >&2 - exit 2 -fi - -# Resolve firmware root the same way conftest.py does (this script sits in -# mcp-server/, firmware repo root is one level up). -FIRMWARE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -USERPREFS_PATH="$FIRMWARE_ROOT/userPrefs.jsonc" -USERPREFS_SIDECAR="$USERPREFS_PATH.mcp-session-bak" - -# ---------- Pre-flight: recover stale userPrefs.jsonc from prior crash ---- -# If conftest.py's atexit hook didn't fire (SIGKILL, kernel panic, OS -# restart), the sidecar is the ground truth. Self-heal before running so we -# don't bake the previous run's dirty state into this run's firmware. -if [[ -f $USERPREFS_SIDECAR ]]; then - echo "[pre-flight] found $USERPREFS_SIDECAR from a prior abnormal exit;" >&2 - echo " restoring userPrefs.jsonc before starting." >&2 - cp "$USERPREFS_SIDECAR" "$USERPREFS_PATH" - rm -f "$USERPREFS_SIDECAR" -fi - -# If userPrefs.jsonc has uncommitted changes BEFORE the run starts, that's -# worth warning about - tests will snapshot this dirty state and restore to -# it at the end, which may not be what the operator wants. -if command -v git >/dev/null 2>&1; then - cd "$FIRMWARE_ROOT" - # Capture the git status into a local first - SC2312 flags command - # substitution inside `[[ -n ... ]]` because the exit code of `git - # status` is masked. A two-step assignment makes the failure path - # explicit (non-git, missing file) and keeps the bracket test clean. - _git_status_porcelain="$(git status --porcelain userPrefs.jsonc 2>/dev/null || true)" - if [[ -n $_git_status_porcelain ]]; then - echo "[pre-flight] warning: userPrefs.jsonc has uncommitted changes." >&2 - echo " Tests will snapshot THIS state and restore to it" >&2 - echo " at teardown. If that's not intended, run:" >&2 - echo " git checkout userPrefs.jsonc" >&2 - echo " and re-invoke." >&2 - fi - cd "$SCRIPT_DIR" -fi - -# ---------- Seed default -------------------------------------------------- -# Per-machine default so repeated runs from the same operator land on the -# same PSK (makes --assume-baked valid across invocations). Operator can -# override with an explicit env var if they want isolation (e.g. CI). -if [[ -z ${MESHTASTIC_MCP_SEED-} ]]; then - WHO="$(whoami 2>/dev/null || echo anon)" - HOST="$(hostname -s 2>/dev/null || echo host)" - export MESHTASTIC_MCP_SEED="mcp-${WHO}-${HOST}" -fi - -# ---------- Flash progress log -------------------------------------------- -# pio.py / hw_tools.py tee subprocess output (pio run -t upload, esptool, -# nrfutil, picotool) to this file line-by-line as it arrives when this env -# var is set. The TUI tails it so the operator sees live flash progress -# instead of 3 minutes of silence during `test_00_bake.py`. Plain CLI users -# also benefit - the log is a post-run diagnostic even without the TUI. -# Truncate at session start so each run gets a clean log. -export MESHTASTIC_MCP_FLASH_LOG="$SCRIPT_DIR/tests/flash.log" -: >"$MESHTASTIC_MCP_FLASH_LOG" - -# ---------- Detect connected hardware ------------------------------------- -# In-process call to the same Python API the test fixtures use, so the -# script never drifts from what pytest sees. Returns a JSON object -# {role: port, ...}. -ROLES_JSON="$( - "$VENV_PY" - <<'PY' -import json -import sys - -sys.path.insert(0, "src") -from meshtastic_mcp import devices - -# Role → canonical VID map. Kept in sync with -# `tests/conftest.py::hub_profile` defaults; if that changes, this must too. -ROLE_BY_VID = { - 0x239A: "nrf52", # Adafruit / RAK nRF52 native USB (app + DFU) - 0x303A: "esp32s3", # Espressif native USB (ESP32-S3) - 0x10C4: "esp32s3", # CP2102 USB-UART (common on Heltec/LilyGO ESP32 boards) -} - -out: dict[str, str] = {} -for dev in devices.list_devices(include_unknown=True): - vid_raw = dev.get("vid") or "" - try: - if isinstance(vid_raw, str) and vid_raw.startswith("0x"): - vid = int(vid_raw, 16) - else: - vid = int(vid_raw) - except (TypeError, ValueError): - continue - role = ROLE_BY_VID.get(vid) - # First port wins per role - matches hub_devices fixture semantics. - if role and role not in out: - out[role] = dev["port"] - -json.dump(out, sys.stdout) -PY -)" - -# ---------- Map role → pio env -------------------------------------------- -# Honor MESHTASTIC_MCP_ENV_ operator overrides; fall back to the -# same defaults hardcoded in tests/conftest.py::_DEFAULT_ROLE_ENVS. -resolve_env() { - local role="$1" - local default="$2" - local upper - upper="$(echo "$role" | tr '[:lower:]' '[:upper:]')" - local var="MESHTASTIC_MCP_ENV_${upper}" - eval "local override=\${$var:-}" - if [[ -n $override ]]; then - echo "$override" - else - echo "$default" - fi -} - -NRF52_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("nrf52", ""))')" -ESP32S3_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("esp32s3", ""))')" - -DETECTED="" -if [[ -n $NRF52_PORT ]]; then - NRF52_ENV="$(resolve_env nrf52 rak4631)" - export MESHTASTIC_MCP_ENV_NRF52="$NRF52_ENV" - DETECTED="${DETECTED} nrf52 @ ${NRF52_PORT} -> env=${NRF52_ENV}\n" -fi -if [[ -n $ESP32S3_PORT ]]; then - ESP32S3_ENV="$(resolve_env esp32s3 heltec-v3)" - export MESHTASTIC_MCP_ENV_ESP32S3="$ESP32S3_ENV" - DETECTED="${DETECTED} esp32s3 @ ${ESP32S3_PORT} -> env=${ESP32S3_ENV}\n" -fi - -# ---------- Pre-flight summary -------------------------------------------- -# Surface what pytest is about to do with respect to the bake phase: the -# operator should see "will verify + bake if needed" by default, so a -# 3-minute flash appearing mid-run isn't a surprise. Detection of the -# explicit overrides is best-effort - we just scan $@ for the known flags. -_bake_mode="auto (verify + bake if needed)" -for _arg in "$@"; do - case "$_arg" in - --assume-baked) _bake_mode="skip (--assume-baked)" ;; - --force-bake) _bake_mode="force (--force-bake)" ;; - *) ;; # any other arg: pass-through; bake mode unchanged - esac -done - -echo "mcp-server test runner" -echo " firmware root : $FIRMWARE_ROOT" -echo " seed : $MESHTASTIC_MCP_SEED" -echo " bake : $_bake_mode" -if [[ -n $DETECTED ]]; then - echo " detected hub :" - printf "%b" "$DETECTED" -else - echo " detected hub : (none)" -fi -echo - -# ---------- Invoke pytest ------------------------------------------------- -# If no devices detected, only the unit tier would produce meaningful -# PASS/FAIL - every hardware test would SKIP with "role not present". We -# narrow to tests/unit explicitly so the summary reads as "no hardware, -# unit suite only" instead of "big skip count looks suspicious". -# Keep terminal output condensed (`-q -r fE`) so skip-heavy runs do not print -# each skipped test in full; skip counts still appear in pytest's summary. -if [[ -z $DETECTED && $# -eq 0 ]]; then - echo "[pre-flight] no supported devices detected; running unit tier only." - echo - exec "$VENV_PY" -m pytest tests/unit -q -r fE --report-log=tests/reportlog.jsonl -fi - -# Default pytest args when the user passed none. Power users can invoke -# `./run-tests.sh tests/mesh -v --tb=long` and skip all of these defaults. -# -# NOTE: `--assume-baked` is DELIBERATELY omitted here. `tests/test_00_bake.py` -# has an internal skip-if-already-baked check (`_bake_role`: query device_info, -# compare region + primary_channel to the session profile, skip on match). -# So the fast path is ~8-10 s of verification overhead when the devices are -# already baked - negligible next to the 2-6 min suite runtime. Letting -# test_00_bake.py run means a fresh device, a re-seeded session, or a post- -# factory-reset device gets flashed automatically instead of silently -# skipping half the hardware tests with "not baked with session profile" -# errors. Power users who know their hardware is current and want to shave -# those seconds can pass `--assume-baked` explicitly. -# Defaults also use condensed reporting (`-q -r fE`) to avoid listing every -# skipped test verbatim while still surfacing failures/errors and summary data. -if [[ $# -eq 0 ]]; then - set -- tests/ \ - --html=tests/report.html --self-contained-html \ - --junitxml=tests/junit.xml \ - -q -r fE --tb=short -fi - -# UI tier requires opencv-python-headless (and ideally easyocr). If it's -# not installed, auto-deselect tests/ui so operators without the [ui] -# extra still get a green run. Printed in yellow; silent when cv2 is -# present. -_cv2_ok=0 -if "$VENV_PY" -c "import cv2" >/dev/null 2>&1; then - _cv2_ok=1 -fi -_running_ui=0 -for _arg in "$@"; do - case "$_arg" in - *tests/ui* | tests/) _running_ui=1 ;; - *) ;; - esac -done -if [[ $_running_ui -eq 1 && $_cv2_ok -eq 0 ]]; then - printf '\033[33m[pre-flight] tests/ui tier detected, but opencv-python-headless is not installed - deselecting.\033[0m\n' - printf ' install with: .venv/bin/pip install -e "mcp-server/.[ui]"\n' - echo - set -- "$@" --ignore=tests/ui -fi - -# Recovery tier needs `uhubctl` on PATH - it power-cycles devices via USB -# hub PPPS. The tier's conftest already skips cleanly, so this is just a -# friendly heads-up before the skip happens. `baked_single`'s auto- -# recovery hook also benefits from having uhubctl available across the -# whole suite. -if ! command -v uhubctl >/dev/null 2>&1; then - printf "\033[33m[pre-flight] uhubctl not found on PATH - recovery tier will skip, and\n" - printf " wedged-device auto-recovery is disabled.\033[0m\n" - printf " install with: brew install uhubctl (macOS) or apt install uhubctl (Debian/Ubuntu).\n" - echo -fi - -# Always emit `tests/reportlog.jsonl` (unless the operator explicitly passed -# their own `--report-log=...`). Consumers - notably the -# `meshtastic-mcp-test-tui` TUI - tail the reportlog for live per-test state. -# Appending here means power-user invocations like `./run-tests.sh tests/mesh` -# also produce it, not just the all-defaults invocation. -_has_report_log=0 -for _arg in "$@"; do - case "$_arg" in - --report-log | --report-log=*) _has_report_log=1 ;; - *) ;; # any other arg: no-op; loop continues - esac -done -if [[ $_has_report_log -eq 0 ]]; then - set -- "$@" --report-log=tests/reportlog.jsonl -fi - -exec "$VENV_PY" -m pytest "$@" diff --git a/mcp-server/scripts/datadog-dashboard.json b/mcp-server/scripts/datadog-dashboard.json deleted file mode 100644 index 37d87b680..000000000 --- a/mcp-server/scripts/datadog-dashboard.json +++ /dev/null @@ -1,217 +0,0 @@ -{ - "title": "Meshtastic Firmware - Recorder Stream", - "description": "Live view of `.mtlog/` streams shipped by `mtlog_to_datadog.py`. Heap, packet volume, log levels, errors. One row per port.", - "widgets": [ - { - "definition": { - "title": "Free heap (bytes)", - "type": "timeseries", - "show_legend": true, - "requests": [ - { - "queries": [ - { - "name": "free_heap", - "data_source": "metrics", - "query": "avg:mesh.local.heap_free_bytes{service:meshtastic-firmware} by {port}" - } - ], - "response_format": "timeseries", - "display_type": "line" - } - ], - "yaxis": { "label": "bytes" } - } - }, - { - "definition": { - "title": "Heap slope (bytes/min) - last 1h", - "type": "query_value", - "precision": 0, - "requests": [ - { - "queries": [ - { - "name": "slope", - "data_source": "metrics", - "query": "derivative(avg:mesh.local.heap_free_bytes{service:meshtastic-firmware})", - "aggregator": "avg" - } - ], - "response_format": "scalar" - } - ], - "conditional_formats": [ - { "comparator": "<", "value": -100, "palette": "white_on_red" }, - { "comparator": "<", "value": 0, "palette": "white_on_yellow" }, - { "comparator": ">=", "value": 0, "palette": "white_on_green" } - ] - } - }, - { - "definition": { - "title": "Total heap (bytes)", - "type": "timeseries", - "requests": [ - { - "queries": [ - { - "name": "total_heap", - "data_source": "metrics", - "query": "avg:mesh.local.heap_total_bytes{service:meshtastic-firmware} by {port}" - } - ], - "response_format": "timeseries", - "display_type": "line" - } - ] - } - }, - { - "definition": { - "title": "Battery level (%)", - "type": "timeseries", - "requests": [ - { - "queries": [ - { - "name": "battery", - "data_source": "metrics", - "query": "avg:mesh.device.battery_level{service:meshtastic-firmware} by {port}" - } - ], - "response_format": "timeseries", - "display_type": "line" - } - ], - "yaxis": { "min": "0", "max": "105" } - } - }, - { - "definition": { - "title": "Air utilization (TX %)", - "type": "timeseries", - "requests": [ - { - "queries": [ - { - "name": "airutil", - "data_source": "metrics", - "query": "avg:mesh.device.air_util_tx{service:meshtastic-firmware} by {port}" - } - ], - "response_format": "timeseries", - "display_type": "line" - } - ] - } - }, - { - "definition": { - "title": "Channel utilization (%)", - "type": "timeseries", - "requests": [ - { - "queries": [ - { - "name": "chutil", - "data_source": "metrics", - "query": "avg:mesh.device.channel_utilization{service:meshtastic-firmware} by {port}" - } - ], - "response_format": "timeseries", - "display_type": "line" - } - ] - } - }, - { - "definition": { - "title": "Log volume by level", - "type": "timeseries", - "show_legend": true, - "requests": [ - { - "response_format": "timeseries", - "display_type": "bars", - "queries": [ - { - "name": "log_count", - "data_source": "logs", - "indexes": ["*"], - "compute": { "aggregation": "count" }, - "search": { "query": "service:meshtastic-firmware" }, - "group_by": [ - { - "facet": "@level", - "limit": 10, - "sort": { "order": "desc", "aggregation": "count" } - } - ] - } - ] - } - ] - } - }, - { - "definition": { - "title": "Recent ERROR / CRIT firmware logs", - "type": "list_stream", - "requests": [ - { - "response_format": "event_list", - "query": { - "data_source": "logs_stream", - "query_string": "service:meshtastic-firmware (status:error OR @level:ERROR OR @level:CRIT)", - "indexes": [], - "sort": { "column": "timestamp", "order": "desc" } - }, - "columns": [ - { "field": "timestamp", "width": "auto" }, - { "field": "host", "width": "auto" }, - { "field": "@port", "width": "auto" }, - { "field": "@level", "width": "auto" }, - { "field": "@thread", "width": "auto" }, - { "field": "message", "width": "stretch" } - ] - } - ] - } - }, - { - "definition": { - "title": "Recorder marker events", - "type": "list_stream", - "requests": [ - { - "response_format": "event_list", - "query": { - "data_source": "logs_stream", - "query_string": "service:meshtastic-firmware @level:MARK", - "indexes": [], - "sort": { "column": "timestamp", "order": "desc" } - }, - "columns": [ - { "field": "timestamp", "width": "auto" }, - { "field": "host", "width": "auto" }, - { "field": "message", "width": "stretch" } - ] - } - ] - } - } - ], - "template_variables": [ - { - "name": "port", - "prefix": "port", - "available_values": [], - "default": "*" - }, - { "name": "host", "prefix": "host", "available_values": [], "default": "*" } - ], - "layout_type": "ordered", - "notify_list": [], - "reflow_type": "auto" -} diff --git a/mcp-server/scripts/mtlog_to_datadog.py b/mcp-server/scripts/mtlog_to_datadog.py deleted file mode 100755 index d8c34e2a6..000000000 --- a/mcp-server/scripts/mtlog_to_datadog.py +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/env python3 -"""Forward selected recorder JSONL streams to Datadog. - -Reads `.mtlog/logs.jsonl` and `.mtlog/telemetry.jsonl`, ships logs to the -Logs Intake API and telemetry numerics to the Metrics v2 series API. -Resumes from `.mtlog/.dd-cursor.json` so a daemon restart doesn't -duplicate rows already shipped from the current live files. - -This forwarder does not currently backfill rotated `.jsonl.gz` archives. -If the recorder rotates before this process drains the live file, or the -forwarder is down across a rotation, those older rows are skipped. - -Usage: - DD_API_KEY=... ./scripts/mtlog_to_datadog.py --tail - ./scripts/mtlog_to_datadog.py --once # catch up + exit - ./scripts/mtlog_to_datadog.py --since 3600 # backfill last hour from start - -Default `DD_SITE` is `us5.datadoghq.com` - the team's Datadog instance. -Override via `DD_SITE=...` env var or `--site` flag for one-offs. - -The forwarder is a separate process by design - a Datadog outage or -auth failure must not backpressure the recorder. We exit non-zero on -fatal config errors (missing API key) and keep retrying on transient -network/HTTP errors. -""" - -from __future__ import annotations - -import argparse -import json -import os -import socket -import sys -import time -from pathlib import Path -from typing import Any, Iterator - -try: - import requests -except ImportError: - print( - "requests is required. Install it in the mcp-server venv: " - "uv pip install requests", - file=sys.stderr, - ) - sys.exit(2) - - -_DEFAULT_LOG_DIR = Path(__file__).resolve().parents[1] / ".mtlog" -_LOG_INTAKE_TPL = "https://http-intake.logs.{site}/api/v2/logs" -_METRICS_TPL = "https://api.{site}/api/v2/series" -_LOG_BATCH = 50 -_METRICS_BATCH = 100 -_MAX_RETRIES = 5 -_RETRY_BASE_S = 1.5 - - -# --- streaming JSONL with byte-position cursor ------------------------- - - -class _StreamReader: - """Reads a single rotating JSONL with cursor-based resume. - - This tails only the live `.jsonl` file. The recorder rotates files - (live `.jsonl` → `.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`), which means - the live file shrinks abruptly. We detect that via inode change OR live - size < cursor position, and reset the live-file cursor to 0. - """ - - def __init__(self, path: Path, cursor: dict[str, Any]): - self.path = path - self.cursor = cursor - - def _state(self) -> tuple[int, int]: - """Return (inode, size) for the live file. (0, 0) if missing.""" - try: - st = self.path.stat() - return (st.st_ino, st.st_size) - except FileNotFoundError: - return (0, 0) - - def iter_new_records(self) -> Iterator[dict[str, Any]]: - ino, size = self._state() - last_ino = self.cursor.get("ino") - last_pos = int(self.cursor.get("pos") or 0) - if ino == 0: - return - if last_ino is not None and last_ino != ino: - # Rotation happened. Start over. - last_pos = 0 - if last_pos > size: - # Live file truncated/shrunk under us - recorder rotated. - last_pos = 0 - try: - with self.path.open("r", encoding="utf-8") as fh: - fh.seek(last_pos) - for line in fh: - line = line.rstrip("\n") - if not line: - continue - try: - yield json.loads(line) - except json.JSONDecodeError: - continue - last_pos = fh.tell() - except FileNotFoundError: - return - self.cursor["ino"] = ino - self.cursor["pos"] = last_pos - - -def _load_cursor(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - try: - return json.loads(path.read_text()) - except (OSError, json.JSONDecodeError): - return {} - - -def _save_cursor(path: Path, data: dict[str, Any]) -> None: - tmp = path.with_suffix(".json.tmp") - tmp.write_text(json.dumps(data, separators=(",", ":"))) - tmp.replace(path) - - -# --- Datadog clients --------------------------------------------------- - - -class _DDSession: - """Pool one HTTPS session, share retry logic.""" - - def __init__(self, api_key: str, site: str, hostname: str) -> None: - self.api_key = api_key - self.site = site - self.hostname = hostname - self.session = requests.Session() - self.session.headers.update( - { - "DD-API-KEY": api_key, - "Content-Type": "application/json", - } - ) - - def _post(self, url: str, payload: Any) -> bool: - for attempt in range(_MAX_RETRIES): - try: - resp = self.session.post(url, json=payload, timeout=30) - except requests.RequestException as e: - _wait_retry(attempt, f"network error: {e}") - continue - if 200 <= resp.status_code < 300: - return True - if resp.status_code in (408, 429, 500, 502, 503, 504): - _wait_retry( - attempt, - f"HTTP {resp.status_code} retrying", - ) - continue - print( - f"datadog refused: {resp.status_code} {resp.text[:200]}", - file=sys.stderr, - ) - return False - return False - - def send_logs(self, records: list[dict[str, Any]]) -> int: - if not records: - return 0 - url = _LOG_INTAKE_TPL.format(site=self.site) - sent = 0 - for i in range(0, len(records), _LOG_BATCH): - batch = records[i : i + _LOG_BATCH] - if self._post(url, batch): - sent += len(batch) - return sent - - def send_metrics(self, series: list[dict[str, Any]]) -> int: - if not series: - return 0 - url = _METRICS_TPL.format(site=self.site) - sent = 0 - for i in range(0, len(series), _METRICS_BATCH): - batch = series[i : i + _METRICS_BATCH] - if self._post(url, {"series": batch}): - sent += len(batch) - return sent - - -def _wait_retry(attempt: int, reason: str) -> None: - wait = _RETRY_BASE_S * (2**attempt) - print( - f" retry {attempt + 1}/{_MAX_RETRIES} in {wait:.1f}s ({reason})", - file=sys.stderr, - ) - time.sleep(wait) - - -# --- record → datadog payload ------------------------------------------ - - -def _log_record_to_dd(rec: dict[str, Any], host: str) -> dict[str, Any]: - line = rec.get("line") or "" - tags = [ - f"role:{rec.get('role')}", - f"port:{rec.get('port')}", - ] - level = rec.get("level") - if level: - tags.append(f"level:{level}") - tag = rec.get("tag") - if tag: - tags.append(f"thread:{tag}") - return { - "ddsource": "meshtastic-firmware", - "service": "meshtastic-firmware", - "hostname": host, - "message": line, - "ddtags": ",".join(t for t in tags if t and "None" not in t), - "timestamp": int((rec.get("ts") or time.time()) * 1000), - "level": level, - } - - -def _telemetry_record_to_metrics( - rec: dict[str, Any], host: str -) -> list[dict[str, Any]]: - fields = rec.get("fields") or {} - if not isinstance(fields, dict): - return [] - variant = rec.get("variant") or "unknown" - ts = int(rec.get("ts") or time.time()) - out: list[dict[str, Any]] = [] - tags = [] - if rec.get("port"): - tags.append(f"port:{rec['port']}") - if rec.get("role"): - tags.append(f"role:{rec['role']}") - if rec.get("from_node"): - tags.append(f"from_node:{rec['from_node']}") - tags.append(f"variant:{variant}") - for field, value in fields.items(): - if not isinstance(value, (int, float)) or isinstance(value, bool): - continue - metric = f"mesh.{variant}.{_metric_safe(field)}" - out.append( - { - "metric": metric, - "type": 3, # GAUGE - "points": [{"timestamp": ts, "value": float(value)}], - "tags": tags, - "resources": [{"type": "host", "name": host}], - } - ) - return out - - -def _metric_safe(name: str) -> str: - # Lowercase, replace non-alnum with underscore for safe metric names. - return "".join(c.lower() if c.isalnum() else "_" for c in name) - - -# --- main loop --------------------------------------------------------- - - -def run( - log_dir: Path, - *, - once: bool, - since_seconds: float | None, - poll_interval: float, - dd: _DDSession, -) -> int: - cursor_path = log_dir / ".dd-cursor.json" - cursors = _load_cursor(cursor_path) - - # `--since` overrides cursor: rewind to (now-since) timestamp. - # We can't seek by timestamp directly (cursor is byte position), so - # we just reset cursors to 0 and let the time filter in iter_new - # drop older records. - cutoff_ts: float | None = None - if since_seconds is not None: - cursors = {} - cutoff_ts = time.time() - since_seconds - - sent_total = {"logs": 0, "telemetry": 0} - - while True: - # logs.jsonl → DD logs - log_cursor = cursors.setdefault("logs", {}) - log_batch: list[dict[str, Any]] = [] - for rec in _StreamReader(log_dir / "logs.jsonl", log_cursor).iter_new_records(): - if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts: - continue - log_batch.append(_log_record_to_dd(rec, dd.hostname)) - if log_batch: - n = dd.send_logs(log_batch) - sent_total["logs"] += n - print(f"logs: sent {n}/{len(log_batch)}") - - # telemetry.jsonl → DD metrics - telem_cursor = cursors.setdefault("telemetry", {}) - metric_series: list[dict[str, Any]] = [] - for rec in _StreamReader( - log_dir / "telemetry.jsonl", telem_cursor - ).iter_new_records(): - if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts: - continue - metric_series.extend(_telemetry_record_to_metrics(rec, dd.hostname)) - if metric_series: - n = dd.send_metrics(metric_series) - sent_total["telemetry"] += n - print(f"telemetry: sent {n}/{len(metric_series)} metric points") - - _save_cursor(cursor_path, cursors) - - if once: - print(f"done. logs={sent_total['logs']} metrics={sent_total['telemetry']}") - return 0 - time.sleep(poll_interval) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--log-dir", - default=str(_DEFAULT_LOG_DIR), - help="Path to .mtlog/ (default: mcp-server/.mtlog)", - ) - mode = parser.add_mutually_exclusive_group() - mode.add_argument("--once", action="store_true", help="Catch up then exit") - mode.add_argument( - "--tail", - action="store_true", - help="Daemon: poll forever (default)", - ) - parser.add_argument( - "--since", - type=float, - default=None, - help="Backfill last N seconds. Resets cursor.", - ) - parser.add_argument( - "--poll-interval", - type=float, - default=5.0, - help="Seconds between tail polls (default 5)", - ) - parser.add_argument( - "--site", - default=os.environ.get("DD_SITE", "us5.datadoghq.com"), - help=( - "Datadog site. Default is the team's instance (us5.datadoghq.com). " - "Override via DD_SITE env var or this flag." - ), - ) - parser.add_argument( - "--host", - default=socket.gethostname(), - help="Hostname tag (default: socket.gethostname())", - ) - args = parser.parse_args(argv) - - api_key = os.environ.get("DD_API_KEY") - if not api_key: - print("DD_API_KEY env var required.", file=sys.stderr) - return 2 - - log_dir = Path(args.log_dir) - if not log_dir.exists(): - print( - f"log dir {log_dir} does not exist - start the mcp-server first.", - file=sys.stderr, - ) - return 2 - - dd = _DDSession(api_key=api_key, site=args.site, hostname=args.host) - once = args.once and not args.tail - return run( - log_dir, - once=once, - since_seconds=args.since, - poll_interval=args.poll_interval, - dd=dd, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/mcp-server/src/meshtastic_mcp/__init__.py b/mcp-server/src/meshtastic_mcp/__init__.py deleted file mode 100644 index 14b6275e5..000000000 --- a/mcp-server/src/meshtastic_mcp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Meshtastic MCP server - device discovery, PlatformIO tooling, and device admin.""" - -__version__ = "0.1.0" diff --git a/mcp-server/src/meshtastic_mcp/__main__.py b/mcp-server/src/meshtastic_mcp/__main__.py deleted file mode 100644 index 4ed67db38..000000000 --- a/mcp-server/src/meshtastic_mcp/__main__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Entry point for `python -m meshtastic_mcp`.""" - -from meshtastic_mcp.server import app - - -def main() -> None: - app.run() - - -if __name__ == "__main__": - main() diff --git a/mcp-server/src/meshtastic_mcp/admin.py b/mcp-server/src/meshtastic_mcp/admin.py deleted file mode 100644 index f9520e8bf..000000000 --- a/mcp-server/src/meshtastic_mcp/admin.py +++ /dev/null @@ -1,417 +0,0 @@ -"""Device administration: owner, config, channels, messaging, admin actions. - -All operations use the same `connect()` context manager so port selection, -port-busy detection, and cleanup are handled uniformly. - -Config writes use a dot-path: the first segment names a section (e.g. -`"lora"` in LocalConfig or `"mqtt"` in LocalModuleConfig), remaining segments -walk protobuf fields. Enum fields accept their string names (`"US"` for -`lora.region`) so callers don't need to know the numeric values. -""" - -from __future__ import annotations - -from typing import Any - -from google.protobuf import descriptor as pb_descriptor -from google.protobuf import json_format -from meshtastic.protobuf import localonly_pb2 - -from .connection import connect - - -class AdminError(RuntimeError): - pass - - -LOCAL_CONFIG_SECTIONS = {f.name for f in localonly_pb2.LocalConfig.DESCRIPTOR.fields} -MODULE_CONFIG_SECTIONS = { - f.name for f in localonly_pb2.LocalModuleConfig.DESCRIPTOR.fields -} - - -def _require_confirm(confirm: bool, operation: str) -> None: - if not confirm: - raise AdminError(f"{operation} is destructive and requires confirm=True.") - - -def _message_to_dict(msg: Any) -> dict[str, Any]: - # `including_default_value_fields` was renamed to - # `always_print_fields_with_no_presence` in protobuf 5.26+. Pick whichever - # kwarg the installed version accepts so we work against both. - kwargs: dict[str, Any] = {"preserving_proto_field_name": True} - import inspect - - sig = inspect.signature(json_format.MessageToDict) - if "always_print_fields_with_no_presence" in sig.parameters: - kwargs["always_print_fields_with_no_presence"] = False - elif "including_default_value_fields" in sig.parameters: - kwargs["including_default_value_fields"] = False - return json_format.MessageToDict(msg, **kwargs) - - -# ---------- owner ---------------------------------------------------------- - - -def set_owner( - long_name: str, - short_name: str | None = None, - port: str | None = None, -) -> dict[str, Any]: - if short_name is not None and len(short_name) > 4: - raise AdminError("short_name must be 4 characters or fewer") - with connect(port=port) as iface: - iface.localNode.setOwner(long_name=long_name, short_name=short_name) - return { - "ok": True, - "long_name": long_name, - "short_name": short_name, - } - - -# ---------- config reads --------------------------------------------------- - - -def _section_container(node, section: str) -> tuple[Any, str]: - """Return (container_message, parent_name) for a section name. - - Parent is 'localConfig' or 'moduleConfig' so callers know where to call - writeConfig() after mutating. - """ - if section in LOCAL_CONFIG_SECTIONS: - return getattr(node.localConfig, section), "localConfig" - if section in MODULE_CONFIG_SECTIONS: - return getattr(node.moduleConfig, section), "moduleConfig" - raise AdminError( - f"Unknown config section: {section!r}. " - f"Valid sections: {sorted(LOCAL_CONFIG_SECTIONS | MODULE_CONFIG_SECTIONS)}" - ) - - -def get_config(section: str | None = None, port: str | None = None) -> dict[str, Any]: - """Read one or all config sections. - - `section` may be any name in LocalConfig (device, lora, position, power, - network, display, bluetooth, security) or LocalModuleConfig (mqtt, serial, - telemetry, ...). Omit `section` or pass `"all"` for everything. - """ - with connect(port=port) as iface: - node = iface.localNode - if section in (None, "all"): - lc = _message_to_dict(node.localConfig) - mc = _message_to_dict(node.moduleConfig) - return { - "config": { - "localConfig": lc, - "moduleConfig": mc, - } - } - container, _parent = _section_container(node, section) - return {"config": {section: _message_to_dict(container)}} - - -# ---------- config writes -------------------------------------------------- - - -def _coerce_enum(field: pb_descriptor.FieldDescriptor, value: Any) -> int: - """Accept an enum value as either its int or its string name.""" - enum_type = field.enum_type - if isinstance(value, bool): - raise AdminError(f"{field.name}: expected enum {enum_type.name}, got bool") - if isinstance(value, int): - if enum_type.values_by_number.get(value) is None: - raise AdminError( - f"{field.name}: {value} is not a valid {enum_type.name} value" - ) - return value - if isinstance(value, str): - upper = value.upper() - ev = enum_type.values_by_name.get(upper) - if ev is None: - valid = sorted(enum_type.values_by_name.keys()) - raise AdminError( - f"{field.name}: {value!r} is not a valid {enum_type.name}. " - f"Valid: {valid}" - ) - return ev.number - raise AdminError( - f"{field.name}: expected enum {enum_type.name}, got {type(value).__name__}" - ) - - -def _coerce_scalar(field: pb_descriptor.FieldDescriptor, value: Any) -> Any: - t = field.type - FT = pb_descriptor.FieldDescriptor - if t == FT.TYPE_ENUM: - return _coerce_enum(field, value) - if t == FT.TYPE_BOOL: - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in ("true", "yes", "1", "on") - if isinstance(value, int): - return bool(value) - if t in ( - FT.TYPE_INT32, - FT.TYPE_INT64, - FT.TYPE_UINT32, - FT.TYPE_UINT64, - FT.TYPE_SINT32, - FT.TYPE_SINT64, - FT.TYPE_FIXED32, - FT.TYPE_FIXED64, - ): - return int(value) - if t in (FT.TYPE_FLOAT, FT.TYPE_DOUBLE): - return float(value) - if t == FT.TYPE_STRING: - return str(value) - if t == FT.TYPE_BYTES: - if isinstance(value, (bytes, bytearray)): - return bytes(value) - return str(value).encode("utf-8") - raise AdminError( - f"{field.name}: unsupported field type {t}. Use raw protobuf for this field." - ) - - -def _walk_to_field( - root_msg: Any, path_segments: list[str] -) -> tuple[Any, pb_descriptor.FieldDescriptor]: - """Walk `root_msg` by field names until the leaf; return (parent_msg, leaf_field_descriptor).""" - msg = root_msg - for i, name in enumerate(path_segments): - desc = msg.DESCRIPTOR - field = desc.fields_by_name.get(name) - if field is None: - trail = ".".join(path_segments[:i] or [""]) - valid = [f.name for f in desc.fields] - raise AdminError(f"No field {name!r} in {trail}. Valid: {valid}") - is_last = i == len(path_segments) - 1 - if is_last: - return msg, field - if field.type != pb_descriptor.FieldDescriptor.TYPE_MESSAGE: - raise AdminError( - f"{'.'.join(path_segments[:i+1])} is a scalar; cannot descend into it" - ) - msg = getattr(msg, name) - # path_segments was empty - raise AdminError("Empty config path") - - -def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]: - """Set a single config field by dot-path and write it to the device. - - Examples: - set_config("lora.region", "US") - set_config("lora.modem_preset", "LONG_FAST") - set_config("device.role", "ROUTER") - set_config("mqtt.enabled", True) - set_config("mqtt.address", "mqtt.example.com") - - """ - segments = [s for s in path.split(".") if s] - if not segments: - raise AdminError("path cannot be empty") - section = segments[0] - - with connect(port=port) as iface: - node = iface.localNode - container, parent_name = _section_container(node, section) - - # Treat the section as the root; the rest of the path walks into it. - leaf_parent, field = _walk_to_field(container, segments[1:] or []) - # Use `is_repeated` (modern upb protobuf API) rather than the - # deprecated `label == LABEL_REPEATED` check - the C-extension - # FieldDescriptor in protobuf >= 5.x doesn't expose `.label` at - # all, and `is_repeated` is the supported replacement that works - # across both the pure-python and upb backends. - if field.is_repeated: - raise AdminError( - f"{path!r} is a repeated field; v1 only supports scalar sets. " - "Use the raw meshtastic CLI for now." - ) - old_raw = getattr(leaf_parent, field.name) - coerced = _coerce_scalar(field, value) - try: - setattr(leaf_parent, field.name, coerced) - except (TypeError, ValueError) as exc: - raise AdminError(f"{path}: {exc}") from exc - - node.writeConfig(section) - - # Stringify enums for the response (so the caller can see the change in - # the same vocabulary they used to set it). - if field.type == pb_descriptor.FieldDescriptor.TYPE_ENUM: - try: - old_display = field.enum_type.values_by_number[old_raw].name - new_display = field.enum_type.values_by_number[coerced].name - except Exception: - old_display, new_display = old_raw, coerced - else: - old_display, new_display = old_raw, coerced - - return { - "ok": True, - "path": path, - "section": section, - "parent": parent_name, - "old_value": old_display, - "new_value": new_display, - } - - -# ---------- channels ------------------------------------------------------- - - -def get_channel_url( - include_all: bool = False, port: str | None = None -) -> dict[str, Any]: - with connect(port=port) as iface: - url = iface.localNode.getURL(includeAll=include_all) - return {"url": url} - - -def set_channel_url(url: str, port: str | None = None) -> dict[str, Any]: - with connect(port=port) as iface: - # setURL replaces the channel set from the URL's contents. It does not - # return a count; we infer by counting non-DISABLED channels after. - iface.localNode.setURL(url) - channels = iface.localNode.channels or [] - active = sum(1 for c in channels if getattr(c, "role", 0) != 0) - return {"ok": True, "channels_imported": active} - - -# ---------- messaging ------------------------------------------------------ - - -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]: - destination = to if to is not None else "^all" - with connect(port=port) as iface: - packet = iface.sendText( - text, - destinationId=destination, - wantAck=want_ack, - channelIndex=channel_index, - ) - packet_id = getattr(packet, "id", None) - return {"ok": True, "packet_id": packet_id, "destination": destination} - - -# ---------- diagnostics ---------------------------------------------------- - - -def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]: - """Toggle `config.security.debug_log_api_enabled` on the local node. - - When enabled, firmware emits log lines as protobuf `LogRecord` messages - over the StreamAPI instead of raw text. meshtastic-python surfaces them - on pubsub topic `meshtastic.log.line`, which flows through the SAME - SerialInterface our tests already hold open - no `pio device monitor` - needed, no port-contention with admin/info calls. - - Firmware gate: `src/SerialConsole.cpp` (`usingProtobufs && - config.security.debug_log_api_enabled`). Setting persists in NVS; it - survives reboot. `factory_reset(full=False)` clears it unless it's - re-applied after reset. - - Previously-documented concurrency hazard (emitLogRecord sharing the - main packet-emission buffers) has been fixed - see `StreamAPI.h` - where the log path now owns dedicated `fromRadioScratchLog` / - `txBufLog` buffers, and `StreamAPI::emitTxBuffer` + - `StreamAPI::emitLogRecord` both serialize their `stream->write` - calls via `streamLock`. Leaving the flag on under traffic is safe. - """ - with connect(port=port) as iface: - sec = iface.localNode.localConfig.security - sec.debug_log_api_enabled = bool(enabled) - iface.localNode.writeConfig("security") - return {"ok": True, "debug_log_api_enabled": bool(enabled)} - - -# ---------- admin actions -------------------------------------------------- - - -def reboot( - port: str | None = None, confirm: bool = False, seconds: int = 10 -) -> dict[str, Any]: - _require_confirm(confirm, "reboot") - with connect(port=port) as iface: - iface.localNode.reboot(secs=seconds) - return {"ok": True, "rebooting_in_s": seconds} - - -def shutdown( - port: str | None = None, confirm: bool = False, seconds: int = 10 -) -> dict[str, Any]: - _require_confirm(confirm, "shutdown") - with connect(port=port) as iface: - iface.localNode.shutdown(secs=seconds) - return {"ok": True, "shutting_down_in_s": seconds} - - -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 press / key / gesture) into the UI. - - Wraps `AdminMessage.send_input_event` (handled in firmware at - src/modules/AdminModule.cpp::handleSendInputEvent). Local-only - no PKI - warmup needed since the admin message is addressed to `my_node_num`. - - `event_code` accepts an int, a case-insensitive name - (`"RIGHT"` / `"input_broker_right"`), or an `InputEventCode`. The - firmware-side enum lives in src/input/InputBroker.h and is mirrored in - `meshtastic_mcp.input_events`. - """ - from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped] - - from .input_events import coerce_event_code - - code = coerce_event_code(event_code) - if not 0 <= kb_char <= 255: - raise ValueError(f"kb_char out of u8 range: {kb_char}") - if not 0 <= touch_x <= 65535: - raise ValueError(f"touch_x out of u16 range: {touch_x}") - if not 0 <= touch_y <= 65535: - raise ValueError(f"touch_y out of u16 range: {touch_y}") - - with connect(port=port) as iface: - msg = admin_pb2.AdminMessage() - msg.send_input_event.event_code = code - msg.send_input_event.kb_char = kb_char - msg.send_input_event.touch_x = touch_x - msg.send_input_event.touch_y = touch_y - iface.localNode._sendAdmin(msg) - return {"ok": True, "event_code": code, "kb_char": kb_char} - - -def factory_reset( - port: str | None = None, confirm: bool = False, full: bool = False -) -> dict[str, Any]: - """Tell the node to factory-reset its config. - - Works around a meshtastic-python 2.7.8 bug: `Node.factoryReset(full=True)` - internally does `p.factory_reset_config = True` where the field is - int32. protobuf 5.x rejects bool→int assignment as a TypeError. We build - the AdminMessage directly with int values (1=non-full, 2=full) and call - `_sendAdmin` to sidestep the SDK bug entirely. - """ - _require_confirm(confirm, "factory_reset") - from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped] - - with connect(port=port) as iface: - msg = admin_pb2.AdminMessage() - msg.factory_reset_config = 2 if full else 1 - iface.localNode._sendAdmin(msg) - return {"ok": True, "full": full} diff --git a/mcp-server/src/meshtastic_mcp/boards.py b/mcp-server/src/meshtastic_mcp/boards.py deleted file mode 100644 index 9e2378a94..000000000 --- a/mcp-server/src/meshtastic_mcp/boards.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Board / PlatformIO env enumeration. - -Parses `pio project config --json-output` - a nested list of -`[section_name, [[key, value], ...]]` pairs - into a dict keyed by env name, -extracting the `custom_meshtastic_*` metadata the firmware variants expose. - -The parsed config is cached and invalidated when `platformio.ini`'s mtime -changes, so subsequent calls don't pay the 1-2s pio startup cost. -""" - -from __future__ import annotations - -import threading -from typing import Any - -from . import config, pio - -_CACHE_LOCK = threading.Lock() -_CACHE: dict[str, Any] = {"mtime": None, "envs": None} - - -def _parse_bool(value: Any) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in ("true", "yes", "1", "on") - return bool(value) - - -def _parse_int(value: Any) -> int | None: - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _parse_tags(value: Any) -> list[str]: - if value is None: - return [] - if isinstance(value, list): - return [str(v).strip() for v in value if str(v).strip()] - return [t.strip() for t in str(value).replace(",", " ").split() if t.strip()] - - -def _env_record(env_name: str, items: list[list[Any]]) -> dict[str, Any]: - """Build a normalized dict for one env section.""" - d = dict(items) - return { - "env": env_name, - "architecture": d.get("custom_meshtastic_architecture"), - "hw_model": _parse_int(d.get("custom_meshtastic_hw_model")), - "hw_model_slug": d.get("custom_meshtastic_hw_model_slug"), - "display_name": d.get("custom_meshtastic_display_name"), - "actively_supported": _parse_bool( - d.get("custom_meshtastic_actively_supported") - ), - "support_level": _parse_int(d.get("custom_meshtastic_support_level")), - "board_level": d.get("board_level"), # "pr", "extra", or None - "tags": _parse_tags(d.get("custom_meshtastic_tags")), - "images": _parse_tags(d.get("custom_meshtastic_images")), - "board": d.get("board"), - "upload_speed": _parse_int(d.get("upload_speed")), - "upload_protocol": d.get("upload_protocol"), - "monitor_speed": _parse_int(d.get("monitor_speed")), - "monitor_filters": d.get("monitor_filters") or [], - "_raw": d, # Full dict for get_board - } - - -def _load_all() -> dict[str, dict[str, Any]]: - """Parse `pio project config` into `{env_name: record}`.""" - raw = pio.run_json(["project", "config"], timeout=pio.TIMEOUT_PROJECT_CONFIG) - result: dict[str, dict[str, Any]] = {} - for section_name, items in raw: - if not isinstance(section_name, str) or not section_name.startswith("env:"): - continue - env_name = section_name.split(":", 1)[1] - result[env_name] = _env_record(env_name, items) - return result - - -def _get_cached() -> dict[str, dict[str, Any]]: - root = config.firmware_root() - platformio_ini = root / "platformio.ini" - try: - mtime = platformio_ini.stat().st_mtime - except FileNotFoundError: - mtime = None - - with _CACHE_LOCK: - if _CACHE["envs"] is not None and _CACHE["mtime"] == mtime: - return _CACHE["envs"] - envs = _load_all() - _CACHE["envs"] = envs - _CACHE["mtime"] = mtime - return envs - - -def invalidate_cache() -> None: - with _CACHE_LOCK: - _CACHE["envs"] = None - _CACHE["mtime"] = None - - -def _public_record(rec: dict[str, Any]) -> dict[str, Any]: - """Strip the `_raw` field for list outputs.""" - return {k: v for k, v in rec.items() if not k.startswith("_")} - - -def list_boards( - architecture: str | None = None, - actively_supported_only: bool = False, - query: str | None = None, - board_level: str | None = None, # "release" | "pr" | "extra" -) -> list[dict[str, Any]]: - """Enumerate PlatformIO envs with Meshtastic metadata. - - Filters are cumulative (AND). `board_level="release"` means envs with no - explicit `board_level` set (the default release targets). - """ - envs = _get_cached() - q = query.lower().strip() if query else None - - out = [] - for rec in envs.values(): - if architecture and rec.get("architecture") != architecture: - continue - if actively_supported_only and not rec.get("actively_supported"): - continue - if board_level is not None: - rec_level = rec.get("board_level") - if board_level == "release": - if rec_level not in (None, ""): - continue - elif rec_level != board_level: - continue - if q: - display = (rec.get("display_name") or "").lower() - env_name = rec.get("env", "").lower() - slug = (rec.get("hw_model_slug") or "").lower() - if q not in display and q not in env_name and q not in slug: - continue - out.append(_public_record(rec)) - - out.sort(key=lambda r: (r.get("architecture") or "", r.get("env"))) - return out - - -def get_board(env: str) -> dict[str, Any]: - """Full metadata for one env, including the raw pio config dict.""" - envs = _get_cached() - rec = envs.get(env) - if rec is None: - raise KeyError( - f"Unknown env: {env!r}. Use list_boards() to see available envs." - ) - public = _public_record(rec) - public["raw_config"] = rec["_raw"] - return public diff --git a/mcp-server/src/meshtastic_mcp/camera.py b/mcp-server/src/meshtastic_mcp/camera.py deleted file mode 100644 index e69ec20b2..000000000 --- a/mcp-server/src/meshtastic_mcp/camera.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Cross-platform USB-webcam capture for UI tests + the `capture_screen` tool. - -Backends: -- `opencv` - cv2.VideoCapture (AVFoundation on macOS, V4L2 on Linux). -- `ffmpeg` - subprocess shelling out to the system `ffmpeg` binary. Slower - per frame, but zero Python deps beyond stdlib. -- `null` - no-op stub returning a 1×1 black PNG. Used when no camera is - configured; keeps code paths alive without forcing every operator to - hook up hardware. - -Environment variables (read at `get_camera()` call time): -- `MESHTASTIC_UI_CAMERA_BACKEND` - one of `opencv` / `ffmpeg` / `null` / - `auto` (default). `auto` picks opencv if `cv2` imports, else ffmpeg if - `ffmpeg --version` resolves, else null. -- `MESHTASTIC_UI_CAMERA_DEVICE` - generic default (index or path). -- `MESHTASTIC_UI_CAMERA_DEVICE_` - per-role override, e.g. - `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3. - Role suffix is uppercased before lookup. - -Dependencies land in the optional `[ui]` extra; imports are lazy so clients -without `opencv-python-headless` installed can still import this module. -""" - -from __future__ import annotations - -import io -import os -import shutil -import subprocess -import sys -import time -import warnings -from pathlib import Path -from typing import Protocol - - -class CameraError(RuntimeError): - """Raised when a camera backend fails to initialize or capture.""" - - -class CameraBackend(Protocol): - name: str - - def capture(self) -> bytes: - """Return one PNG-encoded frame.""" - ... - - def close(self) -> None: ... - - -# ---------- OpenCV backend ------------------------------------------------- - - -class OpenCVBackend: - name = "opencv" - - def __init__(self, device: int | str, warmup_frames: int = 5) -> None: - try: - import cv2 # type: ignore[import-untyped] # noqa: PLC0415 - except ImportError as exc: - raise CameraError( - "opencv backend requested but `cv2` is not installed. " - "Install the mcp-server [ui] extra: pip install -e '.[ui]'" - ) from exc - - self._cv2 = cv2 - device_arg: int | str - if isinstance(device, str) and device.isdigit(): - device_arg = int(device) - else: - device_arg = device - self._cap = cv2.VideoCapture(device_arg) - if not self._cap.isOpened(): - raise CameraError( - f"cv2.VideoCapture({device_arg!r}) failed to open. " - "On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access." - ) - - # Drop the first few frames - auto-exposure + white-balance settle. - for _ in range(warmup_frames): - self._cap.read() - # Detect a stuck black-frame camera early rather than silently - # producing all-black captures. - ok, frame = self._cap.read() - if not ok or frame is None: - self._cap.release() - raise CameraError(f"camera {device_arg!r} opened but returned no frames") - - def capture(self) -> bytes: - cv2 = self._cv2 - ok, frame = self._cap.read() - if not ok or frame is None: - raise CameraError("cv2.VideoCapture.read() returned no frame") - success, buf = cv2.imencode(".png", frame) - if not success: - raise CameraError("cv2.imencode('.png', ...) failed") - return bytes(buf) - - def close(self) -> None: - try: - self._cap.release() - except Exception: # noqa: BLE001 - pass - - -# ---------- ffmpeg subprocess backend -------------------------------------- - - -class FfmpegBackend: - name = "ffmpeg" - - def __init__(self, device: int | str) -> None: - if shutil.which("ffmpeg") is None: - raise CameraError("ffmpeg backend requested but `ffmpeg` is not on PATH") - - self._device = str(device) - # Platform-specific -f flag: - # macOS → avfoundation (index like "0") - # Linux → v4l2 (device like "/dev/video0" or "0") - if sys.platform == "darwin": - self._input_format = "avfoundation" - self._input_spec = self._device # bare index for avfoundation - else: - self._input_format = "v4l2" - self._input_spec = ( - self._device - if self._device.startswith("/dev/") - else f"/dev/video{self._device}" - ) - - def capture(self) -> bytes: - cmd = [ - "ffmpeg", - "-hide_banner", - "-loglevel", - "error", - "-f", - self._input_format, - "-i", - self._input_spec, - "-frames:v", - "1", - "-f", - "image2pipe", - "-vcodec", - "png", - "-", - ] - try: - out = subprocess.run( - cmd, capture_output=True, check=True, timeout=15 # noqa: S603 - ) - except subprocess.CalledProcessError as exc: - raise CameraError( - f"ffmpeg capture failed (rc={exc.returncode}): {exc.stderr.decode(errors='replace')[:200]}" - ) from exc - except subprocess.TimeoutExpired as exc: - raise CameraError("ffmpeg capture timed out after 15s") from exc - return out.stdout - - def close(self) -> None: - pass # stateless - each capture spawns a new process - - -# ---------- Null backend --------------------------------------------------- - - -# A tiny valid 1×1 transparent PNG so callers always get a decodable image. -_BLACK_1X1_PNG = bytes.fromhex( - "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" - "0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082" -) - - -class NullBackend: - name = "null" - - def capture(self) -> bytes: - return _BLACK_1X1_PNG - - def close(self) -> None: - pass - - -# ---------- Factory -------------------------------------------------------- - - -def _resolve_device(role: str | None) -> str | None: - if role: - specific = os.environ.get(f"MESHTASTIC_UI_CAMERA_DEVICE_{role.upper()}") - if specific: - return specific - return os.environ.get("MESHTASTIC_UI_CAMERA_DEVICE") - - -def get_camera(role: str | None = None) -> CameraBackend: - """Return a CameraBackend for the given device role (e.g. `"esp32s3"`). - - Falls back to `NullBackend` if no camera is configured or the selected - backend fails to init - tests should treat captures as best-effort - evidence, not a blocker. - """ - backend = os.environ.get("MESHTASTIC_UI_CAMERA_BACKEND", "auto").lower() - device = _resolve_device(role) - - if backend in ("null", "none") or device is None: - return NullBackend() - - if backend == "auto": - # Prefer opencv if importable; fall back to ffmpeg; else null. - try: - import cv2 # type: ignore[import-untyped] # noqa: F401,PLC0415 - - backend = "opencv" - except ImportError: - backend = "ffmpeg" if shutil.which("ffmpeg") else "null" - - if backend == "opencv": - try: - return OpenCVBackend(device) - except CameraError as exc: - warnings.warn( - f"camera backend {backend!r} failed to initialize for device " - f"{device!r}: {exc}; falling back to null backend", - RuntimeWarning, - stacklevel=2, - ) - return NullBackend() - if backend == "ffmpeg": - try: - return FfmpegBackend(device) - except CameraError as exc: - warnings.warn( - f"camera backend {backend!r} failed to initialize for device " - f"{device!r}: {exc}; falling back to null backend", - RuntimeWarning, - stacklevel=2, - ) - return NullBackend() - if backend == "null": - return NullBackend() - - raise CameraError(f"unknown MESHTASTIC_UI_CAMERA_BACKEND: {backend!r}") - - -def save_capture(png_bytes: bytes, path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(png_bytes) - - -def capture_to_file(role: str | None, path: Path) -> dict[str, object]: - """One-shot: open camera, capture, write PNG, close. Returns metadata.""" - started = time.monotonic() - cam = get_camera(role) - try: - data = cam.capture() - finally: - cam.close() - save_capture(data, path) - return { - "backend": cam.name, - "path": str(path), - "bytes": len(data), - "elapsed_s": round(time.monotonic() - started, 3), - } - - -def _is_png(data: bytes) -> bool: - return data.startswith(b"\x89PNG\r\n\x1a\n") - - -# Exposed so callers can sanity-check a capture without a full PIL import. -__all__ = [ - "CameraBackend", - "CameraError", - "FfmpegBackend", - "NullBackend", - "OpenCVBackend", - "capture_to_file", - "get_camera", - "save_capture", -] - -# Keep `io` import used (pyflakes is picky) via a small guard used at import -# time to normalize stdin/stdout if a subclass ever needs it. -_ = io.BytesIO # noqa: SLF001 diff --git a/mcp-server/src/meshtastic_mcp/cli/__init__.py b/mcp-server/src/meshtastic_mcp/cli/__init__.py deleted file mode 100644 index ff3b8e03d..000000000 --- a/mcp-server/src/meshtastic_mcp/cli/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Command-line entry points that sit alongside the MCP server. - -Modules here are loaded on-demand by `[project.scripts]` entries in -`pyproject.toml`. They are NOT imported by `meshtastic_mcp.server` or the -admin/info tool surface - the MCP server stays pure stdio JSON-RPC. -""" diff --git a/mcp-server/src/meshtastic_mcp/cli/_flashlog.py b/mcp-server/src/meshtastic_mcp/cli/_flashlog.py deleted file mode 100644 index 32c95d012..000000000 --- a/mcp-server/src/meshtastic_mcp/cli/_flashlog.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Flash progress log tailer for ``meshtastic-mcp-test-tui``. - -``pio.py`` / ``hw_tools.py`` tee subprocess output (``pio run -t upload``, -``esptool erase_flash``, ``nrfutil dfu``, etc.) to ``tests/flash.log`` -line-by-line as it arrives - controlled by the ``MESHTASTIC_MCP_FLASH_LOG`` -env var that ``run-tests.sh`` sets. The TUI tails that file so the operator -sees live flash progress in the pytest pane instead of 3 minutes of silence -during ``test_00_bake``. - -Separate from ``_fwlog.py`` because that one parses JSONL, this one -streams plain text lines. Same daemon-thread + EOF-backoff structure. -""" - -from __future__ import annotations - -import pathlib -import threading -import time -from typing import Callable - - -class FlashLogTailer(threading.Thread): - """Tail a plain-text log file, publish each stripped line via ``post``. - - ``post`` is invoked with a single ``str`` for every new line. Lines are - stripped of trailing newlines; empty lines after stripping are dropped. - - The file may not exist yet when this thread starts - it's truncated by - ``run-tests.sh`` at session start, but if the tailer races the shell, - we tolerate FileNotFoundError for up to ``wait_s`` seconds. - """ - - def __init__( - self, - path: pathlib.Path, - post: Callable[[str], None], - stop: threading.Event, - *, - wait_s: float = 30.0, - ) -> None: - super().__init__(daemon=True, name="flashlog-tail") - self._path = path - self._post = post - self._stop = stop - self._wait_s = wait_s - - def run(self) -> None: - deadline = time.monotonic() + self._wait_s - while not self._path.is_file(): - if self._stop.is_set() or time.monotonic() > deadline: - return - time.sleep(0.1) - try: - fh = self._path.open("r", encoding="utf-8", errors="replace") - except OSError: - return - try: - while not self._stop.is_set(): - line = fh.readline() - if not line: - time.sleep(0.05) - continue - line = line.rstrip("\r\n") - if not line: - continue - try: - self._post(line) - except Exception: - # A post failure (e.g. closed app) is terminal for this - # thread but we still want to close the file handle. - return - finally: - fh.close() diff --git a/mcp-server/src/meshtastic_mcp/cli/_fwlog.py b/mcp-server/src/meshtastic_mcp/cli/_fwlog.py deleted file mode 100644 index 74ef4946e..000000000 --- a/mcp-server/src/meshtastic_mcp/cli/_fwlog.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Firmware log tail worker for ``meshtastic-mcp-test-tui``. - -Complements v1's reportlog-tail worker. ``tests/conftest.py`` owns a -session-scoped autouse fixture (``_firmware_log_stream``) that mirrors -every ``meshtastic.log.line`` pubsub event to ``tests/fwlog.jsonl`` - -one JSON object per line: - - {"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "..."} - -The TUI tails that file from a worker thread; each new line becomes a -:class:`FirmwareLogLine` message posted to the App. Same pattern as the -reportlog tail worker - truncate on launch, tolerate missing file for -30 s, back off at EOF. - -Kept in its own module so the (large) ``test_tui.py`` stays focused on -the Textual App shell. -""" - -from __future__ import annotations - -import json -import pathlib -import threading -import time -from typing import Any, Callable - - -class FirmwareLogTailer(threading.Thread): - """Tail ``tests/fwlog.jsonl``, publish parsed records via ``post``. - - ``post`` is the App's ``post_message`` (or any callable that accepts a - single payload arg). We pass parsed dicts rather than constructing - Textual Message objects here - keeps this module free of the - textual dependency so it's unit-testable in a bare venv. - - Parameters - ---------- - path: - Path to ``tests/fwlog.jsonl``. The file may not exist yet at - startup - pytest only creates it once the session fixture runs. - post: - Callable invoked with a dict ``{"ts", "port", "line"}`` for every - new line parsed from the file. - stop: - An event the App sets to signal shutdown. - wait_s: - How long to poll for the file's creation before giving up. Default - 30 s; pytest collection on a cold cache can be slow. - - """ - - def __init__( - self, - path: pathlib.Path, - post: Callable[[dict[str, Any]], None], - stop: threading.Event, - *, - wait_s: float = 30.0, - ) -> None: - super().__init__(daemon=True, name="fwlog-tail") - self._path = path - self._post = post - self._stop = stop - self._wait_s = wait_s - - def run(self) -> None: - deadline = time.monotonic() + self._wait_s - while not self._path.is_file(): - if self._stop.is_set() or time.monotonic() > deadline: - return - time.sleep(0.1) - try: - fh = self._path.open("r", encoding="utf-8") - except OSError: - return - try: - while not self._stop.is_set(): - line = fh.readline() - if not line: - time.sleep(0.05) - continue - line = line.strip() - if not line: - continue - try: - record = json.loads(line) - except json.JSONDecodeError: - continue - # Defensive: require the three fields we rely on. - if not isinstance(record, dict): - continue - if "line" not in record: - continue - self._post(record) - finally: - fh.close() diff --git a/mcp-server/src/meshtastic_mcp/cli/_history.py b/mcp-server/src/meshtastic_mcp/cli/_history.py deleted file mode 100644 index 7cfc43ef6..000000000 --- a/mcp-server/src/meshtastic_mcp/cli/_history.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Cross-run history for ``meshtastic-mcp-test-tui``. - -Persists one JSON object per pytest run to -``mcp-server/tests/.history/runs.jsonl``. The TUI reads the last N -entries on launch to render a duration sparkline in the header - a -quick read on whether the suite is slowing down over time. - -Schema (keep small; the file can grow for months): - - {"run": 42, "ts": 1729100000.0, "duration_s": 387.2, - "passed": 52, "failed": 0, "skipped": 23, "exit_code": 0, - "seed": "mcp-user-host"} -""" - -from __future__ import annotations - -import json -import pathlib -import time -from dataclasses import asdict, dataclass -from typing import Iterable - -# Sparkline glyphs, low → high. 8 levels is the Unicode convention. -_SPARK_BLOCKS = "▁▂▃▄▅▆▇█" - - -@dataclass -class RunRecord: - run: int - ts: float - duration_s: float - passed: int - failed: int - skipped: int - exit_code: int - seed: str - - -class HistoryStore: - """Append-only JSONL store with bounded read. - - Writes are fsynced after each append (the file is tiny; fsync cost - is negligible and protects against truncation on a crash). - """ - - def __init__(self, path: pathlib.Path, *, keep_last: int = 50) -> None: - self._path = path - self._keep_last = keep_last - - def append(self, record: RunRecord) -> None: - try: - self._path.parent.mkdir(parents=True, exist_ok=True) - with self._path.open("a", encoding="utf-8") as fh: - fh.write(json.dumps(asdict(record)) + "\n") - fh.flush() - except Exception: - # Non-fatal: history is cosmetic. - pass - - def read_recent(self) -> list[RunRecord]: - """Return the last ``keep_last`` records in chronological order.""" - if not self._path.is_file(): - return [] - try: - lines = self._path.read_text(encoding="utf-8").splitlines() - except OSError: - return [] - out: list[RunRecord] = [] - # Parse tail-first so we don't waste work on a huge history. - for line in lines[-self._keep_last :]: - line = line.strip() - if not line: - continue - try: - raw = json.loads(line) - except json.JSONDecodeError: - continue - try: - out.append(RunRecord(**raw)) - except TypeError: - # Schema drift; skip the record rather than crash. - continue - return out - - def record_run( - self, - *, - run: int, - duration_s: float, - passed: int, - failed: int, - skipped: int, - exit_code: int, - seed: str, - ) -> RunRecord: - rec = RunRecord( - run=run, - ts=time.time(), - duration_s=float(duration_s), - passed=int(passed), - failed=int(failed), - skipped=int(skipped), - exit_code=int(exit_code), - seed=seed, - ) - self.append(rec) - return rec - - -def sparkline(values: Iterable[float], *, width: int = 20) -> str: - """Render a Unicode block-character sparkline from the last ``width`` values. - - Returns an empty string for empty input so the header handles - "no history yet" gracefully. - """ - buf = [v for v in values if v >= 0][-width:] - if not buf: - return "" - lo, hi = min(buf), max(buf) - if hi - lo < 1e-9: - return _SPARK_BLOCKS[len(_SPARK_BLOCKS) // 2] * len(buf) - n = len(_SPARK_BLOCKS) - 1 - out = [] - for v in buf: - idx = int(round((v - lo) / (hi - lo) * n)) - out.append(_SPARK_BLOCKS[max(0, min(n, idx))]) - return "".join(out) diff --git a/mcp-server/src/meshtastic_mcp/cli/_reproducer.py b/mcp-server/src/meshtastic_mcp/cli/_reproducer.py deleted file mode 100644 index ffc553c70..000000000 --- a/mcp-server/src/meshtastic_mcp/cli/_reproducer.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Reproducer bundle builder for ``meshtastic-mcp-test-tui``. - -When the operator presses ``x`` on a failed test leaf, we package the -minimum viable failure context into a tarball under -``mcp-server/tests/reproducers/``: - -:: - - repro--.tar.gz - ├── README.md human-readable overview - ├── test_report.json the failing TestReport event from reportlog - ├── fwlog.jsonl firmware log filtered to the failure window - ├── devices.json per-device device_info + lora config snapshot - └── env.json seed, run #, pytest version, platform, hostname - -Separate module so the logic can be unit-tested without Textual. The -TUI glue is thin - one key binding calls :func:`build_reproducer_bundle` -with the focused test's state and shows the path in a modal. -""" - -from __future__ import annotations - -import io -import json -import pathlib -import platform -import re -import socket -import tarfile -import time -from dataclasses import dataclass -from typing import Any, Iterable - - -@dataclass -class ReproContext: - """Everything :func:`build_reproducer_bundle` needs. Shaped to map - cleanly onto the state the TUI already tracks - no extra data - collection required at export time.""" - - nodeid: str - longrepr: str - sections: list[tuple[str, str]] - start_ts: float | None - stop_ts: float | None - seed: str - run_number: int - exit_code: int | None - fwlog_path: pathlib.Path - output_dir: pathlib.Path - extra_device_rows: list[dict[str, Any]] # [{role, port, info, ...}, ...] - - -def _short_nodeid(nodeid: str) -> str: - """Collapse a pytest nodeid into a filename-safe slug (<= 60 chars).""" - # Drop the file path prefix; keep test name + parametrization. - tail = nodeid.split("::", 1)[-1] if "::" in nodeid else nodeid - slug = re.sub(r"[^A-Za-z0-9_.\-]", "_", tail) - return slug[:60].strip("_.-") or "test" - - -def _filtered_fwlog( - fwlog_path: pathlib.Path, - start_ts: float | None, - stop_ts: float | None, - *, - pad_s: float = 5.0, -) -> bytes: - """Return fwlog.jsonl lines whose ``ts`` lies in [start-pad, stop+pad].""" - if not fwlog_path.is_file(): - return b"" - if start_ts is None or stop_ts is None: - # Without a time window, include the whole file - rare; happens - # when a test fails in setup before pytest emitted a start ts. - try: - return fwlog_path.read_bytes() - except OSError: - return b"" - lo, hi = start_ts - pad_s, stop_ts + pad_s - out = io.BytesIO() - try: - with fwlog_path.open("r", encoding="utf-8") as fh: - for line in fh: - stripped = line.strip() - if not stripped: - continue - try: - record = json.loads(stripped) - except json.JSONDecodeError: - continue - ts = record.get("ts") - if not isinstance(ts, (int, float)): - continue - if lo <= ts <= hi: - out.write(line.encode("utf-8")) - except OSError: - return b"" - return out.getvalue() - - -def _readme(ctx: ReproContext) -> str: - t = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime()) - return f"""# Reproducer bundle - -Exported by `meshtastic-mcp-test-tui` on {t}. - -## Failing test - -- **nodeid:** `{ctx.nodeid}` -- **seed:** `{ctx.seed}` -- **run #:** {ctx.run_number} -- **suite exit code (at export time):** {ctx.exit_code if ctx.exit_code is not None else "in progress"} - -## Files in this archive - -| File | Contents | -|---|---| -| `test_report.json` | The pytest-reportlog `TestReport` event for the failing test - includes `longrepr`, captured `sections` (stdout/stderr/log), `duration`, `location`, `keywords`. | -| `fwlog.jsonl` | Firmware log lines (from `meshtastic.log.line` pubsub) filtered to [start−5s, stop+5s] around the test's run window. Each line is `{{ts, port, line}}`. | -| `devices.json` | Per-device snapshot at export time: `device_info` + `lora` config per detected role. | -| `env.json` | Python version, platform, hostname, seed, run number. | - -## How to triage - -1. Open `test_report.json` and read `longrepr` + `sections` - most failures explain themselves there. -2. If the failure is a mesh/telemetry assertion, `fwlog.jsonl` is where the answer usually lives. Grep for `Error=`, `NAK`, `PKI_UNKNOWN_PUBKEY`, `Skip send`, `Guru Meditation`, or the uptime timestamps around the assertion event. -3. Compare `devices.json` against the expected state (e.g. `num_nodes >= 2`, `primary_channel == "McpTest"`, `region == "US"`). If fields disagree with the seed-derived USERPREFS profile, the device probably wasn't baked with this session's profile. - -## Reproducing locally - -```bash -cd mcp-server -MESHTASTIC_MCP_SEED='{ctx.seed}' .venv/bin/pytest '{ctx.nodeid}' --tb=long -v -``` -""" - - -def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path: - """Build a tarball under ``ctx.output_dir`` and return its path. - - Parent dirs are created as needed. Errors during optional sections - (devices, env) are swallowed - the bundle is still useful without - them; refusing to export because the device poller had a hiccup - would be worse than the export missing a file. - """ - ctx.output_dir.mkdir(parents=True, exist_ok=True) - ts = int(time.time()) - slug = _short_nodeid(ctx.nodeid) - archive_path = ctx.output_dir / f"repro-{ts}-{slug}.tar.gz" - - with tarfile.open(archive_path, "w:gz") as tar: - - def _add(name: str, data: bytes) -> None: - info = tarfile.TarInfo(name=name) - info.size = len(data) - info.mtime = ts - tar.addfile(info, io.BytesIO(data)) - - # README - _add("README.md", _readme(ctx).encode("utf-8")) - - # test_report.json - reconstruct from the fields the TUI stashes. - test_report = { - "nodeid": ctx.nodeid, - "outcome": "failed", - "longrepr": ctx.longrepr, - "sections": [list(s) for s in ctx.sections], - "start": ctx.start_ts, - "stop": ctx.stop_ts, - } - _add( - "test_report.json", - json.dumps(test_report, indent=2, default=str).encode("utf-8"), - ) - - # fwlog.jsonl (filtered) - _add("fwlog.jsonl", _filtered_fwlog(ctx.fwlog_path, ctx.start_ts, ctx.stop_ts)) - - # devices.json - try: - devices_payload = json.dumps( - ctx.extra_device_rows or [], indent=2, default=str - ) - except Exception: - devices_payload = "[]" - _add("devices.json", devices_payload.encode("utf-8")) - - # env.json - try: - from importlib.metadata import version as _pkg_version - - pytest_version = _pkg_version("pytest") - except Exception: - pytest_version = "unknown" - env_payload = { - "seed": ctx.seed, - "run": ctx.run_number, - "exit_code": ctx.exit_code, - "export_ts": ts, - "python": platform.python_version(), - "pytest": pytest_version, - "platform": f"{platform.system()} {platform.release()} {platform.machine()}", - "hostname": socket.gethostname(), - } - _add("env.json", json.dumps(env_payload, indent=2).encode("utf-8")) - - return archive_path - - -def iter_entries(archive_path: pathlib.Path) -> Iterable[str]: - """Yield member names - used by callers that want to confirm the bundle shape.""" - with tarfile.open(archive_path, "r:gz") as tar: - for m in tar.getmembers(): - yield m.name diff --git a/mcp-server/src/meshtastic_mcp/cli/_uicap.py b/mcp-server/src/meshtastic_mcp/cli/_uicap.py deleted file mode 100644 index e87128b75..000000000 --- a/mcp-server/src/meshtastic_mcp/cli/_uicap.py +++ /dev/null @@ -1,83 +0,0 @@ -"""UI-capture transcript tailer for ``meshtastic-mcp-test-tui``. - -Watches ``tests/ui_captures//`` for new transcript lines -(one per ``frame_capture()`` call from the UI tier) and posts them to -the TUI. Enabled by ``MESHTASTIC_UI_TUI_CAMERA=1``. - -Design mirrors ``_flashlog.py``: -- Daemon thread, cooperative stop via ``threading.Event``. -- Tolerates the captures directory not existing yet (UI tier hasn't run). -- Per-file seek state so we only forward genuinely-new lines. -""" - -from __future__ import annotations - -import pathlib -import threading -import time -from typing import Callable - - -class UiCaptureTailer(threading.Thread): - """Recursively watch a captures root for new `transcript.md` lines. - - Invokes ``post(test_id, line)`` for each new line, where ``test_id`` - is derived from the path - the sanitized nodeid directory name. - """ - - def __init__( - self, - root: pathlib.Path, - post: Callable[[str, str], None], - stop: threading.Event, - *, - poll_interval: float = 0.5, - ) -> None: - super().__init__(daemon=True, name="uicap-tail") - self._root = root - self._post = post - self._stop = stop - self._poll_interval = poll_interval - # path → byte offset we've already read through - self._offsets: dict[pathlib.Path, int] = {} - - def run(self) -> None: - while not self._stop.is_set(): - try: - self._scan_once() - except Exception: - # Best-effort tailer - never bring down the TUI because a - # directory vanished mid-scan. - pass - time.sleep(self._poll_interval) - - def _scan_once(self) -> None: - if not self._root.is_dir(): - return - for transcript in self._root.rglob("transcript.md"): - test_id = transcript.parent.name - offset = self._offsets.get(transcript, 0) - try: - size = transcript.stat().st_size - except OSError: - continue - if size < offset: - # File truncated / rewritten - reset and re-emit. - offset = 0 - if size == offset: - continue - try: - with transcript.open("rb") as fh: - fh.seek(offset) - chunk = fh.read(size - offset).decode("utf-8", errors="replace") - except OSError: - continue - for line in chunk.splitlines(): - line = line.rstrip() - if not line or line.startswith("#"): - continue - try: - self._post(test_id, line) - except Exception: - return - self._offsets[transcript] = size diff --git a/mcp-server/src/meshtastic_mcp/cli/test_tui.py b/mcp-server/src/meshtastic_mcp/cli/test_tui.py deleted file mode 100644 index 32718577a..000000000 --- a/mcp-server/src/meshtastic_mcp/cli/test_tui.py +++ /dev/null @@ -1,1911 +0,0 @@ -"""Textual TUI wrapping `mcp-server/run-tests.sh`. - -Launch: ``meshtastic-mcp-test-tui [pytest-args]`` - -The TUI *wraps* ``run-tests.sh``; it never replaces it. Same script, same -env-var resolution, same ``userPrefs.jsonc`` session fixture. Four data -sources drive live state: - -1. ``tests/reportlog.jsonl`` - written by ``pytest-reportlog``. Tailed in a - worker thread; each JSON line is published as a :class:`ReportLogEvent` - message. This is the authoritative source for tree population + per-test - outcome. -2. The pytest subprocess ``stdout`` + ``stderr`` streams - line-by-line, - published as :class:`PytestLine` messages and rendered verbatim in the - pytest pane. -3. ``tests/fwlog.jsonl`` - firmware log stream. Written by the - ``_firmware_log_stream`` autouse session fixture in ``conftest.py`` - (mirrors every ``meshtastic.log.line`` pubsub event), tailed by the - :class:`FirmwareLogTailer` worker, displayed in a wrap-enabled - RichLog with cycleable port filter. -4. ``devices.list_devices()`` + ``info.device_info(port)`` - polled only at - startup and again after ``RunFinished``. Device polling while pytest - holds a SerialInterface would deadlock on the exclusive port lock; the - existing ``hub_devices`` fixture is session-scoped so there is no safe - "between tests" window. The header reflects this with a "(stale)" - marker while the run is active. - -Key bindings (see :class:`TestTuiApp.BINDINGS`): - ``r`` re-run focused ``f`` filter tree ``d`` failure detail - ``g`` open report.html ``l`` cycle firmware-log port filter - ``x`` export reproducer bundle ``c`` tool-coverage panel - ``q`` / Ctrl-C graceful quit with SIGINT → SIGTERM → SIGKILL escalation - -Shipped today (v1 + v2 slice): test tree + tier counters with progress bars, -pytest tail, live firmware log with port filter, device strip with -"currently running" status column, failure-detail modal, reproducer bundle -export (filters fwlog by test's start/stop timestamps), tool-coverage -modal, cross-run history sparkline in the header, clean SIGINT -propagation. Still open (see the plan file): mesh topology mini-diagram -and airtime / channel-utilization gauges. -""" - -from __future__ import annotations - -import argparse -import json -import os -import pathlib -import signal -import subprocess -import sys -import threading -import time -from dataclasses import dataclass, field -from typing import Any, Iterator - -# --------------------------------------------------------------------------- -# Configuration constants -# --------------------------------------------------------------------------- - -# Tier names that map nodeids like "tests//..." to counter buckets. -# Order here == display order in the tier-counters table. Matches the order -# `pytest_collection_modifyitems` in `conftest.py` uses: -# bake → unit → mesh → telemetry → monitor → fleet → admin → provisioning -# so the counters table reads top-to-bottom in execution order. -# -# "bake" is the synthetic tier for `tests/test_00_bake.py` - the file sits -# at the `tests/` root rather than under a tier subdirectory, so without -# this mapping `_tier_of_nodeid` would return "other" and the bake outcomes -# would be silently dropped from both the tier table and the history -# record (which sums tier counters to compute passed/failed/skipped). -TIERS = ( - "bake", - "unit", - "mesh", - "telemetry", - "monitor", - "fleet", - "admin", - "provisioning", -) - -# Relative paths from the mcp-server root. -_REPORTLOG_RELATIVE = "tests/reportlog.jsonl" -_FWLOG_RELATIVE = "tests/fwlog.jsonl" -# pio / esptool / nrfutil / picotool tee subprocess output here when -# `MESHTASTIC_MCP_FLASH_LOG` is set (see `pio._run_capturing`). run-tests.sh -# sets that env var; the TUI also sets it for direct `_spawn_pytest` calls -# so `r`-key re-runs that skip the wrapper still get tee'd output. -_FLASHLOG_RELATIVE = "tests/flash.log" -_REPORT_HTML_RELATIVE = "tests/report.html" -_TOOL_COVERAGE_RELATIVE = "tests/tool_coverage.json" -_HISTORY_RELATIVE = "tests/.history/runs.jsonl" -_REPRODUCERS_RELATIVE = "tests/reproducers" -_RUN_TESTS_RELATIVE = "run-tests.sh" -_RUN_COUNTER_RELATIVE = "tests/.tui-runs" - -# Graceful-shutdown budgets (seconds) for the pytest subprocess when the -# user hits `q`. Matches what the existing CLI's atexit + userprefs sidecar -# self-heal expects. -_SIGINT_GRACE_S = 5.0 -_SIGTERM_GRACE_S = 5.0 - - -# --------------------------------------------------------------------------- -# Path resolution -# --------------------------------------------------------------------------- - - -def _mcp_server_root() -> pathlib.Path: - """Locate the mcp-server directory (the one containing run-tests.sh).""" - here = pathlib.Path(__file__).resolve() - # Walk up until we find pyproject.toml with a matching project name, or - # default to the three-up ancestor (src/meshtastic_mcp/cli/test_tui.py → - # .../mcp-server). The walk-up protects against unusual checkouts. - for parent in (here.parent, *here.parents): - if (parent / "pyproject.toml").is_file() and ( - parent / "run-tests.sh" - ).is_file(): - return parent - return here.parents[3] - - -# --------------------------------------------------------------------------- -# Data classes -# --------------------------------------------------------------------------- - - -@dataclass -class LeafReport: - """Per-test state drawn from reportlog events. - - Outcomes mirror pytest's: "passed" | "failed" | "skipped" | "running". - """ - - nodeid: str - tier: str - outcome: str = "pending" - duration_s: float = 0.0 - longrepr: str = "" - # Captured stdout / stderr / firmware-log sections from the test's - # `TestReport.sections` - shown in the failure-detail modal. - sections: list[tuple[str, str]] = field(default_factory=list) - # Wall-clock start/stop from the TestReport event. Used by the - # reproducer exporter (`x`) to filter `tests/fwlog.jsonl` down to - # just the lines around the failure window. - start_ts: float | None = None - stop_ts: float | None = None - - -@dataclass -class TierCounters: - tier: str - passed: int = 0 - failed: int = 0 - skipped: int = 0 - running: int = 0 - remaining: int = 0 - - -@dataclass -class DeviceRow: - role: str | None - port: str - vid: str - pid: str - description: str - # Populated from info.device_info when available; empty dict when we - # haven't queried (or when the poller is paused). - info: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class State: - """Shared state owned by the App; written by workers under `lock`. - - UI code reads via Textual Message handlers which run on the UI thread - in the order workers called `post_message` - so reads don't need the - lock themselves. - """ - - lock: threading.Lock = field(default_factory=threading.Lock) - tiers: dict[str, TierCounters] = field( - default_factory=lambda: {t: TierCounters(tier=t) for t in TIERS} - ) - leaves: dict[str, LeafReport] = field(default_factory=dict) - # Ordered list of nodeids in the order they were first seen - lets us - # rebuild the tree deterministically. - nodeid_order: list[str] = field(default_factory=list) - devices: list[DeviceRow] = field(default_factory=list) - run_active: bool = False - exit_code: int | None = None - # nodeid of the currently-running test. Set on `when="setup"` + - # outcome="passed" (body about to execute); cleared on `when="call"` - # (any outcome) or on `when="setup"` + outcome="failed" (no body - # window). Drives the device-table "Status" column so the operator - # can see which test is touching a given device right now. - running_nodeid: str | None = None - # `time.monotonic()` captured when `running_nodeid` was set. Surfaced - # as live-updating elapsed-time ("RUNNING: test_bake_nrf52 (1:23)") so - # an operator staring at a ~3 min `test_00_bake` or a `mesh_formation` - # with a 60 s ceiling has concrete evidence the test isn't stuck. - running_started_at: float | None = None - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _tier_of_nodeid(nodeid: str) -> str: - """Map a pytest nodeid to its tier bucket. Unknown → 'other'. - - `tests/test_00_bake.py::...` is special-cased to the synthetic `bake` - tier - it's a top-level file (no tier subdirectory) so the generic - "second path segment" logic would miss it and route the bake outcomes - into the non-existent `other` bucket. - """ - parts = nodeid.split("/", 2) - if len(parts) >= 2 and parts[0] == "tests": - # Bake file sits at `tests/test_00_bake.py` - dedicated bucket. - if parts[1].startswith("test_00_bake"): - return "bake" - candidate = parts[1] - if candidate in TIERS: - return candidate - return "other" - - -def _file_of_nodeid(nodeid: str) -> str: - """Extract the test file name (e.g. 'test_boards.py') from a nodeid.""" - left = nodeid.split("::", 1)[0] - return left.rsplit("/", 1)[-1] - - -def _testname_of_nodeid(nodeid: str) -> str: - """Extract the 'test_foo[param]' suffix from a nodeid, or the full thing.""" - if "::" in nodeid: - return nodeid.split("::", 1)[1] - return nodeid - - -def _roles_from_nodeid(nodeid: str) -> set[str]: - """Infer which device roles a parametrized test touches. - - Patterns we recognize (from the existing ``conftest.py`` parametrization - in ``pytest_generate_tests``): - - - ``test_foo[nrf52]`` → {"nrf52"} (baked_single) - - ``test_foo[nrf52->esp32s3]`` → {"nrf52", "esp32s3"} (mesh_pair) - - Unparametrized tests (no bracket) return an empty set - the caller - should fall back to "this test involves ALL detected devices" rather - than pretending it touches none. - """ - if "[" not in nodeid or not nodeid.endswith("]"): - return set() - try: - inner = nodeid.rsplit("[", 1)[1][:-1] - except Exception: - return set() - # Split on "->" for directed mesh pairs; otherwise treat as single role. - parts = [p.strip() for p in inner.split("->")] if "->" in inner else [inner.strip()] - return {p for p in parts if p} - - -def _parse_events(path: pathlib.Path) -> Iterator[dict[str, Any]]: - """Yield parsed JSON dicts from a reportlog file, skipping malformed lines. - - Used for smoke-testing the parser against a finished file; the live - worker has its own tail loop. - """ - if not path.is_file(): - return - with path.open("r", encoding="utf-8") as fh: - for line in fh: - line = line.strip() - if not line: - continue - try: - yield json.loads(line) - except json.JSONDecodeError: - continue - - -def _load_run_number(counter_path: pathlib.Path) -> int: - """Bump + persist a monotonic run counter used in the TUI header.""" - try: - n = int(counter_path.read_text().strip()) - except Exception: - n = 0 - n += 1 - try: - counter_path.parent.mkdir(parents=True, exist_ok=True) - counter_path.write_text(str(n)) - except Exception: - # Non-fatal: the counter is cosmetic. - pass - return n - - -def _resolve_seed() -> str: - """Mirror the default-seed resolution from run-tests.sh. - - Operator can override via MESHTASTIC_MCP_SEED. Matches the - per-user/per-host default so repeated invocations land on the same PSK - (makes --assume-baked valid across invocations). - """ - if explicit := os.environ.get("MESHTASTIC_MCP_SEED"): - return explicit - try: - who = os.environ.get("USER") or os.environ.get("LOGNAME") or "anon" - except Exception: - who = "anon" - try: - import socket - - host = socket.gethostname().split(".", 1)[0] - except Exception: - host = "host" - return f"mcp-{who}-{host}" - - -def _format_duration(seconds: float) -> str: - if seconds < 60: - return f"{seconds:5.1f}s" - m, s = divmod(int(seconds), 60) - return f"{m:d}:{s:02d}" - - -# --------------------------------------------------------------------------- -# Textual imports (lazy - only when main() runs, so `_parse_events` can be -# imported by smoke tests without requiring textual installed in every env) -# --------------------------------------------------------------------------- - - -def _import_textual() -> Any: - """Return a namespace carrying every Textual class we use. - - Deferred import keeps `_parse_events` + `_tier_of_nodeid` importable - from tests / smoke scripts without pulling in the UI stack. - """ - import textual - from textual.app import App, ComposeResult - from textual.binding import Binding - from textual.containers import Horizontal, Vertical - from textual.message import Message - from textual.screen import ModalScreen - from textual.widgets import DataTable, Footer, Input, RichLog, Static, Tree - - ns = argparse.Namespace() - ns.App = App - ns.Binding = Binding - ns.ComposeResult = ComposeResult - ns.DataTable = DataTable - ns.Footer = Footer - ns.Horizontal = Horizontal - ns.Input = Input - ns.Message = Message - ns.ModalScreen = ModalScreen - ns.RichLog = RichLog - ns.Static = Static - ns.Tree = Tree - ns.Vertical = Vertical - ns.textual = textual - return ns - - -# --------------------------------------------------------------------------- -# main() - the important scaffolding lives here so that when we bail out -# before entering the Textual event loop (missing terminal, --help, etc.) -# nothing has grabbed the screen yet. -# --------------------------------------------------------------------------- - - -def main(argv: list[str] | None = None) -> int: - """Entry point for `meshtastic-mcp-test-tui`.""" - argv = list(argv if argv is not None else sys.argv[1:]) - - parser = argparse.ArgumentParser( - prog="meshtastic-mcp-test-tui", - description=( - "Live Textual TUI wrapping mcp-server/run-tests.sh. " - "Passes any unrecognized arguments through to pytest." - ), - allow_abbrev=False, - ) - parser.add_argument( - "--no-tui", - action="store_true", - help=( - "Skip the TUI and exec run-tests.sh directly. Useful as a health " - "check that the wrapper argv+env resolution is working." - ), - ) - args, pytest_args = parser.parse_known_args(argv) - - root = _mcp_server_root() - run_tests = root / _RUN_TESTS_RELATIVE - reportlog = root / _REPORTLOG_RELATIVE - fwlog = root / _FWLOG_RELATIVE - flashlog = root / _FLASHLOG_RELATIVE - counter = root / _RUN_COUNTER_RELATIVE - - if not run_tests.is_file(): - print( - f"error: could not locate {_RUN_TESTS_RELATIVE} relative to " - f"{root}. Is this the mcp-server checkout?", - file=sys.stderr, - ) - return 2 - - # Always clear stale log files before launching pytest. The TUI's tail - # workers race pytest file-creation; starting from a known-empty state - # avoids mid-line-decode confusion from the prior run. The fwlog session - # fixture also truncates on its end, and run-tests.sh truncates the - # flashlog - triple-truncate is deliberate (whichever side creates the - # file first, it starts empty). - for p in (reportlog, fwlog, flashlog): - try: - p.unlink(missing_ok=True) - except Exception: - pass - - # Compute + persist the run counter for the header (cosmetic). - run_number = _load_run_number(counter) - seed = _resolve_seed() - # Export the seed so the subprocess inherits the SAME value the TUI - # displays. run-tests.sh computes its own fallback if unset, and we'd - # end up with a header / wrapper-header mismatch if we let that happen. - os.environ.setdefault("MESHTASTIC_MCP_SEED", seed) - # Turn on subprocess-output tee'ing so `pio._run_capturing` writes each - # line of pio / esptool / nrfutil / picotool output to `tests/flash.log` - # as it arrives. The TUI tails that file and routes each line to the - # pytest pane so the operator sees live flash progress during long - # `pio run -t upload` / `esptool erase_flash` operations. run-tests.sh - # also sets this when invoked directly - `setdefault` so the wrapper's - # value wins when present. - os.environ.setdefault("MESHTASTIC_MCP_FLASH_LOG", str(flashlog)) - - # --no-tui: exec run-tests.sh directly. Useful for diagnosing wrapper - # env / argv handling without getting into Textual's alternate screen. - if args.no_tui: - cmd = [str(run_tests), *pytest_args] - os.execv(str(run_tests), cmd) # noqa: S606 - intentional - - # Textual UI import is deferred so `--help` and `--no-tui` do not pay - # the ~40 MB startup cost. - try: - tx = _import_textual() - except ImportError as exc: - print( - f"error: textual is not installed ({exc}). Install with: " - f"pip install -e '.[test]'", - file=sys.stderr, - ) - return 2 - - # Narrow-terminal warning (see plan §8 risk 2). Textual itself degrades, - # but a heads-up helps a first-time user. - term = os.environ.get("TERM", "") - if term in ("", "dumb", "screen") and not os.environ.get("TEXTUAL_NO_TERM_HINT"): - print( - f"[hint] TERM={term!r} may render poorly. Try " - f"`TERM=xterm-256color meshtastic-mcp-test-tui ...` if the layout " - f"looks broken.", - file=sys.stderr, - ) - - app = _build_app( - tx=tx, - root=root, - run_tests=run_tests, - reportlog=reportlog, - fwlog=fwlog, - flashlog=flashlog, - seed=seed, - run_number=run_number, - pytest_args=pytest_args, - ) - - # App.run() returns the subprocess exit code via `app.exit(returncode)`. - return_value = app.run() - if isinstance(return_value, int): - return return_value - return 0 - - -# --------------------------------------------------------------------------- -# Everything below is only reachable once Textual is importable. `tx` is -# the namespace returned by `_import_textual()` so we don't scatter `from -# textual import ...` across the file. -# --------------------------------------------------------------------------- - - -def _build_app( - *, - tx: Any, - root: pathlib.Path, - run_tests: pathlib.Path, - reportlog: pathlib.Path, - fwlog: pathlib.Path, - flashlog: pathlib.Path, - seed: str, - run_number: int, - pytest_args: list[str], -) -> Any: - """Assemble TestTuiApp with its Textual-dependent inner classes. - - Keeping the class definitions inside a factory means `main()` can - short-circuit (--no-tui, terminal-check, argparse error) before we - force Textual's import cost. - """ - - # Helper modules - lazy-imported here so the top-of-file import cost - # only kicks in when main() has decided to run the TUI. - from . import _flashlog as _flashlog_mod - from . import _fwlog as _fwlog_mod - from . import _history as _history_mod - from . import _reproducer as _reproducer_mod - from . import _uicap as _uicap_mod - - # ---------------- Messages ---------------- - - class ReportLogEvent(tx.Message): - def __init__(self, event: dict[str, Any]) -> None: - self.event = event - super().__init__() - - class PytestLine(tx.Message): - def __init__(self, source: str, line: str) -> None: - self.source = source # "stdout" | "stderr" - self.line = line - super().__init__() - - class FirmwareLogLine(tx.Message): - def __init__(self, record: dict[str, Any]) -> None: - # {"ts": float, "port": str | None, "line": str} - self.record = record - super().__init__() - - class FlashLogLine(tx.Message): - """Plain-text line from `tests/flash.log` - pio / esptool / nrfutil / - picotool output tee'd by `pio._run_capturing`. Routed to the pytest - pane so the operator sees live flash progress during `test_00_bake` - instead of 3 minutes of pytest-captured silence.""" - - def __init__(self, line: str) -> None: - self.line = line - super().__init__() - - class UiCaptureLine(tx.Message): - """Live line from the UI-tier camera transcript - one per - `frame_capture()` call. Posted only when the camera panel is - enabled via `MESHTASTIC_UI_TUI_CAMERA=1`.""" - - def __init__(self, test_id: str, line: str) -> None: - self.test_id = test_id - self.line = line - super().__init__() - - class DeviceSnapshot(tx.Message): - def __init__(self, rows: list[DeviceRow]) -> None: - self.rows = rows - super().__init__() - - class RunFinished(tx.Message): - def __init__(self, returncode: int) -> None: - self.returncode = returncode - super().__init__() - - # ---------------- Workers ---------------- - - class ReportlogWorker(threading.Thread): - """Tail `reportlog.jsonl`, publish each event.""" - - def __init__(self, app: Any, path: pathlib.Path, stop: threading.Event) -> None: - super().__init__(daemon=True, name="reportlog-tail") - self._app = app - self._path = path - self._stop = stop - - def run(self) -> None: - # Wait up to 30 s for pytest to create the file (first call on - # a cold cache can be slow). - wait_deadline = time.monotonic() + 30.0 - while not self._path.is_file(): - if self._stop.is_set() or time.monotonic() > wait_deadline: - return - time.sleep(0.1) - try: - fh = self._path.open("r", encoding="utf-8") - except OSError: - return - try: - while not self._stop.is_set(): - line = fh.readline() - if not line: - time.sleep(0.05) - continue - line = line.strip() - if not line: - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - self._app.post_message(ReportLogEvent(event)) - finally: - fh.close() - - class SubprocessReaderWorker(threading.Thread): - """Read one stream line-by-line and publish PytestLine messages.""" - - def __init__( - self, - app: Any, - stream: Any, - source: str, - stop: threading.Event, - ) -> None: - super().__init__(daemon=True, name=f"subprocess-{source}") - self._app = app - self._stream = stream - self._source = source - self._stop = stop - - def run(self) -> None: - try: - for line in iter(self._stream.readline, ""): - if self._stop.is_set(): - break - self._app.post_message( - PytestLine(source=self._source, line=line.rstrip("\n")) - ) - except Exception: - # stream closed / subprocess died; not fatal. - pass - - class DevicePollerWorker(threading.Thread): - """Poll list_devices() + device_info() at startup and after RunFinished. - - Deliberately NOT polling during the run - `hub_devices` is a - session-scoped fixture holding SerialInterfaces across the whole - session, and device_info() would deadlock on the exclusive port - lock. Header shows "(stale)" during the gap. - """ - - def __init__(self, app: Any, state: State, stop: threading.Event) -> None: - super().__init__(daemon=True, name="device-poller") - self._app = app - self._state = state - self._stop = stop - self._trigger = threading.Event() - - def trigger(self) -> None: - self._trigger.set() - - def run(self) -> None: - # Perform one poll at startup; then wait for explicit triggers. - self._poll_once() - while not self._stop.is_set(): - if self._trigger.wait(timeout=0.5): - self._trigger.clear() - if self._stop.is_set(): - break - with self._state.lock: - active = self._state.run_active - if active: - continue - self._poll_once() - - def _poll_once(self) -> None: - try: - from meshtastic_mcp import devices as devices_mod - from meshtastic_mcp import info as info_mod - except Exception as exc: # pragma: no cover - self._app.post_message( - PytestLine( - source="stderr", line=f"[tui] device import failed: {exc!r}" - ) - ) - return - rows: list[DeviceRow] = [] - try: - raw = devices_mod.list_devices(include_unknown=True) - except Exception as exc: - self._app.post_message( - PytestLine( - source="stderr", line=f"[tui] list_devices failed: {exc!r}" - ) - ) - return - for d in raw: - vid_raw = d.get("vid") or "" - try: - vid_i = ( - int(vid_raw, 16) - if isinstance(vid_raw, str) and vid_raw.startswith("0x") - else int(vid_raw) - ) - except (TypeError, ValueError): - vid_i = 0 - role = None - if vid_i == 0x239A: - role = "nrf52" - elif vid_i in (0x303A, 0x10C4): - role = "esp32s3" - if not role and not d.get("likely_meshtastic"): - continue - row = DeviceRow( - role=role, - port=d.get("port", ""), - vid=str(vid_raw), - pid=str(d.get("pid") or ""), - description=d.get("description", "") or "", - ) - if role: - try: - row.info = info_mod.device_info(port=row.port, timeout_s=6.0) - except Exception as exc: - row.info = {"error": repr(exc)} - rows.append(row) - self._app.post_message(DeviceSnapshot(rows=rows)) - - # ---------------- Modals ---------------- - - class FailureDetailScreen(tx.ModalScreen): - """Show a failed test's longrepr + captured sections.""" - - BINDINGS = [tx.Binding("escape,q", "dismiss", "close")] - - def __init__(self, leaf: LeafReport, report_html: pathlib.Path) -> None: - self._leaf = leaf - self._report_html = report_html - super().__init__() - - def compose(self) -> Any: - yield tx.Static( - f"[bold]{self._leaf.nodeid}[/bold] " - f"outcome=[red]{self._leaf.outcome}[/red] " - f"duration={_format_duration(self._leaf.duration_s)}", - id="failure-detail-header", - ) - log = tx.RichLog( - highlight=False, markup=False, wrap=False, id="failure-detail-log" - ) - yield log - yield tx.Static( - f"[dim]Full HTML report: {self._report_html}[/dim] [esc] close", - id="failure-detail-footer", - ) - - def on_mount(self) -> None: - log = self.query_one("#failure-detail-log", tx.RichLog) - if self._leaf.longrepr: - log.write(self._leaf.longrepr) - log.write("") - for section_name, section_text in self._leaf.sections: - log.write(f"--- {section_name} ---") - log.write(section_text) - log.write("") - if not self._leaf.longrepr and not self._leaf.sections: - log.write("(no longrepr or captured sections in reportlog event)") - - def action_dismiss(self, _result: Any = None) -> None: - self.dismiss() - - class FilterInputScreen(tx.ModalScreen[str]): - """Prompt the user for a tree filter substring (empty clears).""" - - BINDINGS = [tx.Binding("escape", "cancel", "cancel")] - - def compose(self) -> Any: - yield tx.Static("filter test tree (substring, empty = clear):") - yield tx.Input(placeholder="nodeid substring", id="filter-input") - - def on_input_submitted(self, event: Any) -> None: - self.dismiss(event.value.strip()) - - def action_cancel(self) -> None: - self.dismiss(None) - - class CoverageModal(tx.ModalScreen): - """Read `tests/tool_coverage.json` (written by `tests/tool_coverage.py` - at `pytest_sessionfinish`) and render a two-column summary of which - MCP tools got exercised by the run. `(no coverage data yet)` while - the run is in flight.""" - - BINDINGS = [tx.Binding("escape,q,c", "dismiss", "close")] - - def __init__(self, coverage_path: pathlib.Path) -> None: - self._path = coverage_path - super().__init__() - - def compose(self) -> Any: - yield tx.Static("[bold]MCP tool coverage[/bold]", id="coverage-header") - yield tx.RichLog( - highlight=False, markup=True, wrap=False, id="coverage-log" - ) - yield tx.Static( - f"[dim]{self._path}[/dim] [esc] close", - id="coverage-footer", - ) - - def on_mount(self) -> None: - log = self.query_one("#coverage-log", tx.RichLog) - if not self._path.is_file(): - log.write("(no coverage data - tool_coverage.json not written yet)") - log.write("") - log.write("Coverage is emitted at pytest_sessionfinish; this") - log.write("file appears after the suite completes.") - return - try: - data = json.loads(self._path.read_text(encoding="utf-8")) - except Exception as exc: - log.write(f"[red]failed to read {self._path}:[/red] {exc!r}") - return - calls = data.get("calls") or {} - if not calls: - log.write("(tool_coverage.json present but no calls recorded)") - return - exercised = sorted( - ((n, c) for n, c in calls.items() if c > 0), key=lambda x: -x[1] - ) - unexercised = sorted(n for n, c in calls.items() if c == 0) - log.write(f"[b]{len(exercised)} / {len(calls)} MCP tools exercised[/b]") - log.write("") - log.write("[green]exercised[/green] (count):") - for name, count in exercised: - log.write(f" {count:>4} {name}") - if unexercised: - log.write("") - log.write("[dim]not exercised:[/dim]") - for name in unexercised: - log.write(f" {name}") - - def action_dismiss(self, _result: Any = None) -> None: - self.dismiss() - - class ReproducerResultModal(tx.ModalScreen): - """Show the exported reproducer tarball path with a short instruction.""" - - BINDINGS = [tx.Binding("escape,q,enter", "dismiss", "close")] - - def __init__( - self, archive_path: pathlib.Path, error: str | None = None - ) -> None: - self._archive = archive_path - self._error = error - super().__init__() - - def compose(self) -> Any: - if self._error: - yield tx.Static(f"[red]Reproducer export failed:[/red] {self._error}") - else: - yield tx.Static("[bold green]Reproducer bundle written[/bold green]") - yield tx.Static(f"[cyan]{self._archive}[/cyan]") - yield tx.Static("") - yield tx.Static( - "Contains: README.md, test_report.json, fwlog.jsonl (time-filtered)," - ) - yield tx.Static( - "devices.json, env.json. Attach to an issue / paste the path in chat." - ) - yield tx.Static("") - yield tx.Static("[dim][esc] close[/dim]") - - def action_dismiss(self, _result: Any = None) -> None: - self.dismiss() - - # ---------------- App ---------------- - - class TestTuiApp(tx.App): - CSS = """ - Screen { layout: vertical; } - #header-bar { height: 2; padding: 0 1; background: $panel; } - #tier-table { height: auto; max-height: 11; } - #body { height: 1fr; } - #tree-pane { width: 50%; border-right: solid $primary-background; } - #right-pane { width: 50%; layout: vertical; } - #pytest-pane { height: 50%; border-bottom: solid $primary-background; } - #fwlog-header { height: 1; padding: 0 1; background: $panel; } - #fwlog-pane { height: 1fr; } - #uicap-header { height: 1; padding: 0 1; background: $boost; } - #uicap-pane { height: 14; border-top: solid $primary-background; } - #uicap-image { width: 36; border-right: solid $primary-background; padding: 0 1; } - #uicap-log { width: 1fr; height: 14; } - Tree { height: 100%; } - RichLog { height: 100%; } - #device-table { height: auto; max-height: 6; } - """ - - TITLE = "mcp-server test runner" - - BINDINGS = [ - tx.Binding("r", "rerun_focused", "re-run focused"), - tx.Binding("f", "filter_tree", "filter"), - tx.Binding("d", "failure_detail", "failure detail"), - tx.Binding("g", "open_html_report", "open report.html"), - tx.Binding("x", "export_reproducer", "export reproducer"), - tx.Binding("c", "coverage_panel", "coverage"), - tx.Binding("l", "cycle_fwlog_filter", "fw log filter"), - tx.Binding("q,ctrl+c", "quit_app", "quit"), - ] - - def __init__(self) -> None: - super().__init__() - self._state = State() - self._root = root - self._run_tests = run_tests - self._reportlog = reportlog - self._fwlog = fwlog - self._flashlog = flashlog - self._report_html = root / _REPORT_HTML_RELATIVE - self._tool_coverage = root / _TOOL_COVERAGE_RELATIVE - self._repro_dir = root / _REPRODUCERS_RELATIVE - self._seed = seed - self._run_number = run_number - self._pytest_args = pytest_args - self._start_time = time.monotonic() - self._proc: subprocess.Popen[str] | None = None - self._stop = threading.Event() - self._reportlog_worker: ReportlogWorker | None = None - self._stdout_worker: SubprocessReaderWorker | None = None - self._stderr_worker: SubprocessReaderWorker | None = None - self._device_worker: DevicePollerWorker | None = None - self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None - self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None - self._uicap_worker: _uicap_mod.UiCaptureTailer | None = None - # Env-gated; only mounts the UI-capture panel when operator asks for it. - self._ui_camera_enabled = bool( - int(os.environ.get("MESHTASTIC_UI_TUI_CAMERA", "0") or "0") - ) - self._tree_filter: str = "" - self._sigint_count = 0 - # Firmware-log port filter: None = all, else exact port match. - self._fwlog_filter: str | None = None - # Ordered set of distinct ports we've seen firmware log lines - # from - the `l` key cycles through these. - self._fwlog_ports: list[str] = [] - # Cross-run history. - self._history_store = _history_mod.HistoryStore( - root / _HISTORY_RELATIVE, keep_last=40 - ) - self._history_cache = self._history_store.read_recent() - - # -------- composition / mount -------- - - def compose(self) -> Any: - yield tx.Static(self._header_text(), id="header-bar") - tier_table = tx.DataTable(id="tier-table", show_cursor=False) - yield tier_table - with tx.Horizontal(id="body"): - with tx.Vertical(id="tree-pane"): - yield tx.Tree("tests", id="test-tree") - with tx.Vertical(id="right-pane"): - with tx.Vertical(id="pytest-pane"): - yield tx.RichLog( - id="pytest-log", - highlight=False, - markup=False, - wrap=False, - max_lines=5000, - ) - yield tx.Static(self._fwlog_header_text(), id="fwlog-header") - with tx.Vertical(id="fwlog-pane"): - yield tx.RichLog( - id="fwlog-log", - highlight=False, - markup=False, - # `wrap=True` so long firmware log lines (some - # hit ~200 chars - full packet hex dumps plus - # source tags) don't get truncated at the - # right edge. The right pane is ~50% of the - # terminal so even a wide terminal has a - # ~90-char cap; plain truncation dropped the - # uptime counter or packet id off the end. - wrap=True, - max_lines=5000, - ) - if self._ui_camera_enabled: - yield tx.Static( - "UI camera - latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)", - id="uicap-header", - ) - with tx.Horizontal(id="uicap-pane"): - yield tx.Static( - "(waiting…)", id="uicap-image", markup=False - ) - yield tx.RichLog( - id="uicap-log", - highlight=False, - markup=False, - wrap=True, - max_lines=500, - ) - yield tx.DataTable(id="device-table", show_cursor=False) - yield tx.Footer() - - def _fwlog_header_text(self) -> str: - filt = self._fwlog_filter or "(all ports)" - return f"firmware log filter: [b]{filt}[/b] [l] cycle" - - def on_mount(self) -> None: - # Tier-counters table. `add_column` (singular) lets us pick - # the key explicitly - `add_columns` (plural) in textual 8.x - # returns auto-generated keys that are tedious to track - # separately, and update_cell(column_key=