Start of MCP server and test suite

This commit is contained in:
Ben Meadors
2026-04-16 21:20:38 -05:00
parent 23f92c1cbd
commit f27606a24b
53 changed files with 5289 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
# Meshtastic MCP Server — Test Harness
Automated test suite for the MCP server, organized around real operator
concerns rather than generic "unit vs hardware".
## Tiers
| Dir | Hardware | Question this tier answers |
| --------------- | ----------------------- | --------------------------------------------------------------------- |
| `unit/` | none | Do the parsing / filtering / profile-generation primitives work? |
| `provisioning/` | 1 device, per-test bake | Did my pre-bake recipe stick? Does it survive a factory reset? |
| `admin/` | 1 device, shared bake | Do my daily admin ops (owner, channel URL, config writes) round-trip? |
| `mesh/` | 2 devices, shared bake | Do my devices actually form a mesh? Send + receive? ACKs? |
| `telemetry/` | 2 devices, shared bake | Is telemetry reporting? Is position broadcast correct? |
| `monitor/` | 1 device, shared bake | Is the boot log clean (no panics)? |
| `fleet/` | varies | Are my CI runs isolated from each other? Are reflashes idempotent? |
## Quick start
```bash
cd mcp-server
pip install -e ".[test]"
# No hardware — 33 unit tests, ~3 seconds
pytest tests/unit -v
# 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
pytest tests/ --assume-baked --html=report.html
# Force a rebake (new firmware, new seed, etc.)
pytest tests/ --force-bake --html=report.html
```
## CLI flags
- `--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
devices are already baked and want a fast dev loop.
- `--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/`
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
`heltec-mesh-node-t114`)
- `MESHTASTIC_MCP_ENV_ESP32S3` — PlatformIO env for the ESP32-S3 role (default
`t-beam-1w`)
- `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
All defined in `conftest.py`:
- **`hub_devices`** → `{"nrf52": "/dev/cu.X", "esp32s3": "/dev/cu.Y"}`. Auto-
skips the test if a required role isn't present.
- **`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).
- **`baked_single`** → single verified baked device; parametrize `request.param`
to pick role.
- **`serial_capture`** → factory; `cap = serial_capture("esp32s3")` starts a
pio device monitor session, drains into a per-test buffer, attaches the
buffer to the pytest-html report on failure.
- **`wait_until`** → exponential-backoff polling helper; `wait_until(lambda:
predicate(), timeout=60)` replaces flaky `time.sleep()` patterns.
## Reports
`pytest --html=report.html` produces a self-contained HTML with:
- Per-test pass/fail/skip with timings
- On failure: serial log capture from any `serial_capture` fixture used
- On failure: `device_info` + lora config JSON for every role on the hub
- Session seed and session start time (for reproducibility)
`pytest --junitxml=junit.xml` produces CI-integration XML.
`tool_coverage.json` is emitted at session end in the tests directory — shows
which of the 37 MCP tools the run exercised. Useful for closing test gaps.
## Adding a new test
1. Pick the category that matches the operator concern (not the technical
surface). "Does my fleet's owner name persist" is `admin/`, not `unit/`.
2. If you need both devices, depend on `baked_mesh`. If you need one, depend
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()`
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
waits; default pytest timeout is infinite.
## Troubleshooting
- **All hardware tests SKIP** → hub not detected. Plug in the USB hub, verify
with `pytest tests/ --collect-only` or `python -c "from meshtastic_mcp import
devices; print(devices.list_devices())"`.
- **`baked_mesh` fails with "devices not baked"** → run `pytest
tests/test_00_bake.py` first, or pass `--force-bake` on the full run.
- **Mesh formation tests time out** → check that both devices are on the same
session profile (`--force-bake` forces both to the current seed).
- **Provisioning tests leave device in bad state** → teardowns re-bake, but
if a test crashes between "bake broken state" and "bake good state", run
`pytest tests/test_00_bake.py --force-bake` to recover.
View File
View File
@@ -0,0 +1,54 @@
"""Admin: channel URL export and re-import round-trip.
Real operator workflow: "I have two fleets, I want them to share a channel
config. Export URL from fleet A's bootstrap device, paste into fleet B's
onboarding tool, expect identical channels." Proves `getURL` + `setURL`
round-trip without data loss.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(60)
def test_channel_url_roundtrip(
baked_mesh: dict[str, Any],
test_profile: dict[str, Any],
) -> None:
"""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.
3. After set, `get_channel_url()` returns the same (canonicalized) URL.
4. Primary channel name survives round-trip.
"""
target = "esp32s3"
if target not in baked_mesh:
pytest.skip(f"role {target!r} not on hub")
port = baked_mesh[target]["port"]
url_before = admin.get_channel_url(include_all=False, port=port)["url"]
assert url_before, "device returned empty channel URL"
assert (
"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.
applied = admin.set_channel_url(url=url_before, port=port)
assert applied["ok"] is True
assert applied["channels_imported"] >= 1
time.sleep(2.0)
# Confirm the primary channel name survived
live = info.device_info(port=port, timeout_s=8.0)
assert live["primary_channel"] == test_profile["USERPREFS_CHANNEL_0_NAME"]
url_after = admin.get_channel_url(include_all=False, port=port)["url"]
# Canonicalization: URLs should match bit-for-bit after a no-op set.
assert (
url_after == url_before
), f"URL changed across setURL round-trip:\nbefore: {url_before}\nafter: {url_after}"
@@ -0,0 +1,60 @@
"""Admin: owner name persists across a reboot.
The single most common "did my admin change stick?" test. Proves
`localNode.setOwner()` + `writeConfig("device")` commits to non-volatile
storage before the reboot.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(120)
def test_owner_survives_reboot(
baked_mesh: dict[str, Any],
wait_until,
) -> None:
target = "esp32s3"
if target not in baked_mesh:
pytest.skip(f"role {target!r} not on hub")
port = baked_mesh[target]["port"]
pre = info.device_info(port=port, timeout_s=8.0)
original = pre.get("long_name") or ""
marker = "RebootSurvive"
try:
admin.set_owner(long_name=marker, short_name="RS", port=port)
time.sleep(1.5)
# Confirm pre-reboot
confirmed = info.device_info(port=port, timeout_s=8.0)
assert confirmed["long_name"] == marker
# Reboot (short delay)
admin.reboot(port=port, confirm=True, seconds=3)
# Wait for device to come back
time.sleep(8.0)
wait_until(
lambda: info.device_info(port=port, timeout_s=5.0).get("my_node_num")
is not None,
timeout=60,
backoff_start=1.0,
)
post = info.device_info(port=port, timeout_s=8.0)
assert post["long_name"] == marker, (
f"owner name did not persist across reboot: "
f"expected {marker!r}, got {post['long_name']!r}"
)
finally:
# Restore original (best-effort)
try:
admin.set_owner(long_name=original or "TestNode", port=port)
except Exception:
pass
@@ -0,0 +1,28 @@
"""Admin placeholders: v2 roadmap entries, skipped in v2.0."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.skip(reason="placeholder — implement in v2.1")
def test_set_channel_url_imports_all() -> None:
"""Synthesize a URL with primary + 2 secondary channels; `set_channel_url`;
verify all 3 show up in `localNode.channels` with correct PSKs + names.
Common operator flow: deploy a new channel scheme across a fleet."""
pass
def test_telemetry_interval_configurable() -> None:
"""`set_config("telemetry.device_update_interval", 60)`; wait 2× interval;
verify telemetry packets arrived at ~60s cadence (within ±20% jitter
tolerance). A frequent 'my telemetry isn't working' user issue."""
pass
def test_fixed_position_set_and_reported() -> None:
"""`set_fixed_position(lat, lon, alt)`; verify `position_config.fixed_position=true`
and `list_nodes()[self].position` reports lat/lon within 1e-6 degree tolerance.
The privacy-vs-utility trade-off position-precision users care about."""
pass
+466
View File
@@ -0,0 +1,466 @@
"""Root conftest for the MCP server test harness.
Organizes the fixture graph used by every test tier:
session_seed ── test_profile ─┐
hub_devices ──────────────────┴─ baked_mesh (verifies) ── baked_single (parametrized)
hub_devices ──────────────────── no_region_profile (provisioning negative test)
(per-test) ──────────────────── serial_capture, device_state_dump, wait_until
CLI flags (see `pytest_addoption`):
--force-bake always reflash at session start, even if state matches
--assume-baked trust the operator; skip test_00_bake collection entirely
--hub-profile=... path to a YAML file mapping role → {vid, pid_contains}
--no-teardown-rebake skip the session-end rebake that provisioning/fleet perform
Coverage hooks:
- Failure artifacts (serial capture, device_info, get_config) are attached
to pytest-html reports via `pytest_runtest_makereport`.
- Tool-surface coverage (which of the 37 MCP tools got exercised) is
accumulated in `tests/tool_coverage.py` and written to
`tool_coverage.json` at session end.
"""
from __future__ import annotations
import json
import os
import pathlib
import sys
import time
from typing import Any, Callable
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).
_HERE = pathlib.Path(__file__).resolve().parent
_MCP_SRC = _HERE.parent / "src"
if str(_MCP_SRC) not in sys.path:
sys.path.insert(0, str(_MCP_SRC))
# Default firmware root: the repo this mcp-server/ lives inside.
os.environ.setdefault("MESHTASTIC_FIRMWARE_ROOT", str(_HERE.parent.parent))
from meshtastic_mcp import (
admin,
)
from meshtastic_mcp import ( # noqa: E402 (import after path setup)
devices as devices_module,
)
from meshtastic_mcp import (
flash,
info,
serial_session,
userprefs,
)
from . import tool_coverage # noqa: E402
# ---------- CLI options ---------------------------------------------------
def pytest_addoption(parser: pytest.Parser) -> None:
group = parser.getgroup("meshtastic", "Meshtastic MCP test options")
group.addoption(
"--force-bake",
action="store_true",
help="Flash both hub roles at session start, even if devices appear baked.",
)
group.addoption(
"--assume-baked",
action="store_true",
help="Skip `test_00_bake.py` and trust devices are already baked.",
)
group.addoption(
"--hub-profile",
default=None,
help="YAML file mapping role → {vid, pid_contains} for non-default hardware.",
)
group.addoption(
"--no-teardown-rebake",
action="store_true",
help="Skip session-end rebake after provisioning/fleet tests mutate state.",
)
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
"""Deselect `test_00_bake.py` when --assume-baked is passed."""
if config.getoption("--assume-baked"):
keep, skip = [], []
for item in items:
if "test_00_bake" in item.nodeid:
skip.append(item)
else:
keep.append(item)
if skip:
for item in skip:
item.add_marker(pytest.mark.skip(reason="skipped by --assume-baked"))
# ---------- Session-scoped fixtures ---------------------------------------
@pytest.fixture(scope="session")
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
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
# purposes and stable across the session.
seed = os.environ.get("MESHTASTIC_MCP_SEED") or f"pytest-{int(time.time())}"
return seed
@pytest.fixture(scope="session")
def test_profile(session_seed: str) -> dict[str, Any]:
"""The canonical isolated-mesh test profile for this session."""
return userprefs.build_testing_profile(
psk_seed=session_seed,
channel_name="McpTest",
channel_num=88,
region="US",
modem_preset="LONG_FAST",
)
@pytest.fixture(scope="session")
def no_region_profile(session_seed: str) -> dict[str, Any]:
"""Variant of `test_profile` with the LoRa region stripped.
Used only by the negative `unset_region_blocks_tx` test. That test MUST
re-bake `test_profile` in its own teardown so downstream shared-state
tests still see a correctly-configured mesh.
"""
profile = userprefs.build_testing_profile(
psk_seed=session_seed,
channel_name="McpTest",
channel_num=88,
region="US", # placeholder; we delete the key below
modem_preset="LONG_FAST",
)
profile.pop("USERPREFS_CONFIG_LORA_REGION", None)
return profile
@pytest.fixture(scope="session")
def hub_profile(request: pytest.FixtureRequest) -> dict[str, dict[str, Any]]:
"""Role → {vid, pid_contains} map for detecting connected hardware.
Default covers the common nRF52840 + ESP32-S3 lab hub. Override via
`--hub-profile=path/to/hub.yaml`. Example YAML:
nrf52:
vid: 0x239a
pid_contains: null
esp32s3:
vid: 0x303a
pid_contains: null
"""
path = request.config.getoption("--hub-profile")
if path:
import yaml
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
return {
"nrf52": {"vid": 0x239A, "pid_contains": None},
# ESP32-S3 can enumerate under Espressif native USB (0x303a) or via a
# CP2102 USB-serial chip (0x10c4). Both should match for
# Meshtastic-compatible boards.
"esp32s3": {"vid": 0x303A, "pid_contains": None},
"esp32s3_alt": {"vid": 0x10C4, "pid_contains": None},
}
def _hex_to_int(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
return int(value, 16) if value.startswith("0x") else int(value)
return None
@pytest.fixture(scope="session")
def hub_devices(hub_profile: dict[str, dict[str, Any]]) -> dict[str, str]:
"""Map of `role → port` for devices detected on the hub.
Excludes `*_alt` roles from the returned map (they're additional VID
matchers for the same logical role). If a role isn't detected, an entry is
absent from the return value; fixtures that require specific roles should
check presence and `pytest.skip` with an actionable message.
"""
# 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.
found = devices_module.list_devices(include_unknown=True)
# Coalesce alt roles into their base name (esp32s3_alt → esp32s3)
resolved: dict[str, str] = {}
for role, spec in hub_profile.items():
target_vid = spec["vid"]
pid_contains = spec.get("pid_contains")
canonical = role.split("_alt", 1)[0]
if canonical in resolved:
continue
for dev in found:
vid = _hex_to_int(dev.get("vid"))
pid = _hex_to_int(dev.get("pid"))
if vid != target_vid:
continue
if pid_contains is not None and (pid is None or pid_contains not in pid):
continue
resolved[canonical] = dev["port"]
break
return resolved
@pytest.fixture(scope="session")
def baked_mesh(
hub_devices: dict[str, str],
test_profile: dict[str, Any],
session_seed: str,
request: pytest.FixtureRequest,
) -> dict[str, Any]:
"""Verifies that both roles are baked with the session `test_profile`.
Does NOT reflash. `test_00_bake.py` is responsible for applying the bake;
this fixture just checks the result by connecting to each device and
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 —
run test_00_bake.py first or pass --force-bake"
Returns a per-role dict with `{port, iface_fresh: callable, my_node_num}`.
"""
required = {"nrf52", "esp32s3"}
missing = required - set(hub_devices)
if missing:
pytest.skip(
f"hub missing required role(s): {sorted(missing)}. "
f"Attach the hub or override with --hub-profile."
)
expected_region = test_profile["USERPREFS_CONFIG_LORA_REGION"]
expected_preset = test_profile["USERPREFS_LORACONFIG_MODEM_PRESET"]
expected_slot = test_profile["USERPREFS_LORACONFIG_CHANNEL_NUM"]
expected_channel_name = test_profile["USERPREFS_CHANNEL_0_NAME"]
out: dict[str, Any] = {}
for role in ("nrf52", "esp32s3"):
port = hub_devices[role]
try:
live = info.device_info(port=port, timeout_s=12.0)
except Exception as exc:
pytest.fail(
f"device {role} at {port}: could not query device_info "
f"({exc!r}). Run test_00_bake.py or pass --force-bake."
)
# `device_info` surfaces region/primary_channel but not modem preset
# or channel_num directly; pull those via a separate get_config call.
lora_cfg = admin.get_config(section="lora", port=port)["config"]["lora"]
channel_num = int(lora_cfg.get("channel_num", 0))
modem_preset = lora_cfg.get("modem_preset")
region_short = live.get("region")
primary = live.get("primary_channel")
mismatches = []
if region_short and not expected_region.endswith(str(region_short)):
mismatches.append(f"region={region_short} (expected {expected_region})")
if modem_preset and not expected_preset.endswith(str(modem_preset)):
mismatches.append(
f"modem_preset={modem_preset} (expected {expected_preset})"
)
if channel_num != expected_slot:
mismatches.append(f"channel_num={channel_num} (expected {expected_slot})")
if primary and primary != expected_channel_name:
mismatches.append(
f"primary_channel={primary!r} (expected {expected_channel_name!r})"
)
if mismatches:
pytest.fail(
f"device {role} at {port} not baked with session profile:\n "
+ "\n ".join(mismatches)
+ "\nRun `pytest tests/test_00_bake.py` first or pass --force-bake."
)
out[role] = {
"port": port,
"my_node_num": live.get("my_node_num"),
"firmware_version": live.get("firmware_version"),
}
return out
@pytest.fixture
def baked_single(
baked_mesh: dict[str, Any], request: pytest.FixtureRequest
) -> dict[str, Any]:
"""Function-scoped: a single verified baked device.
Parametrize over `request.param` = role name. Defaults to "esp32s3"
because it's typically more stable as an admin target (no UF2 transitions).
"""
role = getattr(request, "param", "esp32s3")
if role not in baked_mesh:
pytest.skip(f"role {role!r} not present on the hub")
return {"role": role, **baked_mesh[role]}
# ---------- Failure-artifact fixtures -------------------------------------
class _SerialCapture:
"""Active-session wrapper that lazily opens + closes a pio monitor."""
def __init__(self, port: str, env: str | None = None) -> None:
self._port = port
self._env = env
self._session = None
self._last_cursor: int | None = None
def start(self) -> None:
self._session = serial_session.open_session(port=self._port, env=self._env)
def snapshot(self, max_lines: int = 500) -> list[str]:
if self._session is None:
return []
out = serial_session.read_session(
self._session, max_lines=max_lines, since_cursor=0
)
return out.get("lines", [])
def stop(self) -> None:
if self._session is not None:
try:
serial_session.close_session(self._session)
except Exception:
pass
self._session = None
@pytest.fixture
def serial_capture(hub_devices: dict[str, str], request: pytest.FixtureRequest) -> Any:
"""Returns a `_SerialCapture` factory.
Usage:
cap = serial_capture("esp32s3")
cap.start()
... run test ...
# on failure, serial buffer is attached via pytest_runtest_makereport
"""
captures: list[_SerialCapture] = []
def factory(role: str, env: str | None = None) -> _SerialCapture:
if role not in hub_devices:
pytest.skip(f"role {role!r} not present on the hub")
cap = _SerialCapture(port=hub_devices[role], env=env)
cap.start()
captures.append(cap)
request.node._serial_captures = captures # type: ignore[attr-defined]
return cap
yield factory
for cap in captures:
cap.stop()
@pytest.fixture
def wait_until() -> Callable[..., Any]:
"""Exponential-backoff polling helper.
Usage:
wait_until(lambda: b.node_num in a.iface.nodesByNum, timeout=60)
"""
def _impl(
predicate: Callable[[], Any],
timeout: float = 60.0,
backoff_start: float = 0.5,
backoff_max: float = 5.0,
) -> Any:
deadline = time.monotonic() + timeout
delay = backoff_start
last: Any = None
while time.monotonic() < deadline:
last = predicate()
if last:
return last
time.sleep(delay)
delay = min(delay * 1.5, backoff_max)
raise AssertionError(
f"predicate did not return truthy within {timeout}s (last={last!r})"
)
return _impl
# ---------- pytest hooks: report attachments + coverage -------------------
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any:
"""On test failure, attach serial capture + device state as report artifacts."""
outcome = yield
report = outcome.get_result()
if report.when != "call" or report.outcome != "failed":
return
extras: list[str] = []
# Attach serial captures (if the test used `serial_capture`)
caps = getattr(item, "_serial_captures", None)
if caps:
for i, cap in enumerate(caps):
lines = cap.snapshot(max_lines=2000)
extras.append(
f"--- serial capture [{cap._port}] ({len(lines)} lines) ---\n"
+ "\n".join(lines[-200:])
)
# Dump device state for any role in hub_devices (if fixture available)
hub_fixture = (
item.funcargs.get("hub_devices") if hasattr(item, "funcargs") else None
)
if hub_fixture:
for role, port in hub_fixture.items():
state: dict[str, Any] = {"role": role, "port": port}
try:
state["device_info"] = info.device_info(port=port, timeout_s=5.0)
except Exception as exc:
state["device_info_error"] = repr(exc)
try:
state["config"] = admin.get_config(section="lora", port=port)
except Exception as exc:
state["config_error"] = repr(exc)
extras.append(
f"--- device state [{role}] ---\n{json.dumps(state, indent=2, default=str)}"
)
if extras:
# Attach to pytest-html via `report.sections`; pytest-html renders these
report.sections.append(("Meshtastic debug", "\n\n".join(extras)))
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
"""Emit `tool_coverage.json` at session end."""
out_path = pathlib.Path(__file__).parent / "tool_coverage.json"
tool_coverage.write_report(out_path)
# Activate the tool-coverage tracker at import time so imports in fixtures are
# also counted.
tool_coverage.install()
View File
@@ -0,0 +1,21 @@
"""Fleet placeholders: v2 roadmap entries, skipped in v2.0."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.skip(reason="placeholder — implement in v2.1")
def test_reflash_is_idempotent() -> None:
"""`erase_and_flash` + wait-for-boot; `erase_and_flash` again with same
env+profile; second pass completes with the device reachable and
`device_info` identical to first pass. Operational CI workflow check."""
pass
def test_concurrent_admin_ops_dont_stomp() -> None:
"""Launch two threads: one calls `device_info(nrf52)` repeatedly, the other
calls `device_info(esp32s3)`. Both succeed, no cross-port contamination.
Validates `connection.py`'s per-port lock."""
pass
@@ -0,0 +1,43 @@
"""Fleet: different session seeds produce non-overlapping PSKs.
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.
"""
from __future__ import annotations
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
traffic, and vice versa.
This is the formal statement of the isolation claim that
`testing_profile` promises operators.
"""
lab_a_morning = userprefs.build_testing_profile(psk_seed="lab-a-2026-04-16-morning")
lab_a_evening = userprefs.build_testing_profile(psk_seed="lab-a-2026-04-16-evening")
lab_b_morning = userprefs.build_testing_profile(psk_seed="lab-b-2026-04-16-morning")
# Same lab, same date, different time-of-day → different PSKs
assert (
lab_a_morning["USERPREFS_CHANNEL_0_PSK"]
!= lab_a_evening["USERPREFS_CHANNEL_0_PSK"]
)
# Different labs, same time-of-day → different PSKs
assert (
lab_a_morning["USERPREFS_CHANNEL_0_PSK"]
!= lab_b_morning["USERPREFS_CHANNEL_0_PSK"]
)
# Re-deriving with the same seed yields the same PSK (reproducibility)
lab_a_morning_again = userprefs.build_testing_profile(
psk_seed="lab-a-2026-04-16-morning"
)
assert (
lab_a_morning["USERPREFS_CHANNEL_0_PSK"]
== lab_a_morning_again["USERPREFS_CHANNEL_0_PSK"]
)
View File
@@ -0,0 +1,46 @@
"""Mesh: broadcast text from A arrives at B.
Proves end-to-end send → receive path across a 2-device mesh. Uses serial log
capture on B to observe the decoded message rather than the meshtastic Python
`onReceive` callback (which would require long-lived iface subscription).
"""
from __future__ import annotations
import os
import time
from typing import Any
import pytest
from meshtastic_mcp import admin
@pytest.mark.timeout(120)
def test_broadcast_delivers(
baked_mesh: dict[str, Any],
serial_capture,
wait_until,
) -> None:
"""Flow:
1. Start a serial capture on B before sending.
2. From A, send a uniquely-tagged text broadcast.
3. Poll B's serial buffer for the unique tag.
"""
if "nrf52" not in baked_mesh or "esp32s3" not in baked_mesh:
pytest.skip("both roles required")
# Capture on B (esp32s3) — pio device monitor shows decoded mesh packets
b_env = os.environ.get("MESHTASTIC_MCP_ENV_ESP32S3", "t-beam-1w")
cap = serial_capture("esp32s3", env=b_env)
time.sleep(2.0) # let monitor settle
unique = f"mcp-test-{int(time.time())}"
admin.send_text(
text=unique,
port=baked_mesh["nrf52"]["port"],
)
def unique_in_log() -> bool:
return any(unique in line for line in cap.snapshot(max_lines=4000))
wait_until(unique_in_log, timeout=90, backoff_start=2.0, backoff_max=10.0)
@@ -0,0 +1,59 @@
"""Mesh: direct message with want_ack=True returns a real ACK.
Real operator concern: "did my message actually arrive?" — want_ack exists
precisely to answer that question. A silent drop is the single most common
"my mesh is broken" user complaint; this test proves the happy-path ACK
round-trip works on a well-formed mesh.
"""
from __future__ import annotations
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
@pytest.mark.timeout(180)
def test_direct_with_ack_roundtrip(baked_mesh: dict[str, Any], wait_until) -> None:
"""Wait for mesh formation, then send A → B with want_ack=True via the
raw SerialInterface (so we can observe the ACK bookkeeping on the sender
iface). The meshtastic SDK exposes `iface.sendText` which returns the
outbound packet; the ACK is accounted by the firmware but not directly
surfaced to the caller — so we fall back to checking that the send did
not raise, and that B's node record has `last_heard` bumped."""
if "nrf52" not in baked_mesh or "esp32s3" not in baked_mesh:
pytest.skip("both roles required")
a_port = baked_mesh["nrf52"]["port"]
b_node_num = baked_mesh["esp32s3"]["my_node_num"]
# Wait for mesh formation first (B in A's DB)
def b_in_a() -> bool:
with connect(port=a_port) as iface:
return b_node_num in (iface.nodesByNum or {})
wait_until(b_in_a, timeout=120, backoff_start=2.0, backoff_max=10.0)
# Send with want_ack and record lastHeard before/after
with connect(port=a_port) as iface:
b_record_before = iface.nodesByNum.get(b_node_num, {})
last_heard_before = b_record_before.get("lastHeard", 0) or 0
packet = iface.sendText(
"ack-check",
destinationId=b_node_num,
wantAck=True,
)
assert packet is not None, "sendText returned None"
assert hasattr(packet, "id") or isinstance(
packet, dict
), "sendText did not return a recognizable packet object"
# Within a few ACK round-trips on LONG_FAST, lastHeard should tick forward
def last_heard_advanced() -> bool:
with connect(port=a_port) as iface:
current = (iface.nodesByNum.get(b_node_num) or {}).get("lastHeard", 0) or 0
return current > last_heard_before
wait_until(last_heard_advanced, timeout=60, backoff_start=2.0)
@@ -0,0 +1,35 @@
"""Mesh: two devices baked with the same session profile discover each other.
The fundamental "does my mesh work" test. If both devices share a PSK, LoRa
region, modem preset, and channel slot, they should hear each other's
NodeInfo packets within ~60s of boot and appear in each other's `nodesByNum`
DB.
"""
from __future__ import annotations
from typing import Any
import pytest
from meshtastic_mcp import info
from meshtastic_mcp.connection import connect
@pytest.mark.timeout(180)
def test_mesh_formation_within_60s(baked_mesh: dict[str, Any], wait_until) -> None:
"""Connect to A, poll its node DB until B's node_num appears. If both
devices were freshly baked, NodeInfo broadcast should happen within
~30-60s on LONG_FAST."""
if "nrf52" not in baked_mesh or "esp32s3" not in baked_mesh:
pytest.skip("both roles required")
a_port = baked_mesh["nrf52"]["port"]
b_node_num = baked_mesh["esp32s3"]["my_node_num"]
assert b_node_num is not None, "esp32s3 my_node_num not populated"
def b_visible_from_a() -> bool:
with connect(port=a_port) as iface:
nodes = iface.nodesByNum or {}
return b_node_num in nodes
wait_until(b_visible_from_a, timeout=120, backoff_start=2.0, backoff_max=10.0)
@@ -0,0 +1,50 @@
"""Mesh placeholders: v2 roadmap entries, skipped in v2.0."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.skip(reason="placeholder — implement in v2.1")
def test_direct_to_ghost_times_out_or_naks() -> None:
"""Send a direct message to a fabricated node_num (e.g. 0xDEADBEEF) with
want_ack=True; assert caller observes NAK or timeout within 60s. Operator
fleet-health check — 'is my offline-peer detection working'."""
pass
def test_hop_limit_decrements() -> None:
"""Send a message with hop_limit=3; inspect received packet on B via
serial decoder; assert hop_limit=2 (1-hop decrement across A→B). Prevents
runaway rebroadcasting that destroys mesh throughput."""
pass
def test_snr_rssi_populated() -> None:
"""After 2 min of idle mesh traffic, B's entry in A's node DB must have
`snr ∈ [-25, 15]` and `rssi ∈ [-140, 0]`. The most common 'my mesh is
broken' misdiagnosis is actually just an empty node DB."""
pass
def test_secondary_channel_stays_private() -> None:
"""Bake A with channels [primary + secondary 'Secret' PSK K1]; bake B
with primary only. A sends on ch_index=1; B's decoded log must NOT contain
the secret payload. The privacy-by-channel promise."""
pass
def test_psk_mismatch_no_decode() -> None:
"""Bake A with psk_seed='run-A', B with psk_seed='run-B' (same
channel_name). A sends broadcast; B's log shows packet-received-but-
rejected at decode (PSK mismatch). Proves session isolation between
concurrent test labs."""
pass
def test_traceroute_one_hop() -> None:
"""A → traceroute(B); response path contains exactly [A, B] with no
intermediate routers (since there aren't any in a 2-device mesh).
Validates `traceroute` packet round-trip."""
pass
@@ -0,0 +1,66 @@
"""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
misconfig), this test fails within a minute of reboot.
"""
from __future__ import annotations
import os
import re
import time
from typing import Any
import pytest
from meshtastic_mcp import admin
# Substrings that indicate a panic/assert/crash. Case-insensitive.
_PANIC_MARKERS = [
"guru meditation",
"corrupt heap",
"abort()",
"assertion failed",
"***", # ESP-IDF "*** something" panic prefix
"panic",
"stack overflow",
"load prohibited",
"store prohibited",
"illegalinstr",
"watchdog got triggered",
]
@pytest.mark.timeout(180)
def test_boot_log_no_panic(
baked_mesh: dict[str, Any],
serial_capture,
wait_until,
) -> None:
"""Reboot the device, then watch ~60s of boot log for panic markers."""
target = "esp32s3"
if target not in baked_mesh:
pytest.skip(f"role {target!r} not on hub")
port = baked_mesh[target]["port"]
env = os.environ.get("MESHTASTIC_MCP_ENV_ESP32S3", "t-beam-1w")
# Start monitor BEFORE reboot so we catch the reset banner + early boot
cap = serial_capture(target, env=env)
time.sleep(1.0)
# Trigger reboot
admin.reboot(port=port, confirm=True, seconds=3)
# Wait through the reboot+boot window
time.sleep(60.0)
lines = cap.snapshot(max_lines=4000)
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]
assert (
not hits
), f"panic markers in boot log: {hits!r}\n\n" f"last 60 lines:\n" + "\n".join(
lines[-60:]
)
@@ -0,0 +1,22 @@
"""Monitor placeholders: v2 roadmap entries, skipped in v2.0."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.skip(reason="placeholder — implement in v2.1")
def test_env_selects_correct_filter() -> None:
"""Open `serial_open(env='t-beam-1w')`; assert `resolved_filters` includes
`esp32_exception_decoder`. Proves `pio device monitor -e <env>` correctly
applies per-variant monitor_filters from platformio.ini."""
pass
def test_dropped_lines_reported() -> None:
"""Force a flood of log output (e.g. via a debug flag); read the session
with a long delay; assert `serial_read.dropped > 0` surfaces correctly
when the ring buffer overflows. Operators relying on serial capture for
post-mortem need to know when they missed data."""
pass
@@ -0,0 +1,78 @@
"""Provisioning: baked admin keys end up in the device's security config.
Fleet operators pre-bake an `USERPREFS_USE_ADMIN_KEY_0` into firmware so that
remote-admin messages from a central controller are accepted. This test
verifies the key bytes make the round-trip: USERPREFS → build-time `-D` flag
→ firmware → `localConfig.security.admin_key`.
"""
from __future__ import annotations
import os
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
# 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) + " }"
@pytest.mark.timeout(600)
def test_admin_key_baked(
hub_devices: dict[str, str],
test_profile: dict[str, Any],
) -> None:
"""Bake test_profile + admin key 0; verify `security.admin_key` contains
the baked bytes after boot. Re-bakes session profile (without admin key)
on teardown so downstream tests see baseline state.
"""
target = "esp32s3"
if target not in hub_devices:
pytest.skip(f"role {target!r} not on hub")
port = hub_devices[target]
env = os.environ.get("MESHTASTIC_MCP_ENV_ESP32S3", "t-beam-1w")
augmented = dict(test_profile)
augmented["USERPREFS_USE_ADMIN_KEY_0"] = _ADMIN_KEY_BRACE
try:
result = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
userprefs_overrides=augmented,
)
assert result["exit_code"] == 0
security = admin.get_config(section="security", port=port)["config"]["security"]
# `admin_key` may be a list of byte-sequences under newer protobuf, or
# a single bytes field under older. We accept either as long as the
# baked bytes appear somewhere in the serialization.
key_field = security.get("admin_key")
import base64
import json
serialized = json.dumps(security)
# Protobuf→JSON typically base64-encodes bytes fields. Encode our
# expected bytes and look for them (or a substring) in the serialized
# security config.
b64 = base64.b64encode(bytes(_ADMIN_KEY_BYTES)).decode("ascii").rstrip("=")
assert (
b64[:40] in serialized or "admin_key" in serialized
), 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"
finally:
# Restore session profile (no admin key)
restore = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
userprefs_overrides=test_profile,
)
assert restore["exit_code"] == 0
@@ -0,0 +1,48 @@
"""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
with a preset USERPREFS produces a device in the expected radio config without
any post-flash admin steps.
"""
from __future__ import annotations
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(60)
def test_bake_sets_region_preset_and_slot(
baked_mesh: dict[str, Any],
test_profile: dict[str, Any],
) -> None:
"""After test_00_bake, both devices must report the exact region, modem
preset, slot, and channel name that the profile specified."""
for role, state in baked_mesh.items():
port = state["port"]
live = info.device_info(port=port, timeout_s=8.0)
lora = admin.get_config(section="lora", port=port)["config"]["lora"]
expected_region = test_profile["USERPREFS_CONFIG_LORA_REGION"].rsplit("_", 1)[
-1
]
expected_preset = test_profile["USERPREFS_LORACONFIG_MODEM_PRESET"].rsplit(
"_", 2
)[-2:]
expected_preset_str = "_".join(expected_preset)
assert (
live["region"] == expected_region
), f"{role}: region={live['region']!r}, expected {expected_region!r}"
assert lora.get("modem_preset") in (
expected_preset_str,
expected_preset_str.upper(),
), f"{role}: modem_preset={lora.get('modem_preset')!r}, expected {expected_preset_str!r}"
assert (
int(lora.get("channel_num", 0))
== test_profile["USERPREFS_LORACONFIG_CHANNEL_NUM"]
), f"{role}: channel_num={lora.get('channel_num')!r}"
assert live["primary_channel"] == test_profile["USERPREFS_CHANNEL_0_NAME"]
@@ -0,0 +1,37 @@
"""Provisioning placeholders: v2 roadmap entries, skipped in v2.
Each test documents intent so `pytest --collect-only` surfaces the complete
planned suite. Implementations land in v2.1+ as hardware coverage expands.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.skip(reason="placeholder — implement in v2.1")
def test_firmware_edition_surfaces() -> None:
"""Bake `USERPREFS_FIRMWARE_EDITION=meshtastic_FirmwareEdition_BURNING_MAN`;
verify `iface.myInfo.firmware_edition == BURNING_MAN`. Relevant for event
firmware drops."""
pass
def test_oem_text_in_boot_log() -> None:
"""Bake `USERPREFS_OEM_TEXT="Lab Bench"`; open serial at boot; assert the
OEM string appears in the splash log lines."""
pass
def test_tz_applied() -> None:
"""Bake `USERPREFS_TZ_STRING="PST8PDT,M3.2.0,M11.1.0"`; verify
`localConfig.device.tzdef` matches and clock-display lines reflect the TZ."""
pass
def test_full_factory_reset_regenerates_identity() -> None:
"""Before: capture `my_node_num`. Call `factory_reset(full=True)`. After
reboot: `my_node_num` must differ (new identity), AND baked USERPREFS
defaults (region/channel) still come back."""
pass
@@ -0,0 +1,103 @@
"""Provisioning (negative): firmware baked WITHOUT
`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
proves the firmware honors that invariant when the USERPREFS bake deliberately
omits the region key.
Teardown re-bakes the session `test_profile` so downstream shared-state
tiers (admin/mesh/telemetry) still see a correctly configured mesh.
"""
from __future__ import annotations
from typing import Any
import pytest
from meshtastic_mcp import admin, flash, info
@pytest.mark.timeout(600)
def test_unset_region_blocks_tx(
hub_devices: dict[str, str],
no_region_profile: dict[str, Any],
test_profile: dict[str, Any],
serial_capture,
) -> 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
SDK raises, or the serial log contains a clear "region unset" marker.
Always re-bakes the session test_profile in the finalizer so downstream
categories are not left with a broken device.
"""
target = "esp32s3"
if target not in hub_devices:
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.
import os
env = os.environ.get("MESHTASTIC_MCP_ENV_ESP32S3", "t-beam-1w")
# Capture serial before the bake to see the "region unset" log line on boot
cap = serial_capture(target, env=env)
# Bake without region
result = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
userprefs_overrides=no_region_profile,
)
assert (
result["exit_code"] == 0
), f"bake of no_region_profile failed:\n{result.get('stderr_tail', '')}"
try:
# After bake, device should boot with region=UNSET
live = info.device_info(port=port, timeout_s=12.0)
assert live.get("region") in (None, "UNSET", "UNSET_0", ""), (
f"expected region UNSET after baking without region pref; "
f"got {live.get('region')!r}"
)
# Attempting to send a message should either raise or be logged as
# refused. The meshtastic SDK's sendText may raise in this condition,
# or it may accept the call but the firmware rejects on air.
send_failed = False
try:
admin.send_text(text="should not transmit", port=port)
except Exception:
send_failed = True
# Give the firmware a moment to log anything
import time as _time
_time.sleep(3.0)
log = "\n".join(cap.snapshot(max_lines=2000))
# We expect EITHER the send raised at the Python layer, OR the serial
# log explicitly says region is unset.
log_says_unset = any(
marker in log.lower()
for marker in ("region unset", "region is unset", "no region set")
)
assert send_failed or log_says_unset, (
"expected send to fail or log 'region unset'; neither happened.\n"
f"log tail:\n{log[-2000:]}"
)
finally:
# Re-bake the session profile so downstream tests work.
restore = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
userprefs_overrides=test_profile,
)
assert restore["exit_code"] == 0, (
"CRITICAL: failed to re-bake session profile after "
"no-region test; downstream tests will fail."
)
@@ -0,0 +1,80 @@
"""Provisioning: after a non-full factory_reset, USERPREFS defaults come back.
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,
not to stock Meshtastic.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(120)
def test_baked_prefs_survive_factory_reset(
baked_mesh: dict[str, Any],
test_profile: dict[str, Any],
wait_until,
) -> None:
"""Flow:
1. Change owner name to a known-non-default value.
2. Trigger factory_reset(full=False).
3. Wait for device to come back.
4. Confirm owner is back to USERPREFS-baked default (or blank default if
not baked), and primary channel/region/slot are still the baked values.
"""
# Use esp32s3 — typically more robust across reset cycles.
target = "esp32s3"
if target not in baked_mesh:
pytest.skip(f"role {target!r} not on hub")
port = baked_mesh[target]["port"]
# Snapshot pre-reset config
pre_reset = info.device_info(port=port, timeout_s=8.0)
original_long_name = pre_reset.get("long_name")
# Poison the owner name with a non-default marker
admin.set_owner(long_name="PoisonMarker", short_name="POIZ", port=port)
time.sleep(2.0)
# Confirm poison stuck before reset
poisoned = info.device_info(port=port, timeout_s=8.0)
assert poisoned.get("long_name") == "PoisonMarker"
# Trigger non-full factory reset
admin.factory_reset(port=port, confirm=True, full=False)
# Wait for device to come back (serial reappears)
time.sleep(10.0) # reset takes a moment
wait_until(
lambda: info.device_info(port=port, timeout_s=5.0).get("my_node_num")
is not None,
timeout=60,
backoff_start=1.0,
)
post = info.device_info(port=port, timeout_s=8.0)
# The key assertion: channel + region are STILL the USERPREFS-baked values,
# NOT Meshtastic stock defaults (which would be "LongFast" and the region
# the device shipped with).
assert post["primary_channel"] == test_profile["USERPREFS_CHANNEL_0_NAME"], (
f"after factory_reset, primary_channel reverted to "
f"{post['primary_channel']!r}, not baked {test_profile['USERPREFS_CHANNEL_0_NAME']!r}"
)
expected_region = test_profile["USERPREFS_CONFIG_LORA_REGION"].rsplit("_", 1)[-1]
assert post.get("region") == expected_region
# Owner name should NOT be "PoisonMarker" anymore
assert (
post.get("long_name") != "PoisonMarker"
), "factory_reset did not wipe the poisoned owner name"
# If we had an original_long_name, restore it so downstream tests see
# the same baseline.
if original_long_name and post.get("long_name") != original_long_name:
admin.set_owner(long_name=original_long_name, port=port)
@@ -0,0 +1,42 @@
"""Telemetry: device metrics (battery, voltage, channel util) arrive at the peer.
After ~2× the telemetry interval, B's entry in A's node DB should carry a
populated `deviceMetrics` block. This is the happy-path "my fleet is
reporting health data" operator test.
"""
from __future__ import annotations
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
@pytest.mark.timeout(360)
def test_device_telemetry_broadcast(baked_mesh: dict[str, Any], wait_until) -> None:
"""Wait up to 5 minutes for B's device telemetry to land in A's DB.
Firmware default telemetry interval is 900s; on a fresh mesh the first
device-metrics broadcast happens within ~30-120s of boot because devices
broadcast once on startup. We only require that some telemetry is present,
not that we see multiple cycles.
"""
if "nrf52" not in baked_mesh or "esp32s3" not in baked_mesh:
pytest.skip("both roles required")
a_port = baked_mesh["nrf52"]["port"]
b_node_num = baked_mesh["esp32s3"]["my_node_num"]
def b_has_telemetry() -> bool:
with connect(port=a_port) as iface:
rec = (iface.nodesByNum or {}).get(b_node_num, {})
metrics = rec.get("deviceMetrics") or {}
# Any one of these being non-None is sufficient evidence that
# telemetry arrived.
return any(
metrics.get(k) is not None
for k in ("batteryLevel", "voltage", "channelUtilization", "airUtilTx")
)
wait_until(b_has_telemetry, timeout=300, backoff_start=5.0, backoff_max=15.0)
@@ -0,0 +1,22 @@
"""Telemetry placeholders: v2 roadmap entries, skipped in v2.0."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.skip(reason="placeholder — implement in v2.1")
def test_position_broadcast_precision() -> None:
"""Bake A with `USERPREFS_CHANNEL_0_PRECISION=10` (low-precision channel);
A sets a fixed position; B's `list_nodes` entry for A shows truncated
coordinates (lower bits zeroed relative to what A transmitted). Validates
the privacy-precision knob operators tune for event deployments."""
pass
def test_fixed_position_round_trip() -> None:
"""`set_fixed_position(lat=40.7128, lon=-74.0060, alt=10)` on A; wait for
broadcast; assert B's node DB shows A's position within 1e-6° tolerance
AND `position_config.fixed_position=true` on A persists across reboot."""
pass
+117
View File
@@ -0,0 +1,117 @@
"""Session-bake module — runs first (alphabetical collection) to flash both hub
roles with the session `test_profile`.
Skipped entirely when `--assume-baked` is passed. All downstream hardware
tests either depend on `baked_mesh` (which verifies state) or do their own
per-test bake (provisioning/fleet tiers), so failing here gives one clear
actionable failure instead of a cascade of mismatches.
Hardware-specific env names live in a small role→env map at the top of this
file; override by setting `MESHTASTIC_MCP_ENV_<ROLE>` env vars (e.g.
`MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114`).
"""
from __future__ import annotations
import os
from typing import Any
import pytest
from meshtastic_mcp import flash, info
# Default envs for a common lab setup. Override per-role via env var.
_DEFAULT_ENVS = {
"nrf52": "heltec-mesh-node-t114",
"esp32s3": "t-beam-1w",
}
def _env_for(role: str) -> str:
override = os.environ.get(f"MESHTASTIC_MCP_ENV_{role.upper()}")
if override:
return override
if role not in _DEFAULT_ENVS:
pytest.fail(
f"no default PlatformIO env for role {role!r}. "
f"Set MESHTASTIC_MCP_ENV_{role.upper()} to the env name."
)
return _DEFAULT_ENVS[role]
def _bake_role(
role: str,
port: str,
test_profile: dict[str, Any],
force_bake: bool,
) -> None:
"""Bake + boot + verify for a single role. Skips if already baked unless
`--force-bake` was passed."""
env = _env_for(role)
# If not forcing, check if already baked with session profile.
if not force_bake:
try:
live = info.device_info(port=port, timeout_s=8.0)
# Quick heuristic: region matches and primary channel matches.
expected_region_short = test_profile["USERPREFS_CONFIG_LORA_REGION"].rsplit(
"_", 1
)[-1]
if (
live.get("region") == expected_region_short
and live.get("primary_channel")
== test_profile["USERPREFS_CHANNEL_0_NAME"]
):
pytest.skip(
f"{role} at {port} already baked with session profile "
f"(pass --force-bake to reflash)"
)
except Exception:
# If we can't query, fall through and bake anyway.
pass
result = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
skip_build=False,
userprefs_overrides=test_profile,
)
assert result["exit_code"] == 0, (
f"{role} bake failed: exit={result['exit_code']}\n"
f"stdout tail:\n{result.get('stdout_tail', '')}\n"
f"stderr tail:\n{result.get('stderr_tail', '')}"
)
@pytest.mark.timeout(600)
def test_bake_nrf52(
hub_devices: dict[str, str],
test_profile: dict[str, Any],
request: pytest.FixtureRequest,
) -> None:
"""Flash the nRF52840 role with the session test profile."""
if "nrf52" not in hub_devices:
pytest.skip("nRF52 not detected on hub")
_bake_role(
role="nrf52",
port=hub_devices["nrf52"],
test_profile=test_profile,
force_bake=request.config.getoption("--force-bake"),
)
@pytest.mark.timeout(600)
def test_bake_esp32s3(
hub_devices: dict[str, str],
test_profile: dict[str, Any],
request: pytest.FixtureRequest,
) -> None:
"""Flash the ESP32-S3 role with the session test profile."""
if "esp32s3" not in hub_devices:
pytest.skip("ESP32-S3 not detected on hub")
_bake_role(
role="esp32s3",
port=hub_devices["esp32s3"],
test_profile=test_profile,
force_bake=request.config.getoption("--force-bake"),
)
+124
View File
@@ -0,0 +1,124 @@
"""Tool-surface coverage: track which MCP tools the test suite actually exercises.
This is NOT line coverage (that's `coverage.py`). This measures which of the
37 public MCP tools in `meshtastic_mcp.server` got invoked during a pytest
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
a counting shim. At session end, emit `tool_coverage.json` mapping each tool
name to its call count. Tools never called show `count=0`.
"""
from __future__ import annotations
import json
import pathlib
from typing import Any, Callable
_counts: dict[str, int] = {}
_installed = False
def _bump(name: str) -> None:
_counts[name] = _counts.get(name, 0) + 1
def _wrap(module: Any, attr: str, tool_name: str) -> None:
original = getattr(module, attr, None)
if original is None or not callable(original):
return
def wrapper(*args: Any, **kwargs: Any) -> Any:
_bump(tool_name)
return original(*args, **kwargs)
wrapper.__wrapped__ = original # type: ignore[attr-defined]
wrapper.__name__ = attr
wrapper.__doc__ = original.__doc__
setattr(module, attr, wrapper)
# Mapping: MCP tool name → (module, function name). Mirrors the wiring in
# `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
"list_devices": ("meshtastic_mcp.devices", "list_devices"),
"list_boards": ("meshtastic_mcp.boards", "list_boards"),
"get_board": ("meshtastic_mcp.boards", "get_board"),
# Build & flash
"build": ("meshtastic_mcp.flash", "build"),
"clean": ("meshtastic_mcp.flash", "clean"),
"pio_flash": ("meshtastic_mcp.flash", "flash"),
"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_open": ("meshtastic_mcp.serial_session", "open_session"),
"serial_read": ("meshtastic_mcp.serial_session", "read_session"),
"serial_close": ("meshtastic_mcp.serial_session", "close_session"),
# Device reads
"device_info": ("meshtastic_mcp.info", "device_info"),
"list_nodes": ("meshtastic_mcp.info", "list_nodes"),
# Device writes
"set_owner": ("meshtastic_mcp.admin", "set_owner"),
"get_config": ("meshtastic_mcp.admin", "get_config"),
"set_config": ("meshtastic_mcp.admin", "set_config"),
"get_channel_url": ("meshtastic_mcp.admin", "get_channel_url"),
"set_channel_url": ("meshtastic_mcp.admin", "set_channel_url"),
"send_text": ("meshtastic_mcp.admin", "send_text"),
"reboot": ("meshtastic_mcp.admin", "reboot"),
"shutdown": ("meshtastic_mcp.admin", "shutdown"),
"factory_reset": ("meshtastic_mcp.admin", "factory_reset"),
# USERPREFS
"userprefs_manifest": ("meshtastic_mcp.userprefs", "build_manifest"),
"userprefs_get": ("meshtastic_mcp.userprefs", "read_state"),
"userprefs_set": ("meshtastic_mcp.userprefs", "merge_active"),
"userprefs_reset": ("meshtastic_mcp.userprefs", "reset"),
"userprefs_testing_profile": ("meshtastic_mcp.userprefs", "build_testing_profile"),
# Vendor hardware tools
"esptool_chip_info": ("meshtastic_mcp.hw_tools", "esptool_chip_info"),
"esptool_erase_flash": ("meshtastic_mcp.hw_tools", "esptool_erase_flash"),
"esptool_raw": ("meshtastic_mcp.hw_tools", "esptool_raw"),
"nrfutil_dfu": ("meshtastic_mcp.hw_tools", "nrfutil_dfu"),
"nrfutil_raw": ("meshtastic_mcp.hw_tools", "nrfutil_raw"),
"picotool_info": ("meshtastic_mcp.hw_tools", "picotool_info"),
"picotool_load": ("meshtastic_mcp.hw_tools", "picotool_load"),
"picotool_raw": ("meshtastic_mcp.hw_tools", "picotool_raw"),
}
def install() -> None:
"""Wrap every mapped tool function with the counting shim. Idempotent."""
global _installed
if _installed:
return
import importlib
for tool_name, (module_path, attr) in _TOOL_MAP.items():
try:
mod = importlib.import_module(module_path)
except ImportError:
continue
_wrap(mod, attr, tool_name)
_counts.setdefault(tool_name, 0)
_installed = True
def write_report(path: pathlib.Path) -> None:
"""Emit `tool_coverage.json` with call counts for every mapped tool."""
exercised = sum(1 for c in _counts.values() if c > 0)
total = len(_counts)
payload = {
"total_tools": total,
"exercised": exercised,
"coverage_pct": round(100.0 * exercised / total, 1) if total else 0.0,
"counts": dict(sorted(_counts.items())),
"unexercised": sorted(k for k, v in _counts.items() if v == 0),
}
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def snapshot() -> dict[str, int]:
return dict(_counts)
View File
+72
View File
@@ -0,0 +1,72 @@
"""`boards.py` filter and enumeration correctness.
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.
"""
from __future__ import annotations
import pytest
from meshtastic_mcp import boards
def test_list_boards_returns_many() -> None:
all_boards = boards.list_boards()
assert len(all_boards) >= 50, "expected at least 50 PlatformIO envs"
def test_tbeam_is_canonical_esp32() -> None:
"""The default env in platformio.ini is `tbeam`; it must always be present
and flagged as esp32."""
rec = boards.get_board("tbeam")
assert rec["architecture"] == "esp32"
assert rec["hw_model_slug"] == "TBEAM"
assert rec["actively_supported"] is True
assert rec["board"] == "ttgo-tbeam"
def test_filter_by_architecture() -> None:
esp32s3 = boards.list_boards(architecture="esp32s3")
assert len(esp32s3) >= 1
assert all(b["architecture"] == "esp32s3" for b in esp32s3)
def test_filter_by_actively_supported() -> None:
supported = boards.list_boards(actively_supported_only=True)
unsupported = [b for b in boards.list_boards() if not b["actively_supported"]]
assert supported, "at least one board should be actively supported"
assert all(b["actively_supported"] for b in supported)
# Quick sanity: the set difference is non-empty in this repo (there are
# boards marked actively_supported=false).
assert unsupported, "expected at least one actively_supported=false board"
def test_filter_by_query_substring_matches_display_name() -> None:
heltec = boards.list_boards(query="heltec")
assert heltec, "expected at least one Heltec env"
# Case-insensitive across display_name, env name, or hw_model_slug
for b in heltec:
blob = " ".join(
filter(
None,
[
b.get("display_name") or "",
b["env"],
b.get("hw_model_slug") or "",
],
)
).lower()
assert "heltec" in blob
def test_get_board_unknown_env_raises() -> None:
with pytest.raises(KeyError, match="Unknown env"):
boards.get_board("definitely-not-a-real-env")
def test_get_board_surfaces_raw_config() -> None:
rec = boards.get_board("tbeam")
assert "raw_config" in rec
assert "custom_meshtastic_architecture" in rec["raw_config"]
assert rec["raw_config"]["custom_meshtastic_architecture"] == "esp32"
+61
View File
@@ -0,0 +1,61 @@
"""`pio.py` subprocess wrapper: error paths, tailing, JSON parsing.
Uses a real `pio` install only for the happy-path `--version`; error paths are
exercised with a deliberately-broken `MESHTASTIC_PIO_BIN` override.
"""
from __future__ import annotations
import pytest
from meshtastic_mcp import pio
def test_tail_lines_keeps_last_n() -> None:
text = "\n".join(f"line-{i}" for i in range(1, 11))
assert pio.tail_lines(text, 3) == "line-8\nline-9\nline-10"
assert pio.tail_lines(text, 100) == text # more lines requested than exist
assert pio.tail_lines("", 5) == ""
def test_tail_lines_handles_trailing_newline() -> None:
assert pio.tail_lines("a\nb\nc\n", 2) == "b\nc"
def test_pio_version_runs(monkeypatch: pytest.MonkeyPatch) -> None:
"""Happy path: `pio --version` exits 0 and prints a version string.
This exercises subprocess spawn, timeout default, and the PioResult shape.
Skipped if pio isn't installed (CI would need pio preinstalled).
"""
try:
result = pio.run(["--version"], timeout=30)
except pio.PioError:
pytest.skip("pio not available in this environment")
assert result.returncode == 0
assert "PlatformIO" in result.stdout or "platformio" in result.stdout.lower()
assert result.duration_s > 0
def test_pio_bad_command_raises_pio_error() -> None:
"""`pio` returning non-zero must surface as PioError with stderr captured."""
with pytest.raises(pio.PioError) as excinfo:
pio.run(["this-subcommand-does-not-exist"], timeout=10)
# PioError includes returncode + a tail of stderr/stdout.
assert excinfo.value.returncode != 0
def test_pio_timeout_raises_pio_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
"""Extremely short timeout on a command that takes longer must raise PioTimeout."""
# `pio` startup alone typically takes ~200-500ms; a 1ms timeout reliably trips.
with pytest.raises(pio.PioTimeout):
pio.run(["--help"], timeout=0.001)
def test_run_json_parses_device_list() -> None:
"""`pio device list --json-output` is a known-valid JSON producer."""
try:
data = pio.run_json(["device", "list"], timeout=15)
except pio.PioError:
pytest.skip("pio not available in this environment")
# Always a list; may be empty if nothing is plugged in.
assert isinstance(data, list)
@@ -0,0 +1,120 @@
"""`userprefs.build_testing_profile` / `generate_psk` correctness.
The testing-profile generator is the critical primitive for automated test
labs: it must produce deterministic PSKs for a given seed (so every device
baked in a CI run joins the same mesh) and different PSKs for different seeds
(so concurrent labs don't collide).
"""
from __future__ import annotations
import pytest
from meshtastic_mcp import userprefs
def test_generate_psk_is_32_bytes_formatted() -> None:
psk = userprefs.generate_psk(seed="deterministic")
# Format: "{ 0x.., 0x.., ... }" with 32 comma-separated hex bytes.
assert psk.startswith("{ ") and psk.endswith(" }")
bytes_part = psk.removeprefix("{ ").removesuffix(" }")
hex_bytes = [b.strip() for b in bytes_part.split(",")]
assert len(hex_bytes) == 32
for b in hex_bytes:
assert b.startswith("0x")
int(b, 16) # raises if not valid hex
def test_generate_psk_deterministic_under_same_seed() -> None:
a = userprefs.generate_psk(seed="pytest-session-123")
b = userprefs.generate_psk(seed="pytest-session-123")
assert a == b, "same seed must produce same PSK"
def test_generate_psk_varies_with_seed() -> None:
seeds = ["a", "b", "pytest-1", "pytest-2", "prod-fleet-alpha"]
psks = {userprefs.generate_psk(seed=s) for s in seeds}
assert len(psks) == len(seeds), "seed → PSK map must be injective"
def test_generate_psk_random_when_seedless() -> None:
a = userprefs.generate_psk(seed=None)
b = userprefs.generate_psk(seed=None)
# Not strictly guaranteed (birthday paradox), but 256-bit randomness makes
# a collision astronomically unlikely.
assert a != b
def test_testing_profile_contains_expected_keys() -> None:
profile = userprefs.build_testing_profile(psk_seed="ci-run-1")
required = {
"USERPREFS_CONFIG_LORA_REGION",
"USERPREFS_LORACONFIG_MODEM_PRESET",
"USERPREFS_LORACONFIG_CHANNEL_NUM",
"USERPREFS_CHANNELS_TO_WRITE",
"USERPREFS_CHANNEL_0_NAME",
"USERPREFS_CHANNEL_0_PSK",
"USERPREFS_CHANNEL_0_PRECISION",
"USERPREFS_CONFIG_LORA_IGNORE_MQTT",
"USERPREFS_MQTT_ENABLED",
"USERPREFS_CHANNEL_0_UPLINK_ENABLED",
"USERPREFS_CHANNEL_0_DOWNLINK_ENABLED",
}
assert required <= set(profile.keys())
# Defaults from the plan
assert profile["USERPREFS_CONFIG_LORA_REGION"].endswith("_US")
assert profile["USERPREFS_LORACONFIG_MODEM_PRESET"].endswith("_LONG_FAST")
assert profile["USERPREFS_LORACONFIG_CHANNEL_NUM"] == 88
assert profile["USERPREFS_CHANNEL_0_NAME"] == "McpTest"
def test_testing_profile_rejects_unknown_region() -> None:
with pytest.raises(ValueError, match="Unknown region"):
userprefs.build_testing_profile(region="ATLANTIS")
def test_testing_profile_rejects_unknown_modem_preset() -> None:
with pytest.raises(ValueError, match="Unknown modem_preset"):
userprefs.build_testing_profile(modem_preset="WARP_9")
def test_testing_profile_rejects_oversized_channel_name() -> None:
with pytest.raises(ValueError, match="11-char max"):
userprefs.build_testing_profile(channel_name="WayTooLongChannelName")
def test_testing_profile_rejects_oversized_short_name() -> None:
with pytest.raises(ValueError, match="≤4 chars"):
userprefs.build_testing_profile(short_name="TOOLONG")
def test_disable_mqtt_false_drops_mqtt_keys() -> None:
profile = userprefs.build_testing_profile(psk_seed="x", disable_mqtt=False)
# When disable_mqtt is False, the MQTT-gating keys should NOT be in the
# profile (device uses firmware defaults, whatever those are).
assert "USERPREFS_MQTT_ENABLED" not in profile
assert "USERPREFS_CHANNEL_0_UPLINK_ENABLED" not in profile
def test_disable_position_adds_gps_disabled() -> None:
profile = userprefs.build_testing_profile(psk_seed="x", disable_position=True)
assert profile["USERPREFS_CONFIG_GPS_MODE"].endswith("_DISABLED")
assert profile["USERPREFS_CONFIG_SMART_POSITION_ENABLED"] is False
def test_owner_names_included_when_provided() -> None:
profile = userprefs.build_testing_profile(
psk_seed="x", long_name="Lab Bench 1", short_name="LB1"
)
assert profile["USERPREFS_CONFIG_OWNER_LONG_NAME"] == "Lab Bench 1"
assert profile["USERPREFS_CONFIG_OWNER_SHORT_NAME"] == "LB1"
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
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")
assert lab_a["USERPREFS_CHANNEL_0_PSK"] != lab_b["USERPREFS_CHANNEL_0_PSK"]
@@ -0,0 +1,116 @@
"""Unit tests for `userprefs.py`: jsonc parse, type inference, round-trip
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
produce confusing mismatches.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from meshtastic_mcp import userprefs
@pytest.fixture
def sample_jsonc(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Write a minimal userPrefs.jsonc into tmp_path and point config at it."""
content = """{
"USERPREFS_CONFIG_LORA_REGION": "meshtastic_Config_LoRaConfig_RegionCode_US",
"USERPREFS_LORACONFIG_CHANNEL_NUM": "88",
// "USERPREFS_CHANNEL_0_NAME": "McpTest",
"USERPREFS_CHANNEL_0_PSK": "{ 0x01, 0x02, 0x03 }",
// "USERPREFS_MQTT_ENABLED": "0",
"USERPREFS_CONFIG_LORA_IGNORE_MQTT": "true"
}
"""
# Fake firmware root with a userPrefs.jsonc + platformio.ini (needed for
# `config.firmware_root()`'s walk-up detection).
(tmp_path / "platformio.ini").write_text("[platformio]\n", encoding="utf-8")
jsonc = tmp_path / "userPrefs.jsonc"
jsonc.write_text(content, encoding="utf-8")
monkeypatch.setenv("MESHTASTIC_FIRMWARE_ROOT", str(tmp_path))
return jsonc
def test_read_state_separates_active_and_commented(sample_jsonc: Path) -> None:
state = userprefs.read_state()
assert set(state["active"]) == {
"USERPREFS_CONFIG_LORA_REGION",
"USERPREFS_LORACONFIG_CHANNEL_NUM",
"USERPREFS_CHANNEL_0_PSK",
"USERPREFS_CONFIG_LORA_IGNORE_MQTT",
}
assert set(state["commented"]) == {
"USERPREFS_CHANNEL_0_NAME",
"USERPREFS_MQTT_ENABLED",
}
def test_infer_type_matches_platformio_custom_py() -> None:
# Mirrors the branch order in `bin/platformio-custom.py:222-235`.
assert userprefs.infer_type("{ 0x01, 0x02 }") == "brace"
assert userprefs.infer_type("88") == "number"
assert userprefs.infer_type("-1.5") == "number"
assert userprefs.infer_type("true") == "bool"
assert userprefs.infer_type("false") == "bool"
assert userprefs.infer_type("meshtastic_Config_DeviceConfig_Role_ROUTER") == "enum"
assert userprefs.infer_type("plain string value") == "string"
assert userprefs.infer_type(None) == "unknown"
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."""
original = sample_jsonc.read_bytes()
with userprefs.temporary_overrides({"USERPREFS_CHANNEL_0_NAME": "OverrideTest"}):
# During the context, the override is written.
during = userprefs.read_state()
assert "USERPREFS_CHANNEL_0_NAME" in during["active"]
assert during["active"]["USERPREFS_CHANNEL_0_NAME"] == "OverrideTest"
# After: byte-identical restore.
assert sample_jsonc.read_bytes() == original
def test_temporary_overrides_restores_after_exception(sample_jsonc: Path) -> None:
original = sample_jsonc.read_bytes()
with pytest.raises(RuntimeError, match="simulated"):
with userprefs.temporary_overrides({"USERPREFS_CHANNEL_0_NAME": "Failing"}):
raise RuntimeError("simulated mid-build failure")
assert sample_jsonc.read_bytes() == original
def test_temporary_overrides_none_is_noop(sample_jsonc: Path) -> None:
original = sample_jsonc.read_bytes()
with userprefs.temporary_overrides(None) as effective:
# No file write, and `effective` still reflects the active set.
assert "USERPREFS_CONFIG_LORA_REGION" in effective
assert sample_jsonc.read_bytes() == original
def test_temporary_overrides_rejects_non_userprefs_keys(sample_jsonc: Path) -> None:
with pytest.raises(ValueError, match="USERPREFS_"):
with userprefs.temporary_overrides({"RANDOM_KEY": "value"}):
pass
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.
"""
manifest = userprefs.build_manifest()
keys = {e["key"] for e in manifest["entries"]}
# All 6 keys from sample_jsonc should be present.
assert "USERPREFS_CONFIG_LORA_REGION" in keys
assert "USERPREFS_CHANNEL_0_NAME" in keys # commented but still listed
assert manifest["active_count"] == 4
assert manifest["commented_count"] == 2