emdashes begone (#10847)
This commit is contained in:
+42
-41
@@ -17,7 +17,7 @@ An [MCP](https://modelcontextprotocol.io) server for working with the Meshtastic
|
||||
## Prerequisites
|
||||
|
||||
- Python ≥ 3.11
|
||||
- [PlatformIO Core](https://platformio.org/install/cli) — `pio` on `$PATH` or at `~/.platformio/penv/bin/pio`
|
||||
- [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
|
||||
|
||||
@@ -35,7 +35,7 @@ Verify:
|
||||
MESHTASTIC_FIRMWARE_ROOT=<firmware-repo> .venv/bin/python -m meshtastic_mcp
|
||||
```
|
||||
|
||||
The server blocks on stdin (that's correct — it speaks MCP over stdio). Ctrl-C to exit.
|
||||
The server blocks on stdin (that's correct - it speaks MCP over stdio). Ctrl-C to exit.
|
||||
|
||||
## Register with Claude Code
|
||||
|
||||
@@ -77,7 +77,7 @@ Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_des
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| `build` | `pio run -e <env>` (+ mtjson target) |
|
||||
| `clean` | `pio run -e <env> -t clean` |
|
||||
| `pio_flash` | `pio run -e <env> -t upload --upload-port <port>` — any architecture |
|
||||
| `pio_flash` | `pio run -e <env> -t upload --upload-port <port>` - any architecture |
|
||||
| `erase_and_flash` | ESP32 full factory flash via `bin/device-install.sh` |
|
||||
| `update_flash` | ESP32 OTA app-partition update via `bin/device-update.sh` |
|
||||
| `touch_1200bps` | 1200-baud open/close to trigger USB CDC bootloader entry |
|
||||
@@ -113,9 +113,9 @@ _The tool tables below document 38 currently registered MCP server tools._
|
||||
| `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` |
|
||||
| `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)
|
||||
|
||||
@@ -162,7 +162,7 @@ rather than auto-`sudo`'ing mid-run.
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -182,7 +182,7 @@ rather than auto-`sudo`'ing mid-run.
|
||||
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`:
|
||||
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
|
||||
@@ -194,9 +194,9 @@ pio run -e native-macos
|
||||
export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403
|
||||
```
|
||||
|
||||
**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the
|
||||
**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`
|
||||
(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`:
|
||||
|
||||
@@ -228,10 +228,10 @@ 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
|
||||
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 —
|
||||
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
|
||||
@@ -239,7 +239,7 @@ TCP-aware fixtures are not part of this surface yet.
|
||||
`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.
|
||||
directory - this one validates the device-facing behavior end-to-end.
|
||||
|
||||
### Invocation
|
||||
|
||||
@@ -262,33 +262,33 @@ in the pre-flight header.
|
||||
|
||||
### Tiers (run in this order)
|
||||
|
||||
- **`bake`** (`tests/test_00_bake.py`) — flashes both hub roles with the
|
||||
- **`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 /
|
||||
- **`unit`** - pure Python, no hardware. boards / PIO wrapper /
|
||||
userPrefs-parse / testing-profile fixtures.
|
||||
- **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK,
|
||||
- **`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`** - 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
|
||||
- **`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`
|
||||
- **`ui`** - input-broker-driven screen navigation (`AdminMessage.send_input_event`
|
||||
injection → `Screen::handleInputEvent` → frame transition). Parametrized
|
||||
on the screen-bearing role (heltec-v3 OLED). Captures images via USB
|
||||
webcam + OCRs them for HTML-report evidence. Requires `pip install -e '.[ui]'`
|
||||
and `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=<index>`; tier is auto-deselected
|
||||
if `cv2` isn't importable.
|
||||
- **`fleet`** — PSK-seed isolation: two labs with different seeds never
|
||||
- **`fleet`** - PSK-seed isolation: two labs with different seeds never
|
||||
overlap.
|
||||
- **`admin`** — owner persistence across reboot, channel URL round-trip,
|
||||
- **`admin`** - owner persistence across reboot, channel URL round-trip,
|
||||
`lora.hop_limit` persistence.
|
||||
- **`provisioning`** — region/channel baking, userPrefs survive
|
||||
- **`provisioning`** - region/channel baking, userPrefs survive
|
||||
`factory_reset(full=False)`.
|
||||
|
||||
#### UI tier setup
|
||||
@@ -297,7 +297,7 @@ 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
|
||||
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.
|
||||
@@ -329,13 +329,13 @@ captures just become 1×1 black PNGs.
|
||||
|
||||
### Artifacts (regenerated every run, under `tests/`)
|
||||
|
||||
- `report.html` — self-contained pytest-html report. Each test gets a
|
||||
- `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
|
||||
- `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
|
||||
@@ -353,11 +353,12 @@ 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.
|
||||
(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
|
||||
|
||||
@@ -375,7 +376,7 @@ Three AI-assisted workflows are wired up for Claude Code operators
|
||||
- `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** —
|
||||
- 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`,
|
||||
@@ -397,7 +398,7 @@ mcp-server/
|
||||
├── 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
|
||||
├── 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
|
||||
@@ -405,8 +406,8 @@ mcp-server/
|
||||
|
||||
## 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.
|
||||
- **"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.
|
||||
|
||||
@@ -25,15 +25,15 @@ test = [
|
||||
]
|
||||
# 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
|
||||
# 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
|
||||
# (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic - no kitty / sixel
|
||||
# dependency. Pure Python, tiny.
|
||||
"rich-pixels>=3.0",
|
||||
]
|
||||
@@ -41,7 +41,7 @@ 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
|
||||
# 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"
|
||||
|
||||
+12
-12
@@ -50,11 +50,11 @@ if [[ -f $USERPREFS_SIDECAR ]]; then
|
||||
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
|
||||
# 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
|
||||
# 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.
|
||||
@@ -84,7 +84,7 @@ fi
|
||||
# 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.
|
||||
# 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"
|
||||
@@ -120,7 +120,7 @@ for dev in devices.list_devices(include_unknown=True):
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
role = ROLE_BY_VID.get(vid)
|
||||
# First port wins per role — matches hub_devices fixture semantics.
|
||||
# First port wins per role - matches hub_devices fixture semantics.
|
||||
if role and role not in out:
|
||||
out[role] = dev["port"]
|
||||
|
||||
@@ -164,7 +164,7 @@ fi
|
||||
# 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.
|
||||
# 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
|
||||
@@ -188,7 +188,7 @@ 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
|
||||
# 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
|
||||
@@ -206,7 +206,7 @@ fi
|
||||
# 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
|
||||
# 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"
|
||||
@@ -237,27 +237,27 @@ for _arg in "$@"; do
|
||||
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 '\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
|
||||
# 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 "\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.
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Meshtastic Firmware — Recorder Stream",
|
||||
"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": [
|
||||
{
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Heap slope (bytes/min) — last 1h",
|
||||
"title": "Heap slope (bytes/min) - last 1h",
|
||||
"type": "query_value",
|
||||
"precision": 0,
|
||||
"requests": [
|
||||
|
||||
@@ -15,10 +15,10 @@ Usage:
|
||||
./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.
|
||||
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
|
||||
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.
|
||||
@@ -89,7 +89,7 @@ class _StreamReader:
|
||||
# Rotation happened. Start over.
|
||||
last_pos = 0
|
||||
if last_pos > size:
|
||||
# Live file truncated/shrunk under us — recorder rotated.
|
||||
# Live file truncated/shrunk under us - recorder rotated.
|
||||
last_pos = 0
|
||||
try:
|
||||
with self.path.open("r", encoding="utf-8") as fh:
|
||||
@@ -369,7 +369,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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.",
|
||||
f"log dir {log_dir} does not exist - start the mcp-server first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Meshtastic MCP server — device discovery, PlatformIO tooling, and device admin."""
|
||||
"""Meshtastic MCP server - device discovery, PlatformIO tooling, and device admin."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -222,7 +222,7 @@ def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]
|
||||
# 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
|
||||
# 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.
|
||||
@@ -313,7 +313,7 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
|
||||
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`
|
||||
SerialInterface our tests already hold open - no `pio device monitor`
|
||||
needed, no port-contention with admin/info calls.
|
||||
|
||||
Firmware gate: `src/SerialConsole.cpp` (`usingProtobufs &&
|
||||
@@ -322,7 +322,7 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
|
||||
re-applied after reset.
|
||||
|
||||
Previously-documented concurrency hazard (emitLogRecord sharing the
|
||||
main packet-emission buffers) has been fixed — see `StreamAPI.h`
|
||||
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`
|
||||
@@ -366,7 +366,7 @@ def send_input_event(
|
||||
"""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
|
||||
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
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""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,
|
||||
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.
|
||||
changes, so subsequent calls don't pay the 1-2s pio startup cost.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""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
|
||||
- `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
|
||||
- `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` /
|
||||
- `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_<ROLE>` — per-role override, e.g.
|
||||
- `MESHTASTIC_UI_CAMERA_DEVICE` - generic default (index or path).
|
||||
- `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` - per-role override, e.g.
|
||||
`MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3.
|
||||
Role suffix is uppercased before lookup.
|
||||
|
||||
@@ -76,7 +76,7 @@ class OpenCVBackend:
|
||||
"On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access."
|
||||
)
|
||||
|
||||
# Drop the first few frames — auto-exposure + white-balance settle.
|
||||
# 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
|
||||
@@ -159,7 +159,7 @@ class FfmpegBackend:
|
||||
return out.stdout
|
||||
|
||||
def close(self) -> None:
|
||||
pass # stateless — each capture spawns a new process
|
||||
pass # stateless - each capture spawns a new process
|
||||
|
||||
|
||||
# ---------- Null backend ---------------------------------------------------
|
||||
@@ -197,7 +197,7 @@ 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
|
||||
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()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
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.
|
||||
admin/info tool surface - the MCP server stays pure stdio JSON-RPC.
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
``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``
|
||||
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``.
|
||||
@@ -25,7 +25,7 @@ class FlashLogTailer(threading.Thread):
|
||||
``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
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
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`` —
|
||||
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
|
||||
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
|
||||
@@ -30,14 +30,14 @@ class FirmwareLogTailer(threading.Thread):
|
||||
|
||||
``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 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.
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
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):
|
||||
|
||||
@@ -14,7 +14,7 @@ minimum viable failure context into a tarball under
|
||||
└── 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`
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -35,7 +35,7 @@ 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
|
||||
cleanly onto the state the TUI already tracks - no extra data
|
||||
collection required at export time."""
|
||||
|
||||
nodeid: str
|
||||
@@ -70,7 +70,7 @@ def _filtered_fwlog(
|
||||
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
|
||||
# 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()
|
||||
@@ -115,14 +115,14 @@ Exported by `meshtastic-mcp-test-tui` on {t}.
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `test_report.json` | The pytest-reportlog `TestReport` event for the failing test — includes `longrepr`, captured `sections` (stdout/stderr/log), `duration`, `location`, `keywords`. |
|
||||
| `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.
|
||||
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.
|
||||
|
||||
@@ -139,7 +139,7 @@ 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
|
||||
(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.
|
||||
"""
|
||||
@@ -159,7 +159,7 @@ def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path:
|
||||
# README
|
||||
_add("README.md", _readme(ctx).encode("utf-8"))
|
||||
|
||||
# test_report.json — reconstruct from the fields the TUI stashes.
|
||||
# test_report.json - reconstruct from the fields the TUI stashes.
|
||||
test_report = {
|
||||
"nodeid": ctx.nodeid,
|
||||
"outcome": "failed",
|
||||
@@ -208,7 +208,7 @@ def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path:
|
||||
|
||||
|
||||
def iter_entries(archive_path: pathlib.Path) -> Iterable[str]:
|
||||
"""Yield member names — used by callers that want to confirm the bundle shape."""
|
||||
"""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
|
||||
|
||||
@@ -22,7 +22,7 @@ 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.
|
||||
is derived from the path - the sanitized nodeid directory name.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -46,7 +46,7 @@ class UiCaptureTailer(threading.Thread):
|
||||
try:
|
||||
self._scan_once()
|
||||
except Exception:
|
||||
# Best-effort tailer — never bring down the TUI because a
|
||||
# Best-effort tailer - never bring down the TUI because a
|
||||
# directory vanished mid-scan.
|
||||
pass
|
||||
time.sleep(self._poll_interval)
|
||||
@@ -62,7 +62,7 @@ class UiCaptureTailer(threading.Thread):
|
||||
except OSError:
|
||||
continue
|
||||
if size < offset:
|
||||
# File truncated / rewritten — reset and re-emit.
|
||||
# File truncated / rewritten - reset and re-emit.
|
||||
offset = 0
|
||||
if size == offset:
|
||||
continue
|
||||
|
||||
@@ -6,19 +6,19 @@ 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
|
||||
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,
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -64,7 +64,7 @@ from typing import Any, Iterator
|
||||
# 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
|
||||
# "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
|
||||
@@ -139,7 +139,7 @@ class LeafReport:
|
||||
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.
|
||||
# `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
|
||||
@@ -175,7 +175,7 @@ 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
|
||||
in the order workers called `post_message` - so reads don't need the
|
||||
lock themselves.
|
||||
"""
|
||||
|
||||
@@ -184,7 +184,7 @@ class State:
|
||||
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
|
||||
# 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)
|
||||
@@ -212,13 +212,13 @@ 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
|
||||
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.
|
||||
# Bake file sits at `tests/test_00_bake.py` - dedicated bucket.
|
||||
if parts[1].startswith("test_00_bake"):
|
||||
return "bake"
|
||||
candidate = parts[1]
|
||||
@@ -249,7 +249,7 @@ def _roles_from_nodeid(nodeid: str) -> set[str]:
|
||||
- ``test_foo[nrf52]`` → {"nrf52"} (baked_single)
|
||||
- ``test_foo[nrf52->esp32s3]`` → {"nrf52", "esp32s3"} (mesh_pair)
|
||||
|
||||
Unparametrized tests (no bracket) return an empty set — the caller
|
||||
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.
|
||||
"""
|
||||
@@ -329,7 +329,7 @@ def _format_duration(seconds: float) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Textual imports (lazy — only when main() runs, so `_parse_events` can be
|
||||
# Textual imports (lazy - only when main() runs, so `_parse_events` can be
|
||||
# imported by smoke tests without requiring textual installed in every env)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -367,7 +367,7 @@ def _import_textual() -> Any:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main() — the important scaffolding lives here so that when we bail out
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -414,7 +414,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# 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
|
||||
# flashlog - triple-truncate is deliberate (whichever side creates the
|
||||
# file first, it starts empty).
|
||||
for p in (reportlog, fwlog, flashlog):
|
||||
try:
|
||||
@@ -434,7 +434,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# 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
|
||||
# also sets this when invoked directly - `setdefault` so the wrapper's
|
||||
# value wins when present.
|
||||
os.environ.setdefault("MESHTASTIC_MCP_FLASH_LOG", str(flashlog))
|
||||
|
||||
@@ -442,7 +442,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# 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
|
||||
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.
|
||||
@@ -512,7 +512,7 @@ def _build_app(
|
||||
force Textual's import cost.
|
||||
"""
|
||||
|
||||
# Helper modules — lazy-imported here so the top-of-file 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
|
||||
@@ -540,7 +540,7 @@ def _build_app(
|
||||
super().__init__()
|
||||
|
||||
class FlashLogLine(tx.Message):
|
||||
"""Plain-text line from `tests/flash.log` — pio / esptool / nrfutil /
|
||||
"""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."""
|
||||
@@ -550,7 +550,7 @@ def _build_app(
|
||||
super().__init__()
|
||||
|
||||
class UiCaptureLine(tx.Message):
|
||||
"""Live line from the UI-tier camera transcript — one per
|
||||
"""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`."""
|
||||
|
||||
@@ -640,7 +640,7 @@ def _build_app(
|
||||
class DevicePollerWorker(threading.Thread):
|
||||
"""Poll list_devices() + device_info() at startup and after RunFinished.
|
||||
|
||||
Deliberately NOT polling during the run — `hub_devices` is a
|
||||
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.
|
||||
@@ -806,7 +806,7 @@ def _build_app(
|
||||
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("(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.")
|
||||
@@ -937,7 +937,7 @@ def _build_app(
|
||||
# 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.
|
||||
# from - the `l` key cycles through these.
|
||||
self._fwlog_ports: list[str] = []
|
||||
# Cross-run history.
|
||||
self._history_store = _history_mod.HistoryStore(
|
||||
@@ -970,7 +970,7 @@ def _build_app(
|
||||
highlight=False,
|
||||
markup=False,
|
||||
# `wrap=True` so long firmware log lines (some
|
||||
# hit ~200 chars — full packet hex dumps plus
|
||||
# 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
|
||||
@@ -981,7 +981,7 @@ def _build_app(
|
||||
)
|
||||
if self._ui_camera_enabled:
|
||||
yield tx.Static(
|
||||
"UI camera — latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
|
||||
"UI camera - latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
|
||||
id="uicap-header",
|
||||
)
|
||||
with tx.Horizontal(id="uicap-pane"):
|
||||
@@ -1004,11 +1004,11 @@ def _build_app(
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# Tier-counters table. `add_column` (singular) lets us pick
|
||||
# the key explicitly — `add_columns` (plural) in textual 8.x
|
||||
# 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=<label>) silently
|
||||
# no-ops because the key is not the label. "Progress" is the
|
||||
# new v2 column — a small [===== ] bar; see `_progress_bar`.
|
||||
# new v2 column - a small [===== ] bar; see `_progress_bar`.
|
||||
tier_table = self.query_one("#tier-table", tx.DataTable)
|
||||
for col in (
|
||||
"Tier",
|
||||
@@ -1023,7 +1023,7 @@ def _build_app(
|
||||
for t in TIERS:
|
||||
tier_table.add_row(t, "0", "0", "0", "0", "0", "", key=t)
|
||||
# Device table. "Status" shows which test (if any) is currently
|
||||
# running on this device — derived from the running_nodeid plus
|
||||
# running on this device - derived from the running_nodeid plus
|
||||
# role inference from the nodeid's `[...]` parametrization.
|
||||
dev_table = self.query_one("#device-table", tx.DataTable)
|
||||
for col in (
|
||||
@@ -1042,14 +1042,14 @@ def _build_app(
|
||||
self._device_worker.start()
|
||||
self._reportlog_worker = ReportlogWorker(self, self._reportlog, self._stop)
|
||||
self._reportlog_worker.start()
|
||||
# Firmware log tail worker — publishes FirmwareLogLine messages.
|
||||
# Firmware log tail worker - publishes FirmwareLogLine messages.
|
||||
self._fwlog_worker = _fwlog_mod.FirmwareLogTailer(
|
||||
path=self._fwlog,
|
||||
post=lambda rec: self.post_message(FirmwareLogLine(rec)),
|
||||
stop=self._stop,
|
||||
)
|
||||
self._fwlog_worker.start()
|
||||
# Flash log tail worker — plain-text pio/esptool/nrfutil/picotool
|
||||
# Flash log tail worker - plain-text pio/esptool/nrfutil/picotool
|
||||
# output tee'd by `pio._run_capturing`. Routes each line into the
|
||||
# pytest pane so the operator has live feedback during long flash
|
||||
# operations (`pio run -t upload` is ~3 min of silence otherwise).
|
||||
@@ -1059,7 +1059,7 @@ def _build_app(
|
||||
stop=self._stop,
|
||||
)
|
||||
self._flashlog_worker.start()
|
||||
# UI-capture transcript tailer — only runs when the camera panel
|
||||
# UI-capture transcript tailer - only runs when the camera panel
|
||||
# is enabled. Watches tests/ui_captures/**/transcript.md for new
|
||||
# lines as UI tests execute.
|
||||
if self._ui_camera_enabled:
|
||||
@@ -1078,7 +1078,7 @@ def _build_app(
|
||||
# Header tick (seed / runtime / sparkline re-renders at 1 Hz).
|
||||
# Also refreshes the device-status column so the per-test elapsed
|
||||
# time climbs live during silent test bodies (flash, long mesh
|
||||
# timeouts, etc.) — cheap: device-table is 1-2 rows.
|
||||
# timeouts, etc.) - cheap: device-table is 1-2 rows.
|
||||
self.set_interval(1.0, self._on_tick)
|
||||
|
||||
def _header_text(self) -> str:
|
||||
@@ -1114,8 +1114,8 @@ def _build_app(
|
||||
|
||||
The device-status cell embeds the running test's elapsed time
|
||||
(`RUNNING: test_bake_nrf52 (1:23)`), which needs to re-render
|
||||
each second during long silent test bodies. Cheap — O(devices),
|
||||
which is 1–2 rows in practice. Skipped when no test is
|
||||
each second during long silent test bodies. Cheap - O(devices),
|
||||
which is 1-2 rows in practice. Skipped when no test is
|
||||
running so we don't burn cycles when the TUI is idle.
|
||||
"""
|
||||
self._refresh_header()
|
||||
@@ -1134,7 +1134,7 @@ def _build_app(
|
||||
# --junitxml=tests/junit.xml -v --tb=short
|
||||
# plus an unconditional `--report-log` append at the end. If we
|
||||
# pre-append `--report-log` here when `extra_args` is empty, $#
|
||||
# becomes 1 and the whole defaults block is skipped — pytest
|
||||
# becomes 1 and the whole defaults block is skipped - pytest
|
||||
# then runs without the `tests/` positional (discovers from the
|
||||
# mcp-server root and potentially drags in production modules
|
||||
# named `test_*.py`), without the HTML/junit reports the /test
|
||||
@@ -1234,11 +1234,11 @@ def _build_app(
|
||||
# bake doesn't match. Without this
|
||||
# branch, those tests would never
|
||||
# register in the tree and the tier
|
||||
# counters would silently lie — e.g.
|
||||
# counters would silently lie - e.g.
|
||||
# the telemetry tier showed 0/0/0
|
||||
# while 4 tests were actually skipped.
|
||||
# `rerun` (pytest-rerunfailures): rewind to pending.
|
||||
# Teardown outcomes are intentionally ignored — a
|
||||
# Teardown outcomes are intentionally ignored - a
|
||||
# teardown failure shouldn't overwrite the call's
|
||||
# authoritative pass/fail.
|
||||
if when == "call" and outcome in ("passed", "failed", "skipped"):
|
||||
@@ -1250,7 +1250,7 @@ def _build_app(
|
||||
return
|
||||
if rt == "SessionFinish":
|
||||
return
|
||||
# Unknown — ignore silently.
|
||||
# Unknown - ignore silently.
|
||||
|
||||
def on_pytest_line(self, message: Any) -> None:
|
||||
log = self.query_one("#pytest-log", tx.RichLog)
|
||||
@@ -1271,8 +1271,8 @@ def _build_app(
|
||||
def on_ui_capture_line(self, message: Any) -> None:
|
||||
"""Route a UI-capture transcript line into the camera panel.
|
||||
|
||||
Each line is already formatted by frame_capture — e.g.
|
||||
`1. **initial** — frame 2/8 name=home — OCR: ...`. We write
|
||||
Each line is already formatted by frame_capture - e.g.
|
||||
`1. **initial** - frame 2/8 name=home - OCR: ...`. We write
|
||||
the text into the RichLog AND try to render the corresponding
|
||||
PNG on the left side (requires rich-pixels, Pillow).
|
||||
"""
|
||||
@@ -1288,7 +1288,7 @@ def _build_app(
|
||||
def _render_latest_ui_capture(self, test_id: str, line: str) -> None:
|
||||
"""Find the PNG that corresponds to `line` and render it on the
|
||||
left of the uicap pane. Soft-fails if rich-pixels isn't
|
||||
installed or the PNG isn't found — operator still has the text
|
||||
installed or the PNG isn't found - operator still has the text
|
||||
transcript on the right.
|
||||
"""
|
||||
try:
|
||||
@@ -1297,7 +1297,7 @@ def _build_app(
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
# Transcript lines look like `1. **label** — ...`. Pull the leading
|
||||
# Transcript lines look like `1. **label** - ...`. Pull the leading
|
||||
# integer to locate the capture file.
|
||||
import re as _re
|
||||
|
||||
@@ -1306,7 +1306,7 @@ def _build_app(
|
||||
return
|
||||
step = int(m.group(1))
|
||||
|
||||
# Captures directory is sibling of tests/ — mirror the path the
|
||||
# Captures directory is sibling of tests/ - mirror the path the
|
||||
# tailer watches. Search both likely layouts (in-mcp-server vs.
|
||||
# firmware-root invocation).
|
||||
candidates = [
|
||||
@@ -1317,7 +1317,7 @@ def _build_app(
|
||||
if captures_root is None:
|
||||
return
|
||||
|
||||
# Drill into <session_seed>/<test_id>/ — test_id is the
|
||||
# Drill into <session_seed>/<test_id>/ - test_id is the
|
||||
# sanitized nodeid the tailer already passed through.
|
||||
matches = list(captures_root.rglob(f"{test_id}/{step:03d}-*.png"))
|
||||
if not matches:
|
||||
@@ -1351,7 +1351,7 @@ def _build_app(
|
||||
port = rec.get("port")
|
||||
line = rec.get("line", "")
|
||||
# Track distinct ports for `l` filter cycling. The ordered-set
|
||||
# trick — list membership — is fine here because `_fwlog_ports`
|
||||
# trick - list membership - is fine here because `_fwlog_ports`
|
||||
# is tiny (2-3 entries for a typical lab).
|
||||
if port and port not in self._fwlog_ports:
|
||||
self._fwlog_ports.append(port)
|
||||
@@ -1368,7 +1368,7 @@ def _build_app(
|
||||
log = self.query_one("#fwlog-log", tx.RichLog)
|
||||
port_tag = ""
|
||||
if port:
|
||||
# Show only the last path component — `/dev/cu.usbmodem1101`
|
||||
# Show only the last path component - `/dev/cu.usbmodem1101`
|
||||
# is long; `usbmodem1101` is enough when the filter is
|
||||
# "all".
|
||||
tail = port.rsplit("/", 1)[-1]
|
||||
@@ -1393,13 +1393,13 @@ def _build_app(
|
||||
for row in message.rows:
|
||||
info = row.info or {}
|
||||
role = row.role or "?"
|
||||
fw = info.get("firmware_version", "—")
|
||||
hw = info.get("hw_model", "—")
|
||||
region = info.get("region", "—")
|
||||
channel = info.get("primary_channel", "—")
|
||||
fw = info.get("firmware_version", "-")
|
||||
hw = info.get("hw_model", "-")
|
||||
region = info.get("region", "-")
|
||||
channel = info.get("primary_channel", "-")
|
||||
peers = info.get("num_nodes")
|
||||
if peers is None:
|
||||
peers = "—"
|
||||
peers = "-"
|
||||
else:
|
||||
peers = str(max(int(peers) - 1, 0)) # exclude self
|
||||
status = self._status_for_role(role)
|
||||
@@ -1421,7 +1421,7 @@ def _build_app(
|
||||
|
||||
A running test whose nodeid doesn't carry an explicit role
|
||||
parametrization (no `[...]` bracket) is treated as touching
|
||||
every device — that matches how `test_bidirectional` and the
|
||||
every device - that matches how `test_bidirectional` and the
|
||||
pytest_sessionstart-level tests work in practice.
|
||||
|
||||
The trailing `(M:SS)` is live-updated by `_on_tick` at 1 Hz
|
||||
@@ -1436,7 +1436,7 @@ def _build_app(
|
||||
return "idle"
|
||||
short = _testname_of_nodeid(nodeid)
|
||||
# Compute elapsed for the live counter. Budget 8 chars at the
|
||||
# end of the cell — `(12:34)` plus a space. Shorten `short`
|
||||
# end of the cell - `(12:34)` plus a space. Shorten `short`
|
||||
# first, then tack on the elapsed suffix.
|
||||
started = self._state.running_started_at
|
||||
elapsed_suffix = ""
|
||||
@@ -1454,7 +1454,7 @@ def _build_app(
|
||||
"""Update the Status cell for every detected device.
|
||||
|
||||
Called whenever `running_nodeid` transitions (setup → call).
|
||||
Cheap: O(devices) which is 1–2 rows in practice.
|
||||
Cheap: O(devices) which is 1-2 rows in practice.
|
||||
"""
|
||||
try:
|
||||
dev_table = self.query_one("#device-table", tx.DataTable)
|
||||
@@ -1468,7 +1468,7 @@ def _build_app(
|
||||
)
|
||||
except Exception:
|
||||
# Row key might not exist yet if a snapshot hasn't
|
||||
# populated it — harmless; next snapshot will carry
|
||||
# populated it - harmless; next snapshot will carry
|
||||
# the fresh status value.
|
||||
pass
|
||||
|
||||
@@ -1481,7 +1481,7 @@ def _build_app(
|
||||
# Trigger a fresh device poll now that ports are free again.
|
||||
if self._device_worker is not None:
|
||||
self._device_worker.trigger()
|
||||
# Persist a history record — one line per run, tailed by the
|
||||
# Persist a history record - one line per run, tailed by the
|
||||
# sparkline on every subsequent TUI launch.
|
||||
duration_s = time.monotonic() - self._start_time
|
||||
passed = sum(t.passed for t in self._state.tiers.values())
|
||||
@@ -1526,13 +1526,13 @@ def _build_app(
|
||||
leaf = self._state.leaves.get(nodeid)
|
||||
if leaf is None:
|
||||
# First event for this nodeid is the report itself (no
|
||||
# collection event seen) — register on the fly.
|
||||
# collection event seen) - register on the fly.
|
||||
self._register_leaf(nodeid)
|
||||
leaf = self._state.leaves[nodeid]
|
||||
prev = leaf.outcome
|
||||
leaf.outcome = outcome
|
||||
leaf.duration_s = float(ev.get("duration", 0.0) or 0.0)
|
||||
# Wall-clock start/stop — pytest-reportlog emits these as
|
||||
# Wall-clock start/stop - pytest-reportlog emits these as
|
||||
# float seconds (Unix epoch). Used by the reproducer exporter
|
||||
# to window fwlog.jsonl down to just the failure's context.
|
||||
start = ev.get("start")
|
||||
@@ -1661,7 +1661,7 @@ def _build_app(
|
||||
if getattr(node, "data", None):
|
||||
target = str(node.data) # leaf: full nodeid
|
||||
else:
|
||||
# Internal node — derive a pytest arg.
|
||||
# Internal node - derive a pytest arg.
|
||||
labels = []
|
||||
cur: Any = node
|
||||
while cur is not None and cur.parent is not None:
|
||||
@@ -1720,7 +1720,7 @@ def _build_app(
|
||||
self.bell()
|
||||
return
|
||||
try:
|
||||
# macOS + Linux cover — falls through silently on failure.
|
||||
# macOS + Linux cover - falls through silently on failure.
|
||||
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
||||
subprocess.Popen([opener, str(self._report_html)]) # noqa: S603,S607
|
||||
except Exception:
|
||||
@@ -1863,7 +1863,7 @@ def _build_app(
|
||||
# interrupted (SIGINT during a test body) it may linger.
|
||||
self._state.running_nodeid = None
|
||||
self._state.running_started_at = None
|
||||
# Device status cells need to go back to "idle" — otherwise
|
||||
# Device status cells need to go back to "idle" - otherwise
|
||||
# the prior run's RUNNING: marker sticks until the next test
|
||||
# actually starts.
|
||||
self._refresh_device_status()
|
||||
@@ -1881,7 +1881,7 @@ def _build_app(
|
||||
log = self.query_one("#pytest-log", tx.RichLog)
|
||||
log.write("")
|
||||
log.write("[tui] --- re-run ---")
|
||||
# Clear the fwlog pane too — it's fresh context for the new run.
|
||||
# Clear the fwlog pane too - it's fresh context for the new run.
|
||||
try:
|
||||
self.query_one("#fwlog-log", tx.RichLog).clear()
|
||||
except Exception:
|
||||
|
||||
@@ -42,7 +42,7 @@ def parse_tcp_port(port: str) -> tuple[str, int]:
|
||||
"""Parse `tcp://host[:port]` → (host, port). Defaults to 4403.
|
||||
|
||||
Validates host shape (non-empty, no path separators) and port range
|
||||
(1..65535). Raises `ConnectionError` on malformed input — never lets
|
||||
(1..65535). Raises `ConnectionError` on malformed input - never lets
|
||||
a raw `ValueError` bubble up to a tool surface.
|
||||
"""
|
||||
if not port.startswith(TCP_SCHEME):
|
||||
@@ -65,7 +65,7 @@ def parse_tcp_port(port: str) -> tuple[str, int]:
|
||||
if any(c in host for c in ("/", "\\")):
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: host {host!r} contains a path "
|
||||
"separator. TCP hostnames cannot contain '/' or '\\' — did you "
|
||||
"separator. TCP hostnames cannot contain '/' or '\\' - did you "
|
||||
"pass a serial port path or a Windows drive path by mistake?"
|
||||
)
|
||||
if not (1 <= tcp_port <= 65535):
|
||||
@@ -95,7 +95,7 @@ def normalize_tcp_endpoint(endpoint: str) -> str:
|
||||
|
||||
|
||||
def reject_if_tcp(port: str | None, tool_name: str) -> None:
|
||||
"""Raise if `port` is a TCP endpoint — for tools that need real USB
|
||||
"""Raise if `port` is a TCP endpoint - for tools that need real USB
|
||||
hardware (flash, bootloader, vendor escape hatches, serial monitor).
|
||||
|
||||
Only checks the explicit arg; auto-selection via env var is the caller's
|
||||
@@ -143,7 +143,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
For serial: raises `ConnectionError` immediately if another serial
|
||||
session holds the port (a `pio device monitor` in `serial_sessions/`).
|
||||
For TCP: no exclusive-access requirement, so the serial-session check
|
||||
is skipped — but the `port_lock` still serializes parallel `connect()`
|
||||
is skipped - but the `port_lock` still serializes parallel `connect()`
|
||||
calls to the same daemon endpoint.
|
||||
|
||||
`timeout_s` is plumbed through to both `SerialInterface(timeout=...)`
|
||||
@@ -164,7 +164,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
lock = registry.port_lock(resolved)
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ConnectionError(
|
||||
f"TCP endpoint {resolved} is busy — another device operation "
|
||||
f"TCP endpoint {resolved} is busy - another device operation "
|
||||
"is in flight. Retry shortly."
|
||||
)
|
||||
|
||||
@@ -204,7 +204,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
lock = registry.port_lock(resolved)
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ConnectionError(
|
||||
f"Port {resolved} is busy — another device operation is in flight. "
|
||||
f"Port {resolved} is busy - another device operation is in flight. "
|
||||
"Retry shortly."
|
||||
)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def _tcp_endpoint_from_env() -> dict[str, Any] | None:
|
||||
|
||||
If the env var is malformed (non-integer port, path-like host, etc.),
|
||||
return an entry with `likely_meshtastic=False` and the parser error in
|
||||
the description, rather than raising — `list_devices` is the diagnostic
|
||||
the description, rather than raising - `list_devices` is the diagnostic
|
||||
tool a user reaches for when their env var isn't working, so it must
|
||||
not crash on misconfiguration.
|
||||
"""
|
||||
@@ -48,7 +48,7 @@ def _tcp_endpoint_from_env() -> dict[str, Any] | None:
|
||||
# user can see exactly what they set and why it was rejected.
|
||||
# Don't double the scheme if the user already prefixed `tcp://`.
|
||||
port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}"
|
||||
description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}"
|
||||
description = f"meshtasticd (TCP) - invalid MESHTASTIC_MCP_TCP_HOST: {e}"
|
||||
likely = False
|
||||
return {
|
||||
"port": port,
|
||||
@@ -122,7 +122,7 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
||||
# Stable ordering: likely_meshtastic first; within rank, TCP wins over
|
||||
# USB (explicit env-var configuration takes precedence over USB
|
||||
# enumeration); then by port path. A misconfigured TCP entry has
|
||||
# likely_meshtastic=False and lands among the other ignored entries —
|
||||
# likely_meshtastic=False and lands among the other ignored entries -
|
||||
# it does NOT pre-empt real USB devices at the top of the list.
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Fake NodeDB fixture push — Portduino file copy + hardware XModem upload.
|
||||
"""Fake NodeDB fixture push - Portduino file copy + hardware XModem upload.
|
||||
|
||||
The fixture pipeline is two-stage:
|
||||
1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N
|
||||
@@ -78,7 +78,7 @@ def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compile step — shells out to bin/seed-json-to-proto.py so the MCP module
|
||||
# Compile step - shells out to bin/seed-json-to-proto.py so the MCP module
|
||||
# doesn't have to duplicate the proto-encoding logic.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None:
|
||||
@@ -117,7 +117,7 @@ def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portduino push — file copy into ~/.portduino/<config>/prefs/
|
||||
# Portduino push - file copy into ~/.portduino/<config>/prefs/
|
||||
# ---------------------------------------------------------------------------
|
||||
def _portduino_prefs_dir(config_name: str) -> pathlib.Path:
|
||||
home = pathlib.Path.home()
|
||||
@@ -152,7 +152,7 @@ def _push_portduino(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware push — XModem over BLE/serial via the meshtastic Python interface.
|
||||
# Hardware push - XModem over BLE/serial via the meshtastic Python interface.
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class _AckEvent:
|
||||
@@ -165,7 +165,7 @@ def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEve
|
||||
return q.get(timeout=timeout_s)
|
||||
except queue.Empty as exc:
|
||||
raise FixtureError(
|
||||
f"XModem response timeout after {timeout_s:.1f}s — device not responding"
|
||||
f"XModem response timeout after {timeout_s:.1f}s - device not responding"
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -180,14 +180,14 @@ def _push_hardware(
|
||||
try:
|
||||
from meshtastic.protobuf import mesh_pb2, xmodem_pb2
|
||||
from pubsub import pub
|
||||
except ImportError as exc: # pragma: no cover — dep missing
|
||||
except ImportError as exc: # pragma: no cover - dep missing
|
||||
raise FixtureError(
|
||||
f"hardware push requires the meshtastic + pypubsub packages: {exc}"
|
||||
) from exc
|
||||
|
||||
if is_tcp_port(port):
|
||||
raise FixtureError(
|
||||
"hardware push over TCP/portduino is not supported — use "
|
||||
"hardware push over TCP/portduino is not supported - use "
|
||||
"target='portduino' to drop the fixture directly into the prefs dir."
|
||||
)
|
||||
|
||||
@@ -322,7 +322,7 @@ def _push_hardware(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — registered as an MCP tool in server.py.
|
||||
# Public entry point - registered as an MCP tool in server.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
def push_fake_nodedb(
|
||||
size: int,
|
||||
@@ -338,11 +338,11 @@ def push_fake_nodedb(
|
||||
"""Compile a fresh-timestamp NodeDatabase fixture and push it to a device.
|
||||
|
||||
Args:
|
||||
size: 250, 500, 1000, or 2000 — selects which committed seed JSONL to use.
|
||||
size: 250, 500, 1000, or 2000 - selects which committed seed JSONL to use.
|
||||
target: "portduino" (file copy to ~/.portduino/<config>/prefs/) or
|
||||
"hardware" (XModem upload to /prefs/nodes.proto + reboot).
|
||||
port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX)
|
||||
or BLE identifier. TCP endpoints are rejected — use target="portduino"
|
||||
or BLE identifier. TCP endpoints are rejected - use target="portduino"
|
||||
instead.
|
||||
portduino_config: which Portduino instance dir under ~/.portduino/. Default "default".
|
||||
backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak.<ts>
|
||||
@@ -354,7 +354,7 @@ def push_fake_nodedb(
|
||||
test scenario.
|
||||
|
||||
Returns:
|
||||
dict with transport, bytes, sha256, etc. — depends on target.
|
||||
dict with transport, bytes, sha256, etc. - depends on target.
|
||||
|
||||
"""
|
||||
if size not in _VALID_SIZES:
|
||||
@@ -371,7 +371,7 @@ def push_fake_nodedb(
|
||||
if target == "hardware":
|
||||
if not confirm:
|
||||
raise FixtureError(
|
||||
"hardware push writes flash and triggers a reboot — pass confirm=True."
|
||||
"hardware push writes flash and triggers a reboot - pass confirm=True."
|
||||
)
|
||||
if not port:
|
||||
raise FixtureError(
|
||||
|
||||
@@ -47,7 +47,7 @@ def _require_confirm(confirm: bool, operation: str) -> None:
|
||||
|
||||
|
||||
def _reject_native_env(env: str, operation: str) -> None:
|
||||
"""`native*` envs build a host executable, not firmware — there's no
|
||||
"""`native*` envs build a host executable, not firmware - there's no
|
||||
upload step. The user wants `build` (or just runs the binary directly).
|
||||
"""
|
||||
if env.startswith("native"):
|
||||
@@ -121,7 +121,7 @@ def build(
|
||||
`{"DEBUG_HEAP": 1}` enables per-thread leak detection + `[heap N]`
|
||||
prefix on every log line. Combines with the recorder so heap shows
|
||||
up at log cadence (much higher resolution than the ~60 s LocalStats
|
||||
packet) — see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
|
||||
packet) - see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
|
||||
expand to bare `-D<NAME>` (presence-only flags).
|
||||
"""
|
||||
args = ["run", "-e", env]
|
||||
@@ -183,10 +183,10 @@ def flash(
|
||||
) -> dict[str, Any]:
|
||||
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
|
||||
|
||||
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
|
||||
`userprefs_overrides` (optional): see `build()` - the rebuild-before-upload
|
||||
that pio performs will pick up the injected values.
|
||||
|
||||
`build_flags` (optional): same shape as `build()` — `PLATFORMIO_BUILD_FLAGS`
|
||||
`build_flags` (optional): same shape as `build()` - `PLATFORMIO_BUILD_FLAGS`
|
||||
is exported for the rebuild-before-upload, so the uploaded firmware
|
||||
actually carries the flags. Without this propagation, `pio run -t upload`
|
||||
would relink without the env var and silently drop them. Common use:
|
||||
@@ -361,7 +361,7 @@ def update_flash(
|
||||
def _do_1200bps_touch(port: str, settle_ms: int, touch_timeout_s: float = 3.0) -> None:
|
||||
"""Open port at 1200 baud and close, bounded by a worker thread.
|
||||
|
||||
Both the open and the close can block on a busy CDC device — we wrap the
|
||||
Both the open and the close can block on a busy CDC device - we wrap the
|
||||
whole thing in a worker so the caller returns in at most `touch_timeout_s`
|
||||
regardless. The touch is signal-only: the USB configuration change to
|
||||
1200 baud alone is enough to trip the Adafruit bootloader's reset, so a
|
||||
@@ -433,7 +433,7 @@ def touch_1200bps(
|
||||
poll_timeout_s: float = 8.0,
|
||||
retries: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""Open port at 1200 baud, close immediately — triggers USB CDC bootloader.
|
||||
"""Open port at 1200 baud, close immediately - triggers USB CDC bootloader.
|
||||
|
||||
Works for: nRF52840 (Adafruit bootloader), ESP32-S3 (native USB download
|
||||
mode), RP2040 (when built with 1200bps-reset stdio), Arduino Leonardo/Micro.
|
||||
@@ -442,7 +442,7 @@ def touch_1200bps(
|
||||
VID/PID (0x239A / 0x0029) for up to `poll_timeout_s` seconds. Adafruit's
|
||||
bootloader docs note a touch sometimes needs to be repeated, so this
|
||||
retries up to `retries` times. The returned `new_port` is the bootloader
|
||||
port (distinct from the app port) — exactly what's needed for `pio run
|
||||
port (distinct from the app port) - exactly what's needed for `pio run
|
||||
-t upload` to drive nrfutil.
|
||||
|
||||
For non-nRF52 devices (ESP32-S3, RP2040, Arduino), falls back to
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Direct wrappers around vendor flashing tools: esptool, nrfutil, picotool.
|
||||
|
||||
These are escape hatches. Prefer the pio-based tools in flash.py when they
|
||||
cover the operation — pio knows the correct offsets, protocols, and filters
|
||||
cover the operation - pio knows the correct offsets, protocols, and filters
|
||||
for every supported board. Use these when pio doesn't: to erase a bricked
|
||||
ESP32, DFU-flash an nRF52 zip package, or inspect an RP2040's bootloader.
|
||||
|
||||
@@ -216,7 +216,7 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]:
|
||||
|
||||
def picotool_info(port: str | None = None) -> dict[str, Any]:
|
||||
"""Read device info from a Pico in BOOTSEL mode. `port` is informational
|
||||
only — picotool auto-detects."""
|
||||
only - picotool auto-detects."""
|
||||
connection.reject_if_tcp(port, "picotool_info")
|
||||
binary = config.picotool_bin()
|
||||
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Used by `admin.send_input_event` + `tests/ui/` so callers can say
|
||||
`InputEventCode.RIGHT` instead of hard-coding 20. Values MUST stay in sync
|
||||
with the firmware enum — unit test `tests/unit/test_input_event_codes.py`
|
||||
with the firmware enum - unit test `tests/unit/test_input_event_codes.py`
|
||||
pins the mapping.
|
||||
"""
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ def telemetry_timeline(
|
||||
"""
|
||||
end = time.time()
|
||||
if isinstance(window, (int, float)):
|
||||
# Numeric `window` is a duration in seconds — "last N seconds".
|
||||
# Numeric `window` is a duration in seconds - "last N seconds".
|
||||
# Without this branch, `_parse_time(-N)` would treat -N as an
|
||||
# absolute epoch timestamp (i.e., Jan 1 1970 minus N seconds),
|
||||
# producing a wildly negative `start` and matching nothing.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""OCR wrapper for UI tests + the `capture_screen` tool.
|
||||
|
||||
Auto-selects a reader in priority order:
|
||||
1. `easyocr` (deep-learning, high quality on OLED screens — but ~100 MB
|
||||
1. `easyocr` (deep-learning, high quality on OLED screens - but ~100 MB
|
||||
model download on first use).
|
||||
2. `pytesseract` (requires system `tesseract` binary on PATH).
|
||||
3. `null` — returns `""` with a warning. Tests fall back to log + image
|
||||
3. `null` - returns `""` with a warning. Tests fall back to log + image
|
||||
evidence when OCR is unavailable.
|
||||
|
||||
Override via `MESHTASTIC_UI_OCR_BACKEND=easyocr|pytesseract|null|auto`
|
||||
@@ -132,7 +132,7 @@ def warm() -> None:
|
||||
Pytest session fixture calls this once so the first real capture doesn't
|
||||
eat the model-load latency.
|
||||
"""
|
||||
# A 64×32 white PNG — decodes clean, no text to extract.
|
||||
# A 64×32 white PNG - decodes clean, no text to extract.
|
||||
white_png = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e"
|
||||
"0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080"
|
||||
|
||||
@@ -6,12 +6,12 @@ have a single place that owns timeouts, buffer sizes, JSON parsing, and the
|
||||
|
||||
`run()` has two execution paths:
|
||||
|
||||
* Fast path (default): `subprocess.run(capture_output=True)` — buffered, one
|
||||
* Fast path (default): `subprocess.run(capture_output=True)` - buffered, one
|
||||
return; fine for sub-second pio calls like `pio --version` or
|
||||
`pio project config --json-output`.
|
||||
* Streaming path: when the `MESHTASTIC_MCP_FLASH_LOG` env var is set, each
|
||||
output line is tee'd to that file as it arrives via a threaded reader.
|
||||
The TUI tails the file to give live flash progress — otherwise a 3-minute
|
||||
The TUI tails the file to give live flash progress - otherwise a 3-minute
|
||||
`pio run -t upload` is completely silent to the operator.
|
||||
|
||||
`hw_tools.py` shares the streaming helper via `pio._run_capturing()` so
|
||||
@@ -110,7 +110,7 @@ def _run_capturing(
|
||||
reader threads accumulate into result strings AND append each line to
|
||||
the flash log file. Stdout and stderr stay separate in the return value
|
||||
(so `stderr_tail` still means stderr), but are interleaved in the log
|
||||
file in the order they arrived — that's what a human wants to read.
|
||||
file in the order they arrived - that's what a human wants to read.
|
||||
"""
|
||||
log_path = _flash_log_path()
|
||||
t0 = time.monotonic()
|
||||
@@ -119,7 +119,7 @@ def _run_capturing(
|
||||
env = {**os.environ, **extra_env}
|
||||
|
||||
if log_path is None:
|
||||
# Fast path — unchanged.
|
||||
# Fast path - unchanged.
|
||||
proc = subprocess.run(
|
||||
list(argv),
|
||||
cwd=str(cwd) if cwd else None,
|
||||
@@ -172,7 +172,7 @@ def _run_capturing(
|
||||
log_fh.flush()
|
||||
except OSError:
|
||||
# Log file disappeared (umount, operator deleted the dir).
|
||||
# Don't let that bubble up — the subprocess output is still
|
||||
# Don't let that bubble up - the subprocess output is still
|
||||
# collected in-memory for the return value.
|
||||
try:
|
||||
log_fh.close()
|
||||
@@ -248,7 +248,7 @@ def run(
|
||||
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
|
||||
non-zero exit; set `check=False` to inspect `returncode` manually.
|
||||
|
||||
`extra_env` merges into the subprocess environment — used for
|
||||
`extra_env` merges into the subprocess environment - used for
|
||||
`PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar build-time
|
||||
toggles that can't be expressed as command-line args.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Two flavors of log line cross our pubsub subscription:
|
||||
accumulates bytes between protobuf frames and emits the full
|
||||
firmware-formatted line, e.g.
|
||||
"INFO | 12:34:56 12345 [Main] Booting"
|
||||
— level, HH:MM:SS, uptime seconds, thread bracket, then message.
|
||||
- level, HH:MM:SS, uptime seconds, thread bracket, then message.
|
||||
2. LogRecord protobuf path (debug_log_api enabled): the lib calls
|
||||
`_handleLogLine(record.message)` with ONLY the message body. The
|
||||
level/source/time fields on the LogRecord are dropped before
|
||||
@@ -29,7 +29,7 @@ from typing import Any
|
||||
|
||||
# Match: LEVEL | HH:MM:SS UPTIME [Thread] message
|
||||
# HH:MM:SS may be ??:??:?? when RTC isn't valid. The level alternation
|
||||
# below is the canonical list — DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
|
||||
# below is the canonical list - DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
|
||||
# macros must stay in sync with these strings.
|
||||
_LINE_RE = re.compile(
|
||||
r"""
|
||||
@@ -84,7 +84,7 @@ _HEAP_BRACKET_RE = re.compile(r"^heap\s+(?P<heap>\d+)$")
|
||||
def parse_log_line(line: str) -> dict[str, Any]:
|
||||
"""Best-effort decompose a raw firmware log line.
|
||||
|
||||
Returns a dict with at least `line` (the original, unmodified — ANSI
|
||||
Returns a dict with at least `line` (the original, unmodified - ANSI
|
||||
codes preserved for fidelity). Adds `level`, `tag`, `clock`,
|
||||
`uptime_s`, and `msg` when the full prefix is present.
|
||||
|
||||
@@ -93,7 +93,7 @@ def parse_log_line(line: str) -> dict[str, Any]:
|
||||
(the BLE/StreamAPI path inherited the colored body in some builds).
|
||||
We strip ANSI before regex matching so the prefix survives.
|
||||
- DEBUG_HEAP injects `[heap N]` after the thread bracket. When NO
|
||||
thread name is set, the heap takes the thread bracket position —
|
||||
thread name is set, the heap takes the thread bracket position -
|
||||
looks like `[heap 12345] msg`. We detect that shape and move it
|
||||
out of `tag` and into `heap_free`.
|
||||
|
||||
@@ -137,7 +137,7 @@ def parse_log_line(line: str) -> dict[str, Any]:
|
||||
msg = m.group("msg")
|
||||
out["msg"] = msg
|
||||
else:
|
||||
# No prefix — bare LogRecord.message body. Inspect the whole
|
||||
# No prefix - bare LogRecord.message body. Inspect the whole
|
||||
# line for DEBUG_HEAP-style content; the heap-prefix and
|
||||
# thread-leak patterns can survive on either path.
|
||||
msg = clean
|
||||
@@ -204,7 +204,7 @@ _TELEMETRY_VARIANTS = (
|
||||
|
||||
def extract_telemetry(packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Pull the telemetry variant + flat fields out of a `meshtastic.receive.telemetry`
|
||||
packet. Returns None when the shape isn't what we expect — so the
|
||||
packet. Returns None when the shape isn't what we expect - so the
|
||||
caller can fall back to a generic packets.jsonl row.
|
||||
"""
|
||||
if not isinstance(packet, dict):
|
||||
@@ -251,7 +251,7 @@ def summarize_packet(
|
||||
packet: dict[str, Any], *, payload_hex_len: int = 64
|
||||
) -> dict[str, Any]:
|
||||
"""Reduce a packet dict to a stable, queryable summary. Drops the
|
||||
full payload bytes — the recorder records summaries, not pcaps.
|
||||
full payload bytes - the recorder records summaries, not pcaps.
|
||||
"""
|
||||
if not isinstance(packet, dict):
|
||||
return {"raw_type": type(packet).__name__}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
Subscribes once to the meshtastic pubsub fan-out and writes four append-only
|
||||
JSONL streams under `mcp-server/.mtlog/`. The pubsub fan-out is
|
||||
process-global — a single subscription captures every active interface
|
||||
process-global - a single subscription captures every active interface
|
||||
without per-connection bookkeeping.
|
||||
|
||||
Files:
|
||||
logs.jsonl — every `meshtastic.log.line` event (best-effort prefix
|
||||
logs.jsonl - every `meshtastic.log.line` event (best-effort prefix
|
||||
parsed for level/tag/uptime; raw `line` always preserved)
|
||||
telemetry.jsonl — `meshtastic.receive.telemetry` packets, flattened by
|
||||
telemetry.jsonl - `meshtastic.receive.telemetry` packets, flattened by
|
||||
variant (device / local / environment / power / etc.)
|
||||
packets.jsonl — every other `meshtastic.receive.*` packet, summarized
|
||||
packets.jsonl - every other `meshtastic.receive.*` packet, summarized
|
||||
(portnum, hops, RSSI/SNR, payload size + 64-byte hex)
|
||||
events.jsonl — connection lifecycle, node-DB updates, and manual
|
||||
events.jsonl - connection lifecycle, node-DB updates, and manual
|
||||
`mark_event` rows. Lower volume; useful for aligning
|
||||
timelines.
|
||||
|
||||
@@ -83,7 +83,7 @@ class Recorder:
|
||||
self._started = False
|
||||
|
||||
def pause(self, reason: str | None = None) -> None:
|
||||
# Write the pause marker BEFORE flipping the flag — `_write_event`
|
||||
# Write the pause marker BEFORE flipping the flag - `_write_event`
|
||||
# short-circuits when paused, so the order matters for this event
|
||||
# to actually land in events.jsonl.
|
||||
self._write_event(
|
||||
@@ -108,7 +108,7 @@ class Recorder:
|
||||
def _wire_pubsub(self) -> None:
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
# Subscribers — one per topic. Each pubsub publisher sends
|
||||
# Subscribers - one per topic. Each pubsub publisher sends
|
||||
# keyword args matching its handler's signature; pubsub
|
||||
# introspects the function signature to route args.
|
||||
bindings = [
|
||||
@@ -225,7 +225,7 @@ class Recorder:
|
||||
Same parse + heap-synthesis path as `_on_log_line`, but receives
|
||||
the raw text-formatted line (full level/clock/uptime/thread/`[heap N]`/
|
||||
body). On DEBUG_HEAP builds in text mode this gives us per-log-line
|
||||
heap data — far higher cadence than LocalStats, and works without
|
||||
heap data - far higher cadence than LocalStats, and works without
|
||||
protobuf API mode (no SerialInterface required).
|
||||
"""
|
||||
files = self._files_snapshot()
|
||||
@@ -255,7 +255,7 @@ class Recorder:
|
||||
files["logs"].write(record)
|
||||
|
||||
# Synthesize a heap_free telemetry sample whenever the line
|
||||
# carries one — same logic as _on_log_line, tagged source so
|
||||
# carries one - same logic as _on_log_line, tagged source so
|
||||
# consumers can distinguish text-mode tap from protobuf path.
|
||||
heap_free = parsed.get("heap_free")
|
||||
if isinstance(heap_free, int):
|
||||
@@ -285,7 +285,7 @@ class Recorder:
|
||||
tags = parsers.interface_label(interface)
|
||||
extracted = parsers.extract_telemetry(packet)
|
||||
if extracted is None:
|
||||
# Couldn't extract a known variant — fall through to the
|
||||
# Couldn't extract a known variant - fall through to the
|
||||
# generic `_on_receive` path, which will still fire for
|
||||
# this packet via the parent topic.
|
||||
return
|
||||
@@ -304,7 +304,7 @@ class Recorder:
|
||||
|
||||
def _on_receive(self, packet: dict[str, Any], interface: Any = None) -> None:
|
||||
# Generic-receive fires for EVERY packet. Telemetry packets get
|
||||
# recorded twice (here and in _on_telemetry) — that's intentional:
|
||||
# recorded twice (here and in _on_telemetry) - that's intentional:
|
||||
# packets.jsonl is the universal record, telemetry.jsonl is the
|
||||
# structured timeseries view.
|
||||
files = self._files_snapshot()
|
||||
@@ -338,7 +338,7 @@ class Recorder:
|
||||
def _on_node_updated(
|
||||
self, node: dict[str, Any] | None = None, interface: Any = None
|
||||
) -> None:
|
||||
# Lower-volume than packets but informative — node ID, hops away,
|
||||
# Lower-volume than packets but informative - node ID, hops away,
|
||||
# last heard. Skip the user dict if absent.
|
||||
try:
|
||||
user = (node or {}).get("user") if isinstance(node, dict) else None
|
||||
@@ -381,7 +381,7 @@ class Recorder:
|
||||
"role": "marker",
|
||||
"level": "MARK",
|
||||
"tag": "mark_event",
|
||||
"line": f"[mark] {label}" + (f" — {note}" if note else ""),
|
||||
"line": f"[mark] {label}" + (f" - {note}" if note else ""),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
@@ -399,7 +399,7 @@ class Recorder:
|
||||
) -> float:
|
||||
ts = time.time()
|
||||
# Lifecycle markers (recorder_start, recorder_pause, recorder_resume)
|
||||
# arrive at choreographed moments — `pause()` writes BEFORE flipping
|
||||
# arrive at choreographed moments - `pause()` writes BEFORE flipping
|
||||
# the flag and `resume()` writes AFTER clearing it, so those calls
|
||||
# see _paused=False here. Other event kinds short-circuit when
|
||||
# paused via the snapshot guard below.
|
||||
|
||||
@@ -6,7 +6,7 @@ it is closed, gzipped to `<name>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`,
|
||||
and the live file resets to empty. Old archives past `keep_archives` are
|
||||
unlinked oldest-first.
|
||||
|
||||
Size check is amortized — `os.fstat` runs every `check_every` writes,
|
||||
Size check is amortized - `os.fstat` runs every `check_every` writes,
|
||||
not per-write, so the hot path stays at one `fh.write` + one `fh.flush`.
|
||||
|
||||
Threading: every public method acquires `self._lock`. The recorder runs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Long-running serial monitor sessions via `pio device monitor`.
|
||||
|
||||
Why pio instead of raw pyserial: pio applies the board's monitor_filters —
|
||||
Why pio instead of raw pyserial: pio applies the board's monitor_filters -
|
||||
`esp32_exception_decoder` symbolicates crash stacks, `time` adds timestamps,
|
||||
etc. Raw pyserial would give us bytes; pio gives us developer-grade logs.
|
||||
|
||||
@@ -53,7 +53,7 @@ def _drain(session: SerialSession) -> None:
|
||||
own port. This is the text-mode tap path: when no SerialInterface is
|
||||
open, the firmware emits full formatted lines (level + clock + uptime
|
||||
+ thread + `[heap N]` prefix on DEBUG_HEAP builds + body), and we
|
||||
fan them out to whoever is listening. Pubsub is best-effort —
|
||||
fan them out to whoever is listening. Pubsub is best-effort -
|
||||
publish failures must never block the reader.
|
||||
"""
|
||||
# Lazy import: pubsub isn't required just to import this module
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FastMCP server wiring — 43 tools across 9 categories (adds uhubctl power control).
|
||||
"""FastMCP server wiring - 43 tools across 9 categories (adds uhubctl power control).
|
||||
|
||||
Each tool handler is a thin delegation to a named module (pio.py, admin.py,
|
||||
etc.). Business logic does not live here.
|
||||
@@ -32,7 +32,7 @@ app = FastMCP("meshtastic-mcp")
|
||||
|
||||
|
||||
def _start_recorder() -> None:
|
||||
# Persistent device-log capture. Starts on first import — pubsub fan-out
|
||||
# Persistent device-log capture. Starts on first import - pubsub fan-out
|
||||
# is process-global, so subscribing here captures every active interface
|
||||
# (whether opened by an MCP tool, a pytest fixture, or a serial_session).
|
||||
# Files land in mcp-server/.mtlog/ (gitignored). See recorder/recorder.py
|
||||
@@ -154,7 +154,7 @@ def pio_flash(
|
||||
|
||||
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for the
|
||||
rebuild-before-upload, e.g. `{"DEBUG_HEAP": 1}`. Required for the flags
|
||||
to actually land in the uploaded firmware — without it, the implicit
|
||||
to actually land in the uploaded firmware - without it, the implicit
|
||||
rebuild relinks without the env var and silently drops them.
|
||||
"""
|
||||
return flash.flash(
|
||||
@@ -219,7 +219,7 @@ def userprefs_manifest() -> dict[str, Any]:
|
||||
"""Full manifest of USERPREFS_* keys the firmware knows about.
|
||||
|
||||
Combines `userPrefs.jsonc` (active + commented examples) with a scan of
|
||||
`src/**` for `USERPREFS_<KEY>` references — so every key the firmware
|
||||
`src/**` for `USERPREFS_<KEY>` references - so every key the firmware
|
||||
actually consumes shows up, even if undocumented in the jsonc.
|
||||
|
||||
Each entry has: key, active (is it uncommented), value (current), example
|
||||
@@ -268,7 +268,7 @@ def userprefs_reset() -> dict[str, Any]:
|
||||
"""Restore userPrefs.jsonc from the most recent MCP backup (if any).
|
||||
|
||||
The backup is only created by the legacy `userprefs_set` workflow (not
|
||||
currently written automatically). Returns `{restored: bool, ...}` — false
|
||||
currently written automatically). Returns `{restored: bool, ...}` - false
|
||||
when no backup is present, in which case the caller should edit the
|
||||
jsonc directly.
|
||||
"""
|
||||
@@ -293,7 +293,7 @@ def userprefs_testing_profile(
|
||||
- Run on a deterministic non-default LoRa slot (default 88 on US LONG_FAST,
|
||||
well off the `hash("LongFast")` slot a stock production device uses)
|
||||
- Join a private channel with a name and PSK that differ from public
|
||||
defaults — so no accidental mesh-with-production-devices
|
||||
defaults - so no accidental mesh-with-production-devices
|
||||
- Have MQTT disabled (no uplink/downlink bridge), so test traffic never
|
||||
leaks to a public broker
|
||||
- Optionally disable GPS for bench-test conditions
|
||||
@@ -314,8 +314,8 @@ def userprefs_testing_profile(
|
||||
(fine one-off, useless for multi-device clusters).
|
||||
channel_name: primary channel name (≤11 chars). Default "McpTest".
|
||||
channel_num: 1-indexed LoRa slot (0 = fall back to name-hash). Default
|
||||
88 — mid-upper US band, unlikely to collide with production slots.
|
||||
region: short code — one of US, EU_433, EU_868, CN, JP, ANZ, KR, TW,
|
||||
88 - mid-upper US band, unlikely to collide with production slots.
|
||||
region: short code - one of US, EU_433, EU_868, CN, JP, ANZ, KR, TW,
|
||||
RU, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, LORA_24.
|
||||
modem_preset: one of LONG_FAST, LONG_SLOW, LONG_MODERATE, VERY_LONG_SLOW,
|
||||
MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, SHORT_TURBO.
|
||||
@@ -345,7 +345,7 @@ def touch_1200bps(port: str, settle_ms: int = 250) -> dict[str, Any]:
|
||||
|
||||
After the touch, polls serial devices for up to 3 seconds and reports any
|
||||
new port that appeared (the bootloader often enumerates as a different
|
||||
device). Not destructive — this is just a reset signal.
|
||||
device). Not destructive - this is just a reset signal.
|
||||
"""
|
||||
return flash.touch_1200bps(port, settle_ms=settle_ms)
|
||||
|
||||
@@ -363,7 +363,7 @@ def serial_open(
|
||||
"""Open a `pio device monitor` session reading from `port`.
|
||||
|
||||
If `env` is set, pio picks up monitor_speed and monitor_filters from
|
||||
platformio.ini — recommended for firmware debugging since it enables
|
||||
platformio.ini - recommended for firmware debugging since it enables
|
||||
esp32_exception_decoder / esp32_c3_exception_decoder for ESP32 envs.
|
||||
|
||||
Without `env`, uses the supplied baud and filters (default ["direct"]).
|
||||
@@ -398,7 +398,7 @@ def serial_read(
|
||||
or `since_cursor=0` to read from the start of the in-memory buffer.
|
||||
|
||||
Returns `dropped` = count of lines that aged out of the 10k-line ring
|
||||
buffer between reads — so a value > 0 means you missed data.
|
||||
buffer between reads - so a value > 0 means you missed data.
|
||||
"""
|
||||
session = registry.get_session(session_id)
|
||||
return serial_session.read_session(
|
||||
@@ -502,13 +502,13 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
|
||||
When true, firmware streams log lines as protobuf `LogRecord` messages
|
||||
over the StreamAPI (topic `meshtastic.log.line` in meshtastic-python)
|
||||
instead of raw text. Lets diagnostic clients capture firmware-side logs
|
||||
through the SAME SerialInterface used for admin/info calls — no
|
||||
through the SAME SerialInterface used for admin/info calls - no
|
||||
separate `pio device monitor` session needed, no exclusive-port-lock
|
||||
conflict. Persists across reboot via NVS; wiped by factory_reset
|
||||
unless re-applied.
|
||||
|
||||
The earlier emitLogRecord race (shared tx buffer) is fixed at the
|
||||
firmware level — the log path has a dedicated scratch + txBuf and
|
||||
firmware level - the log path has a dedicated scratch + txBuf and
|
||||
both emission paths serialize via a mutex. Safe to leave on under
|
||||
traffic.
|
||||
"""
|
||||
@@ -625,7 +625,7 @@ def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]:
|
||||
def uhubctl_list() -> list[dict[str, Any]]:
|
||||
"""List every USB hub + per-port device attachment as seen by `uhubctl`.
|
||||
|
||||
Read-only — no confirm required. Each hub entry includes its location
|
||||
Read-only - no confirm required. Each hub entry includes its location
|
||||
(`1-1.3`), descriptor, whether it supports Per-Port Power Switching,
|
||||
and a list of populated ports with VID:PID of attached devices.
|
||||
Useful for pre-flight checks before a destructive power-cycle call.
|
||||
@@ -645,12 +645,12 @@ def uhubctl_power(
|
||||
) -> dict[str, Any]:
|
||||
"""Power a USB hub port on or off via `uhubctl -a on|off`.
|
||||
|
||||
Target the port by either (`location`, `port`) — raw uhubctl syntax,
|
||||
e.g. `location="1-1.3", port=2` — OR by `role` ("nrf52", "esp32s3").
|
||||
Target the port by either (`location`, `port`) - raw uhubctl syntax,
|
||||
e.g. `location="1-1.3", port=2` - OR by `role` ("nrf52", "esp32s3").
|
||||
Role lookup honors `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
|
||||
`_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
|
||||
|
||||
`action="off"` requires `confirm=True` (destructive — the attached
|
||||
`action="off"` requires `confirm=True` (destructive - the attached
|
||||
device will immediately disappear from the OS).
|
||||
"""
|
||||
from . import uhubctl as uhubctl_mod
|
||||
@@ -678,7 +678,7 @@ def uhubctl_cycle(
|
||||
) -> dict[str, Any]:
|
||||
"""Power a USB hub port off, wait `delay_s` seconds, then on.
|
||||
|
||||
The typical hard-reset sequence — shorter than off+on as two RPCs
|
||||
The typical hard-reset sequence - shorter than off+on as two RPCs
|
||||
because uhubctl handles the timing in-process. Target by (location,
|
||||
port) or by role (see `uhubctl_power`). Requires `confirm=True`.
|
||||
"""
|
||||
@@ -714,7 +714,7 @@ def _resolve_uhubctl_target(
|
||||
def esptool_chip_info(port: str) -> dict[str, Any]:
|
||||
"""Run `esptool flash_id` and return chip, MAC, crystal, and flash size.
|
||||
|
||||
Read-only — no confirm required. Prefer this over parsing pio upload logs
|
||||
Read-only - no confirm required. Prefer this over parsing pio upload logs
|
||||
when you just want to identify the chip.
|
||||
"""
|
||||
return hw_tools.esptool_chip_info(port)
|
||||
@@ -738,7 +738,7 @@ def esptool_raw(
|
||||
erase_flash, erase_region, merge_bin) require confirm=True.
|
||||
|
||||
Prefer the high-level `pio_flash` / `erase_and_flash` / `update_flash`
|
||||
tools where possible — they know board-specific offsets and protocols.
|
||||
tools where possible - they know board-specific offsets and protocols.
|
||||
"""
|
||||
return hw_tools.esptool_raw(args, port=port, confirm=confirm)
|
||||
|
||||
@@ -747,7 +747,7 @@ def esptool_raw(
|
||||
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
|
||||
"""DFU-flash a .zip package to an nRF52840 via `nrfutil dfu serial`.
|
||||
|
||||
Prefer `pio_flash` for flashing firmware built from this repo — pio handles
|
||||
Prefer `pio_flash` for flashing firmware built from this repo - pio handles
|
||||
the DFU invocation automatically. Use this tool when flashing a pre-built
|
||||
release zip or a custom bootloader. Requires confirm=True.
|
||||
"""
|
||||
@@ -786,7 +786,7 @@ def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
||||
|
||||
# ---------- Persistent device-log capture (recorder) ----------------------
|
||||
#
|
||||
# The recorder is autouse — it starts at server import and continuously
|
||||
# The recorder is autouse - it starts at server import and continuously
|
||||
# writes every meshtastic pubsub event to JSONL files under .mtlog/. These
|
||||
# tools are query-only over those files, plus a few lifecycle controls.
|
||||
|
||||
@@ -810,7 +810,7 @@ def logs_window(
|
||||
Time strings: "-15m", "-2h", "-3d", "now", or ISO 8601.
|
||||
|
||||
Note: lines arriving via the LogRecord protobuf path (when
|
||||
set_debug_log_api(True) is on) come without level prefix — the
|
||||
set_debug_log_api(True) is on) come without level prefix - the
|
||||
meshtastic Python lib drops record.level before fan-out. For those,
|
||||
`level` filter won't match; use `grep` instead.
|
||||
"""
|
||||
@@ -840,7 +840,7 @@ def telemetry_timeline(
|
||||
heap_free_bytes) are normalized.
|
||||
|
||||
Returns slope_per_min (linear-regression slope, units/minute) so a
|
||||
leak detector can read one number — negative slope on free_heap over
|
||||
leak detector can read one number - negative slope on free_heap over
|
||||
a long window indicates a real leak.
|
||||
|
||||
LocalStats variant ("local") cadence is ~60 s (whatever the device's
|
||||
@@ -868,7 +868,7 @@ def packets_window(
|
||||
"""Recent mesh packets recorded by the recorder.
|
||||
|
||||
Each row is a summary (portnum, from/to, hop_limit, RSSI/SNR, payload
|
||||
size + first 64 bytes hex) — full payload bytes are not stored.
|
||||
size + first 64 bytes hex) - full payload bytes are not stored.
|
||||
`portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP".
|
||||
"""
|
||||
return log_query.packets_window(
|
||||
@@ -927,7 +927,7 @@ def recorder_status() -> dict[str, Any]:
|
||||
|
||||
@app.tool()
|
||||
def recorder_pause(reason: str | None = None) -> dict[str, Any]:
|
||||
"""Pause writes to all four streams. Pubsub subscriptions stay active —
|
||||
"""Pause writes to all four streams. Pubsub subscriptions stay active -
|
||||
we just drop events on the floor while paused. Resume with `recorder_resume`.
|
||||
|
||||
Use when capturing a known-good baseline that you don't want to
|
||||
@@ -983,9 +983,9 @@ def push_fake_nodedb(
|
||||
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
|
||||
|
||||
Two transports:
|
||||
target="portduino" — file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
|
||||
target="portduino" - file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
|
||||
Fast, no device connection needed.
|
||||
target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto.
|
||||
target="hardware" - XModem upload over serial/BLE to /prefs/nodes.proto.
|
||||
Requires `port` + `confirm=True`. Triggers a reboot
|
||||
so loadFromDisk picks up the new file at next boot.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""USB hub power control via `uhubctl` — hard-recovery for wedged devices +
|
||||
"""USB hub power control via `uhubctl` - hard-recovery for wedged devices +
|
||||
deliberate offline-peer simulation for mesh tests.
|
||||
|
||||
Why: when a Meshtastic device's serial port wedges (stuck in a boot loop,
|
||||
frozen USB CDC, crashed firmware that didn't reboot), the only recovery is
|
||||
a physical unplug. uhubctl toggles VBUS per-port on any hub with Per-Port
|
||||
Power Switching (PPPS) support — which is most externally-powered hubs
|
||||
from the last ~5 years — so the harness can power-cycle a device
|
||||
Power Switching (PPPS) support - which is most externally-powered hubs
|
||||
from the last ~5 years - so the harness can power-cycle a device
|
||||
programmatically.
|
||||
|
||||
Architecture:
|
||||
@@ -24,7 +24,7 @@ without root, but Linux without udev rules (or old macOS with specific
|
||||
driver quirks) still needs it. We run uhubctl non-root; if stderr
|
||||
matches the classic permission pattern we raise `UhubctlError` with an
|
||||
install hint pointing at the uhubctl docs. Auto-wrapping with `sudo`
|
||||
would prompt in the middle of test runs — bad for CI.
|
||||
would prompt in the middle of test runs - bad for CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -60,7 +60,7 @@ class UhubctlError(RuntimeError):
|
||||
# ---------- Role → VID map -------------------------------------------------
|
||||
|
||||
# Mirrors the default hub_profile in `mcp-server/tests/conftest.py:335`.
|
||||
# Note: esp32s3 and esp32s3_alt share a logical role — we search both.
|
||||
# Note: esp32s3 and esp32s3_alt share a logical role - we search both.
|
||||
ROLE_VIDS: dict[str, tuple[int, ...]] = {
|
||||
"nrf52": (0x239A,),
|
||||
"esp32s3": (0x303A, 0x10C4),
|
||||
@@ -75,8 +75,8 @@ def _normalize_role(role: str) -> str:
|
||||
# ---------- Core subprocess runner -----------------------------------------
|
||||
|
||||
|
||||
# If uhubctl hits a permission problem — most commonly Linux without the
|
||||
# udev rules, or a macOS variant where the kernel holds the hub driver —
|
||||
# If uhubctl hits a permission problem - most commonly Linux without the
|
||||
# udev rules, or a macOS variant where the kernel holds the hub driver -
|
||||
# it prints something like "Permission denied. Try running as root".
|
||||
# Linux error text varies; we match a broad substring rather than exact.
|
||||
_PERM_ERROR_PATTERNS = (
|
||||
@@ -170,7 +170,7 @@ def parse_list_output(output: str) -> list[dict[str, Any]]:
|
||||
def list_hubs() -> list[dict[str, Any]]:
|
||||
"""Enumerate every hub uhubctl can see, with per-port device attachments.
|
||||
|
||||
Pure read — no power state changes. Useful as a pre-flight check before
|
||||
Pure read - no power state changes. Useful as a pre-flight check before
|
||||
a destructive `power_off` call.
|
||||
"""
|
||||
result = _run_uhubctl([], timeout=15.0)
|
||||
@@ -189,7 +189,7 @@ def find_port_for_vid(
|
||||
) -> list[tuple[str, int]]:
|
||||
"""Return ALL (location, port) matches for a device VID (optionally +PID).
|
||||
|
||||
`only_ppps=True` filters out hubs that don't advertise PPPS — we can't
|
||||
`only_ppps=True` filters out hubs that don't advertise PPPS - we can't
|
||||
control them anyway. Callers that want to diagnose a missing device can
|
||||
pass `only_ppps=False` to see if the device is on a non-controllable
|
||||
hub (and raise a clearer error).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""USERPREFS: build-time constants baked into the firmware binary.
|
||||
|
||||
The firmware repo has `userPrefs.jsonc` at its root — a JSONC file with every
|
||||
The firmware repo has `userPrefs.jsonc` at its root - a JSONC file with every
|
||||
available USERPREFS_* key listed, most commented out. At build time,
|
||||
`bin/platformio-custom.py` reads it, strips comments, and emits
|
||||
`-DUSERPREFS_<KEY>=<value>` build flags into the compile step. Firmware code
|
||||
@@ -10,7 +10,7 @@ owner name, LoRa region, OEM branding, MQTT credentials, etc.
|
||||
This module:
|
||||
1. Parses `userPrefs.jsonc` (preserving which keys are active vs commented)
|
||||
2. Greps `src/` for the set of keys the firmware actually consumes (the
|
||||
real discovery manifest — anything here that isn't in the jsonc is still
|
||||
real discovery manifest - anything here that isn't in the jsonc is still
|
||||
a valid override)
|
||||
3. Provides a context manager for temporarily swapping in overrides during
|
||||
a build/flash, then restoring the original file
|
||||
@@ -111,7 +111,7 @@ def read_state() -> dict[str, Any]:
|
||||
def _scan_consumed_keys() -> dict[str, list[str]]:
|
||||
"""Grep firmware src/ for USERPREFS_* references.
|
||||
|
||||
Returns {key: [relative_file_paths]} — only includes files under `src/`.
|
||||
Returns {key: [relative_file_paths]} - only includes files under `src/`.
|
||||
"""
|
||||
src_dir = config.firmware_root() / "src"
|
||||
if not src_dir.is_dir():
|
||||
@@ -153,7 +153,7 @@ def build_manifest() -> dict[str, Any]:
|
||||
- `declared_in_jsonc` bool (key appears anywhere in userPrefs.jsonc)
|
||||
- `consumed_by` list of source files that reference it
|
||||
- `inferred_type`: one of "brace", "number", "bool", "enum", "string"
|
||||
— matches platformio-custom.py's value-wrapping switch
|
||||
- matches platformio-custom.py's value-wrapping switch
|
||||
"""
|
||||
state = read_state()
|
||||
consumed = _scan_consumed_keys()
|
||||
@@ -210,7 +210,7 @@ def infer_type(value: str | None) -> str:
|
||||
def _format_jsonc_line(key: str, value: str, commented: bool) -> str:
|
||||
prefix = " // " if commented else " "
|
||||
# Escape backslashes and quotes inside value the way platformio-custom.py
|
||||
# expects — the original jsonc uses raw strings for most content. Keep it
|
||||
# expects - the original jsonc uses raw strings for most content. Keep it
|
||||
# literal; callers are responsible for correct escaping if they pass
|
||||
# dict/enum-init values that contain quotes.
|
||||
return f'{prefix}"{key}": "{value}",'
|
||||
@@ -289,7 +289,7 @@ def _stringify(value: Any) -> str:
|
||||
|
||||
bool → "true" / "false"; int/float → str(); anything else → str(value).
|
||||
Callers passing brace-init strings (`"{ 0x01, 0x02, ... }"`) must format
|
||||
them themselves — this function doesn't try to synthesize them.
|
||||
them themselves - this function doesn't try to synthesize them.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
@@ -420,9 +420,9 @@ def build_testing_profile(
|
||||
short_name: optional owner short-name stamp (≤4 chars). None = unset.
|
||||
long_name: optional owner long-name stamp. None = unset.
|
||||
disable_mqtt: if True (default), disables the MQTT module and the
|
||||
uplink/downlink bridge on the primary channel — so private test
|
||||
uplink/downlink bridge on the primary channel - so private test
|
||||
traffic never leaks to a public broker.
|
||||
disable_position: if True, disables GPS + position broadcasts — useful
|
||||
disable_position: if True, disables GPS + position broadcasts - useful
|
||||
when test devices sit on a bench without antennas.
|
||||
enable_ui_log: if True, stamps `USERPREFS_UI_TEST_LOG=true` so the
|
||||
firmware emits one `Screen: frame N/M name=... reason=...` log
|
||||
|
||||
+16
-16
@@ -1,4 +1,4 @@
|
||||
# Meshtastic MCP Server — Test Harness
|
||||
# Meshtastic MCP Server - Test Harness
|
||||
|
||||
Automated test suite for the MCP server, organized around real operator
|
||||
concerns rather than generic "unit vs hardware".
|
||||
@@ -21,13 +21,13 @@ concerns rather than generic "unit vs hardware".
|
||||
cd mcp-server
|
||||
pip install -e ".[test]"
|
||||
|
||||
# No hardware — 33 unit tests, ~3 seconds
|
||||
# No hardware - 33 unit tests, ~3 seconds
|
||||
pytest tests/unit -v
|
||||
|
||||
# Hub attached (nRF52840 + ESP32-S3) — first run bakes, then exercises everything
|
||||
# Hub attached (nRF52840 + ESP32-S3) - first run bakes, then exercises everything
|
||||
pytest tests/ --html=report.html
|
||||
|
||||
# Hub already baked with session profile (dev loop) — skip bake
|
||||
# Hub already baked with session profile (dev loop) - skip bake
|
||||
pytest tests/ --assume-baked --html=report.html
|
||||
|
||||
# Force a rebake (new firmware, new seed, etc.)
|
||||
@@ -36,23 +36,23 @@ pytest tests/ --force-bake --html=report.html
|
||||
|
||||
## CLI flags
|
||||
|
||||
- `--force-bake` — always reflash both roles at session start, even if the
|
||||
- `--force-bake` - always reflash both roles at session start, even if the
|
||||
current state matches the session profile.
|
||||
- `--assume-baked` — skip `test_00_bake.py` entirely. Use when you know the
|
||||
- `--assume-baked` - skip `test_00_bake.py` entirely. Use when you know the
|
||||
devices are already baked and want a fast dev loop.
|
||||
- `--hub-profile=<yaml>` — point at a YAML file for non-default hub hardware.
|
||||
- `--hub-profile=<yaml>` - point at a YAML file for non-default hub hardware.
|
||||
Default targets VID `0x239a` (nRF52) and `0x303a`/`0x10c4` (ESP32-S3).
|
||||
- `--no-teardown-rebake` — skip the session-end rebake that `provisioning/`
|
||||
- `--no-teardown-rebake` - skip the session-end rebake that `provisioning/`
|
||||
and `fleet/` tests perform. Useful in rapid iteration.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `MESHTASTIC_FIRMWARE_ROOT` — firmware repo path (defaults to `../` from tests/)
|
||||
- `MESHTASTIC_MCP_ENV_NRF52` — PlatformIO env for the nRF52 role (default
|
||||
- `MESHTASTIC_FIRMWARE_ROOT` - firmware repo path (defaults to `../` from tests/)
|
||||
- `MESHTASTIC_MCP_ENV_NRF52` - PlatformIO env for the nRF52 role (default
|
||||
`rak4631`)
|
||||
- `MESHTASTIC_MCP_ENV_ESP32S3` — PlatformIO env for the ESP32-S3 role (default
|
||||
- `MESHTASTIC_MCP_ENV_ESP32S3` - PlatformIO env for the ESP32-S3 role (default
|
||||
`heltec-v3`)
|
||||
- `MESHTASTIC_MCP_SEED` — override the session PSK seed (default:
|
||||
- `MESHTASTIC_MCP_SEED` - override the session PSK seed (default:
|
||||
`pytest-<unix-ts>`). Set this to reproduce a specific failing run.
|
||||
|
||||
## Fixtures you'll use when adding tests
|
||||
@@ -64,7 +64,7 @@ All defined in `conftest.py`:
|
||||
- **`test_profile`** → USERPREFS dict for the session (`build_testing_profile`).
|
||||
- **`no_region_profile`** → variant without `USERPREFS_CONFIG_LORA_REGION`.
|
||||
- **`baked_mesh`** → verifies both devices are baked with the session profile
|
||||
(does NOT reflash — that's `test_00_bake.py`'s job).
|
||||
(does NOT reflash - that's `test_00_bake.py`'s job).
|
||||
- **`baked_single`** → single verified baked device; parametrize `request.param`
|
||||
to pick role.
|
||||
- **`serial_capture`** → factory; `cap = serial_capture("esp32s3")` starts a
|
||||
@@ -84,7 +84,7 @@ predicate(), timeout=60)` replaces flaky `time.sleep()` patterns.
|
||||
|
||||
`pytest --junitxml=junit.xml` produces CI-integration XML.
|
||||
|
||||
`tool_coverage.json` is emitted at session end in the tests directory — shows
|
||||
`tool_coverage.json` is emitted at session end in the tests directory - shows
|
||||
which of the 38 MCP tools the run exercised. Useful for closing test gaps.
|
||||
|
||||
## Adding a new test
|
||||
@@ -95,11 +95,11 @@ which of the 38 MCP tools the run exercised. Useful for closing test gaps.
|
||||
on `baked_single`. If you need to mutate hardware state, put it in
|
||||
`provisioning/` or `fleet/` and add a `try/finally` teardown that re-bakes
|
||||
the session profile.
|
||||
3. Use `wait_until` for anything involving LoRa timing — fixed `sleep()`
|
||||
3. Use `wait_until` for anything involving LoRa timing - fixed `sleep()`
|
||||
produces flakes.
|
||||
4. Use `serial_capture` when you need to observe firmware log output (e.g.
|
||||
"did the packet get decoded?").
|
||||
5. Add a `@pytest.mark.timeout(N)` — mesh tests routinely hit LoRa-airtime
|
||||
5. Add a `@pytest.mark.timeout(N)` - mesh tests routinely hit LoRa-airtime
|
||||
waits; default pytest timeout is infinite.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Role-to-port rediscovery after USB CDC re-enumeration.
|
||||
|
||||
Used by tests that mutate device identity in ways macOS treats as a
|
||||
"new device" — notably ``factory_reset(full=False)`` on the nRF52840 and
|
||||
"new device" - notably ``factory_reset(full=False)`` on the nRF52840 and
|
||||
any operation that kicks the device through its bootloader. Both cases
|
||||
cause the kernel to re-assign the ``/dev/cu.usbmodem*`` path; a test that
|
||||
captured the pre-operation port and reuses it after will fail with
|
||||
@@ -10,7 +10,7 @@ captured the pre-operation port and reuses it after will fail with
|
||||
The helper polls :func:`meshtastic_mcp.devices.list_devices` (the same API
|
||||
``run-tests.sh`` and ``conftest.py::hub_devices`` use for initial hub
|
||||
detection) filtered by the role's canonical USB VID. Returns the first
|
||||
matching port — equivalent to "give me the single nRF52 (or ESP32-S3) on
|
||||
matching port - equivalent to "give me the single nRF52 (or ESP32-S3) on
|
||||
the bench right now, whichever `cu.*` path it happens to be at".
|
||||
|
||||
Test-harness-local (not exported from ``meshtastic_mcp``): a thin wrapper
|
||||
@@ -18,7 +18,7 @@ over public ``devices.list_devices`` with no extra moving parts. If a
|
||||
non-test caller ever needs this, it's trivial to promote.
|
||||
|
||||
Caveat: the session-scoped ``hub_devices`` fixture snapshots ports at
|
||||
session start and is dict-keyed — it doesn't learn about re-enumerations.
|
||||
session start and is dict-keyed - it doesn't learn about re-enumerations.
|
||||
Tests that call ``resolve_port_by_role`` should use the returned port
|
||||
locally for the rest of the test body rather than expecting
|
||||
``hub_devices[role]`` to update.
|
||||
@@ -70,13 +70,13 @@ def resolve_port_by_role(
|
||||
``role``'s VID appears. Returns the first matching port.
|
||||
|
||||
On timeout raises :class:`AssertionError` with the list of devices that
|
||||
WERE seen — helpful when debugging "wrong board connected" vs. "no
|
||||
WERE seen - helpful when debugging "wrong board connected" vs. "no
|
||||
board connected" vs. "still re-enumerating".
|
||||
|
||||
Args:
|
||||
role: ``"nrf52"`` or ``"esp32s3"`` (keys of ``_ROLE_VIDS``).
|
||||
timeout_s: upper bound on how long to wait for the device to
|
||||
re-appear. Default 30 s — nRF52 factory_reset observed at
|
||||
re-appear. Default 30 s - nRF52 factory_reset observed at
|
||||
2-12 s on a healthy lab hub.
|
||||
poll_start: initial poll interval in seconds. Default 0.5 s.
|
||||
poll_max: cap on poll interval after backoff. Default 5 s.
|
||||
@@ -98,7 +98,7 @@ def resolve_port_by_role(
|
||||
last_seen = devices_module.list_devices(include_unknown=True)
|
||||
except Exception as exc:
|
||||
# list_devices is wrapped by meshtastic_mcp.devices and
|
||||
# shouldn't raise on normal enumeration — but a kernel-level
|
||||
# shouldn't raise on normal enumeration - but a kernel-level
|
||||
# USB hiccup during re-enumeration can bubble up briefly.
|
||||
# Treat as "nothing seen this round" and retry.
|
||||
last_seen = [{"error": repr(exc)}]
|
||||
@@ -109,7 +109,7 @@ def resolve_port_by_role(
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 1.5, poll_max)
|
||||
|
||||
# Timeout path — include what we saw so the operator can tell
|
||||
# Timeout path - include what we saw so the operator can tell
|
||||
# "nothing plugged in" from "wrong VID" from "transient USB error".
|
||||
raise AssertionError(
|
||||
f"no device matching role {role!r} (VIDs "
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""USB hub power control for tests — thin composition of the `uhubctl`
|
||||
"""USB hub power control for tests - thin composition of the `uhubctl`
|
||||
module + `_port_discovery.resolve_port_by_role`.
|
||||
|
||||
Why separate from the production module:
|
||||
@@ -11,7 +11,7 @@ Why separate from the production module:
|
||||
the `factory_reset` flow. Composing the two gives a one-call helper.
|
||||
|
||||
Also exposes `is_uhubctl_available()` so fixtures can skip cleanly when
|
||||
uhubctl isn't installed — we never want "no uhubctl" to look like a test
|
||||
uhubctl isn't installed - we never want "no uhubctl" to look like a test
|
||||
failure.
|
||||
"""
|
||||
|
||||
@@ -29,14 +29,14 @@ from ._port_discovery import resolve_port_by_role
|
||||
def is_uhubctl_available() -> bool:
|
||||
"""Return True iff `config.uhubctl_bin()` resolves AND the binary is callable.
|
||||
|
||||
Soft-fails silently — fixtures use this to `pytest.skip` with an
|
||||
Soft-fails silently - fixtures use this to `pytest.skip` with an
|
||||
actionable message when the operator hasn't installed uhubctl.
|
||||
"""
|
||||
try:
|
||||
config_mod.uhubctl_bin()
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
# Do NOT actually invoke uhubctl here — on macOS a non-sudo run would
|
||||
# Do NOT actually invoke uhubctl here - on macOS a non-sudo run would
|
||||
# fail, which is a config issue, not a tool-missing issue. That gets
|
||||
# surfaced to the user when they actually run a recovery action.
|
||||
return True
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_channel_url_roundtrip(
|
||||
) -> None:
|
||||
"""Runs once per connected role. Verify:
|
||||
1. `get_channel_url()` on a baked device returns a non-empty URL.
|
||||
2. The URL parses — `set_channel_url(url)` accepts it without error.
|
||||
2. The URL parses - `set_channel_url(url)` accepts it without error.
|
||||
3. After set, `get_channel_url()` returns the same (canonicalized) URL.
|
||||
4. Primary channel name survives round-trip.
|
||||
"""
|
||||
@@ -34,7 +34,7 @@ def test_channel_url_roundtrip(
|
||||
"meshtastic" in url_before.lower() or "#" in url_before
|
||||
), f"URL does not look like a Meshtastic channel URL: {url_before!r}"
|
||||
|
||||
# Re-apply the same URL — no-op in content but exercises the setURL path.
|
||||
# Re-apply the same URL - no-op in content but exercises the setURL path.
|
||||
applied = admin.set_channel_url(url=url_before, port=port)
|
||||
assert applied["ok"] is True
|
||||
assert applied["channels_imported"] >= 1
|
||||
|
||||
@@ -4,7 +4,7 @@ This is the most-critical admin behavior not tested elsewhere. If
|
||||
config persistence breaks in a firmware release, every deployed device
|
||||
gets bricked on its next reboot (channels lost, region lost, owner lost,
|
||||
everything back to Meshtastic stock). The fleet blast radius is "every
|
||||
unit on every shelf" — easily worth one explicit test per release.
|
||||
unit on every shelf" - easily worth one explicit test per release.
|
||||
|
||||
Pattern: single-device (``baked_single``, one test per role). Mutate a
|
||||
benign, easy-to-observe LoRa field (``lora.hop_limit``), confirm
|
||||
@@ -12,9 +12,9 @@ pre-reboot, reboot, rediscover port (nRF52 may re-enumerate), verify
|
||||
the value survived, restore original for downstream tests.
|
||||
|
||||
Why ``lora.hop_limit`` specifically:
|
||||
* Non-destructive — doesn't change region, channel, or PSK, so
|
||||
* Non-destructive - doesn't change region, channel, or PSK, so
|
||||
downstream mesh tests still work regardless of the flipped value.
|
||||
* Bounded small-integer (1..7) — easy to flip to a definitively
|
||||
* Bounded small-integer (1..7) - easy to flip to a definitively
|
||||
different value and read back.
|
||||
* Persisted via ``writeConfig("lora")`` which is the same path
|
||||
every other LoRa config mutation uses, so we're really testing
|
||||
@@ -64,7 +64,7 @@ def test_lora_hop_limit_survives_reboot(
|
||||
|
||||
# Pre-reboot sanity: the write reached the device and
|
||||
# get_config reflects it in-memory. If this fails, the persist
|
||||
# test below is moot — something's wrong with the write path
|
||||
# test below is moot - something's wrong with the write path
|
||||
# itself, not with persistence.
|
||||
assert _get_hop_limit(port) == new_value, (
|
||||
f"pre-reboot readback failed: set {new_value}, got "
|
||||
@@ -92,12 +92,12 @@ def test_lora_hop_limit_survives_reboot(
|
||||
assert post == new_value, (
|
||||
f"lora.hop_limit did not survive reboot: set to {new_value} "
|
||||
f"pre-reboot, read back {post} post-reboot. Config persistence "
|
||||
f"is broken — downstream fleet impact would be total."
|
||||
f"is broken - downstream fleet impact would be total."
|
||||
)
|
||||
finally:
|
||||
# Restore so downstream tests see the original hop_limit.
|
||||
# Wrapped in its own try to avoid masking the real assertion
|
||||
# if the restore itself races the reboot — the worst case
|
||||
# if the restore itself races the reboot - the worst case
|
||||
# there is a non-default hop_limit sticks around, which is
|
||||
# benign (mesh still works at hop_limit 3 or 5).
|
||||
try:
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_owner_survives_reboot(
|
||||
baked_single: dict[str, Any],
|
||||
wait_until,
|
||||
) -> None:
|
||||
"""Runs once per connected role — proves the reboot-persistence
|
||||
"""Runs once per connected role - proves the reboot-persistence
|
||||
round-trip works on each device independently, not just one."""
|
||||
port = baked_single["port"]
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import pytest
|
||||
# Ensure the MCP server is on `sys.path` without requiring installation in
|
||||
# development mode for every checkout (we DO install in .venv but this makes
|
||||
# `pytest tests/` work from a fresh clone too). The path mutation must
|
||||
# happen before `meshtastic_mcp.*` imports below — hence the `noqa: E402`
|
||||
# happen before `meshtastic_mcp.*` imports below - hence the `noqa: E402`
|
||||
# markers on those imports (ruff's "module-level import not at top of file"
|
||||
# rule doesn't understand path-bootstrapping patterns).
|
||||
_HERE = pathlib.Path(__file__).resolve().parent
|
||||
@@ -106,8 +106,8 @@ def pytest_collection_modifyitems(
|
||||
|
||||
def sort_key(item: pytest.Item) -> tuple[int, str]:
|
||||
path = str(getattr(item, "fspath", "") or item.nodeid)
|
||||
# Session-start bake runs FIRST. `baked_mesh` only verifies state —
|
||||
# nothing else actually reflashes — so if test_00_bake doesn't run
|
||||
# Session-start bake runs FIRST. `baked_mesh` only verifies state -
|
||||
# nothing else actually reflashes - so if test_00_bake doesn't run
|
||||
# before the tier tests, `--force-bake` silently becomes a no-op for
|
||||
# the tier tests and only flashes at the very end of the session.
|
||||
# Top-level nodeid ("tests/test_00_bake.py") otherwise falls into the
|
||||
@@ -128,7 +128,7 @@ def pytest_collection_modifyitems(
|
||||
# after it starts from a known re-enumerated + re-verified state.
|
||||
if "/recovery/" in path or "tests/recovery" in path:
|
||||
return (4, item.nodeid)
|
||||
# UI tier slots here — read-only w.r.t. mesh state, only mutates
|
||||
# UI tier slots here - read-only w.r.t. mesh state, only mutates
|
||||
# the on-screen UI (BACK×5 guard restores home before each test).
|
||||
if "/ui/" in path or "tests/ui" in path:
|
||||
return (5, item.nodeid)
|
||||
@@ -152,12 +152,12 @@ def pytest_collection_modifyitems(
|
||||
def session_seed(request: pytest.FixtureRequest) -> str:
|
||||
"""Deterministic PSK seed for this pytest session.
|
||||
|
||||
Logged in the HTML report header so two runs can be correlated — and so a
|
||||
Logged in the HTML report header so two runs can be correlated - and so a
|
||||
flaky-looking test can be reproduced exactly by passing the seed back via
|
||||
an env var (future extension).
|
||||
"""
|
||||
# Pytest session `starttime` isn't directly exposed on the pytest API we
|
||||
# care about, so derive from process start time — unique enough for human
|
||||
# care about, so derive from process start time - unique enough for human
|
||||
# purposes and stable across the session.
|
||||
seed = os.environ.get("MESHTASTIC_MCP_SEED") or f"pytest-{int(time.time())}"
|
||||
return seed
|
||||
@@ -169,7 +169,7 @@ def test_profile(session_seed: str) -> dict[str, Any]:
|
||||
|
||||
`enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware
|
||||
emits `Screen: frame N/M name=... reason=...` log lines per UI
|
||||
transition — consumed by the `tests/ui/` tier. Harmless on boards
|
||||
transition - consumed by the `tests/ui/` tier. Harmless on boards
|
||||
without a screen (the `#ifdef` sits behind `HAS_SCREEN`).
|
||||
"""
|
||||
return userprefs.build_testing_profile(
|
||||
@@ -186,25 +186,25 @@ def test_profile(session_seed: str) -> dict[str, Any]:
|
||||
def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
"""Snapshot `userPrefs.jsonc`, apply the session test profile, restore at
|
||||
session end. Guards against the suite leaving test-profile USERPREFS
|
||||
values baked into the file — if that happened, any firmware build a
|
||||
values baked into the file - if that happened, any firmware build a
|
||||
contributor ran next would silently inherit the test PSK / test channel
|
||||
name / test admin key etc.
|
||||
|
||||
Layered safety:
|
||||
1. In-memory snapshot taken before any mutation; teardown writes it back.
|
||||
2. Sidecar `userPrefs.jsonc.mcp-session-bak` on disk — belt to the
|
||||
2. Sidecar `userPrefs.jsonc.mcp-session-bak` on disk - belt to the
|
||||
in-memory suspenders. If Python segfaults or SIGKILLs, the next
|
||||
session self-heals from this file at startup.
|
||||
3. `atexit.register()` fallback: if pytest exits abnormally (Ctrl-C
|
||||
mid-test, fatal exception before teardown), the atexit hook still
|
||||
restores from the in-memory snapshot.
|
||||
4. Startup self-heal: if the sidecar exists at session start, a prior
|
||||
session crashed without cleanup — the sidecar IS the truth; restore
|
||||
session crashed without cleanup - the sidecar IS the truth; restore
|
||||
from it before taking this session's snapshot. That way a crash
|
||||
during test A doesn't propagate dirty state into test B's baseline.
|
||||
|
||||
Autouse + depends on `test_profile` so it applies on every run (even
|
||||
unit-only) — cheap, unified code path, no ordering surprises.
|
||||
unit-only) - cheap, unified code path, no ordering surprises.
|
||||
"""
|
||||
path = userprefs.jsonc_path()
|
||||
backup_path = path.with_name(path.name + ".mcp-session-bak")
|
||||
@@ -214,7 +214,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
yield
|
||||
return
|
||||
|
||||
# (4) Startup self-heal — prior session crashed without teardown.
|
||||
# (4) Startup self-heal - prior session crashed without teardown.
|
||||
if backup_path.is_file():
|
||||
try:
|
||||
sidecar_bytes = backup_path.read_bytes()
|
||||
@@ -241,7 +241,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
except Exception as exc:
|
||||
print(f"[userprefs] could not write sidecar: {exc!r}", file=sys.stderr)
|
||||
|
||||
# (3) atexit fallback — fires even if pytest aborts before fixture teardown.
|
||||
# (3) atexit fallback - fires even if pytest aborts before fixture teardown.
|
||||
restored = {"done": False}
|
||||
|
||||
def _atexit_restore() -> None:
|
||||
@@ -263,8 +263,8 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
# Apply the session test profile on top of the snapshot. The firmware
|
||||
# reads userPrefs.jsonc at build time via `bin/platformio-custom.py`,
|
||||
# so every `pio run` during the session picks up the test values.
|
||||
# Delegate to `userprefs.merge_active` — the public API that already
|
||||
# parses, merges, validates, and writes — rather than reaching into
|
||||
# Delegate to `userprefs.merge_active` - the public API that already
|
||||
# parses, merges, validates, and writes - rather than reaching into
|
||||
# the private parser/renderer machinery from here.
|
||||
try:
|
||||
userprefs.merge_active(test_profile)
|
||||
@@ -276,7 +276,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
# tests that don't (unit) still run. But the restore below is
|
||||
# unconditional, so we can't leave a half-written file behind.
|
||||
print(
|
||||
f"[userprefs] failed to apply test profile: {exc!r} — "
|
||||
f"[userprefs] failed to apply test profile: {exc!r} - "
|
||||
f"file left at original state",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -298,7 +298,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
# exception from the yielded body); use a flag so the cleanup
|
||||
# control-flow stays linear and exceptions propagate normally.
|
||||
print(
|
||||
f"[userprefs] teardown restore failed: {exc!r} — "
|
||||
f"[userprefs] teardown restore failed: {exc!r} - "
|
||||
f"sidecar {backup_path} retained for manual recovery",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -388,7 +388,7 @@ def hub_devices(hub_profile: dict[str, dict[str, Any]]) -> dict[str, str]:
|
||||
"""
|
||||
# include_unknown=True so non-whitelisted VIDs (e.g. CP2102 at 0x10c4) that
|
||||
# are configured as hub roles still match. The hub_profile itself gates
|
||||
# which VIDs we consider — no risk of unrelated serial ports sneaking in.
|
||||
# which VIDs we consider - no risk of unrelated serial ports sneaking in.
|
||||
found = devices_module.list_devices(include_unknown=True)
|
||||
# Coalesce alt roles into their base name (esp32s3_alt → esp32s3)
|
||||
resolved: dict[str, str] = {}
|
||||
@@ -413,7 +413,7 @@ def hub_devices(hub_profile: dict[str, dict[str, Any]]) -> dict[str, str]:
|
||||
def _reset_transmit_history_state(role: str, port: str) -> str:
|
||||
"""Wipe `/prefs/transmit_history.dat` + in-memory throttle cache via
|
||||
delete_file_request + reboot. Returns the post-reboot port (nRF52
|
||||
re-enumerates). Best-effort — errors log to stderr + return original
|
||||
re-enumerates). Best-effort - errors log to stderr + return original
|
||||
port so a flaky start doesn't block the session.
|
||||
"""
|
||||
from ._port_discovery import resolve_port_by_role
|
||||
@@ -467,7 +467,7 @@ def _session_clear_transmit_history(hub_devices: dict[str, str]) -> None:
|
||||
if not hub_devices:
|
||||
yield
|
||||
return
|
||||
# Iterate over a snapshot — _reset_transmit_history_state can mutate
|
||||
# Iterate over a snapshot - _reset_transmit_history_state can mutate
|
||||
# hub_devices mid-loop via the update below, and dict-iteration isn't
|
||||
# safe during mutation.
|
||||
for role, port in list(hub_devices.items()):
|
||||
@@ -491,12 +491,12 @@ def baked_mesh(
|
||||
comparing the live config to the expected profile.
|
||||
|
||||
Raises with an actionable error if state is missing or mismatched:
|
||||
"device nrf52 at /dev/cu.X not baked with session profile —
|
||||
"device nrf52 at /dev/cu.X not baked with session profile -
|
||||
run test_00_bake.py first or pass --force-bake"
|
||||
|
||||
Returns a per-role dict with `{port, iface_fresh: callable, my_node_num}`.
|
||||
"""
|
||||
# Verify every role that's present — don't require a fixed set.
|
||||
# Verify every role that's present - don't require a fixed set.
|
||||
# Tests that NEED a specific role (mesh_pair, bidirectional) check
|
||||
# presence in their own fixtures and skip there with an actionable
|
||||
# message. That keeps single-device tests runnable on a one-device
|
||||
@@ -518,7 +518,7 @@ def baked_mesh(
|
||||
try:
|
||||
live = info.device_info(port=port, timeout_s=12.0)
|
||||
except Exception as exc:
|
||||
# Per-role failure — drop this role from the baked set and let
|
||||
# Per-role failure - drop this role from the baked set and let
|
||||
# any test parametrized against it skip with the actionable
|
||||
# message. Other roles still proceed.
|
||||
per_role_errors[role] = f"device_info failed: {exc!r}"
|
||||
@@ -576,11 +576,11 @@ def baked_mesh(
|
||||
# the SerialInterface. Operators who want log capture can opt in via the
|
||||
# `set_debug_log_api` MCP tool (or `admin.set_debug_log_api` directly) on
|
||||
# a case-by-case basis. The autouse `_debug_log_buffer` fixture is still
|
||||
# armed below — if a test explicitly enables the flag, its output will
|
||||
# armed below - if a test explicitly enables the flag, its output will
|
||||
# be captured and attached to failures. Firmware-side fix would need
|
||||
# a separate tx buffer or a mutex — out of scope for the MCP harness.
|
||||
# a separate tx buffer or a mutex - out of scope for the MCP harness.
|
||||
|
||||
# If EVERY detected role errored, skip the session — nothing testable.
|
||||
# If EVERY detected role errored, skip the session - nothing testable.
|
||||
# Otherwise yield the partial set. Tests parametrized against a role
|
||||
# not in `out` will skip via the `baked_single`/`mesh_pair` presence
|
||||
# check with "role not present on the hub".
|
||||
@@ -608,7 +608,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
||||
the test still COLLECTS cleanly (it'll just skip via the
|
||||
`hub_devices` missing-role check inside the fixture).
|
||||
|
||||
Honors `--hub-profile=<yaml>` for non-default hardware — when set, only
|
||||
Honors `--hub-profile=<yaml>` for non-default hardware - when set, only
|
||||
roles defined in the YAML are parametrized. (So e.g. a yaml with only
|
||||
`esp32s3` skips every `[nrf52]` variant at collection time.)
|
||||
"""
|
||||
@@ -675,7 +675,7 @@ def baked_single(
|
||||
"""Function-scoped: a single verified baked device.
|
||||
|
||||
Auto-parametrized by `pytest_generate_tests` over every detected hub
|
||||
role — so any test taking this fixture runs once per connected device
|
||||
role - so any test taking this fixture runs once per connected device
|
||||
(e.g. `test_owner_survives_reboot[nrf52]` +
|
||||
`test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role
|
||||
and never skip a device that happens to be connected.
|
||||
@@ -727,7 +727,7 @@ def power_cycle(
|
||||
) -> Callable[..., str]:
|
||||
"""Return a callable `(role, delay_s=2) -> new_port` that hard-resets the
|
||||
hub port hosting `role`. Skips the test cleanly when uhubctl isn't
|
||||
installed — never want "no uhubctl" to look like a test failure.
|
||||
installed - never want "no uhubctl" to look like a test failure.
|
||||
|
||||
The callable mutates `hub_devices[role]` in place so subsequent fixture
|
||||
lookups pick up the post-cycle port (mirrors the pattern in
|
||||
@@ -905,19 +905,19 @@ def _firmware_log_stream() -> Any:
|
||||
firmware logs *in memory* for pytest-html failure attachments, but a
|
||||
live viewer (``meshtastic-mcp-test-tui``) can't read in-process
|
||||
pubsub events from a different process. This fixture adds a
|
||||
session-long, durable mirror — one JSON object per line, with
|
||||
``port``, ``ts``, and ``line`` fields — that the TUI tails from a
|
||||
session-long, durable mirror - one JSON object per line, with
|
||||
``port``, ``ts``, and ``line`` fields - that the TUI tails from a
|
||||
worker thread.
|
||||
|
||||
Schema (kept trivially small so the file grows slowly):
|
||||
|
||||
{"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "INFO | ... [SerialConsole] Boot..."}
|
||||
|
||||
The file is truncated at session start (no append across runs — the
|
||||
The file is truncated at session start (no append across runs - the
|
||||
TUI also unlinks it on launch, so double-truncate is deliberate).
|
||||
Gitignored via ``mcp-server/.gitignore``.
|
||||
|
||||
Runs alongside ``_debug_log_buffer`` — both subscribe to the same
|
||||
Runs alongside ``_debug_log_buffer`` - both subscribe to the same
|
||||
pubsub topic; pubsub fans out to every subscriber so there's no
|
||||
interference.
|
||||
"""
|
||||
@@ -943,7 +943,7 @@ def _firmware_log_stream() -> Any:
|
||||
def handler(line: str, interface: Any) -> None:
|
||||
# `interface` is the meshtastic SerialInterface; `.devPath`
|
||||
# carries the /dev/cu.* we care about. Defensive about missing
|
||||
# attribute — the pubsub handler must never raise.
|
||||
# attribute - the pubsub handler must never raise.
|
||||
try:
|
||||
port = getattr(interface, "devPath", None) or getattr(
|
||||
interface, "stream", None
|
||||
@@ -959,7 +959,7 @@ def _firmware_log_stream() -> Any:
|
||||
fh.write(json.dumps(record) + "\n")
|
||||
fh.flush()
|
||||
except Exception:
|
||||
# Swallow — firmware log mirroring is best-effort.
|
||||
# Swallow - firmware log mirroring is best-effort.
|
||||
pass
|
||||
|
||||
pub.subscribe(handler, "meshtastic.log.line")
|
||||
@@ -980,7 +980,7 @@ def _firmware_log_stream() -> Any:
|
||||
def _debug_log_buffer(request: pytest.FixtureRequest) -> Any:
|
||||
"""Per-test capture of `meshtastic.log.line` pubsub events.
|
||||
|
||||
Automatic — every test gets this for free. The pubsub topic fires when
|
||||
Automatic - every test gets this for free. The pubsub topic fires when
|
||||
a connected device has `security.debug_log_api_enabled=True` AND the
|
||||
client (us) is talking protobufs over its SerialInterface. `baked_mesh`
|
||||
flips the flag on at session start, so every subsequent test that opens
|
||||
@@ -989,7 +989,7 @@ def _debug_log_buffer(request: pytest.FixtureRequest) -> Any:
|
||||
|
||||
The captured lines are attached to the test's pytest-html failure report
|
||||
by `pytest_runtest_makereport`, so mesh/telemetry failures ship with the
|
||||
firmware-side log context inline — no separate pio monitor, no
|
||||
firmware-side log context inline - no separate pio monitor, no
|
||||
port-lock conflict.
|
||||
"""
|
||||
import threading as _threading
|
||||
@@ -1026,7 +1026,7 @@ def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any:
|
||||
`meshtastic.SerialInterface` construction can hang indefinitely on a
|
||||
misconfigured or unresponsive port. pytest-timeout fires from the main
|
||||
thread via SIGALRM, which doesn't protect code running inside
|
||||
`pytest_runtest_makereport` — that hook runs outside the test's timer. So
|
||||
`pytest_runtest_makereport` - that hook runs outside the test's timer. So
|
||||
we wrap each device query in a bounded worker.
|
||||
"""
|
||||
import concurrent.futures
|
||||
@@ -1064,7 +1064,7 @@ def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
|
||||
label = f"{cap.get('step', '?')}: {cap.get('label', '')}"
|
||||
frame = cap.get("frame") or {}
|
||||
frame_str = (
|
||||
f" — frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
|
||||
f" - frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
|
||||
)
|
||||
if png_path:
|
||||
try:
|
||||
@@ -1085,7 +1085,7 @@ def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
|
||||
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any:
|
||||
"""On test failure, attach serial capture + device state as report artifacts.
|
||||
|
||||
Hard-bounded by `_run_with_timeout` — if the device is unreachable (stuck
|
||||
Hard-bounded by `_run_with_timeout` - if the device is unreachable (stuck
|
||||
port, unbaked firmware, dead board), the dump is skipped rather than
|
||||
hanging the session.
|
||||
|
||||
@@ -1096,7 +1096,7 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
|
||||
# Attach UI captures on any outcome (pass + fail) — these are the whole
|
||||
# Attach UI captures on any outcome (pass + fail) - these are the whole
|
||||
# point of the UI tier. Do this before the failure-only branch below so
|
||||
# passing tests still get their image strip.
|
||||
if report.when == "call":
|
||||
@@ -1108,7 +1108,7 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
|
||||
extras: list[str] = []
|
||||
|
||||
# Attach firmware log stream captured via the StreamAPI (populated only
|
||||
# when the device has security.debug_log_api_enabled=True — baked_mesh
|
||||
# when the device has security.debug_log_api_enabled=True - baked_mesh
|
||||
# flips this on at session start). Cheap and high-signal: last 200 lines
|
||||
# of firmware log interleaved with whatever the test was doing.
|
||||
log_buffer = getattr(item, "_debug_log_buffer", None)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Fleet: different session seeds produce non-overlapping PSKs.
|
||||
|
||||
No hardware needed — this is a pure property check on the test profile
|
||||
No hardware needed - this is a pure property check on the test profile
|
||||
generator, elevated into the `fleet/` tier because it's the critical
|
||||
invariant for running concurrent CI labs without cross-contamination.
|
||||
"""
|
||||
@@ -12,7 +12,7 @@ from meshtastic_mcp import userprefs
|
||||
|
||||
def test_psk_seed_isolates_runs() -> None:
|
||||
"""Two labs running simultaneously with different seeds must end up with
|
||||
different PSKs — which means firmware baked in lab A cannot decode lab B's
|
||||
different PSKs - which means firmware baked in lab A cannot decode lab B's
|
||||
traffic, and vice versa.
|
||||
|
||||
This is the formal statement of the isolation claim that
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Shared helper for mesh receive tests.
|
||||
|
||||
`pio device monitor` captures firmware log output, which does NOT include
|
||||
decoded text message contents or telemetry payloads — those are only
|
||||
decoded text message contents or telemetry payloads - those are only
|
||||
accessible through `meshtastic.SerialInterface`'s pubsub mechanism.
|
||||
|
||||
`ReceiveCollector` opens a long-lived SerialInterface on a port, subscribes
|
||||
@@ -9,7 +9,7 @@ to the pubsub topic of interest, and exposes an atomic `wait_for(predicate)`
|
||||
that mesh tests use to verify end-to-end delivery.
|
||||
|
||||
This module also exposes two module-level helpers for forcing a device to
|
||||
broadcast a fresh NodeInfo — the on-demand path that sidesteps the
|
||||
broadcast a fresh NodeInfo - the on-demand path that sidesteps the
|
||||
firmware's 10-minute NodeInfo rate-limit. Tests doing directed PKI-encrypted
|
||||
sends need BOTH endpoints to hold current pubkeys for each other:
|
||||
|
||||
@@ -31,7 +31,7 @@ from typing import Any, Callable
|
||||
def nudge_nodeinfo(iface: Any) -> None:
|
||||
"""Force the device behind ``iface`` to broadcast a fresh NodeInfo.
|
||||
|
||||
Sends a ``ToRadio.Heartbeat(nonce=1)`` — the firmware's documented
|
||||
Sends a ``ToRadio.Heartbeat(nonce=1)`` - the firmware's documented
|
||||
on-demand NodeInfo trigger (see `src/mesh/api/PacketAPI.cpp:74-79`
|
||||
for TCP/UDP and `src/mesh/PhoneAPI.cpp::handleToRadio` for serial,
|
||||
both routed to `NodeInfoModule::sendOurNodeInfo(..., shorterTimeout=true)`
|
||||
@@ -49,7 +49,7 @@ def nudge_nodeinfo(iface: Any) -> None:
|
||||
|
||||
|
||||
def nudge_nodeinfo_port(port: str) -> None:
|
||||
"""Open ``port`` briefly, nudge, close — for when no iface is open yet.
|
||||
"""Open ``port`` briefly, nudge, close - for when no iface is open yet.
|
||||
|
||||
Uses the meshtastic_mcp port-lock-aware `connect()` context manager
|
||||
so we don't race ReceiveCollector or other long-lived handles on
|
||||
@@ -99,7 +99,7 @@ class ReceiveCollector:
|
||||
)
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
# pubsub uses weak refs by default — we stash a strong ref so the
|
||||
# pubsub uses weak refs by default - we stash a strong ref so the
|
||||
# handler doesn't disappear between subscribe and wait_for.
|
||||
def handler(packet: dict, interface: Any) -> None:
|
||||
with self._lock:
|
||||
@@ -169,7 +169,7 @@ class ReceiveCollector:
|
||||
"""Send a text packet through the already-open SerialInterface.
|
||||
|
||||
Use this when a test also has a ReceiveCollector open on the same port
|
||||
— `admin.send_text(port=...)` would try to open a second SerialInterface
|
||||
- `admin.send_text(port=...)` would try to open a second SerialInterface
|
||||
and fail the port lock.
|
||||
"""
|
||||
if self._iface is None:
|
||||
@@ -187,7 +187,7 @@ class ReceiveCollector:
|
||||
Thin wrapper around the module-level :func:`nudge_nodeinfo` that
|
||||
also validates the context-manager invariant. Delegates so tests
|
||||
that need to nudge BOTH sides (bilateral PKI warmup) share one
|
||||
implementation — the caller just passes each iface in turn.
|
||||
implementation - the caller just passes each iface in turn.
|
||||
|
||||
Firmware-side details (rate-limit bypass, nonce==1 trigger path,
|
||||
shorterTimeout=true window) are documented on the module-level
|
||||
|
||||
@@ -4,7 +4,7 @@ Opens a ReceiveCollector on EVERY role, sends a uniquely-tagged broadcast
|
||||
from each role in turn, and asserts every OTHER role saw it. One atomic
|
||||
test that answers "is the mesh actually working both directions?".
|
||||
|
||||
Not parametrized — it inherently involves the full hub.
|
||||
Not parametrized - it inherently involves the full hub.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,7 +44,7 @@ def test_bidirectional_mesh_communication(
|
||||
time.sleep(2.0)
|
||||
|
||||
# From each role, send a uniquely-tagged broadcast. We MUST send through
|
||||
# the already-open collector — opening a new SerialInterface here would
|
||||
# the already-open collector - opening a new SerialInterface here would
|
||||
# race the collector's exclusive lock on the port.
|
||||
tags: dict[str, str] = {}
|
||||
for sender in roles:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Mesh: broadcast text from TX arrives at RX.
|
||||
|
||||
Uses `meshtastic.SerialInterface` pubsub on RX to detect the decoded text
|
||||
packet — `pio device monitor` output doesn't include message bodies.
|
||||
packet - `pio device monitor` output doesn't include message bodies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_direct_with_ack_roundtrip(
|
||||
|
||||
unique = f"mcp-ack-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
|
||||
# TX iface stays open across the RX wait — sendText+wantAck relies on
|
||||
# TX iface stays open across the RX wait - sendText+wantAck relies on
|
||||
# the firmware's retransmit loop, which races the SerialInterface close.
|
||||
# Bilateral NodeInfo nudge: directed packets are PKI-encrypted, so BOTH
|
||||
# sides need current pubkeys (err=35/39 otherwise). See
|
||||
@@ -72,7 +72,7 @@ def test_direct_with_ack_roundtrip(
|
||||
)
|
||||
|
||||
# Retry covers LoRa collisions. Re-nudge both sides between
|
||||
# attempts — if RX's cached TX pubkey is stale, just re-sending
|
||||
# attempts - if RX's cached TX pubkey is stale, just re-sending
|
||||
# the text doesn't heal it; re-broadcasting NodeInfo does.
|
||||
got = None
|
||||
for _attempt in range(2):
|
||||
|
||||
@@ -16,7 +16,7 @@ from meshtastic_mcp.connection import connect
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_mesh_formation_within_60s(mesh_pair: dict[str, Any], wait_until) -> None:
|
||||
"""Runs for every directed role pair — so we prove `A sees B in its node
|
||||
"""Runs for every directed role pair - so we prove `A sees B in its node
|
||||
DB` AND `B sees A in its node DB` independently. A one-sided pass can
|
||||
mask a real problem (e.g. device A's RX works but its TX is dead).
|
||||
"""
|
||||
|
||||
@@ -11,15 +11,15 @@ mesh exercises:
|
||||
* when the established relay drops and returns, delivery recovers rather than
|
||||
black-holing (the M3 stale-route decay / re-learn path).
|
||||
|
||||
TOPOLOGY REQUIREMENT — why this usually SKIPS:
|
||||
TOPOLOGY REQUIREMENT - why this usually SKIPS:
|
||||
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
|
||||
Three co-located radios all hear each other, so A→C is a single direct hop and
|
||||
next_hop never engages. To run this test the bench must be a *line* — A — B — C
|
||||
— with the endpoints out of each other's direct RF range (physical distance or
|
||||
next_hop never engages. To run this test the bench must be a *line* - A - B - C
|
||||
- with the endpoints out of each other's direct RF range (physical distance or
|
||||
attenuators). The `multihop_topology` fixture detects this automatically: it
|
||||
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
|
||||
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
|
||||
file is safe to commit and run anywhere — it only *asserts* when the topology
|
||||
file is safe to commit and run anywhere - it only *asserts* when the topology
|
||||
genuinely requires a relay.
|
||||
|
||||
REQUIREMENTS:
|
||||
@@ -56,12 +56,12 @@ def _hops_away(rec: dict[str, Any]) -> int | None:
|
||||
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
|
||||
"""Flood a fresh NodeInfo from every node so the whole mesh (including
|
||||
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
|
||||
distances. Best-effort — a single node failing to nudge shouldn't abort."""
|
||||
distances. Best-effort - a single node failing to nudge shouldn't abort."""
|
||||
for _ in range(rounds):
|
||||
for port in ports:
|
||||
try:
|
||||
nudge_nodeinfo_port(port)
|
||||
except Exception: # noqa: BLE001 — warmup is best-effort
|
||||
except Exception: # noqa: BLE001 - warmup is best-effort
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
time.sleep(settle)
|
||||
@@ -135,7 +135,7 @@ def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
|
||||
_warm_mesh([port for port, _ in by_role.values()])
|
||||
|
||||
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
|
||||
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
|
||||
# (cheap - no traceroute yet). On an all-direct bench nothing qualifies.
|
||||
multihop_pair: tuple[str, str] | None = None
|
||||
for a_role in roles:
|
||||
a_port, _ = by_role[a_role]
|
||||
@@ -157,8 +157,8 @@ def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
if not multihop_pair:
|
||||
pytest.skip(
|
||||
"no multi-hop pair found — every device appears to be a direct "
|
||||
"neighbor. Arrange the bench as a line (A — B — C) with the "
|
||||
"no multi-hop pair found - every device appears to be a direct "
|
||||
"neighbor. Arrange the bench as a line (A - B - C) with the "
|
||||
"endpoints out of direct RF range (distance or attenuators) so a "
|
||||
"relay is actually required, then re-run."
|
||||
)
|
||||
@@ -231,21 +231,21 @@ def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
|
||||
|
||||
assert got is not None, (
|
||||
f"multi-hop directed DM {tx_role}→{rx_role} via relay "
|
||||
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
|
||||
f"{relay_role!r} never landed - NextHop multi-hop delivery is broken"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(600)
|
||||
def test_multihop_relay_recovery(
|
||||
multihop_topology: dict[str, Any],
|
||||
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
|
||||
power_cycle, # noqa: ARG001 - forces the uhubctl-availability skip
|
||||
) -> None:
|
||||
"""Delivery recovers after the established relay drops and returns.
|
||||
|
||||
Establishes a baseline DM (route via relay learned), powers the relay OFF
|
||||
(confirming TX survives sending across a downed relay), then powers it back
|
||||
ON and asserts directed delivery resumes — the M3 stale-route decay /
|
||||
re-learn path. With a strict A — B — C line there is no path while B is down,
|
||||
ON and asserts directed delivery resumes - the M3 stale-route decay /
|
||||
re-learn path. With a strict A - B - C line there is no path while B is down,
|
||||
so we only assert TX doesn't crash during the outage; the delivery assertion
|
||||
is after B returns.
|
||||
"""
|
||||
@@ -266,7 +266,7 @@ def test_multihop_relay_recovery(
|
||||
post = f"mh-recover-post-{int(time.time())}"
|
||||
|
||||
# Baseline: confirm delivery works (so the route via the relay is learned)
|
||||
# before we perturb anything — otherwise a later failure is ambiguous.
|
||||
# before we perturb anything - otherwise a later failure is ambiguous.
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
@@ -279,7 +279,7 @@ def test_multihop_relay_recovery(
|
||||
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
|
||||
)
|
||||
is not None
|
||||
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
|
||||
), "baseline multi-hop delivery failed - skipping recovery to avoid a false result"
|
||||
|
||||
# Power the relay OFF.
|
||||
try:
|
||||
@@ -304,7 +304,7 @@ def test_multihop_relay_recovery(
|
||||
)
|
||||
assert pkt is not None
|
||||
time.sleep(8.0) # let retransmissions + route decay run
|
||||
except Exception as exc: # noqa: BLE001 — restore bench state before failing
|
||||
except Exception as exc: # noqa: BLE001 - restore bench state before failing
|
||||
_power.power_on(relay_role)
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
raise AssertionError(
|
||||
@@ -316,7 +316,7 @@ def test_multihop_relay_recovery(
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
|
||||
except Exception: # noqa: BLE001 - relay port isn't one we connect to directly
|
||||
pass
|
||||
time.sleep(8.0)
|
||||
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
|
||||
@@ -343,5 +343,5 @@ def test_multihop_relay_recovery(
|
||||
|
||||
assert got is not None, (
|
||||
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}→{rx_role} "
|
||||
"never resumed — stale-route recovery (M3) may be broken"
|
||||
"never resumed - stale-route recovery (M3) may be broken"
|
||||
)
|
||||
|
||||
@@ -5,16 +5,16 @@ off mid-send via uhubctl, then powered back on.
|
||||
|
||||
Flow (parametrized over every directed mesh_pair):
|
||||
1. Bilateral PKI warmup (same pattern as test_direct_with_ack).
|
||||
2. TX sends a broadcast text "msg-1" — RX confirms receipt via pubsub.
|
||||
2. TX sends a broadcast text "msg-1" - RX confirms receipt via pubsub.
|
||||
3. Power OFF RX via uhubctl. The RX device disappears from the OS.
|
||||
4. TX sends a directed text "msg-2" with wantAck=True. Firmware retries
|
||||
internally for ~30s before giving up. Assertion: the packet object
|
||||
was accepted by the TX stack (non-None) — we don't assert an ACK
|
||||
was accepted by the TX stack (non-None) - we don't assert an ACK
|
||||
since there's no peer to send one.
|
||||
5. Power ON RX. Wait for re-enumeration + boot.
|
||||
6. Bilateral PKI re-nudge — RX's in-RAM PKI cache was wiped on reboot,
|
||||
6. Bilateral PKI re-nudge - RX's in-RAM PKI cache was wiped on reboot,
|
||||
so the first directed send may err=35 without a fresh NodeInfo ping.
|
||||
7. TX sends a directed "msg-3" — RX receives it via pubsub, confirming
|
||||
7. TX sends a directed "msg-3" - RX receives it via pubsub, confirming
|
||||
the mesh recovered.
|
||||
|
||||
Skips cleanly if uhubctl isn't installed (via the `power_cycle` fixture's
|
||||
@@ -38,7 +38,7 @@ from ._receive import ReceiveCollector, nudge_nodeinfo
|
||||
@pytest.mark.timeout(360)
|
||||
def test_peer_offline_then_recovers(
|
||||
mesh_pair: dict[str, Any],
|
||||
power_cycle, # noqa: ARG001 — forces uhubctl-availability skip
|
||||
power_cycle, # noqa: ARG001 - forces uhubctl-availability skip
|
||||
hub_devices: dict[str, str],
|
||||
) -> None:
|
||||
tx_port = mesh_pair["tx"]["port"]
|
||||
@@ -80,7 +80,7 @@ def test_peer_offline_then_recovers(
|
||||
timeout=30,
|
||||
)
|
||||
assert got is not None, (
|
||||
f"baseline directed send ({tx_role}→{rx_role}) didn't land — "
|
||||
f"baseline directed send ({tx_role}→{rx_role}) didn't land - "
|
||||
"skipping offline test to avoid false positive"
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_peer_offline_then_recovers(
|
||||
assert packet is not None
|
||||
# Give firmware a moment to do a retry or two while RX is down.
|
||||
time.sleep(5.0)
|
||||
except Exception as exc: # noqa: BLE001 — TX should survive the peer being gone
|
||||
except Exception as exc: # noqa: BLE001 - TX should survive the peer being gone
|
||||
# Restore RX before reraising so the bench state is sane.
|
||||
_power.power_on(rx_role)
|
||||
resolve_port_by_role(rx_role, timeout_s=30.0)
|
||||
@@ -151,5 +151,5 @@ def test_peer_offline_then_recovers(
|
||||
|
||||
assert got is not None, (
|
||||
f"post-recovery directed send {unique_post!r} ({tx_role}→{rx_role}) "
|
||||
"never landed — recovery path may be broken"
|
||||
"never landed - recovery path may be broken"
|
||||
)
|
||||
|
||||
@@ -33,13 +33,13 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
Why the listener is on TX (not RX):
|
||||
The traceroute RESPONSE is addressed to TX (the original requester).
|
||||
The meshtastic Python client publishes `meshtastic.receive.traceroute`
|
||||
on the interface that received that response — which is TX's iface.
|
||||
on the interface that received that response - which is TX's iface.
|
||||
A listener on RX would only see the inbound REQUEST, which lacks
|
||||
the SNR-towards / SNR-back fields the firmware only fills on reply.
|
||||
|
||||
Why we ping RX's NodeInfo before sending:
|
||||
Traceroute requests are directed sends (wantResponse=True, specific
|
||||
destinationId) — subject to the same PKI_SEND_FAIL_PUBLIC_KEY trap
|
||||
destinationId) - subject to the same PKI_SEND_FAIL_PUBLIC_KEY trap
|
||||
as `test_direct_with_ack`. We open RX briefly to trigger the
|
||||
on-demand NodeInfo broadcast, then wait for TX's nodesByNum to
|
||||
populate RX's publicKey before calling sendTraceRoute.
|
||||
@@ -54,7 +54,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
with ReceiveCollector(
|
||||
tx_port, topic="meshtastic.receive.traceroute"
|
||||
) as tx_listener:
|
||||
# Bilateral PKI warmup — traceroute requests are directed and
|
||||
# Bilateral PKI warmup - traceroute requests are directed and
|
||||
# PKI-encrypted, so both sides need current pubkeys. See
|
||||
# `_receive.py::nudge_nodeinfo` and the test_direct_with_ack
|
||||
# comment for the full rationale (one-sided nudge lets err=35
|
||||
@@ -105,7 +105,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
# sendTraceRoute already waited for the response internally, but
|
||||
# pubsub dispatch runs on the meshtastic-python reader thread —
|
||||
# pubsub dispatch runs on the meshtastic-python reader thread -
|
||||
# give it a short grace window to queue the packet.
|
||||
packet = tx_listener.wait_for(
|
||||
lambda p: p.get("from") == rx_node_num,
|
||||
@@ -138,7 +138,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
f"traceroute `routeBack` should be empty on a 2-device direct "
|
||||
f"mesh; got {back_hops!r}"
|
||||
)
|
||||
# `snr_towards` has len(route) + 1 entries — one per hop plus a final
|
||||
# `snr_towards` has len(route) + 1 entries - one per hop plus a final
|
||||
# entry for the destination's receive SNR. Direct mesh → len(route)
|
||||
# is 0 → exactly 1 SNR entry.
|
||||
assert len(snr_towards) == 1, (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Monitor: boot log is clean — no panic markers in the first 60 seconds.
|
||||
"""Monitor: boot log is clean - no panic markers in the first 60 seconds.
|
||||
|
||||
This is the single highest-signal test for catching firmware regressions.
|
||||
If a commit broke something critical at boot (stack overflow, NULL deref, HAL
|
||||
@@ -36,7 +36,7 @@ def test_boot_log_no_panic(
|
||||
role_env,
|
||||
wait_until,
|
||||
) -> None:
|
||||
"""Runs once per connected role — each device must boot cleanly,
|
||||
"""Runs once per connected role - each device must boot cleanly,
|
||||
independently. A panic on one role shouldn't mask another."""
|
||||
role = baked_single["role"]
|
||||
port = baked_single["port"]
|
||||
@@ -52,7 +52,7 @@ def test_boot_log_no_panic(
|
||||
time.sleep(60.0)
|
||||
|
||||
lines = cap.snapshot(max_lines=4000)
|
||||
assert lines, "serial capture returned no log lines — monitor may have failed"
|
||||
assert lines, "serial capture returned no log lines - monitor may have failed"
|
||||
blob = "\n".join(lines).lower()
|
||||
|
||||
hits = [marker for marker in _PANIC_MARKERS if marker in blob]
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Any
|
||||
import pytest
|
||||
from meshtastic_mcp import admin, flash
|
||||
|
||||
# Deterministic 32-byte "admin key" — just the byte values 0..31 for easy
|
||||
# Deterministic 32-byte "admin key" - just the byte values 0..31 for easy
|
||||
# recognition in the output, formatted as a C brace-init.
|
||||
_ADMIN_KEY_BYTES = list(range(32))
|
||||
_ADMIN_KEY_BRACE = "{ " + ", ".join(f"0x{b:02x}" for b in _ADMIN_KEY_BYTES) + " }"
|
||||
@@ -71,7 +71,7 @@ def test_admin_key_baked(
|
||||
), f"admin_key bytes not visible in security config: {security!r}"
|
||||
assert (
|
||||
key_field is not None
|
||||
), "security.admin_key field absent — baking key 0 didn't stick"
|
||||
), "security.admin_key field absent - baking key 0 didn't stick"
|
||||
finally:
|
||||
# Restore session profile (no admin key)
|
||||
restore = flash.erase_and_flash(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Provisioning: the pre-bake recipe (US/LONG_FAST/slot 88/private channel)
|
||||
lands on the device exactly as specified.
|
||||
|
||||
This is THE test that proves the MCP's core value prop — flashing firmware
|
||||
This is THE test that proves the MCP's core value prop - flashing firmware
|
||||
with a preset USERPREFS produces a device in the expected radio config without
|
||||
any post-flash admin steps.
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
`USERPREFS_CONFIG_LORA_REGION` must refuse to transmit.
|
||||
|
||||
Real operator concern: FCC compliance. A device shipped without an explicit
|
||||
region setting must not emit RF until the operator sets a region — this test
|
||||
region setting must not emit RF until the operator sets a region - this test
|
||||
proves the firmware honors that invariant when the USERPREFS bake deliberately
|
||||
omits the region key.
|
||||
|
||||
@@ -32,7 +32,7 @@ def test_unset_region_blocks_tx(
|
||||
) -> None:
|
||||
"""Bake a device with no LoRa region, then assert:
|
||||
1. `config.lora.region` reads as "UNSET" (or 0).
|
||||
2. An attempt to `send_text` surfaces a refusal — either the meshtastic
|
||||
2. An attempt to `send_text` surfaces a refusal - either the meshtastic
|
||||
SDK raises, or the serial log contains a clear "region unset" marker.
|
||||
|
||||
Always re-bakes the session test_profile in the finalizer so downstream
|
||||
@@ -43,7 +43,7 @@ def test_unset_region_blocks_tx(
|
||||
pytest.skip(f"role {target!r} not on hub")
|
||||
port = hub_devices[target]
|
||||
|
||||
# Pick the right env for this role — must match what test_00_bake used.
|
||||
# Pick the right env for this role - must match what test_00_bake used.
|
||||
import os
|
||||
|
||||
env = os.environ.get("MESHTASTIC_MCP_ENV_ESP32S3", "t-beam-1w")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Real operator concern: "if someone resets my fleet device, will it come back
|
||||
on my private mesh or on Meshtastic defaults?" A baked USERPREFS recipe
|
||||
should be the factory floor for the device — reset goes back to THAT state,
|
||||
should be the factory floor for the device - reset goes back to THAT state,
|
||||
not to stock Meshtastic.
|
||||
"""
|
||||
|
||||
@@ -51,12 +51,12 @@ def test_baked_prefs_survive_factory_reset(
|
||||
# Trigger non-full factory reset
|
||||
admin.factory_reset(port=port, confirm=True, full=False)
|
||||
|
||||
# Device re-enumerates — rediscover its port before probing. nRF52's
|
||||
# Device re-enumerates - rediscover its port before probing. nRF52's
|
||||
# CDC endpoint drops and comes back with a new `/dev/cu.usbmodem*`
|
||||
# path on macOS; ESP32-S3 usually keeps the same path but the helper
|
||||
# works either way (it just returns the current path for this role).
|
||||
# Early sleep lets the USB kernel driver settle before we start
|
||||
# polling — list_devices during a transient re-enumeration can return
|
||||
# polling - list_devices during a transient re-enumeration can return
|
||||
# an empty list and the helper's poll-with-backoff handles that too,
|
||||
# so the sleep is optimization not correctness.
|
||||
time.sleep(10.0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Recovery tier — exercises `uhubctl` power control end-to-end.
|
||||
"""Recovery tier - exercises `uhubctl` power control end-to-end.
|
||||
|
||||
Requires `uhubctl` installed AND at least one connected device on a
|
||||
PPPS-capable hub. The whole tier skips cleanly via
|
||||
|
||||
@@ -25,7 +25,7 @@ def _recovery_tier_guard() -> None:
|
||||
)
|
||||
|
||||
# Probe: can we even list hubs? (A macOS user without sudo gets a
|
||||
# permission error here — we'd rather find out once at tier-start than
|
||||
# permission error here - we'd rather find out once at tier-start than
|
||||
# 6 tests later.)
|
||||
from meshtastic_mcp import uhubctl
|
||||
|
||||
@@ -39,6 +39,6 @@ def _recovery_tier_guard() -> None:
|
||||
|
||||
if not any(h["ppps"] for h in hubs):
|
||||
pytest.skip(
|
||||
"no PPPS-capable hubs detected — recovery tier has nothing to exercise.",
|
||||
"no PPPS-capable hubs detected - recovery tier has nothing to exercise.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from meshtastic_mcp import uhubctl
|
||||
@pytest.mark.timeout(30)
|
||||
def test_list_hubs_returns_at_least_one_ppps_hub() -> None:
|
||||
hubs = uhubctl.list_hubs()
|
||||
assert hubs, "uhubctl found no hubs at all — is a USB hub connected?"
|
||||
assert hubs, "uhubctl found no hubs at all - is a USB hub connected?"
|
||||
assert any(h["ppps"] for h in hubs), (
|
||||
"no PPPS-capable hubs detected; power control won't work. "
|
||||
"Check that the hub supports Per-Port Power Switching."
|
||||
@@ -31,13 +31,13 @@ def test_list_hubs_structure(hub_devices: dict[str, str]) -> None:
|
||||
assert "status" in port
|
||||
|
||||
# At least one of the detected Meshtastic roles should show up in some
|
||||
# port's device_vid — otherwise the recovery tier can't drive them.
|
||||
# port's device_vid - otherwise the recovery tier can't drive them.
|
||||
seen_vids = {
|
||||
p["device_vid"] for h in hubs for p in h["ports"] if p["device_vid"] is not None
|
||||
}
|
||||
expected_any = {0x239A, 0x303A, 0x10C4} & seen_vids
|
||||
assert expected_any or not hub_devices, (
|
||||
f"hub_devices detected roles {list(hub_devices)} but uhubctl sees "
|
||||
f"VIDs {sorted(hex(v) for v in seen_vids)} — the devices may be on "
|
||||
f"VIDs {sorted(hex(v) for v in seen_vids)} - the devices may be on "
|
||||
"a hub that uhubctl can't see (e.g. built-in laptop ports)."
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Two-path verification:
|
||||
1. Listen on TX's pubsub for inbound telemetry packets originating from
|
||||
RX's node_num — if one arrives within the window, telemetry works.
|
||||
RX's node_num - if one arrives within the window, telemetry works.
|
||||
2. Fall back to checking TX's node DB for a populated `deviceMetrics`
|
||||
block on the RX record (which the firmware writes on receipt).
|
||||
|
||||
@@ -30,7 +30,7 @@ from ..mesh._receive import ReceiveCollector
|
||||
@pytest.mark.timeout(600)
|
||||
def test_device_telemetry_broadcast(mesh_pair: dict[str, Any]) -> None:
|
||||
"""Runs for every directed pair. Waits up to ~8 minutes for TX to see
|
||||
RX's device telemetry — either as a live inbound pubsub packet or as
|
||||
RX's device telemetry - either as a live inbound pubsub packet or as
|
||||
a populated deviceMetrics on RX's node-DB record.
|
||||
|
||||
Firmware default telemetry interval is 900s; after a fresh boot the
|
||||
@@ -56,14 +56,14 @@ def test_device_telemetry_broadcast(mesh_pair: dict[str, Any]) -> None:
|
||||
# Path 1: wait for a telemetry packet from RX on TX's pubsub.
|
||||
got = tx_rx.wait_for(
|
||||
lambda pkt: pkt.get("from") == rx_node_num,
|
||||
timeout=420, # 7 min — well above the 30-120s typical first broadcast
|
||||
timeout=420, # 7 min - well above the 30-120s typical first broadcast
|
||||
)
|
||||
if got is not None:
|
||||
return # Path 1 confirmed delivery.
|
||||
|
||||
# Path 2: re-query TX's node DB for a populated deviceMetrics on RX.
|
||||
# Device may have reported telemetry before we subscribed, or the
|
||||
# pubsub delivery might race with our window — re-check nodesByNum.
|
||||
# pubsub delivery might race with our window - re-check nodesByNum.
|
||||
with connect(port=tx_port) as iface:
|
||||
rec = (iface.nodesByNum or {}).get(rx_node_num, {})
|
||||
metrics = rec.get("deviceMetrics") or {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Telemetry: on-demand device-metrics request gets a prompt reply.
|
||||
|
||||
Complementary to ``test_device_telemetry_broadcast`` — that one witnesses the
|
||||
Complementary to ``test_device_telemetry_broadcast`` - that one witnesses the
|
||||
firmware's *periodic* broadcast (900 s default interval, up to ~7 min worst
|
||||
case). This one exercises the *request/reply* path: TX sends a
|
||||
``meshtastic_Telemetry`` with the ``device_metrics`` variant-tag set and
|
||||
@@ -13,14 +13,14 @@ Validates:
|
||||
* ``sendData(portNum=TELEMETRY_APP, want_response=True)`` encodes + routes
|
||||
to RX (directed, PKI-encrypted to RX's pubkey)
|
||||
* RX's ``DeviceTelemetryModule::handleReceivedProtobuf`` dispatches to
|
||||
``allocReply`` — which is only invoked by the framework when
|
||||
``allocReply`` - which is only invoked by the framework when
|
||||
``want_response`` is set on the incoming packet
|
||||
* The reply carries a ``DeviceMetrics`` sub-message with at least one
|
||||
non-zero field (uptime_seconds is guaranteed non-zero a few seconds
|
||||
after boot, so it reliably survives protobuf's default-value
|
||||
serialization stripping)
|
||||
* The reply routes back to TX and gets matched against the original
|
||||
request via ``request_id`` — using the library's ``onResponse``
|
||||
request via ``request_id`` - using the library's ``onResponse``
|
||||
callback mechanism, which stores the handler at
|
||||
``responseHandlers[sent_packet.id]`` and dispatches when a packet
|
||||
arrives with ``decoded.request_id == sent_packet.id``. This is more
|
||||
@@ -74,7 +74,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
# onResponse, not pubsub), but keeping a concrete topic avoids the
|
||||
# surprise of a pubsub wildcard receiving every packet type.
|
||||
with ReceiveCollector(tx_port, topic="meshtastic.receive.telemetry") as tx_listener:
|
||||
# Bilateral PKI warmup — nudge BOTH sides to rebroadcast their
|
||||
# Bilateral PKI warmup - nudge BOTH sides to rebroadcast their
|
||||
# NodeInfo (with current pubkey) before the directed send.
|
||||
# * Nudging only RX gets RX's key → TX, but leaves RX with a
|
||||
# potentially stale TX pubkey → RX NAKs our request with
|
||||
@@ -92,7 +92,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
if last_rec.get("user", {}).get("publicKey"):
|
||||
break
|
||||
if time.monotonic() - last_nudge > 15.0:
|
||||
# Re-nudge both sides — LoRa collisions can drop either
|
||||
# Re-nudge both sides - LoRa collisions can drop either
|
||||
# direction's NodeInfo broadcast independently.
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
tx_listener.broadcast_nodeinfo_ping()
|
||||
@@ -113,14 +113,14 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
# An empty `Telemetry()` has `which_variant = UNSET (0)`, so we MUST
|
||||
# explicitly set the variant. `CopyFrom(DeviceMetrics())` with a
|
||||
# default-constructed sub-message is the canonical Python-protobuf
|
||||
# idiom for "set the oneof tag without populating fields" — matching
|
||||
# idiom for "set the oneof tag without populating fields" - matching
|
||||
# how `MeshInterface.sendTelemetry()` constructs requests for the
|
||||
# other variants.
|
||||
#
|
||||
# Matching the reply: the meshtastic client's `onResponse` callback
|
||||
# mechanism fires ONLY for packets whose `decoded.request_id` equals
|
||||
# the original outgoing packet's `id`. That's exactly the semantic
|
||||
# we want — rejects periodic broadcasts (no request_id), rejects
|
||||
# we want - rejects periodic broadcasts (no request_id), rejects
|
||||
# stale replies to prior requests (different request_id), and
|
||||
# tolerates the firmware's reply_id/request_id naming quirk
|
||||
# (firmware's `setReplyTo` writes the original packet's id into
|
||||
@@ -160,7 +160,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
f"{[hex(p.get('from') or 0) for p in tx_listener.snapshot()]!r}"
|
||||
)
|
||||
|
||||
# Sanity: the reply's origin matches — a firmware bug that routed
|
||||
# Sanity: the reply's origin matches - a firmware bug that routed
|
||||
# the response to the wrong sender would make onResponse fire on
|
||||
# the wrong packet.
|
||||
assert got.get("from") == rx_node_num, (
|
||||
@@ -177,7 +177,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
# A populated reply must contain at least one DeviceMetrics field.
|
||||
# Protobuf's JSON serializer strips default-valued (zero) fields,
|
||||
# so a bare `deviceMetrics: {}` would mean the firmware wrote the
|
||||
# sub-message but every field was zero — plausible right at boot
|
||||
# sub-message but every field was zero - plausible right at boot
|
||||
# but not for a device that's been running long enough for a test
|
||||
# session's warmup + NodeInfo exchange (~10-30 s uptime minimum).
|
||||
populated = [k for k in _DEVICE_METRICS_FIELDS if k in dm]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Session-bake module — runs first in the tier order to flash both hub roles
|
||||
"""Session-bake module - runs first in the tier order to flash both hub roles
|
||||
with the session `test_profile`.
|
||||
|
||||
Ordered first by `pytest_collection_modifyitems` in `conftest.py` (bucket
|
||||
-1) because `baked_mesh` only *verifies* state — it does not reflash. Without
|
||||
-1) because `baked_mesh` only *verifies* state - it does not reflash. Without
|
||||
the explicit order pin, the top-level path `tests/test_00_bake.py` falls
|
||||
into the fallback bucket and sorts AFTER every tier, silently turning
|
||||
`--force-bake` into a no-op for the tier tests.
|
||||
@@ -52,9 +52,9 @@ def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> No
|
||||
|
||||
Root cause for the retry loop: esptool / nrfutil / pio all take an
|
||||
*exclusive* serial port lock (fcntl LOCK_EX on macOS, EAGAIN otherwise).
|
||||
Anything that held the port recently — the TUI's startup `DevicePollerWorker._poll_once()`,
|
||||
Anything that held the port recently - the TUI's startup `DevicePollerWorker._poll_once()`,
|
||||
a prior `device_info` call, a lingering `meshtastic-mcp` subprocess
|
||||
spawned by the operator's MCP host, or a stale `pio device monitor` —
|
||||
spawned by the operator's MCP host, or a stale `pio device monitor` -
|
||||
can still be holding it when `test_00_bake` reaches the flash step. The
|
||||
result is esptool exiting 2 in ~0.1s with `[Errno 35] Resource
|
||||
temporarily unavailable`.
|
||||
@@ -84,7 +84,7 @@ def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> No
|
||||
pass
|
||||
return
|
||||
raise AssertionError(
|
||||
f"{role_prefix}port {port} still busy after {timeout_s:.0f}s — "
|
||||
f"{role_prefix}port {port} still busy after {timeout_s:.0f}s - "
|
||||
f"something else holds an exclusive lock. Last error: {last_exc!r}. "
|
||||
f"Identify the holder with `lsof {port}` and kill it; common "
|
||||
f"culprits are a lingering `meshtastic-mcp` subprocess from the "
|
||||
@@ -102,7 +102,7 @@ def _prepare_nrf52_for_upload(port: str) -> str:
|
||||
|
||||
`touch_1200bps` does the heavy lifting: bounded open/close, polls for the
|
||||
Adafruit-bootloader PID specifically, retries the touch up to twice.
|
||||
Fails loudly if the device doesn't enter DFU — no point trying pio
|
||||
Fails loudly if the device doesn't enter DFU - no point trying pio
|
||||
upload against an app-mode device, it'll just hang.
|
||||
"""
|
||||
result = flash.touch_1200bps(port=port, settle_ms=500, retries=2)
|
||||
@@ -162,14 +162,14 @@ def _bake_role(
|
||||
# If we can't query, fall through and bake anyway.
|
||||
pass
|
||||
|
||||
# All architectures go through `pio run -t upload` — pio knows the right
|
||||
# All architectures go through `pio run -t upload` - pio knows the right
|
||||
# protocol per variant (esptool for ESP32, adafruit-nrfutil for nRF52,
|
||||
# picotool for RP2040). We don't use `bin/device-install.sh` for ESP32
|
||||
# because it requires the external `mt-esp32s3-ota.bin` helper that's
|
||||
# downloaded from releases, not generated by the build.
|
||||
#
|
||||
# IMPORTANT: `pio run -t upload` on ESP32 only overwrites the APP
|
||||
# partition — the LittleFS partition (config + NodeDB) survives. That
|
||||
# partition - the LittleFS partition (config + NodeDB) survives. That
|
||||
# means USERPREFS-baked defaults never take effect on a device that was
|
||||
# already provisioned, because NodeDB init prefers the saved config. To
|
||||
# force USERPREFS to apply cleanly, we erase the full chip first on
|
||||
@@ -184,7 +184,7 @@ def _bake_role(
|
||||
if arch in _NRF52_ARCHES:
|
||||
upload_port = _prepare_nrf52_for_upload(port)
|
||||
elif arch in _ESP32_ARCHES:
|
||||
# Full chip erase — wipes NVS + LittleFS so USERPREFS defaults apply.
|
||||
# Full chip erase - wipes NVS + LittleFS so USERPREFS defaults apply.
|
||||
erase_result = hw_tools.esptool_erase_flash(port=port, confirm=True)
|
||||
assert erase_result["exit_code"] == 0, (
|
||||
f"{role}: esptool erase_flash failed:\n"
|
||||
@@ -196,7 +196,7 @@ def _bake_role(
|
||||
|
||||
# Post-erase, pre-upload: full chip erase on ESP32 drops the CDC
|
||||
# endpoint for a moment while the bootloader re-enters download mode.
|
||||
# Wait for the port to settle before pio reopens it for upload —
|
||||
# Wait for the port to settle before pio reopens it for upload -
|
||||
# otherwise a fast machine can race and hit the same errno 35.
|
||||
if arch in _ESP32_ARCHES:
|
||||
_wait_port_free(upload_port, role=role, timeout_s=10.0)
|
||||
@@ -207,7 +207,7 @@ def _bake_role(
|
||||
# and will restore the original file at session end. A local
|
||||
# `temporary_overrides` here would be a no-op (file is already baked)
|
||||
# AND would cause the session fixture's teardown to see different
|
||||
# stat / mtime than it snapshotted — keep the mutation in one place.
|
||||
# stat / mtime than it snapshotted - keep the mutation in one place.
|
||||
result = flash.flash(
|
||||
env=env,
|
||||
port=upload_port,
|
||||
@@ -220,7 +220,7 @@ def _bake_role(
|
||||
)
|
||||
|
||||
# Post-flash: for nRF52, the DFU process only overwrites the app
|
||||
# partition — the NVS region holding the existing NodeDB/config is
|
||||
# partition - the NVS region holding the existing NodeDB/config is
|
||||
# untouched, so the firmware will prefer the saved config over the
|
||||
# baked USERPREFS defaults. Trigger a full factory reset to wipe NVS
|
||||
# so USERPREFS takes effect on the next boot.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This is NOT line coverage (that's `coverage.py`). This measures which of the
|
||||
38 public MCP tools in `meshtastic_mcp.server` got invoked during a pytest
|
||||
run — a quick signal for "where are the test-coverage gaps".
|
||||
run - a quick signal for "where are the test-coverage gaps".
|
||||
|
||||
Approach: introspect `meshtastic_mcp.server.app` for registered tools, find
|
||||
the underlying handler functions in their source modules, and wrap each with
|
||||
@@ -40,7 +40,7 @@ def _wrap(module: Any, attr: str, tool_name: str) -> None:
|
||||
|
||||
|
||||
# Mapping: MCP tool name → (module, function name). Mirrors the wiring in
|
||||
# `meshtastic_mcp.server`. Keep synchronized manually — adding a tool without
|
||||
# `meshtastic_mcp.server`. Keep synchronized manually - adding a tool without
|
||||
# updating this map means it shows as count=0 in reports even if exercised.
|
||||
_TOOL_MAP: dict[str, tuple[str, str]] = {
|
||||
# Discovery & metadata
|
||||
@@ -54,7 +54,7 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
|
||||
"erase_and_flash": ("meshtastic_mcp.flash", "erase_and_flash"),
|
||||
"update_flash": ("meshtastic_mcp.flash", "update_flash"),
|
||||
"touch_1200bps": ("meshtastic_mcp.flash", "touch_1200bps"),
|
||||
# Serial log sessions — module-level functions on serial_session
|
||||
# Serial log sessions - module-level functions on serial_session
|
||||
"serial_open": ("meshtastic_mcp.serial_session", "open_session"),
|
||||
"serial_read": ("meshtastic_mcp.serial_session", "read_session"),
|
||||
"serial_list": ("meshtastic_mcp.registry", "all_sessions"),
|
||||
@@ -74,7 +74,7 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
|
||||
"shutdown": ("meshtastic_mcp.admin", "shutdown"),
|
||||
"factory_reset": ("meshtastic_mcp.admin", "factory_reset"),
|
||||
"send_input_event": ("meshtastic_mcp.admin", "send_input_event"),
|
||||
# `capture_screen` in server.py calls camera.get_camera — instrument that.
|
||||
# `capture_screen` in server.py calls camera.get_camera - instrument that.
|
||||
"capture_screen": ("meshtastic_mcp.camera", "get_camera"),
|
||||
# USB power control via uhubctl.
|
||||
"uhubctl_list": ("meshtastic_mcp.uhubctl", "list_hubs"),
|
||||
@@ -106,8 +106,8 @@ def install() -> None:
|
||||
import importlib
|
||||
|
||||
# Whitelist the exact module paths this function is ever allowed to
|
||||
# import. `module_path` below is iterated from `_TOOL_MAP` — a file-
|
||||
# local, hardcoded dict literal — but a static whitelist makes the
|
||||
# import. `module_path` below is iterated from `_TOOL_MAP` - a file-
|
||||
# local, hardcoded dict literal - but a static whitelist makes the
|
||||
# "no untrusted input here" invariant legible to reviewers and to
|
||||
# the Semgrep `non-literal-import` audit rule.
|
||||
_allowed_modules = frozenset(path for path, _attr in _TOOL_MAP.values())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""UI tier — input-broker-driven screen navigation tests.
|
||||
"""UI tier - input-broker-driven screen navigation tests.
|
||||
|
||||
Only runs when a screen-bearing role (esp32s3/heltec-v3) is present on the
|
||||
hub AND the firmware was baked with `enable_ui_log=True` (so the
|
||||
|
||||
@@ -122,7 +122,7 @@ def wait_for_reason(
|
||||
) -> FrameEvent:
|
||||
"""Wait for a frame event with `reason=<reason>` after call-start.
|
||||
|
||||
Matches only on `reason` — useful when the caller knows *why* a
|
||||
Matches only on `reason` - useful when the caller knows *why* a
|
||||
transition should happen (e.g. `fn_f1`, `rebuild`) but not which named
|
||||
frame the firmware will land on for this particular board.
|
||||
"""
|
||||
|
||||
@@ -24,11 +24,11 @@ from meshtastic_mcp.input_events import InputEventCode
|
||||
from ._screen_log import FrameEvent, get_current_frame, wait_for_frame
|
||||
|
||||
# Roles that carry a screen the UI tier can drive. Only esp32s3 (heltec-v3
|
||||
# SSD1306) qualifies today — nrf52 (rak4631) has no display.
|
||||
# SSD1306) qualifies today - nrf52 (rak4631) has no display.
|
||||
UI_CAPABLE_ROLES = ("esp32s3",)
|
||||
|
||||
# Where per-test captures land. One subdirectory per session seed, then per
|
||||
# sanitized test nodeid — identical pattern to other pytest artifacts.
|
||||
# sanitized test nodeid - identical pattern to other pytest artifacts.
|
||||
CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures"
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ def _ocr_warm() -> None:
|
||||
"""Pay easyocr's ~100 MB / cold-start cost ONCE per session.
|
||||
|
||||
Subsequent `ocr_text()` calls hit the cached reader and return quickly.
|
||||
Swallows errors — if OCR isn't installed, warm is a no-op.
|
||||
Swallows errors - if OCR isn't installed, warm is a no-op.
|
||||
"""
|
||||
try:
|
||||
ocr_mod.warm()
|
||||
except Exception: # noqa: BLE001 — belt: never block the suite on OCR init
|
||||
except Exception: # noqa: BLE001 - belt: never block the suite on OCR init
|
||||
pass
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ def _ui_screen_kept_on(
|
||||
"""Keep the OLED on throughout the UI tier so input events aren't dropped.
|
||||
|
||||
Why: `InputBroker::handleInputEvent` (src/input/InputBroker.cpp:118-122)
|
||||
silently DROPS any event that arrives while the screen is off — it just
|
||||
silently DROPS any event that arrives while the screen is off - it just
|
||||
wakes the screen and returns. Every first event in each test would
|
||||
disappear. We set `display.screen_on_secs = 86400` at session start
|
||||
(effectively "always on" for the test window) and restore the prior
|
||||
@@ -156,7 +156,7 @@ def _ui_screen_kept_on(
|
||||
time.sleep(1.5) # Let the screen finish its wake transition.
|
||||
except (
|
||||
Exception
|
||||
): # noqa: BLE001 — best-effort; ui_home_state surfaces the real error
|
||||
): # noqa: BLE001 - best-effort; ui_home_state surfaces the real error
|
||||
pass
|
||||
|
||||
try:
|
||||
@@ -193,7 +193,7 @@ class FrameCapture:
|
||||
self._transcript_path = dir_path / "transcript.md"
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
self._transcript_path.write_text(
|
||||
f"# {nodeid} — {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
|
||||
f"# {nodeid} - {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -242,7 +242,7 @@ class FrameCapture:
|
||||
)
|
||||
ocr_summary = (ocr_str or "").replace("\n", " / ")[:80]
|
||||
fh.write(
|
||||
f"{self._step}. **{label}** — {frame_str} — OCR: `{ocr_summary}`\n"
|
||||
f"{self._step}. **{label}** - {frame_str} - OCR: `{ocr_summary}`\n"
|
||||
)
|
||||
return entry
|
||||
|
||||
@@ -273,7 +273,7 @@ def _send_event(port: str, event: InputEventCode) -> None:
|
||||
try:
|
||||
admin_mod.send_input_event(event_code=int(event), port=port)
|
||||
except Exception: # noqa: BLE001
|
||||
# Treat a failed event as soft — the subsequent frame-log assertion
|
||||
# Treat a failed event as soft - the subsequent frame-log assertion
|
||||
# surfaces the real problem with better context.
|
||||
pass
|
||||
|
||||
@@ -298,7 +298,7 @@ def ui_home_state(
|
||||
instead of letting every test body fail with a confusing assertion.
|
||||
|
||||
Autouse scope is restricted to `tests/ui/` by virtue of this fixture
|
||||
living in that directory's conftest.py — no explicit nodeid guard
|
||||
living in that directory's conftest.py - no explicit nodeid guard
|
||||
needed (and earlier attempts at one were wrong, matching `/tests/ui/`
|
||||
against a nodeid that has no leading slash).
|
||||
"""
|
||||
@@ -313,7 +313,7 @@ def ui_home_state(
|
||||
start_len = len(lines)
|
||||
|
||||
# First: a wake event. The screen should already be kept on by
|
||||
# `_ui_screen_kept_on`, but belt + suspenders — if it somehow
|
||||
# `_ui_screen_kept_on`, but belt + suspenders - if it somehow
|
||||
# powered off (sleep after factory_reset, etc.), this first FN_F1
|
||||
# gets dropped by InputBroker's screenWasOff guard. That's fine;
|
||||
# the second FN_F1 below lands cleanly.
|
||||
@@ -322,14 +322,14 @@ def ui_home_state(
|
||||
_send_event(port, InputEventCode.FN_F1)
|
||||
|
||||
# Wait for the fn_f1 transition log. Any new `reason=fn_f1` line
|
||||
# after call-start counts — we don't care about the name (it should
|
||||
# after call-start counts - we don't care about the name (it should
|
||||
# be `home` or `deviceFocused` depending on board-specific frame order).
|
||||
from ._screen_log import wait_for_reason
|
||||
|
||||
try:
|
||||
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
|
||||
except TimeoutError:
|
||||
# One more try — FreeRTOS queue may be draining slowly.
|
||||
# One more try - FreeRTOS queue may be draining slowly.
|
||||
_send_event(port, InputEventCode.FN_F1)
|
||||
try:
|
||||
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
|
||||
@@ -345,12 +345,12 @@ def ui_home_state(
|
||||
f"ui_home_state: events fire but none reach Screen "
|
||||
f"(saw {len(frame_lines)} frame line(s), "
|
||||
f"{len(processing_lines)} admin inject(s)). "
|
||||
f"Device may be in an unusual state — try `--force-bake`."
|
||||
f"Device may be in an unusual state - try `--force-bake`."
|
||||
)
|
||||
else:
|
||||
pytest.skip(
|
||||
"ui_home_state: no `Screen: frame` log after FN_F1. "
|
||||
"Firmware not baked with USERPREFS_UI_TEST_LOG — "
|
||||
"Firmware not baked with USERPREFS_UI_TEST_LOG - "
|
||||
"run with `--force-bake` to reflash, or verify the "
|
||||
"macro is active in the bake."
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_fn_jump_direct_frame(
|
||||
) -> None:
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
start = get_current_frame(lines)
|
||||
assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?"
|
||||
assert start is not None, "no frame log yet - USERPREFS_UI_TEST_LOG not wired?"
|
||||
assert start.name in (
|
||||
"home",
|
||||
"deviceFocused",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
`Screen::handleInputEvent` dispatches FN_F5 unconditionally to
|
||||
`ui->switchToFrame(4)`. The OLEDDisplayUi library typically clamps or
|
||||
silently ignores out-of-range indices, but firmware bugs have existed
|
||||
here — this test protects against a regression that would wedge the UI.
|
||||
here - this test protects against a regression that would wedge the UI.
|
||||
|
||||
If this test fails, first check: did the device actually crash (Guru
|
||||
Meditation in the log)? Or did switchToFrame accept an OOB index and
|
||||
@@ -33,7 +33,7 @@ def test_fn_f5_out_of_bounds(
|
||||
|
||||
if start.count > 5:
|
||||
pytest.skip(
|
||||
f"device has {start.count} frames; FN_F5 is in-bounds — not testing OOB here"
|
||||
f"device has {start.count} frames; FN_F5 is in-bounds - not testing OOB here"
|
||||
)
|
||||
|
||||
frame_capture("initial-home")
|
||||
@@ -43,11 +43,11 @@ def test_fn_f5_out_of_bounds(
|
||||
try:
|
||||
wait_for_reason(lines, "fn_f5", timeout_s=3.0)
|
||||
except TimeoutError:
|
||||
# Firmware may have ignored the event entirely — acceptable.
|
||||
# Firmware may have ignored the event entirely - acceptable.
|
||||
pass
|
||||
|
||||
# Capture whatever is on screen (OCR will tell us if something weird
|
||||
# happened). Device must remain responsive — subsequent events should
|
||||
# happened). Device must remain responsive - subsequent events should
|
||||
# still land.
|
||||
frame_capture("after-fn_f5-oob")
|
||||
|
||||
@@ -57,5 +57,5 @@ def test_fn_f5_out_of_bounds(
|
||||
post = wait_for_reason(lines, "next", timeout_s=5.0)
|
||||
assert (
|
||||
post is not None
|
||||
), "UI wedged after OOB FN_F5 — RIGHT no longer produces frame log"
|
||||
), "UI wedged after OOB FN_F5 - RIGHT no longer produces frame log"
|
||||
frame_capture("after-recovery-right")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""SELECT on the home frame opens the home menu; BACK closes it.
|
||||
|
||||
The home menu is an overlay (menuHandler::homeBaseMenu), not a frame
|
||||
transition — so we verify via OCR difference between before/after
|
||||
transition - so we verify via OCR difference between before/after
|
||||
captures rather than a `Screen: frame` log line. The underlying
|
||||
mechanism is still InputBroker → Screen::handleInputEvent → menu
|
||||
callback.
|
||||
@@ -39,25 +39,25 @@ def test_select_opens_home_menu(
|
||||
opened = frame_capture("after-select")
|
||||
|
||||
# The menu is an overlay (not a frame change). We cannot use log
|
||||
# assertion — instead, OCR should differ because a menu list is now
|
||||
# assertion - instead, OCR should differ because a menu list is now
|
||||
# drawn on top.
|
||||
initial_text = (initial.get("ocr_text") or "").strip()
|
||||
opened_text = (opened.get("ocr_text") or "").strip()
|
||||
if initial_text and opened_text:
|
||||
# When OCR is available, require *some* difference between the two
|
||||
# frames — even a single menu title changes the transcribed text.
|
||||
# frames - even a single menu title changes the transcribed text.
|
||||
assert initial_text != opened_text, (
|
||||
f"expected OCR diff after SELECT; both read {initial_text!r}. "
|
||||
"If both are empty, check camera alignment + OCR backend."
|
||||
)
|
||||
|
||||
# Back out — the menu dismisses on BACK.
|
||||
# Back out - the menu dismisses on BACK.
|
||||
send_event(ui_port, InputEventCode.BACK)
|
||||
time.sleep(0.8)
|
||||
closed = frame_capture("after-back")
|
||||
|
||||
# Soft check: OCR after BACK should look different from the menu
|
||||
# (either back to home or onto a previous frame — BACK's exact
|
||||
# (either back to home or onto a previous frame - BACK's exact
|
||||
# behavior when the menu is up vs. not-up varies). We don't assert
|
||||
# equality because OLED rendering is pixel-stable but camera sampling
|
||||
# introduces noise.
|
||||
|
||||
@@ -3,7 +3,7 @@ message-scroll path (or opens CannedMessages on empty devices).
|
||||
|
||||
Weaker than a "no frame change" assertion because on a fresh bench
|
||||
device the message store is usually empty, and the firmware's UP
|
||||
handler in that case launches CannedMessage — which DOES rebuild
|
||||
handler in that case launches CannedMessage - which DOES rebuild
|
||||
frames. We just verify the path doesn't crash + produce captures for
|
||||
visual inspection.
|
||||
"""
|
||||
@@ -28,7 +28,7 @@ def test_up_down_on_textmessage_survives(
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
frame_capture("initial")
|
||||
|
||||
# Walk RIGHT until we land on textMessage — up to 15 hops.
|
||||
# Walk RIGHT until we land on textMessage - up to 15 hops.
|
||||
for _i in range(15):
|
||||
send_event(ui_port, InputEventCode.RIGHT)
|
||||
time.sleep(0.3)
|
||||
@@ -37,7 +37,7 @@ def test_up_down_on_textmessage_survives(
|
||||
break
|
||||
else:
|
||||
pytest.skip(
|
||||
"couldn't reach textMessage frame within 15 RIGHTs — not present on this board"
|
||||
"couldn't reach textMessage frame within 15 RIGHTs - not present on this board"
|
||||
)
|
||||
|
||||
wait_for_frame(lines, "textMessage", timeout_s=5.0)
|
||||
@@ -57,4 +57,4 @@ def test_up_down_on_textmessage_survives(
|
||||
# The next test's `ui_home_state` will error out if the device is
|
||||
# unresponsive, so we don't need a stricter guarantee here.
|
||||
final = get_current_frame(lines)
|
||||
assert final is not None, "no frame log after UP/DOWN — event path broke"
|
||||
assert final is not None, "no frame log after UP/DOWN - event path broke"
|
||||
|
||||
@@ -25,9 +25,9 @@ def test_input_right_cycles_frames(
|
||||
) -> None:
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
start = get_current_frame(lines)
|
||||
assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?"
|
||||
assert start is not None, "no frame log yet - USERPREFS_UI_TEST_LOG not wired?"
|
||||
# FN_F1 in ui_home_state lands on frame 0. The name at frame 0 varies
|
||||
# by board (home on heltec-v3, deviceFocused on others) — accept either.
|
||||
# by board (home on heltec-v3, deviceFocused on others) - accept either.
|
||||
assert start.name in (
|
||||
"home",
|
||||
"deviceFocused",
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_up_down_on_nodelist_no_frame_change(
|
||||
frame_capture("on-nodelist")
|
||||
|
||||
# UP/DOWN on nodelist scroll internally + `return 0` before
|
||||
# notifyObservers — no frame-change log. Verify.
|
||||
# notifyObservers - no frame-change log. Verify.
|
||||
send_event(ui_port, InputEventCode.UP)
|
||||
assert_no_frame_change(lines, wait_s=1.5)
|
||||
send_event(ui_port, InputEventCode.DOWN)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""`boards.py` filter and enumeration correctness.
|
||||
|
||||
Runs against the real `pio project config` output of this firmware repo —
|
||||
Runs against the real `pio project config` output of this firmware repo -
|
||||
validates that filter predicates match expected envs and don't drift if
|
||||
variants get reorganized.
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for the `build_flags` injection on `flash.build()`.
|
||||
|
||||
We don't actually run pio here — too slow, requires hardware-aware envs.
|
||||
We don't actually run pio here - too slow, requires hardware-aware envs.
|
||||
We test the translation layer (`_build_flags_env`) and that the env vars
|
||||
are threaded through pio.run correctly via mock.
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""TCP transport plumbing in connection.py + devices.py.
|
||||
|
||||
Pure-Python tests — no real device or daemon required. Mocks `TCPInterface`
|
||||
Pure-Python tests - no real device or daemon required. Mocks `TCPInterface`
|
||||
when exercising `connect()`.
|
||||
"""
|
||||
|
||||
@@ -171,7 +171,7 @@ class TestResolvePort:
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
|
||||
# Don't patch list_devices — let the real env-var path run, but stub
|
||||
# Don't patch list_devices - let the real env-var path run, but stub
|
||||
# the USB enumeration to keep the test hermetic.
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
assert connection.resolve_port(None) == "tcp://localhost:4403"
|
||||
@@ -208,7 +208,7 @@ class TestDevicesTcpEntry:
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# `list_devices` is the diagnostic tool reached for when an env var
|
||||
# isn't working — it must not throw on misconfiguration.
|
||||
# isn't working - it must not throw on misconfiguration.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
@@ -222,7 +222,7 @@ class TestDevicesTcpEntry:
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# `likely_meshtastic=False` keeps the bad TCP entry out of the
|
||||
# auto-select path — `resolve_port(None)` should still report
|
||||
# auto-select path - `resolve_port(None)` should still report
|
||||
# "no Meshtastic devices" rather than picking a broken endpoint.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
@@ -274,7 +274,7 @@ class TestDevicesTcpEntry:
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
|
||||
assert ds, "expected at least the USB + TCP entries"
|
||||
# Real USB candidate must be at position 0 — it's likely_meshtastic.
|
||||
# Real USB candidate must be at position 0 - it's likely_meshtastic.
|
||||
assert ds[0]["port"] == "/dev/cu.usbmodem4201"
|
||||
assert ds[0]["likely_meshtastic"] is True
|
||||
# The malformed TCP entry exists but lands among the unlikely entries.
|
||||
@@ -287,7 +287,7 @@ class TestDevicesTcpEntry:
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Conversely, a *valid* TCP env var should sort ahead of USB
|
||||
# candidates of equal likely_meshtastic rank — explicit env-var
|
||||
# candidates of equal likely_meshtastic rank - explicit env-var
|
||||
# configuration is a precedence signal.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403")
|
||||
|
||||
@@ -375,7 +375,7 @@ class TestConnectRoutesTcp:
|
||||
with connection.connect(port="tcp://locktest:4403"):
|
||||
pass
|
||||
|
||||
# Lock should be released — a second connect attempt must not fail
|
||||
# Lock should be released - a second connect attempt must not fail
|
||||
# with "busy".
|
||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
|
||||
mock_tcp.return_value.close.return_value = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py
|
||||
+ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb).
|
||||
|
||||
Lives under tests/unit/ because none of these touch real hardware — they
|
||||
Lives under tests/unit/ because none of these touch real hardware - they
|
||||
shell out to the bin/ scripts and decode the resulting protobufs in-process.
|
||||
"""
|
||||
|
||||
@@ -42,7 +42,7 @@ def _require_v25_bindings() -> None:
|
||||
)
|
||||
if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name:
|
||||
pytest.skip(
|
||||
"Loaded NodeDatabase predates v25 — run `./bin/regen-py-protos.sh`."
|
||||
"Loaded NodeDatabase predates v25 - run `./bin/regen-py-protos.sh`."
|
||||
)
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path)
|
||||
proto = tmp_path / "out.proto"
|
||||
jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip(f"{jsonl} not present — run ./bin/regen-fake-nodedbs.sh")
|
||||
pytest.skip(f"{jsonl} not present - run ./bin/regen-fake-nodedbs.sh")
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)])
|
||||
|
||||
db = NodeDatabase()
|
||||
@@ -199,7 +199,7 @@ def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None:
|
||||
_require_v25_bindings()
|
||||
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip("250-node seed not present — run ./bin/regen-fake-nodedbs.sh")
|
||||
pytest.skip("250-node seed not present - run ./bin/regen-fake-nodedbs.sh")
|
||||
a = tmp_path / "a.proto"
|
||||
b = tmp_path / "b.proto"
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Pin `InputEventCode` values to the firmware `input_broker_event` enum.
|
||||
|
||||
If this test fails, someone changed the firmware enum (or this Python
|
||||
mirror) and they must stay in sync — the admin RPC sends these as u8
|
||||
mirror) and they must stay in sync - the admin RPC sends these as u8
|
||||
wire values directly.
|
||||
|
||||
Also exercises `coerce_event_code` for the happy + error paths.
|
||||
|
||||
@@ -75,7 +75,7 @@ class TestParseLogLine:
|
||||
assert out["msg"] == "raw message body"
|
||||
|
||||
def test_bare_message(self) -> None:
|
||||
# LogRecord.message path — no level prefix at all.
|
||||
# LogRecord.message path - no level prefix at all.
|
||||
out = parse_log_line("just a bare message")
|
||||
assert "level" not in out or out.get("level") is None
|
||||
assert out["line"] == "just a bare message"
|
||||
@@ -155,7 +155,7 @@ class TestRecorderDebugHeapSynthesis:
|
||||
assert synth[-1]["fields"]["heap_total_bytes"] == 200000
|
||||
|
||||
def test_no_heap_no_synthesis(self, recorder: "Recorder") -> None:
|
||||
# Plain log line (no [heap N], no Heap status) — telemetry.jsonl
|
||||
# Plain log line (no [heap N], no Heap status) - telemetry.jsonl
|
||||
# should NOT gain a synth row.
|
||||
before = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
|
||||
recorder._on_log_line("INFO | 00:00:00 1 [Main] just a message", _FakeIface())
|
||||
@@ -234,7 +234,7 @@ class TestSerialTap:
|
||||
def test_serial_line_handler_swallows_exceptions(
|
||||
self, recorder: "Recorder"
|
||||
) -> None:
|
||||
# Hostile input — should not raise.
|
||||
# Hostile input - should not raise.
|
||||
recorder._on_serial_line(None, port="/dev/cu.tap") # type: ignore[arg-type]
|
||||
recorder._on_serial_line(b"\x00\x01\x02\x03", port="/dev/cu.tap") # type: ignore[arg-type]
|
||||
# Survived.
|
||||
@@ -320,11 +320,11 @@ class TestRecorderWrites:
|
||||
recorder._on_log_line("INFO | 12:34:56 99 [T] hi", _FakeIface())
|
||||
path = recorder.base_dir / "logs.jsonl"
|
||||
rows = [json.loads(line) for line in path.read_text().splitlines() if line]
|
||||
# First row is recorder_start_event mirror? No — that's events.jsonl only.
|
||||
# First row is recorder_start_event mirror? No - that's events.jsonl only.
|
||||
assert any(r.get("level") == "INFO" and r.get("tag") == "T" for r in rows)
|
||||
|
||||
def test_telemetry_recorded_and_packet_double(self, recorder: Recorder) -> None:
|
||||
# _on_telemetry alone — only telemetry.jsonl
|
||||
# _on_telemetry alone - only telemetry.jsonl
|
||||
recorder._on_telemetry(
|
||||
{
|
||||
"fromId": "!abc",
|
||||
@@ -367,13 +367,13 @@ class TestRecorderWrites:
|
||||
assert "kept" in post_resume
|
||||
|
||||
def test_pubsub_handler_swallows_exceptions(self, recorder: Recorder) -> None:
|
||||
# If the writer dies, the pubsub callback must NOT raise — that
|
||||
# If the writer dies, the pubsub callback must NOT raise - that
|
||||
# would crash the meshtastic receive thread.
|
||||
bad_packet = object() # not a dict
|
||||
recorder._on_receive(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||
recorder._on_telemetry(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||
recorder._on_log_line(None, _FakeIface()) # type: ignore[arg-type]
|
||||
# No assertion needed — survival is the test.
|
||||
# No assertion needed - survival is the test.
|
||||
|
||||
|
||||
# -- log_query read side ---------------------------------------------
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_owner_names_included_when_provided() -> None:
|
||||
|
||||
def test_psk_seed_isolation_across_ci_runs() -> None:
|
||||
"""The core claim: two test labs running concurrently with different
|
||||
session seeds produce different PSKs — their meshes cannot decode each
|
||||
session seeds produce different PSKs - their meshes cannot decode each
|
||||
other's traffic."""
|
||||
lab_a = userprefs.build_testing_profile(psk_seed="lab-A-nightly")
|
||||
lab_b = userprefs.build_testing_profile(psk_seed="lab-B-nightly")
|
||||
|
||||
@@ -5,9 +5,9 @@ new hub-descriptor fields (e.g. the `, ppps` marker). The parser uses loose
|
||||
regexes to tolerate additions; this test keeps us honest.
|
||||
|
||||
Samples captured from:
|
||||
- v2.6.0 on macOS (Homebrew) — two USB2 hubs, one populated with an
|
||||
- v2.6.0 on macOS (Homebrew) - two USB2 hubs, one populated with an
|
||||
nRF52 and a CP2102, plus chained USB3 hubs.
|
||||
- v2.5.0 on Linux (hypothetical — reconstructed from the project README).
|
||||
- v2.5.0 on Linux (hypothetical - reconstructed from the project README).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Pin the `Screen: frame N/M name=X reason=Y` regex + FrameEvent dataclass.
|
||||
|
||||
The firmware-side format lives in `src/graphics/Screen.cpp::logFrameChange`;
|
||||
if the format string changes, this test — and the parser in
|
||||
`tests/ui/_screen_log.py` — have to be updated together.
|
||||
if the format string changes, this test - and the parser in
|
||||
`tests/ui/_screen_log.py` - have to be updated together.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +23,7 @@ class TestFrameEventParse:
|
||||
|
||||
def test_with_log_prefix(self) -> None:
|
||||
"""Log lines may be preamble-wrapped by the firmware LOG_INFO macro
|
||||
(timestamp, severity, etc.) — the regex uses .search() not .match()
|
||||
(timestamp, severity, etc.) - the regex uses .search() not .match()
|
||||
so prefixes are fine."""
|
||||
raw = "[INFO] 00:12:34 567 Screen: frame 4/12 name=nodelist_nodes reason=fn_f3 "
|
||||
evt = FrameEvent.parse(raw)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
write, and the `temporary_overrides` context manager's byte-for-byte restore.
|
||||
|
||||
None of these require hardware. They validate the contract that the flash/
|
||||
testing-profile tools rely on — if these fail, the provisioning tier will
|
||||
testing-profile tools rely on - if these fail, the provisioning tier will
|
||||
produce confusing mismatches.
|
||||
"""
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_infer_type_matches_platformio_custom_py() -> None:
|
||||
|
||||
def test_temporary_overrides_restores_byte_for_byte(sample_jsonc: Path) -> None:
|
||||
"""The context manager MUST leave the file bit-identical on exit, even on
|
||||
exception — this is the safety guarantee build/flash tools rely on."""
|
||||
exception - this is the safety guarantee build/flash tools rely on."""
|
||||
original = sample_jsonc.read_bytes()
|
||||
|
||||
with userprefs.temporary_overrides({"USERPREFS_CHANNEL_0_NAME": "OverrideTest"}):
|
||||
@@ -104,7 +104,7 @@ def test_build_manifest_surfaces_all_keys(sample_jsonc: Path) -> None:
|
||||
"""Manifest should union the jsonc set with firmware-src consumers.
|
||||
|
||||
In the sample tmpdir there's no `src/` so `consumed_by` is empty for all
|
||||
entries; that's fine — the manifest still lists every jsonc key.
|
||||
entries; that's fine - the manifest still lists every jsonc key.
|
||||
"""
|
||||
manifest = userprefs.build_manifest()
|
||||
keys = {e["key"] for e in manifest["entries"]}
|
||||
|
||||
Reference in New Issue
Block a user