emdashes begone (#10847)

This commit is contained in:
Tom
2026-07-01 19:01:27 -05:00
committed by GitHub
co-authored by GitHub
parent dee94e0758
commit 3becaf2d95
276 changed files with 1795 additions and 1793 deletions
+42 -41
View File
@@ -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.
+4 -4
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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": [
+4 -4
View File
@@ -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 -1
View File
@@ -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"
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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 12s pio startup cost.
changes, so subsequent calls don't pay the 1-2s pio startup cost.
"""
from __future__ import annotations
+9 -9
View File
@@ -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.
"""
+4 -4
View File
@@ -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 [start5s, 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
+3 -3
View File
@@ -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
+61 -61
View File
@@ -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 12 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 12 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:
+6 -6
View File
@@ -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."
)
+3 -3
View File
@@ -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: (
+12 -12
View File
@@ -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(
+7 -7
View File
@@ -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
+2 -2
View File
@@ -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.
"""
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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 -6
View File
@@ -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
+28 -28
View File
@@ -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.
+9 -9
View File
@@ -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).
+8 -8
View File
@@ -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
View File
@@ -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
+7 -7
View File
@@ -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 "
+4 -4
View File
@@ -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"]
+42 -42
View File
@@ -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
+7 -7
View File
@@ -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
+2 -2
View File
@@ -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):
+1 -1
View File
@@ -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"
)
+5 -5
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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,
)
+3 -3
View File
@@ -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]
+12 -12
View File
@@ -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.
+6 -6
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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.
"""
+15 -15
View File
@@ -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."
)
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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")
+5 -5
View File
@@ -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"
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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.
"""
+7 -7
View File
@@ -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.
+7 -7
View File
@@ -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")
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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"]}