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:
co-authored by
Copilot
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent
8fd0a7f283
commit
6b15571e14
@@ -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.
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
@@ -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"]
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user