Add MCP server for interacting with meshtastic devices and testing framework / TUI (#10194)

* Start of MCP server and test suite

* Add MCP server for interacting with meshtastic devices and testing framework / TUI

* Update mcp-server/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix mcp-server review feedback from thread

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control

* Semgrep fixes

* Trunk and semgrep fixes

* optimize pio streaming tee file writes

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* chore: remove redundant log handle assignment

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Consolidate type imports and remove placeholder test files

* Add tests for config persistence and more exchange messages

* Refactor position test to validate on-demand request/reply behavior

* Remove  position request/reply test and update README for telemetry behavior

* Fix transmit history file to get removed on factory reset

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Ben Meadors
2026-04-18 11:29:02 -05:00
co-authored by Copilot copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent 8fd0a7f283
commit 6b15571e14
77 changed files with 10701 additions and 13 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
`rak4631`)
- `MESHTASTIC_MCP_ENV_ESP32S3` — PlatformIO env for the ESP32-S3 role (default
`heltec-v3`)
- `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 38 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
+118
View File
@@ -0,0 +1,118 @@
"""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
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
``FileNotFoundError``.
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
the bench right now, whichever `cu.*` path it happens to be at".
Test-harness-local (not exported from ``meshtastic_mcp``): a thin wrapper
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.
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.
"""
from __future__ import annotations
import time
from meshtastic_mcp import devices as devices_module
# Role → canonical VID(s). Kept in sync with:
# - `mcp-server/run-tests.sh` (ROLE_BY_VID)
# - `mcp-server/tests/conftest.py::hub_profile`
# If any of those change, this must too.
_ROLE_VIDS: dict[str, tuple[int, ...]] = {
"nrf52": (0x239A,), # Adafruit / RAK nRF52840 native USB
"esp32s3": (0x303A, 0x10C4), # Espressif native USB + CP2102 USB-UART
}
def _coerce_vid(raw: object) -> int | None:
"""`devices.list_devices` returns vid as either '0x239a' or an int;
normalize to int. None on un-parseable input (matches the same fault-
tolerance `run-tests.sh` uses for its role detection)."""
if raw is None:
return None
if isinstance(raw, int):
return raw
if isinstance(raw, str):
try:
return int(raw, 16) if raw.lower().startswith("0x") else int(raw)
except ValueError:
return None
return None
def resolve_port_by_role(
role: str,
*,
timeout_s: float = 30.0,
poll_start: float = 0.5,
poll_max: float = 5.0,
) -> str:
"""Return the current ``/dev/cu.*`` path for ``role`` once one appears.
Polls ``devices.list_devices(include_unknown=True)`` every ``poll_start``
seconds (1.5× backoff, capped at ``poll_max``) until a device matching
``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
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
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.
Raises:
AssertionError: if no matching device appears within ``timeout_s``.
ValueError: if ``role`` is not in ``_ROLE_VIDS``.
"""
if role not in _ROLE_VIDS:
raise ValueError(f"unknown role {role!r}; expected one of {sorted(_ROLE_VIDS)}")
wanted_vids = _ROLE_VIDS[role]
deadline = time.monotonic() + timeout_s
delay = poll_start
last_seen: list[dict] = []
while time.monotonic() < deadline:
try:
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
# USB hiccup during re-enumeration can bubble up briefly.
# Treat as "nothing seen this round" and retry.
last_seen = [{"error": repr(exc)}]
for dev in last_seen:
vid = _coerce_vid(dev.get("vid"))
if vid is not None and vid in wanted_vids and dev.get("port"):
return dev["port"]
time.sleep(delay)
delay = min(delay * 1.5, poll_max)
# 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 "
f"{[hex(v) for v in wanted_vids]}) appeared within {timeout_s:.0f}s. "
f"Last enumeration: {last_seen!r}"
)
View File
@@ -0,0 +1,57 @@
"""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_single: dict[str, Any],
test_profile: dict[str, Any],
) -> 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.
3. After set, `get_channel_url()` returns the same (canonicalized) URL.
4. Primary channel name survives round-trip.
"""
port = baked_single["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 is tricky: the firmware may re-serialize the protobuf
# with fields in a different order, producing a visually-different URL
# that encodes the same content. Accept that as a success when the
# primary channel name survived the round-trip (already asserted above)
# and the URL is still a parseable Meshtastic URL. Bit-equality is a
# nice-to-have, not a correctness guarantee.
assert url_after, "URL went blank after setURL"
assert (
"meshtastic" in url_after.lower() or "#" in url_after
), f"URL after setURL no longer looks like a channel URL: {url_after!r}"
@@ -0,0 +1,106 @@
"""Admin: a config mutation survives a reboot.
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.
Pattern: single-device (``baked_single``, one test per role). Mutate a
benign, easy-to-observe LoRa field (``lora.hop_limit``), confirm
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
downstream mesh tests still work regardless of the flipped value.
* 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
the whole lora-config persistence pipeline end-to-end.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
from .._port_discovery import resolve_port_by_role
def _get_hop_limit(port: str) -> int:
"""Read `lora.hop_limit` from the device's current config."""
lora = admin.get_config("lora", port=port).get("config", {}).get("lora", {})
hl = lora.get("hop_limit")
assert isinstance(hl, int), (
f"lora.hop_limit missing or non-int in get_config response: " f"{lora!r}"
)
return hl
@pytest.mark.timeout(180)
def test_lora_hop_limit_survives_reboot(
baked_single: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role. Mutates `lora.hop_limit`, reboots,
verifies the new value is still there after the device comes back.
"""
role = baked_single["role"]
port = baked_single["port"]
original = _get_hop_limit(port)
# Flip to a definitively different value within the protocol's
# valid range (1..7 per LoRaConfig.hop_limit comment). Pick 5 if
# current is != 5, else 4.
new_value = 5 if original != 5 else 4
try:
admin.set_config("lora.hop_limit", new_value, port=port)
# 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
# itself, not with persistence.
assert _get_hop_limit(port) == new_value, (
f"pre-reboot readback failed: set {new_value}, got "
f"{_get_hop_limit(port)}"
)
# Reboot. `seconds=3` gives the Python client time to
# disconnect cleanly; sleep long enough for the boot to start
# before we begin polling.
admin.reboot(port=port, confirm=True, seconds=3)
time.sleep(8.0)
# nRF52 re-enumerates on reboot → rediscover.
port = resolve_port_by_role(role, timeout_s=60.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,
)
# The assertion this test exists for: the mutation persisted
# across the reboot cycle through NVS / LittleFS / UICR.
post = _get_hop_limit(port)
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."
)
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
# there is a non-default hop_limit sticks around, which is
# benign (mesh still works at hop_limit 3 or 5).
try:
admin.set_config("lora.hop_limit", original, port=port)
except Exception:
pass
@@ -0,0 +1,59 @@
"""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_single: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role — proves the reboot-persistence
round-trip works on each device independently, not just one."""
port = baked_single["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
File diff suppressed because it is too large Load Diff
View File
@@ -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
+220
View File
@@ -0,0 +1,220 @@
"""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
accessible through `meshtastic.SerialInterface`'s pubsub mechanism.
`ReceiveCollector` opens a long-lived SerialInterface on a port, subscribes
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
firmware's 10-minute NodeInfo rate-limit. Tests doing directed PKI-encrypted
sends need BOTH endpoints to hold current pubkeys for each other:
nudge_nodeinfo(iface) # nudge an already-open SerialInterface
nudge_nodeinfo_port(port) # open briefly, nudge, close
See `ReceiveCollector.broadcast_nodeinfo_ping` for the firmware-side
rationale (PKI staleness → directed sends NAK with Routing.Error=35
PKI_UNKNOWN_PUBKEY or 39 PKI_SEND_FAIL_PUBLIC_KEY).
"""
from __future__ import annotations
import threading
import time
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
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)`
with the 60-s window rather than the 10-min rate-limit).
Call on BOTH TX and RX ifaces before a directed PKI-encrypted send.
Nudging only one side leaves the other with a stale pubkey cache and
makes the directed send NAK with PKI_UNKNOWN_PUBKEY.
"""
from meshtastic.protobuf import mesh_pb2 # type: ignore[import-untyped]
tr = mesh_pb2.ToRadio()
tr.heartbeat.nonce = 1
iface._sendToRadio(tr)
def nudge_nodeinfo_port(port: str) -> None:
"""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
the same port.
"""
from meshtastic_mcp.connection import connect
with connect(port=port) as iface:
nudge_nodeinfo(iface)
class ReceiveCollector:
"""Listen for meshtastic packets on `port` and let tests wait for a match.
Must be used as a context manager so the underlying SerialInterface is
always closed (leaked interfaces hold the CDC port open and break
subsequent tool calls).
Usage:
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
# ... send from TX ...
assert rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
timeout=60,
), f"packet not received; got {rx.snapshot()!r}"
"""
def __init__(
self,
port: str,
topic: str = "meshtastic.receive",
capture_logs: bool = False,
) -> None:
self._port = port
self._topic = topic
self._capture_logs = capture_logs
self._packets: list[dict[str, Any]] = []
self._log_lines: list[str] = []
self._lock = threading.Lock()
self._iface = None
self._handler_ref = None # keep strong ref so pubsub doesn't GC it
self._log_handler_ref = None
def __enter__(self) -> "ReceiveCollector":
from meshtastic.serial_interface import (
SerialInterface, # type: ignore[import-untyped]
)
from pubsub import pub # type: ignore[import-untyped]
# 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:
self._packets.append(packet)
self._handler_ref = handler
pub.subscribe(handler, self._topic)
# Firmware-side logs come through the SAME SerialInterface when
# `config.security.debug_log_api_enabled = True`. Subscribing here
# captures them for failure-artifact attachment without needing a
# separate pio monitor session that would fight our port lock.
if self._capture_logs:
def log_handler(line: str, interface: Any) -> None:
with self._lock:
self._log_lines.append(line)
self._log_handler_ref = log_handler
pub.subscribe(log_handler, "meshtastic.log.line")
self._iface = SerialInterface(devPath=self._port, connectNow=True)
# Let the config bootstrap complete so we don't miss early arrivals.
time.sleep(1.0)
return self
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
from pubsub import pub # type: ignore[import-untyped]
if self._handler_ref is not None:
try:
pub.unsubscribe(self._handler_ref, self._topic)
except Exception:
pass
if self._log_handler_ref is not None:
try:
pub.unsubscribe(self._log_handler_ref, "meshtastic.log.line")
except Exception:
pass
if self._iface is not None:
try:
self._iface.close()
except Exception:
pass
def snapshot(self) -> list[dict[str, Any]]:
"""Return a thread-safe copy of the list of collected packets."""
with self._lock:
return list(self._packets)
def log_snapshot(self) -> list[str]:
"""Return captured firmware log lines.
Only populated if `capture_logs=True` AND the device has
`security.debug_log_api_enabled=True`.
"""
with self._lock:
return list(self._log_lines)
def send_text(
self,
text: str,
destination_id: Any = "^all",
want_ack: bool = False,
channel_index: int = 0,
) -> Any:
"""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
and fail the port lock.
"""
if self._iface is None:
raise RuntimeError("ReceiveCollector not started; use as context manager")
return self._iface.sendText(
text,
destinationId=destination_id,
wantAck=want_ack,
channelIndex=channel_index,
)
def broadcast_nodeinfo_ping(self) -> None:
"""Force the firmware on `port` to broadcast a fresh NodeInfo.
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.
Firmware-side details (rate-limit bypass, nonce==1 trigger path,
shorterTimeout=true window) are documented on the module-level
helper.
"""
if self._iface is None:
raise RuntimeError("ReceiveCollector not started; use as context manager")
nudge_nodeinfo(self._iface)
def wait_for(
self,
predicate: Callable[[dict[str, Any]], bool],
timeout: float = 60.0,
poll_interval: float = 0.5,
) -> dict[str, Any] | None:
"""Block until a received packet matches `predicate` or timeout.
Returns the matching packet (truthy) or None (falsy).
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self._lock:
for pkt in self._packets:
try:
if predicate(pkt):
return pkt
except Exception:
continue
time.sleep(poll_interval)
return None
@@ -0,0 +1,83 @@
"""Mesh: explicit two-way communication, single pass/fail.
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.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from ._receive import ReceiveCollector
@pytest.mark.timeout(300)
def test_bidirectional_mesh_communication(
baked_mesh: dict[str, Any],
) -> None:
"""Requires ≥2 baked roles.
For each role, broadcast a unique tag. Assert every other role's
ReceiveCollector saw that tag within a 120s window per direction.
"""
roles = sorted(baked_mesh.keys())
if len(roles) < 2:
pytest.skip(f"need ≥2 roles; have {roles!r}")
# Open receive collectors on every role BEFORE sending anything.
collectors: dict[str, ReceiveCollector] = {}
try:
for role in roles:
rx = ReceiveCollector(
baked_mesh[role]["port"], topic="meshtastic.receive.text"
)
rx.__enter__()
collectors[role] = rx
# Let the meshtastic interfaces stabilize before the first send
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
# race the collector's exclusive lock on the port.
tags: dict[str, str] = {}
for sender in roles:
tag = f"bidi-{sender}-{int(time.time() * 1000) % 100_000}"
tags[sender] = tag
collectors[sender].send_text(tag)
# Small gap so airtime doesn't overlap
time.sleep(4.0)
# Every OTHER role must see every sender's tag within 120s each
missing: list[str] = []
for sender, tag in tags.items():
for receiver in roles:
if receiver == sender:
continue
got = collectors[receiver].wait_for(
lambda pkt, t=tag: pkt.get("decoded", {}).get("text") == t,
timeout=120,
)
if got is None:
observed = [
p.get("decoded", {}).get("text")
for p in collectors[receiver].snapshot()
]
missing.append(
f"{sender}->{receiver}: tag {tag!r} not seen; "
f"receiver got {observed!r}"
)
assert not missing, "bidirectional comms incomplete:\n " + "\n ".join(missing)
finally:
for rx in collectors.values():
try:
rx.__exit__(None, None, None)
except Exception:
pass
@@ -0,0 +1,45 @@
"""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.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin
from ._receive import ReceiveCollector
@pytest.mark.timeout(180)
def test_broadcast_delivers(
mesh_pair: dict[str, Any],
) -> None:
"""Runs for every directed role pair. TX sends a unique broadcast text;
RX must receive the decoded text via the meshtastic pubsub receive topic
within 120s.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
unique = f"mcp-{tx_role}-to-{rx_role}-{int(time.time())}"
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
admin.send_text(text=unique, port=tx_port)
got = rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
timeout=120,
)
assert got is not None, (
f"broadcast {unique!r} from {tx_role} not received at {rx_role} within 120s. "
f"RX saw {len(rx.snapshot())} text packet(s): "
f"{[p.get('decoded', {}).get('text') for p in rx.snapshot()]!r}"
)
@@ -0,0 +1,105 @@
"""Mesh: direct text addressed to RX's node_num arrives at RX.
Uses the same pubsub receive pattern as `test_broadcast_delivers`, but sends
with `destinationId=<rx_node_num>` and `wantAck=True`. The assertion is that
the RX firmware accepted and decoded the text; the ACK is handled by the
firmware transparently (and fires automatically when wantAck is set + the
destination is the local node).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from ._receive import ReceiveCollector, nudge_nodeinfo
@pytest.mark.timeout(240)
def test_direct_with_ack_roundtrip(
mesh_pair: dict[str, Any],
) -> None:
"""Runs for every directed pair. Addressed send from TX to RX's node_num
with want_ack=True; RX must receive the decoded text via pubsub.
Why this proves ACK: setting want_ack on a directed send causes the
firmware to retry until an ACK is received. If RX's decoded.text fires
once, both the outbound text AND the inbound ACK happened.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
assert rx_node_num is not None, f"{rx_role} my_node_num missing"
unique = f"mcp-ack-{tx_role}-to-{rx_role}-{int(time.time())}"
# 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
# `tests/mesh/_receive.py::nudge_nodeinfo` for the heartbeat-nonce=1
# firmware path.
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
pk_deadline = time.monotonic() + 45.0
last_nudge = time.monotonic()
last_rec: dict[str, Any] = {}
while time.monotonic() < pk_deadline:
last_rec = (tx_iface.nodesByNum or {}).get(rx_node_num, {})
user = last_rec.get("user", {})
if user.get("publicKey"):
break
# Re-nudge both sides every 15 s in case a broadcast was
# lost to a LoRa collision.
if time.monotonic() - last_nudge > 15.0:
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
last_nudge = time.monotonic()
time.sleep(1.0)
else:
pytest.fail(
f"TX ({tx_role}) never saw RX ({rx_role}) public key "
f"within 45s; nodesByNum entry={last_rec!r}"
)
# Retry covers LoRa collisions. Re-nudge both sides between
# 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):
packet = tx_iface.sendText(
unique,
destinationId=rx_node_num,
wantAck=True,
)
assert packet is not None, "sendText returned None"
got = rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
timeout=30,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(5.0)
assert got is not None, (
f"directed send {unique!r} from {tx_role} to {rx_role} "
f"(node_num 0x{rx_node_num:08x}) not received within 120s. "
f"RX saw {len(rx.snapshot())} text packet(s): "
f"{[p.get('decoded', {}).get('text') for p in rx.snapshot()]!r}"
)
# Additional: confirm the destination matches (not leaked broadcast)
assert got.get("to") == rx_node_num, (
f"received packet destination mismatch: to={got.get('to')}, "
f"expected 0x{rx_node_num:08x}"
)
@@ -0,0 +1,39 @@
"""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.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
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).
"""
observer_port = mesh_pair["tx"]["port"]
target_node_num = mesh_pair["rx"]["my_node_num"]
assert (
target_node_num is not None
), f"{mesh_pair['rx']['role']} my_node_num not populated"
def target_visible_from_observer() -> bool:
with connect(port=observer_port) as iface:
nodes = iface.nodesByNum or {}
return target_node_num in nodes
wait_until(
target_visible_from_observer,
timeout=120,
backoff_start=2.0,
backoff_max=10.0,
)
+147
View File
@@ -0,0 +1,147 @@
"""Mesh: traceroute from TX to RX round-trips with no intermediate hops.
TX sends a `TRACEROUTE_APP` request (RouteDiscovery with `want_response=True`)
addressed to RX's node_num. RX's firmware (`modules/TraceRouteModule.cpp`)
replies with a RouteDiscovery payload whose `route` / `route_back` lists
contain any intermediate relays and `snr_towards` / `snr_back` carry per-hop
SNRs. In a 2-device direct mesh there are no relays between TX and RX, so
both route lists must be empty and each SNR list carries exactly one entry
for the direct TX↔RX link.
Validates the full TRACEROUTE_APP portnum round-trip: request encoding, RX
firmware dispatch, RouteDiscovery payload construction, wire response, and
client-side decode through `meshtastic.__init__.py::protocols[TRACEROUTE_APP]`
(which is what publishes the `meshtastic.receive.traceroute` pubsub topic).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic.mesh_interface import MeshInterface
from ._receive import ReceiveCollector, nudge_nodeinfo_port
@pytest.mark.timeout(240)
def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
"""Runs for every directed pair. Asserts TX sends + RX responds, then
inspects the captured RouteDiscovery to confirm the path is direct.
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.
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
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.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
assert rx_node_num is not None, f"{rx_role} my_node_num missing"
with ReceiveCollector(
tx_port, topic="meshtastic.receive.traceroute"
) as tx_listener:
# 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
# PKI_UNKNOWN_PUBKEY slip through in whichever direction had
# stale RX-side cache).
nudge_nodeinfo_port(rx_port) # RX via brief side-connection
tx_listener.broadcast_nodeinfo_ping() # TX via already-open iface
# Poll TX's view of RX until the publicKey propagates. 45 s matches
# the cap used in `test_direct_with_ack`; the re-nudge at 15 s
# covers a LoRa collision on the first NodeInfo broadcast.
pk_deadline = time.monotonic() + 45.0
last_nudge = time.monotonic()
last_rec: dict[str, Any] = {}
while time.monotonic() < pk_deadline:
last_rec = (tx_listener._iface.nodesByNum or {}).get(rx_node_num, {})
if last_rec.get("user", {}).get("publicKey"):
break
if time.monotonic() - last_nudge > 15.0:
nudge_nodeinfo_port(rx_port)
tx_listener.broadcast_nodeinfo_ping()
last_nudge = time.monotonic()
time.sleep(1.0)
else:
pytest.fail(
f"TX ({tx_role}) never saw RX ({rx_role}) public key within "
f"45s; nodesByNum entry={last_rec!r}"
)
# sendTraceRoute blocks internally on `waitForTraceRoute` and raises
# `MeshInterface.MeshInterfaceError` on timeout. One retry covers a
# transient LoRa collision on either the request or the reply.
ok = False
for _attempt in range(2):
try:
tx_listener._iface.sendTraceRoute(
dest=rx_node_num,
hopLimit=3,
)
ok = True
break
except MeshInterface.MeshInterfaceError:
time.sleep(5.0)
assert ok, (
f"sendTraceRoute {tx_role}{rx_role} timed out twice; the mesh "
f"may be saturated or RX's TraceRouteModule is misrouting the "
f"reply"
)
# sendTraceRoute already waited for the response internally, but
# 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,
timeout=5.0,
)
assert packet is not None, (
f"sendTraceRoute returned OK but no `receive.traceroute` packet "
f"from RX (0x{rx_node_num:08x}) arrived via pubsub. Captured: "
f"{tx_listener.snapshot()!r}"
)
# Inspect the decoded RouteDiscovery. The meshtastic client stores
# the parsed protobuf (as a plain dict via MessageToDict) under
# `decoded.traceroute` for this portnum; keys are camelCase because
# protobuf JSON conversion uses `preserving_proto_field_name=False`
# by default.
decoded = packet.get("decoded", {})
route_info = decoded.get("traceroute") or {}
forward_hops = route_info.get("route") or []
back_hops = route_info.get("routeBack") or []
snr_towards = route_info.get("snrTowards") or []
assert forward_hops == [], (
f"traceroute forward `route` should be empty on a 2-device direct "
f"mesh (no intermediaries between {tx_role} and {rx_role}); got "
f"{forward_hops!r}"
)
assert back_hops == [], (
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
# entry for the destination's receive SNR. Direct mesh → len(route)
# is 0 → exactly 1 SNR entry.
assert len(snr_towards) == 1, (
f"traceroute `snrTowards` should carry exactly 1 entry (direct "
f"link SNR) on a 2-device mesh; got {snr_towards!r}"
)
@@ -0,0 +1,63 @@
"""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 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_single: dict[str, Any],
serial_capture,
role_env,
wait_until,
) -> None:
"""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"]
env = role_env(role)
# Start monitor BEFORE reboot so we catch the reset banner + early boot
cap = serial_capture(role, 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,83 @@
"""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.skip(
reason="test uses flash.erase_and_flash which shells to bin/device-install.sh "
"which needs mt-esp32s3-ota.bin (not in repo). TODO: switch to "
"esptool_erase_flash + flash.flash() like test_00_bake."
)
@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,60 @@
"""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}"
# `modem_preset` is omitted from the protobuf→JSON dump when the
# device is using the default enum value (LONG_FAST). If the key is
# missing AND we expected LONG_FAST, that's a match. Otherwise compare.
live_preset = lora.get("modem_preset")
if live_preset is None:
assert expected_preset_str == "LONG_FAST", (
f"{role}: modem_preset omitted (means default LONG_FAST), "
f"but expected {expected_preset_str!r}"
)
else:
assert live_preset in (
expected_preset_str,
expected_preset_str.upper(),
), f"{role}: modem_preset={live_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,108 @@
"""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.skip(
reason="test uses flash.erase_and_flash which shells to bin/device-install.sh "
"which needs mt-esp32s3-ota.bin (not in repo). TODO: switch to "
"esptool_erase_flash + flash.flash() like test_00_bake."
)
@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,90 @@
"""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
from .._port_discovery import resolve_port_by_role
@pytest.mark.timeout(180)
def test_baked_prefs_survive_factory_reset(
baked_single: dict[str, Any],
test_profile: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role. Flow:
1. Change owner name to a known-non-default value.
2. Trigger factory_reset(full=False).
3. Rediscover the port (macOS re-enumerates the CDC endpoint on nRF52
factory_reset; the path can change e.g. `/dev/cu.usbmodem101` →
`/dev/cu.usbmodem1101`).
4. Wait for device to come back.
5. Confirm owner is back to USERPREFS-baked default (or blank default if
not baked), and primary channel/region/slot are still the baked values.
"""
role = baked_single["role"]
port = baked_single["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)
# 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
# an empty list and the helper's poll-with-backoff handles that too,
# so the sleep is optimization not correctness.
time.sleep(10.0)
port = resolve_port_by_role(role, timeout_s=60.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)
# 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,77 @@
"""Telemetry: device-metrics packets arrive at the peer.
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.
2. Fall back to checking TX's node DB for a populated `deviceMetrics`
block on the RX record (which the firmware writes on receipt).
Both paths prove the same invariant; path 1 gives faster failure signal,
path 2 handles the case where the packet arrived before we subscribed.
Warmup note: when this test runs after `test_baked_prefs_survive_factory_reset`,
both devices have empty node-DBs. We kick a broadcast text from RX through
its own ReceiveCollector so TX learns RX exists and starts accepting its
telemetry; without it, a fresh-boot pair can take 10+ min to swap NODEINFO
before the first telemetry arrives.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
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
a populated deviceMetrics on RX's node-DB record.
Firmware default telemetry interval is 900s; after a fresh boot the
first device-metrics broadcast happens within ~30-120s. We warm up
the mesh first with a cross-broadcast so NODEINFO is exchanged, then
wait up to 7 min for a telemetry packet.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
# Open both sides' pubsub listeners up front so we capture anything that
# arrives during the warmup exchange.
with ReceiveCollector(tx_port, topic="meshtastic.receive.telemetry") as tx_rx:
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx_tx:
# Warmup: send a broadcast from RX through its own collector so
# TX learns about RX (NODEINFO rides along with TEXT_MESSAGE_APP).
# Skipping this turns a 5-min wait into a 15-min wait on a fresh
# factory-reset pair.
rx_tx.send_text(f"warmup-{int(time.time())}")
time.sleep(5.0)
# 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
)
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.
with connect(port=tx_port) as iface:
rec = (iface.nodesByNum or {}).get(rx_node_num, {})
metrics = rec.get("deviceMetrics") or {}
has_any = any(
metrics.get(k) is not None
for k in ("batteryLevel", "voltage", "channelUtilization", "airUtilTx")
)
assert has_any, (
f"no telemetry from node 0x{rx_node_num:08x} within 7 min; "
f"deviceMetrics={metrics!r}"
)
@@ -0,0 +1,187 @@
"""Telemetry: on-demand device-metrics request gets a prompt reply.
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
``want_response=True`` on ``TELEMETRY_APP`` to RX, and RX's
``modules/Telemetry/DeviceTelemetry.cpp::allocReply`` fires immediately with
populated ``DeviceMetrics``. On a direct 2-device mesh the whole round-trip
finishes in under a minute even from a cold boot.
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
``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``
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
precise than a pubsub ``from==rx_node_num`` filter, which can
accidentally match RX's periodic broadcast or a stale reply to a
different prior request.
"""
from __future__ import annotations
import threading
import time
from typing import Any
import pytest
from meshtastic.protobuf import ( # type: ignore[import-untyped]
portnums_pb2,
telemetry_pb2,
)
from ..mesh._receive import ReceiveCollector, nudge_nodeinfo_port
# Fields on the DeviceMetrics sub-message. The camelCase versions are what
# `google.protobuf.json_format.MessageToDict` emits (preserving_proto_field_name
# defaults to False); the snake_case names are the proto-source spellings.
_DEVICE_METRICS_FIELDS = (
"batteryLevel",
"voltage",
"channelUtilization",
"airUtilTx",
"uptimeSeconds",
)
@pytest.mark.timeout(240)
def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
"""Runs for every directed pair. TX requests RX's telemetry via
``want_response=True`` and asserts the reply arrives with populated
DeviceMetrics.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
assert rx_node_num is not None, f"{rx_role} my_node_num missing"
# ReceiveCollector is still used to hold TX's SerialInterface open and
# give us `tx_listener._iface` for sendData / nodesByNum polling. The
# subscribed topic is irrelevant for this test (we match via
# 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
# 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
# err=35 (PKI_UNKNOWN_PUBKEY) and we see no reply.
# * Nudging only TX is the mirror failure.
# See `tests/mesh/_receive.py::nudge_nodeinfo` for firmware path.
nudge_nodeinfo_port(rx_port) # briefly opens RX to send heartbeat
tx_listener.broadcast_nodeinfo_ping() # TX via the already-open iface
pk_deadline = time.monotonic() + 45.0
last_nudge = time.monotonic()
last_rec: dict[str, Any] = {}
while time.monotonic() < pk_deadline:
last_rec = (tx_listener._iface.nodesByNum or {}).get(rx_node_num, {})
if last_rec.get("user", {}).get("publicKey"):
break
if time.monotonic() - last_nudge > 15.0:
# Re-nudge both sides — LoRa collisions can drop either
# direction's NodeInfo broadcast independently.
nudge_nodeinfo_port(rx_port)
tx_listener.broadcast_nodeinfo_ping()
last_nudge = time.monotonic()
time.sleep(1.0)
else:
pytest.fail(
f"TX ({tx_role}) never saw RX ({rx_role}) public key within "
f"45s; nodesByNum entry={last_rec!r}"
)
# Send the request. The Telemetry protobuf has a `which_variant`
# oneof tag that the firmware uses to decide which reply to build
# (see `src/modules/Telemetry/DeviceTelemetry.cpp::allocReply`):
# device_metrics_tag → getDeviceTelemetry()
# local_stats_tag → getLocalStatsTelemetry()
# anything else → return NULL (request silently dropped)
# 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
# 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
# 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
# `decoded.request_id`, not `decoded.reply_id`).
#
# One retry covers transient LoRa collisions on request or reply.
reply_holder: list[dict[str, Any]] = []
got_reply = threading.Event()
def _on_reply(packet: dict[str, Any]) -> None:
reply_holder.append(packet)
got_reply.set()
got = None
for _attempt in range(2):
got_reply.clear()
del reply_holder[:]
req = telemetry_pb2.Telemetry()
req.device_metrics.CopyFrom(telemetry_pb2.DeviceMetrics())
tx_listener._iface.sendData(
req,
destinationId=rx_node_num,
portNum=portnums_pb2.PortNum.TELEMETRY_APP,
wantResponse=True,
onResponse=_on_reply,
hopLimit=3,
)
if got_reply.wait(timeout=45.0):
got = reply_holder[0]
break
time.sleep(5.0)
assert got is not None, (
f"no telemetry reply from {rx_role} (0x{rx_node_num:08x}) within "
f"90s of 2 requests; onResponse callback never fired. Captured "
f"{len(tx_listener.snapshot())} unrelated telemetry packet(s): "
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
# the response to the wrong sender would make onResponse fire on
# the wrong packet.
assert got.get("from") == rx_node_num, (
f"telemetry reply origin mismatch: from=0x{got.get('from'):08x}, "
f"expected 0x{rx_node_num:08x}"
)
# Inspect the decoded Telemetry payload. The meshtastic client stores
# it under `decoded.telemetry`; DeviceMetrics under `.deviceMetrics`.
decoded = got.get("decoded", {})
telem = decoded.get("telemetry") or {}
dm = telem.get("deviceMetrics") or {}
# 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
# 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]
assert populated, (
f"telemetry reply from {rx_role} carried no DeviceMetrics fields; "
f"decoded.telemetry={telem!r}"
)
+291
View File
@@ -0,0 +1,291 @@
"""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
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.
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
import time
from typing import Any
import pytest
import serial # type: ignore[import-untyped]
from meshtastic_mcp import admin, boards, flash, hw_tools, info
# Default envs for a common lab setup. Override per-role via env var.
_DEFAULT_ENVS = {
"nrf52": "rak4631",
"esp32s3": "heltec-v3",
}
_ESP32_ARCHES = {
"esp32",
"esp32-s2",
"esp32s2",
"esp32-s3",
"esp32s3",
"esp32-c3",
"esp32c3",
"esp32-c6",
"esp32c6",
}
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
"""Block until `port` can be exclusively opened, or raise after `timeout_s`.
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()`,
a prior `device_info` call, a lingering `meshtastic-mcp` subprocess
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`.
`pyserial.Serial(exclusive=True)` probes the same lock esptool takes;
a brief open/close cycle is the cleanest way to verify the port is
genuinely free before handing it to a subprocess we can't easily
retry. 200 ms poll interval keeps the failure fast while giving the
kernel time to release a just-closed descriptor.
Raises AssertionError (rather than a generic TimeoutError) so the
pytest summary shows the role + port + a hint at `lsof`.
"""
role_prefix = f"{role}: " if role else ""
deadline = time.monotonic() + timeout_s
last_exc: BaseException | None = None
while time.monotonic() < deadline:
try:
s = serial.Serial(port=port, exclusive=True, timeout=0.5)
except Exception as exc:
last_exc = exc
time.sleep(0.2)
continue
try:
s.close()
except Exception:
pass
return
raise AssertionError(
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 "
f"MCP host (.mcp.json) or a stale `pio device monitor`."
)
def _prepare_nrf52_for_upload(port: str) -> str:
"""Kick the RAK4631 (or similar nRF52 USB-DFU board) into bootloader mode
via 1200bps touch, then return the port where pio should upload.
Adafruit bootloader on RAK4631 interprets 1200bps-open-close as 'enter
DFU'. The device re-enumerates with a distinct USB VID/PID
(0x239A/0x0029) at a different `/dev/cu.usbmodem*` path.
`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
upload against an app-mode device, it'll just hang.
"""
result = flash.touch_1200bps(port=port, settle_ms=500, retries=2)
if not result.get("ok"):
raise AssertionError(
f"nRF52 at {port} did not enter DFU bootloader after "
f"{result.get('attempts')} 1200bps touches. Manual recovery: "
f"double-tap the reset button on the board, then re-run. "
f"Detected port set before/after touch was unchanged."
)
new_port = result["new_port"]
# Small settle so pio/nrfutil sees a fully-ready CDC endpoint.
time.sleep(1.0)
return new_port
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
# 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
# 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
# ESP32 boards. nRF52 DFU naturally wipes the user partition, so no
# erase needed there.
rec = boards.get_board(env)
arch = rec.get("architecture") or ""
# Make sure nothing else (TUI startup poll, MCP-host zombie, pio monitor)
# is holding the port before we hand it to a subprocess. Self-heals the
# [Errno 35] port-busy flake that otherwise fails the bake in ~0.1s.
_wait_port_free(port, role=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.
erase_result = hw_tools.esptool_erase_flash(port=port, confirm=True)
assert erase_result["exit_code"] == 0, (
f"{role}: esptool erase_flash failed:\n"
f"{erase_result.get('stderr_tail', '')}"
)
upload_port = port
else:
upload_port = port
# 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 —
# 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)
# NOTE: no `userprefs_overrides=` here. The session-scoped
# `_session_userprefs` autouse fixture in conftest.py has already baked
# the test profile into userPrefs.jsonc for the duration of the session
# 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.
result = flash.flash(
env=env,
port=upload_port,
confirm=True,
)
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', '')}"
)
# Post-flash: for nRF52, the DFU process only overwrites the app
# 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.
#
# ESP32 devices had their full flash erased BEFORE upload via
# esptool_erase_flash, so they don't need this post-flash reset.
if arch in _NRF52_ARCHES:
# Give the device time to come up from DFU.
time.sleep(8.0)
# Wait for meshtastic to be responsive; `device_info` may take a
# few seconds on the first post-flash boot.
for _ in range(20):
try:
info.device_info(port=port, timeout_s=6.0)
break
except Exception:
time.sleep(1.5)
else:
raise AssertionError(f"{role}: device didn't respond after DFU flash")
# Trigger full factory reset (wipes NVS + identity)
admin.factory_reset(port=port, confirm=True, full=True)
# Wait for the device to reboot and come back with fresh config
# populated from USERPREFS defaults.
time.sleep(10.0)
for _ in range(30):
try:
live = info.device_info(port=port, timeout_s=6.0)
if live.get("my_node_num"):
break
except Exception:
pass
time.sleep(2.0)
else:
raise AssertionError(f"{role}: device didn't return after factory_reset")
@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"),
)
+145
View File
@@ -0,0 +1,145 @@
"""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
38 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
_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_list": ("meshtastic_mcp.registry", "all_sessions"),
"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"),
"set_debug_log_api": ("meshtastic_mcp.admin", "set_debug_log_api"),
"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
# 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
# "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())
for tool_name, (module_path, attr) in _TOOL_MAP.items():
# Defense in depth: if someone mutates `_TOOL_MAP` at runtime
# (shouldn't happen; it's module-level) the whitelist catches it.
# `module_path` is a key from the hardcoded `_TOOL_MAP` dict and
# is gated above by membership in `_allowed_modules` (itself
# derived from the same literal values). There is no path for
# untrusted input to reach the `import_module` call below; the
# Semgrep suppression must sit on the line immediately preceding
# the call (multi-line comment blocks between comment and call
# break the rule's scope detection).
if module_path not in _allowed_modules:
continue
try:
# nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
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,115 @@
"""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
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