emdashes begone (#10847)

This commit is contained in:
Tom
2026-07-01 19:01:27 -05:00
committed by GitHub
co-authored by GitHub
parent dee94e0758
commit 3becaf2d95
276 changed files with 1795 additions and 1793 deletions
+16 -16
View File
@@ -1,4 +1,4 @@
# Meshtastic MCP Server Test Harness
# Meshtastic MCP Server - Test Harness
Automated test suite for the MCP server, organized around real operator
concerns rather than generic "unit vs hardware".
@@ -21,13 +21,13 @@ concerns rather than generic "unit vs hardware".
cd mcp-server
pip install -e ".[test]"
# No hardware 33 unit tests, ~3 seconds
# No hardware - 33 unit tests, ~3 seconds
pytest tests/unit -v
# Hub attached (nRF52840 + ESP32-S3) first run bakes, then exercises everything
# Hub attached (nRF52840 + ESP32-S3) - first run bakes, then exercises everything
pytest tests/ --html=report.html
# Hub already baked with session profile (dev loop) skip bake
# Hub already baked with session profile (dev loop) - skip bake
pytest tests/ --assume-baked --html=report.html
# Force a rebake (new firmware, new seed, etc.)
@@ -36,23 +36,23 @@ pytest tests/ --force-bake --html=report.html
## CLI flags
- `--force-bake` always reflash both roles at session start, even if the
- `--force-bake` - always reflash both roles at session start, even if the
current state matches the session profile.
- `--assume-baked` skip `test_00_bake.py` entirely. Use when you know the
- `--assume-baked` - skip `test_00_bake.py` entirely. Use when you know the
devices are already baked and want a fast dev loop.
- `--hub-profile=<yaml>` point at a YAML file for non-default hub hardware.
- `--hub-profile=<yaml>` - point at a YAML file for non-default hub hardware.
Default targets VID `0x239a` (nRF52) and `0x303a`/`0x10c4` (ESP32-S3).
- `--no-teardown-rebake` skip the session-end rebake that `provisioning/`
- `--no-teardown-rebake` - skip the session-end rebake that `provisioning/`
and `fleet/` tests perform. Useful in rapid iteration.
## Environment variables
- `MESHTASTIC_FIRMWARE_ROOT` firmware repo path (defaults to `../` from tests/)
- `MESHTASTIC_MCP_ENV_NRF52` PlatformIO env for the nRF52 role (default
- `MESHTASTIC_FIRMWARE_ROOT` - firmware repo path (defaults to `../` from tests/)
- `MESHTASTIC_MCP_ENV_NRF52` - PlatformIO env for the nRF52 role (default
`rak4631`)
- `MESHTASTIC_MCP_ENV_ESP32S3` PlatformIO env for the ESP32-S3 role (default
- `MESHTASTIC_MCP_ENV_ESP32S3` - PlatformIO env for the ESP32-S3 role (default
`heltec-v3`)
- `MESHTASTIC_MCP_SEED` override the session PSK seed (default:
- `MESHTASTIC_MCP_SEED` - override the session PSK seed (default:
`pytest-<unix-ts>`). Set this to reproduce a specific failing run.
## Fixtures you'll use when adding tests
@@ -64,7 +64,7 @@ All defined in `conftest.py`:
- **`test_profile`** → USERPREFS dict for the session (`build_testing_profile`).
- **`no_region_profile`** → variant without `USERPREFS_CONFIG_LORA_REGION`.
- **`baked_mesh`** → verifies both devices are baked with the session profile
(does NOT reflash that's `test_00_bake.py`'s job).
(does NOT reflash - that's `test_00_bake.py`'s job).
- **`baked_single`** → single verified baked device; parametrize `request.param`
to pick role.
- **`serial_capture`** → factory; `cap = serial_capture("esp32s3")` starts a
@@ -84,7 +84,7 @@ predicate(), timeout=60)` replaces flaky `time.sleep()` patterns.
`pytest --junitxml=junit.xml` produces CI-integration XML.
`tool_coverage.json` is emitted at session end in the tests directory shows
`tool_coverage.json` is emitted at session end in the tests directory - shows
which of the 38 MCP tools the run exercised. Useful for closing test gaps.
## Adding a new test
@@ -95,11 +95,11 @@ which of the 38 MCP tools the run exercised. Useful for closing test gaps.
on `baked_single`. If you need to mutate hardware state, put it in
`provisioning/` or `fleet/` and add a `try/finally` teardown that re-bakes
the session profile.
3. Use `wait_until` for anything involving LoRa timing fixed `sleep()`
3. Use `wait_until` for anything involving LoRa timing - fixed `sleep()`
produces flakes.
4. Use `serial_capture` when you need to observe firmware log output (e.g.
"did the packet get decoded?").
5. Add a `@pytest.mark.timeout(N)` mesh tests routinely hit LoRa-airtime
5. Add a `@pytest.mark.timeout(N)` - mesh tests routinely hit LoRa-airtime
waits; default pytest timeout is infinite.
## Troubleshooting
+7 -7
View File
@@ -1,7 +1,7 @@
"""Role-to-port rediscovery after USB CDC re-enumeration.
Used by tests that mutate device identity in ways macOS treats as a
"new device" notably ``factory_reset(full=False)`` on the nRF52840 and
"new device" - notably ``factory_reset(full=False)`` on the nRF52840 and
any operation that kicks the device through its bootloader. Both cases
cause the kernel to re-assign the ``/dev/cu.usbmodem*`` path; a test that
captured the pre-operation port and reuses it after will fail with
@@ -10,7 +10,7 @@ captured the pre-operation port and reuses it after will fail with
The helper polls :func:`meshtastic_mcp.devices.list_devices` (the same API
``run-tests.sh`` and ``conftest.py::hub_devices`` use for initial hub
detection) filtered by the role's canonical USB VID. Returns the first
matching port equivalent to "give me the single nRF52 (or ESP32-S3) on
matching port - equivalent to "give me the single nRF52 (or ESP32-S3) on
the bench right now, whichever `cu.*` path it happens to be at".
Test-harness-local (not exported from ``meshtastic_mcp``): a thin wrapper
@@ -18,7 +18,7 @@ over public ``devices.list_devices`` with no extra moving parts. If a
non-test caller ever needs this, it's trivial to promote.
Caveat: the session-scoped ``hub_devices`` fixture snapshots ports at
session start and is dict-keyed it doesn't learn about re-enumerations.
session start and is dict-keyed - it doesn't learn about re-enumerations.
Tests that call ``resolve_port_by_role`` should use the returned port
locally for the rest of the test body rather than expecting
``hub_devices[role]`` to update.
@@ -70,13 +70,13 @@ def resolve_port_by_role(
``role``'s VID appears. Returns the first matching port.
On timeout raises :class:`AssertionError` with the list of devices that
WERE seen helpful when debugging "wrong board connected" vs. "no
WERE seen - helpful when debugging "wrong board connected" vs. "no
board connected" vs. "still re-enumerating".
Args:
role: ``"nrf52"`` or ``"esp32s3"`` (keys of ``_ROLE_VIDS``).
timeout_s: upper bound on how long to wait for the device to
re-appear. Default 30 s nRF52 factory_reset observed at
re-appear. Default 30 s - nRF52 factory_reset observed at
2-12 s on a healthy lab hub.
poll_start: initial poll interval in seconds. Default 0.5 s.
poll_max: cap on poll interval after backoff. Default 5 s.
@@ -98,7 +98,7 @@ def resolve_port_by_role(
last_seen = devices_module.list_devices(include_unknown=True)
except Exception as exc:
# list_devices is wrapped by meshtastic_mcp.devices and
# shouldn't raise on normal enumeration but a kernel-level
# shouldn't raise on normal enumeration - but a kernel-level
# USB hiccup during re-enumeration can bubble up briefly.
# Treat as "nothing seen this round" and retry.
last_seen = [{"error": repr(exc)}]
@@ -109,7 +109,7 @@ def resolve_port_by_role(
time.sleep(delay)
delay = min(delay * 1.5, poll_max)
# Timeout path include what we saw so the operator can tell
# Timeout path - include what we saw so the operator can tell
# "nothing plugged in" from "wrong VID" from "transient USB error".
raise AssertionError(
f"no device matching role {role!r} (VIDs "
+4 -4
View File
@@ -1,4 +1,4 @@
"""USB hub power control for tests thin composition of the `uhubctl`
"""USB hub power control for tests - thin composition of the `uhubctl`
module + `_port_discovery.resolve_port_by_role`.
Why separate from the production module:
@@ -11,7 +11,7 @@ Why separate from the production module:
the `factory_reset` flow. Composing the two gives a one-call helper.
Also exposes `is_uhubctl_available()` so fixtures can skip cleanly when
uhubctl isn't installed we never want "no uhubctl" to look like a test
uhubctl isn't installed - we never want "no uhubctl" to look like a test
failure.
"""
@@ -29,14 +29,14 @@ from ._port_discovery import resolve_port_by_role
def is_uhubctl_available() -> bool:
"""Return True iff `config.uhubctl_bin()` resolves AND the binary is callable.
Soft-fails silently fixtures use this to `pytest.skip` with an
Soft-fails silently - fixtures use this to `pytest.skip` with an
actionable message when the operator hasn't installed uhubctl.
"""
try:
config_mod.uhubctl_bin()
except Exception: # noqa: BLE001
return False
# Do NOT actually invoke uhubctl here on macOS a non-sudo run would
# Do NOT actually invoke uhubctl here - on macOS a non-sudo run would
# fail, which is a config issue, not a tool-missing issue. That gets
# surfaced to the user when they actually run a recovery action.
return True
@@ -22,7 +22,7 @@ def test_channel_url_roundtrip(
) -> None:
"""Runs once per connected role. Verify:
1. `get_channel_url()` on a baked device returns a non-empty URL.
2. The URL parses `set_channel_url(url)` accepts it without error.
2. The URL parses - `set_channel_url(url)` accepts it without error.
3. After set, `get_channel_url()` returns the same (canonicalized) URL.
4. Primary channel name survives round-trip.
"""
@@ -34,7 +34,7 @@ def test_channel_url_roundtrip(
"meshtastic" in url_before.lower() or "#" in url_before
), f"URL does not look like a Meshtastic channel URL: {url_before!r}"
# Re-apply the same URL no-op in content but exercises the setURL path.
# Re-apply the same URL - no-op in content but exercises the setURL path.
applied = admin.set_channel_url(url=url_before, port=port)
assert applied["ok"] is True
assert applied["channels_imported"] >= 1
@@ -4,7 +4,7 @@ This is the most-critical admin behavior not tested elsewhere. If
config persistence breaks in a firmware release, every deployed device
gets bricked on its next reboot (channels lost, region lost, owner lost,
everything back to Meshtastic stock). The fleet blast radius is "every
unit on every shelf" easily worth one explicit test per release.
unit on every shelf" - easily worth one explicit test per release.
Pattern: single-device (``baked_single``, one test per role). Mutate a
benign, easy-to-observe LoRa field (``lora.hop_limit``), confirm
@@ -12,9 +12,9 @@ pre-reboot, reboot, rediscover port (nRF52 may re-enumerate), verify
the value survived, restore original for downstream tests.
Why ``lora.hop_limit`` specifically:
* Non-destructive doesn't change region, channel, or PSK, so
* Non-destructive - doesn't change region, channel, or PSK, so
downstream mesh tests still work regardless of the flipped value.
* Bounded small-integer (1..7) easy to flip to a definitively
* Bounded small-integer (1..7) - easy to flip to a definitively
different value and read back.
* Persisted via ``writeConfig("lora")`` which is the same path
every other LoRa config mutation uses, so we're really testing
@@ -64,7 +64,7 @@ def test_lora_hop_limit_survives_reboot(
# Pre-reboot sanity: the write reached the device and
# get_config reflects it in-memory. If this fails, the persist
# test below is moot something's wrong with the write path
# test below is moot - something's wrong with the write path
# itself, not with persistence.
assert _get_hop_limit(port) == new_value, (
f"pre-reboot readback failed: set {new_value}, got "
@@ -92,12 +92,12 @@ def test_lora_hop_limit_survives_reboot(
assert post == new_value, (
f"lora.hop_limit did not survive reboot: set to {new_value} "
f"pre-reboot, read back {post} post-reboot. Config persistence "
f"is broken downstream fleet impact would be total."
f"is broken - downstream fleet impact would be total."
)
finally:
# Restore so downstream tests see the original hop_limit.
# Wrapped in its own try to avoid masking the real assertion
# if the restore itself races the reboot the worst case
# if the restore itself races the reboot - the worst case
# there is a non-default hop_limit sticks around, which is
# benign (mesh still works at hop_limit 3 or 5).
try:
@@ -19,7 +19,7 @@ def test_owner_survives_reboot(
baked_single: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role proves the reboot-persistence
"""Runs once per connected role - proves the reboot-persistence
round-trip works on each device independently, not just one."""
port = baked_single["port"]
+42 -42
View File
@@ -36,7 +36,7 @@ import pytest
# Ensure the MCP server is on `sys.path` without requiring installation in
# development mode for every checkout (we DO install in .venv but this makes
# `pytest tests/` work from a fresh clone too). The path mutation must
# happen before `meshtastic_mcp.*` imports below hence the `noqa: E402`
# happen before `meshtastic_mcp.*` imports below - hence the `noqa: E402`
# markers on those imports (ruff's "module-level import not at top of file"
# rule doesn't understand path-bootstrapping patterns).
_HERE = pathlib.Path(__file__).resolve().parent
@@ -106,8 +106,8 @@ def pytest_collection_modifyitems(
def sort_key(item: pytest.Item) -> tuple[int, str]:
path = str(getattr(item, "fspath", "") or item.nodeid)
# Session-start bake runs FIRST. `baked_mesh` only verifies state
# nothing else actually reflashes so if test_00_bake doesn't run
# Session-start bake runs FIRST. `baked_mesh` only verifies state -
# nothing else actually reflashes - so if test_00_bake doesn't run
# before the tier tests, `--force-bake` silently becomes a no-op for
# the tier tests and only flashes at the very end of the session.
# Top-level nodeid ("tests/test_00_bake.py") otherwise falls into the
@@ -128,7 +128,7 @@ def pytest_collection_modifyitems(
# after it starts from a known re-enumerated + re-verified state.
if "/recovery/" in path or "tests/recovery" in path:
return (4, item.nodeid)
# UI tier slots here read-only w.r.t. mesh state, only mutates
# UI tier slots here - read-only w.r.t. mesh state, only mutates
# the on-screen UI (BACK×5 guard restores home before each test).
if "/ui/" in path or "tests/ui" in path:
return (5, item.nodeid)
@@ -152,12 +152,12 @@ def pytest_collection_modifyitems(
def session_seed(request: pytest.FixtureRequest) -> str:
"""Deterministic PSK seed for this pytest session.
Logged in the HTML report header so two runs can be correlated and so a
Logged in the HTML report header so two runs can be correlated - and so a
flaky-looking test can be reproduced exactly by passing the seed back via
an env var (future extension).
"""
# Pytest session `starttime` isn't directly exposed on the pytest API we
# care about, so derive from process start time unique enough for human
# care about, so derive from process start time - unique enough for human
# purposes and stable across the session.
seed = os.environ.get("MESHTASTIC_MCP_SEED") or f"pytest-{int(time.time())}"
return seed
@@ -169,7 +169,7 @@ def test_profile(session_seed: str) -> dict[str, Any]:
`enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware
emits `Screen: frame N/M name=... reason=...` log lines per UI
transition consumed by the `tests/ui/` tier. Harmless on boards
transition - consumed by the `tests/ui/` tier. Harmless on boards
without a screen (the `#ifdef` sits behind `HAS_SCREEN`).
"""
return userprefs.build_testing_profile(
@@ -186,25 +186,25 @@ def test_profile(session_seed: str) -> dict[str, Any]:
def _session_userprefs(test_profile: dict[str, Any]) -> Any:
"""Snapshot `userPrefs.jsonc`, apply the session test profile, restore at
session end. Guards against the suite leaving test-profile USERPREFS
values baked into the file if that happened, any firmware build a
values baked into the file - if that happened, any firmware build a
contributor ran next would silently inherit the test PSK / test channel
name / test admin key etc.
Layered safety:
1. In-memory snapshot taken before any mutation; teardown writes it back.
2. Sidecar `userPrefs.jsonc.mcp-session-bak` on disk belt to the
2. Sidecar `userPrefs.jsonc.mcp-session-bak` on disk - belt to the
in-memory suspenders. If Python segfaults or SIGKILLs, the next
session self-heals from this file at startup.
3. `atexit.register()` fallback: if pytest exits abnormally (Ctrl-C
mid-test, fatal exception before teardown), the atexit hook still
restores from the in-memory snapshot.
4. Startup self-heal: if the sidecar exists at session start, a prior
session crashed without cleanup the sidecar IS the truth; restore
session crashed without cleanup - the sidecar IS the truth; restore
from it before taking this session's snapshot. That way a crash
during test A doesn't propagate dirty state into test B's baseline.
Autouse + depends on `test_profile` so it applies on every run (even
unit-only) cheap, unified code path, no ordering surprises.
unit-only) - cheap, unified code path, no ordering surprises.
"""
path = userprefs.jsonc_path()
backup_path = path.with_name(path.name + ".mcp-session-bak")
@@ -214,7 +214,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
yield
return
# (4) Startup self-heal prior session crashed without teardown.
# (4) Startup self-heal - prior session crashed without teardown.
if backup_path.is_file():
try:
sidecar_bytes = backup_path.read_bytes()
@@ -241,7 +241,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
except Exception as exc:
print(f"[userprefs] could not write sidecar: {exc!r}", file=sys.stderr)
# (3) atexit fallback fires even if pytest aborts before fixture teardown.
# (3) atexit fallback - fires even if pytest aborts before fixture teardown.
restored = {"done": False}
def _atexit_restore() -> None:
@@ -263,8 +263,8 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
# Apply the session test profile on top of the snapshot. The firmware
# reads userPrefs.jsonc at build time via `bin/platformio-custom.py`,
# so every `pio run` during the session picks up the test values.
# Delegate to `userprefs.merge_active` the public API that already
# parses, merges, validates, and writes rather than reaching into
# Delegate to `userprefs.merge_active` - the public API that already
# parses, merges, validates, and writes - rather than reaching into
# the private parser/renderer machinery from here.
try:
userprefs.merge_active(test_profile)
@@ -276,7 +276,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
# tests that don't (unit) still run. But the restore below is
# unconditional, so we can't leave a half-written file behind.
print(
f"[userprefs] failed to apply test profile: {exc!r} "
f"[userprefs] failed to apply test profile: {exc!r} - "
f"file left at original state",
file=sys.stderr,
)
@@ -298,7 +298,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
# exception from the yielded body); use a flag so the cleanup
# control-flow stays linear and exceptions propagate normally.
print(
f"[userprefs] teardown restore failed: {exc!r} "
f"[userprefs] teardown restore failed: {exc!r} - "
f"sidecar {backup_path} retained for manual recovery",
file=sys.stderr,
)
@@ -388,7 +388,7 @@ def hub_devices(hub_profile: dict[str, dict[str, Any]]) -> dict[str, str]:
"""
# include_unknown=True so non-whitelisted VIDs (e.g. CP2102 at 0x10c4) that
# are configured as hub roles still match. The hub_profile itself gates
# which VIDs we consider no risk of unrelated serial ports sneaking in.
# which VIDs we consider - no risk of unrelated serial ports sneaking in.
found = devices_module.list_devices(include_unknown=True)
# Coalesce alt roles into their base name (esp32s3_alt → esp32s3)
resolved: dict[str, str] = {}
@@ -413,7 +413,7 @@ def hub_devices(hub_profile: dict[str, dict[str, Any]]) -> dict[str, str]:
def _reset_transmit_history_state(role: str, port: str) -> str:
"""Wipe `/prefs/transmit_history.dat` + in-memory throttle cache via
delete_file_request + reboot. Returns the post-reboot port (nRF52
re-enumerates). Best-effort errors log to stderr + return original
re-enumerates). Best-effort - errors log to stderr + return original
port so a flaky start doesn't block the session.
"""
from ._port_discovery import resolve_port_by_role
@@ -467,7 +467,7 @@ def _session_clear_transmit_history(hub_devices: dict[str, str]) -> None:
if not hub_devices:
yield
return
# Iterate over a snapshot _reset_transmit_history_state can mutate
# Iterate over a snapshot - _reset_transmit_history_state can mutate
# hub_devices mid-loop via the update below, and dict-iteration isn't
# safe during mutation.
for role, port in list(hub_devices.items()):
@@ -491,12 +491,12 @@ def baked_mesh(
comparing the live config to the expected profile.
Raises with an actionable error if state is missing or mismatched:
"device nrf52 at /dev/cu.X not baked with session profile
"device nrf52 at /dev/cu.X not baked with session profile -
run test_00_bake.py first or pass --force-bake"
Returns a per-role dict with `{port, iface_fresh: callable, my_node_num}`.
"""
# Verify every role that's present don't require a fixed set.
# Verify every role that's present - don't require a fixed set.
# Tests that NEED a specific role (mesh_pair, bidirectional) check
# presence in their own fixtures and skip there with an actionable
# message. That keeps single-device tests runnable on a one-device
@@ -518,7 +518,7 @@ def baked_mesh(
try:
live = info.device_info(port=port, timeout_s=12.0)
except Exception as exc:
# Per-role failure drop this role from the baked set and let
# Per-role failure - drop this role from the baked set and let
# any test parametrized against it skip with the actionable
# message. Other roles still proceed.
per_role_errors[role] = f"device_info failed: {exc!r}"
@@ -576,11 +576,11 @@ def baked_mesh(
# the SerialInterface. Operators who want log capture can opt in via the
# `set_debug_log_api` MCP tool (or `admin.set_debug_log_api` directly) on
# a case-by-case basis. The autouse `_debug_log_buffer` fixture is still
# armed below if a test explicitly enables the flag, its output will
# armed below - if a test explicitly enables the flag, its output will
# be captured and attached to failures. Firmware-side fix would need
# a separate tx buffer or a mutex out of scope for the MCP harness.
# a separate tx buffer or a mutex - out of scope for the MCP harness.
# If EVERY detected role errored, skip the session nothing testable.
# If EVERY detected role errored, skip the session - nothing testable.
# Otherwise yield the partial set. Tests parametrized against a role
# not in `out` will skip via the `baked_single`/`mesh_pair` presence
# check with "role not present on the hub".
@@ -608,7 +608,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
the test still COLLECTS cleanly (it'll just skip via the
`hub_devices` missing-role check inside the fixture).
Honors `--hub-profile=<yaml>` for non-default hardware when set, only
Honors `--hub-profile=<yaml>` for non-default hardware - when set, only
roles defined in the YAML are parametrized. (So e.g. a yaml with only
`esp32s3` skips every `[nrf52]` variant at collection time.)
"""
@@ -675,7 +675,7 @@ def baked_single(
"""Function-scoped: a single verified baked device.
Auto-parametrized by `pytest_generate_tests` over every detected hub
role so any test taking this fixture runs once per connected device
role - so any test taking this fixture runs once per connected device
(e.g. `test_owner_survives_reboot[nrf52]` +
`test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role
and never skip a device that happens to be connected.
@@ -727,7 +727,7 @@ def power_cycle(
) -> Callable[..., str]:
"""Return a callable `(role, delay_s=2) -> new_port` that hard-resets the
hub port hosting `role`. Skips the test cleanly when uhubctl isn't
installed never want "no uhubctl" to look like a test failure.
installed - never want "no uhubctl" to look like a test failure.
The callable mutates `hub_devices[role]` in place so subsequent fixture
lookups pick up the post-cycle port (mirrors the pattern in
@@ -905,19 +905,19 @@ def _firmware_log_stream() -> Any:
firmware logs *in memory* for pytest-html failure attachments, but a
live viewer (``meshtastic-mcp-test-tui``) can't read in-process
pubsub events from a different process. This fixture adds a
session-long, durable mirror one JSON object per line, with
``port``, ``ts``, and ``line`` fields that the TUI tails from a
session-long, durable mirror - one JSON object per line, with
``port``, ``ts``, and ``line`` fields - that the TUI tails from a
worker thread.
Schema (kept trivially small so the file grows slowly):
{"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "INFO | ... [SerialConsole] Boot..."}
The file is truncated at session start (no append across runs the
The file is truncated at session start (no append across runs - the
TUI also unlinks it on launch, so double-truncate is deliberate).
Gitignored via ``mcp-server/.gitignore``.
Runs alongside ``_debug_log_buffer`` both subscribe to the same
Runs alongside ``_debug_log_buffer`` - both subscribe to the same
pubsub topic; pubsub fans out to every subscriber so there's no
interference.
"""
@@ -943,7 +943,7 @@ def _firmware_log_stream() -> Any:
def handler(line: str, interface: Any) -> None:
# `interface` is the meshtastic SerialInterface; `.devPath`
# carries the /dev/cu.* we care about. Defensive about missing
# attribute the pubsub handler must never raise.
# attribute - the pubsub handler must never raise.
try:
port = getattr(interface, "devPath", None) or getattr(
interface, "stream", None
@@ -959,7 +959,7 @@ def _firmware_log_stream() -> Any:
fh.write(json.dumps(record) + "\n")
fh.flush()
except Exception:
# Swallow firmware log mirroring is best-effort.
# Swallow - firmware log mirroring is best-effort.
pass
pub.subscribe(handler, "meshtastic.log.line")
@@ -980,7 +980,7 @@ def _firmware_log_stream() -> Any:
def _debug_log_buffer(request: pytest.FixtureRequest) -> Any:
"""Per-test capture of `meshtastic.log.line` pubsub events.
Automatic every test gets this for free. The pubsub topic fires when
Automatic - every test gets this for free. The pubsub topic fires when
a connected device has `security.debug_log_api_enabled=True` AND the
client (us) is talking protobufs over its SerialInterface. `baked_mesh`
flips the flag on at session start, so every subsequent test that opens
@@ -989,7 +989,7 @@ def _debug_log_buffer(request: pytest.FixtureRequest) -> Any:
The captured lines are attached to the test's pytest-html failure report
by `pytest_runtest_makereport`, so mesh/telemetry failures ship with the
firmware-side log context inline no separate pio monitor, no
firmware-side log context inline - no separate pio monitor, no
port-lock conflict.
"""
import threading as _threading
@@ -1026,7 +1026,7 @@ def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any:
`meshtastic.SerialInterface` construction can hang indefinitely on a
misconfigured or unresponsive port. pytest-timeout fires from the main
thread via SIGALRM, which doesn't protect code running inside
`pytest_runtest_makereport` that hook runs outside the test's timer. So
`pytest_runtest_makereport` - that hook runs outside the test's timer. So
we wrap each device query in a bounded worker.
"""
import concurrent.futures
@@ -1064,7 +1064,7 @@ def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
label = f"{cap.get('step', '?')}: {cap.get('label', '')}"
frame = cap.get("frame") or {}
frame_str = (
f" frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
f" - frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
)
if png_path:
try:
@@ -1085,7 +1085,7 @@ def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any:
"""On test failure, attach serial capture + device state as report artifacts.
Hard-bounded by `_run_with_timeout` if the device is unreachable (stuck
Hard-bounded by `_run_with_timeout` - if the device is unreachable (stuck
port, unbaked firmware, dead board), the dump is skipped rather than
hanging the session.
@@ -1096,7 +1096,7 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
outcome = yield
report = outcome.get_result()
# Attach UI captures on any outcome (pass + fail) these are the whole
# Attach UI captures on any outcome (pass + fail) - these are the whole
# point of the UI tier. Do this before the failure-only branch below so
# passing tests still get their image strip.
if report.when == "call":
@@ -1108,7 +1108,7 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
extras: list[str] = []
# Attach firmware log stream captured via the StreamAPI (populated only
# when the device has security.debug_log_api_enabled=True baked_mesh
# when the device has security.debug_log_api_enabled=True - baked_mesh
# flips this on at session start). Cheap and high-signal: last 200 lines
# of firmware log interleaved with whatever the test was doing.
log_buffer = getattr(item, "_debug_log_buffer", None)
@@ -1,6 +1,6 @@
"""Fleet: different session seeds produce non-overlapping PSKs.
No hardware needed this is a pure property check on the test profile
No hardware needed - this is a pure property check on the test profile
generator, elevated into the `fleet/` tier because it's the critical
invariant for running concurrent CI labs without cross-contamination.
"""
@@ -12,7 +12,7 @@ from meshtastic_mcp import userprefs
def test_psk_seed_isolates_runs() -> None:
"""Two labs running simultaneously with different seeds must end up with
different PSKs which means firmware baked in lab A cannot decode lab B's
different PSKs - which means firmware baked in lab A cannot decode lab B's
traffic, and vice versa.
This is the formal statement of the isolation claim that
+7 -7
View File
@@ -1,7 +1,7 @@
"""Shared helper for mesh receive tests.
`pio device monitor` captures firmware log output, which does NOT include
decoded text message contents or telemetry payloads those are only
decoded text message contents or telemetry payloads - those are only
accessible through `meshtastic.SerialInterface`'s pubsub mechanism.
`ReceiveCollector` opens a long-lived SerialInterface on a port, subscribes
@@ -9,7 +9,7 @@ to the pubsub topic of interest, and exposes an atomic `wait_for(predicate)`
that mesh tests use to verify end-to-end delivery.
This module also exposes two module-level helpers for forcing a device to
broadcast a fresh NodeInfo the on-demand path that sidesteps the
broadcast a fresh NodeInfo - the on-demand path that sidesteps the
firmware's 10-minute NodeInfo rate-limit. Tests doing directed PKI-encrypted
sends need BOTH endpoints to hold current pubkeys for each other:
@@ -31,7 +31,7 @@ from typing import Any, Callable
def nudge_nodeinfo(iface: Any) -> None:
"""Force the device behind ``iface`` to broadcast a fresh NodeInfo.
Sends a ``ToRadio.Heartbeat(nonce=1)`` the firmware's documented
Sends a ``ToRadio.Heartbeat(nonce=1)`` - the firmware's documented
on-demand NodeInfo trigger (see `src/mesh/api/PacketAPI.cpp:74-79`
for TCP/UDP and `src/mesh/PhoneAPI.cpp::handleToRadio` for serial,
both routed to `NodeInfoModule::sendOurNodeInfo(..., shorterTimeout=true)`
@@ -49,7 +49,7 @@ def nudge_nodeinfo(iface: Any) -> None:
def nudge_nodeinfo_port(port: str) -> None:
"""Open ``port`` briefly, nudge, close for when no iface is open yet.
"""Open ``port`` briefly, nudge, close - for when no iface is open yet.
Uses the meshtastic_mcp port-lock-aware `connect()` context manager
so we don't race ReceiveCollector or other long-lived handles on
@@ -99,7 +99,7 @@ class ReceiveCollector:
)
from pubsub import pub # type: ignore[import-untyped]
# pubsub uses weak refs by default we stash a strong ref so the
# pubsub uses weak refs by default - we stash a strong ref so the
# handler doesn't disappear between subscribe and wait_for.
def handler(packet: dict, interface: Any) -> None:
with self._lock:
@@ -169,7 +169,7 @@ class ReceiveCollector:
"""Send a text packet through the already-open SerialInterface.
Use this when a test also has a ReceiveCollector open on the same port
`admin.send_text(port=...)` would try to open a second SerialInterface
- `admin.send_text(port=...)` would try to open a second SerialInterface
and fail the port lock.
"""
if self._iface is None:
@@ -187,7 +187,7 @@ class ReceiveCollector:
Thin wrapper around the module-level :func:`nudge_nodeinfo` that
also validates the context-manager invariant. Delegates so tests
that need to nudge BOTH sides (bilateral PKI warmup) share one
implementation the caller just passes each iface in turn.
implementation - the caller just passes each iface in turn.
Firmware-side details (rate-limit bypass, nonce==1 trigger path,
shorterTimeout=true window) are documented on the module-level
+2 -2
View File
@@ -4,7 +4,7 @@ Opens a ReceiveCollector on EVERY role, sends a uniquely-tagged broadcast
from each role in turn, and asserts every OTHER role saw it. One atomic
test that answers "is the mesh actually working both directions?".
Not parametrized it inherently involves the full hub.
Not parametrized - it inherently involves the full hub.
"""
from __future__ import annotations
@@ -44,7 +44,7 @@ def test_bidirectional_mesh_communication(
time.sleep(2.0)
# From each role, send a uniquely-tagged broadcast. We MUST send through
# the already-open collector opening a new SerialInterface here would
# the already-open collector - opening a new SerialInterface here would
# race the collector's exclusive lock on the port.
tags: dict[str, str] = {}
for sender in roles:
@@ -1,7 +1,7 @@
"""Mesh: broadcast text from TX arrives at RX.
Uses `meshtastic.SerialInterface` pubsub on RX to detect the decoded text
packet `pio device monitor` output doesn't include message bodies.
packet - `pio device monitor` output doesn't include message bodies.
"""
from __future__ import annotations
@@ -38,7 +38,7 @@ def test_direct_with_ack_roundtrip(
unique = f"mcp-ack-{tx_role}-to-{rx_role}-{int(time.time())}"
# TX iface stays open across the RX wait sendText+wantAck relies on
# TX iface stays open across the RX wait - sendText+wantAck relies on
# the firmware's retransmit loop, which races the SerialInterface close.
# Bilateral NodeInfo nudge: directed packets are PKI-encrypted, so BOTH
# sides need current pubkeys (err=35/39 otherwise). See
@@ -72,7 +72,7 @@ def test_direct_with_ack_roundtrip(
)
# Retry covers LoRa collisions. Re-nudge both sides between
# attempts if RX's cached TX pubkey is stale, just re-sending
# attempts - if RX's cached TX pubkey is stale, just re-sending
# the text doesn't heal it; re-broadcasting NodeInfo does.
got = None
for _attempt in range(2):
+1 -1
View File
@@ -16,7 +16,7 @@ from meshtastic_mcp.connection import connect
@pytest.mark.timeout(180)
def test_mesh_formation_within_60s(mesh_pair: dict[str, Any], wait_until) -> None:
"""Runs for every directed role pair so we prove `A sees B in its node
"""Runs for every directed role pair - so we prove `A sees B in its node
DB` AND `B sees A in its node DB` independently. A one-sided pass can
mask a real problem (e.g. device A's RX works but its TX is dead).
"""
@@ -11,15 +11,15 @@ mesh exercises:
* when the established relay drops and returns, delivery recovers rather than
black-holing (the M3 stale-route decay / re-learn path).
TOPOLOGY REQUIREMENT why this usually SKIPS:
TOPOLOGY REQUIREMENT - why this usually SKIPS:
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
Three co-located radios all hear each other, so A→C is a single direct hop and
next_hop never engages. To run this test the bench must be a *line* A B C
with the endpoints out of each other's direct RF range (physical distance or
next_hop never engages. To run this test the bench must be a *line* - A - B - C
- with the endpoints out of each other's direct RF range (physical distance or
attenuators). The `multihop_topology` fixture detects this automatically: it
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
file is safe to commit and run anywhere it only *asserts* when the topology
file is safe to commit and run anywhere - it only *asserts* when the topology
genuinely requires a relay.
REQUIREMENTS:
@@ -56,12 +56,12 @@ def _hops_away(rec: dict[str, Any]) -> int | None:
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
"""Flood a fresh NodeInfo from every node so the whole mesh (including
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
distances. Best-effort a single node failing to nudge shouldn't abort."""
distances. Best-effort - a single node failing to nudge shouldn't abort."""
for _ in range(rounds):
for port in ports:
try:
nudge_nodeinfo_port(port)
except Exception: # noqa: BLE001 warmup is best-effort
except Exception: # noqa: BLE001 - warmup is best-effort
pass
time.sleep(0.5)
time.sleep(settle)
@@ -135,7 +135,7 @@ def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
_warm_mesh([port for port, _ in by_role.values()])
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
# (cheap no traceroute yet). On an all-direct bench nothing qualifies.
# (cheap - no traceroute yet). On an all-direct bench nothing qualifies.
multihop_pair: tuple[str, str] | None = None
for a_role in roles:
a_port, _ = by_role[a_role]
@@ -157,8 +157,8 @@ def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
if not multihop_pair:
pytest.skip(
"no multi-hop pair found every device appears to be a direct "
"neighbor. Arrange the bench as a line (A B C) with the "
"no multi-hop pair found - every device appears to be a direct "
"neighbor. Arrange the bench as a line (A - B - C) with the "
"endpoints out of direct RF range (distance or attenuators) so a "
"relay is actually required, then re-run."
)
@@ -231,21 +231,21 @@ def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
assert got is not None, (
f"multi-hop directed DM {tx_role}{rx_role} via relay "
f"{relay_role!r} never landed NextHop multi-hop delivery is broken"
f"{relay_role!r} never landed - NextHop multi-hop delivery is broken"
)
@pytest.mark.timeout(600)
def test_multihop_relay_recovery(
multihop_topology: dict[str, Any],
power_cycle, # noqa: ARG001 forces the uhubctl-availability skip
power_cycle, # noqa: ARG001 - forces the uhubctl-availability skip
) -> None:
"""Delivery recovers after the established relay drops and returns.
Establishes a baseline DM (route via relay learned), powers the relay OFF
(confirming TX survives sending across a downed relay), then powers it back
ON and asserts directed delivery resumes the M3 stale-route decay /
re-learn path. With a strict A B C line there is no path while B is down,
ON and asserts directed delivery resumes - the M3 stale-route decay /
re-learn path. With a strict A - B - C line there is no path while B is down,
so we only assert TX doesn't crash during the outage; the delivery assertion
is after B returns.
"""
@@ -266,7 +266,7 @@ def test_multihop_relay_recovery(
post = f"mh-recover-post-{int(time.time())}"
# Baseline: confirm delivery works (so the route via the relay is learned)
# before we perturb anything otherwise a later failure is ambiguous.
# before we perturb anything - otherwise a later failure is ambiguous.
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
@@ -279,7 +279,7 @@ def test_multihop_relay_recovery(
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
)
is not None
), "baseline multi-hop delivery failed skipping recovery to avoid a false result"
), "baseline multi-hop delivery failed - skipping recovery to avoid a false result"
# Power the relay OFF.
try:
@@ -304,7 +304,7 @@ def test_multihop_relay_recovery(
)
assert pkt is not None
time.sleep(8.0) # let retransmissions + route decay run
except Exception as exc: # noqa: BLE001 restore bench state before failing
except Exception as exc: # noqa: BLE001 - restore bench state before failing
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
raise AssertionError(
@@ -316,7 +316,7 @@ def test_multihop_relay_recovery(
time.sleep(0.5)
try:
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001 relay port isn't one we connect to directly
except Exception: # noqa: BLE001 - relay port isn't one we connect to directly
pass
time.sleep(8.0)
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
@@ -343,5 +343,5 @@ def test_multihop_relay_recovery(
assert got is not None, (
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}{rx_role} "
"never resumed stale-route recovery (M3) may be broken"
"never resumed - stale-route recovery (M3) may be broken"
)
@@ -5,16 +5,16 @@ off mid-send via uhubctl, then powered back on.
Flow (parametrized over every directed mesh_pair):
1. Bilateral PKI warmup (same pattern as test_direct_with_ack).
2. TX sends a broadcast text "msg-1" RX confirms receipt via pubsub.
2. TX sends a broadcast text "msg-1" - RX confirms receipt via pubsub.
3. Power OFF RX via uhubctl. The RX device disappears from the OS.
4. TX sends a directed text "msg-2" with wantAck=True. Firmware retries
internally for ~30s before giving up. Assertion: the packet object
was accepted by the TX stack (non-None) we don't assert an ACK
was accepted by the TX stack (non-None) - we don't assert an ACK
since there's no peer to send one.
5. Power ON RX. Wait for re-enumeration + boot.
6. Bilateral PKI re-nudge RX's in-RAM PKI cache was wiped on reboot,
6. Bilateral PKI re-nudge - RX's in-RAM PKI cache was wiped on reboot,
so the first directed send may err=35 without a fresh NodeInfo ping.
7. TX sends a directed "msg-3" RX receives it via pubsub, confirming
7. TX sends a directed "msg-3" - RX receives it via pubsub, confirming
the mesh recovered.
Skips cleanly if uhubctl isn't installed (via the `power_cycle` fixture's
@@ -38,7 +38,7 @@ from ._receive import ReceiveCollector, nudge_nodeinfo
@pytest.mark.timeout(360)
def test_peer_offline_then_recovers(
mesh_pair: dict[str, Any],
power_cycle, # noqa: ARG001 forces uhubctl-availability skip
power_cycle, # noqa: ARG001 - forces uhubctl-availability skip
hub_devices: dict[str, str],
) -> None:
tx_port = mesh_pair["tx"]["port"]
@@ -80,7 +80,7 @@ def test_peer_offline_then_recovers(
timeout=30,
)
assert got is not None, (
f"baseline directed send ({tx_role}{rx_role}) didn't land "
f"baseline directed send ({tx_role}{rx_role}) didn't land - "
"skipping offline test to avoid false positive"
)
@@ -111,7 +111,7 @@ def test_peer_offline_then_recovers(
assert packet is not None
# Give firmware a moment to do a retry or two while RX is down.
time.sleep(5.0)
except Exception as exc: # noqa: BLE001 TX should survive the peer being gone
except Exception as exc: # noqa: BLE001 - TX should survive the peer being gone
# Restore RX before reraising so the bench state is sane.
_power.power_on(rx_role)
resolve_port_by_role(rx_role, timeout_s=30.0)
@@ -151,5 +151,5 @@ def test_peer_offline_then_recovers(
assert got is not None, (
f"post-recovery directed send {unique_post!r} ({tx_role}{rx_role}) "
"never landed recovery path may be broken"
"never landed - recovery path may be broken"
)
+5 -5
View File
@@ -33,13 +33,13 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
Why the listener is on TX (not RX):
The traceroute RESPONSE is addressed to TX (the original requester).
The meshtastic Python client publishes `meshtastic.receive.traceroute`
on the interface that received that response which is TX's iface.
on the interface that received that response - which is TX's iface.
A listener on RX would only see the inbound REQUEST, which lacks
the SNR-towards / SNR-back fields the firmware only fills on reply.
Why we ping RX's NodeInfo before sending:
Traceroute requests are directed sends (wantResponse=True, specific
destinationId) subject to the same PKI_SEND_FAIL_PUBLIC_KEY trap
destinationId) - subject to the same PKI_SEND_FAIL_PUBLIC_KEY trap
as `test_direct_with_ack`. We open RX briefly to trigger the
on-demand NodeInfo broadcast, then wait for TX's nodesByNum to
populate RX's publicKey before calling sendTraceRoute.
@@ -54,7 +54,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
with ReceiveCollector(
tx_port, topic="meshtastic.receive.traceroute"
) as tx_listener:
# Bilateral PKI warmup traceroute requests are directed and
# Bilateral PKI warmup - traceroute requests are directed and
# PKI-encrypted, so both sides need current pubkeys. See
# `_receive.py::nudge_nodeinfo` and the test_direct_with_ack
# comment for the full rationale (one-sided nudge lets err=35
@@ -105,7 +105,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
)
# sendTraceRoute already waited for the response internally, but
# pubsub dispatch runs on the meshtastic-python reader thread
# pubsub dispatch runs on the meshtastic-python reader thread -
# give it a short grace window to queue the packet.
packet = tx_listener.wait_for(
lambda p: p.get("from") == rx_node_num,
@@ -138,7 +138,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
f"traceroute `routeBack` should be empty on a 2-device direct "
f"mesh; got {back_hops!r}"
)
# `snr_towards` has len(route) + 1 entries one per hop plus a final
# `snr_towards` has len(route) + 1 entries - one per hop plus a final
# entry for the destination's receive SNR. Direct mesh → len(route)
# is 0 → exactly 1 SNR entry.
assert len(snr_towards) == 1, (
@@ -1,4 +1,4 @@
"""Monitor: boot log is clean no panic markers in the first 60 seconds.
"""Monitor: boot log is clean - no panic markers in the first 60 seconds.
This is the single highest-signal test for catching firmware regressions.
If a commit broke something critical at boot (stack overflow, NULL deref, HAL
@@ -36,7 +36,7 @@ def test_boot_log_no_panic(
role_env,
wait_until,
) -> None:
"""Runs once per connected role each device must boot cleanly,
"""Runs once per connected role - each device must boot cleanly,
independently. A panic on one role shouldn't mask another."""
role = baked_single["role"]
port = baked_single["port"]
@@ -52,7 +52,7 @@ def test_boot_log_no_panic(
time.sleep(60.0)
lines = cap.snapshot(max_lines=4000)
assert lines, "serial capture returned no log lines monitor may have failed"
assert lines, "serial capture returned no log lines - monitor may have failed"
blob = "\n".join(lines).lower()
hits = [marker for marker in _PANIC_MARKERS if marker in blob]
@@ -14,7 +14,7 @@ from typing import Any
import pytest
from meshtastic_mcp import admin, flash
# Deterministic 32-byte "admin key" just the byte values 0..31 for easy
# Deterministic 32-byte "admin key" - just the byte values 0..31 for easy
# recognition in the output, formatted as a C brace-init.
_ADMIN_KEY_BYTES = list(range(32))
_ADMIN_KEY_BRACE = "{ " + ", ".join(f"0x{b:02x}" for b in _ADMIN_KEY_BYTES) + " }"
@@ -71,7 +71,7 @@ def test_admin_key_baked(
), f"admin_key bytes not visible in security config: {security!r}"
assert (
key_field is not None
), "security.admin_key field absent baking key 0 didn't stick"
), "security.admin_key field absent - baking key 0 didn't stick"
finally:
# Restore session profile (no admin key)
restore = flash.erase_and_flash(
@@ -1,7 +1,7 @@
"""Provisioning: the pre-bake recipe (US/LONG_FAST/slot 88/private channel)
lands on the device exactly as specified.
This is THE test that proves the MCP's core value prop flashing firmware
This is THE test that proves the MCP's core value prop - flashing firmware
with a preset USERPREFS produces a device in the expected radio config without
any post-flash admin steps.
"""
@@ -2,7 +2,7 @@
`USERPREFS_CONFIG_LORA_REGION` must refuse to transmit.
Real operator concern: FCC compliance. A device shipped without an explicit
region setting must not emit RF until the operator sets a region this test
region setting must not emit RF until the operator sets a region - this test
proves the firmware honors that invariant when the USERPREFS bake deliberately
omits the region key.
@@ -32,7 +32,7 @@ def test_unset_region_blocks_tx(
) -> None:
"""Bake a device with no LoRa region, then assert:
1. `config.lora.region` reads as "UNSET" (or 0).
2. An attempt to `send_text` surfaces a refusal either the meshtastic
2. An attempt to `send_text` surfaces a refusal - either the meshtastic
SDK raises, or the serial log contains a clear "region unset" marker.
Always re-bakes the session test_profile in the finalizer so downstream
@@ -43,7 +43,7 @@ def test_unset_region_blocks_tx(
pytest.skip(f"role {target!r} not on hub")
port = hub_devices[target]
# Pick the right env for this role must match what test_00_bake used.
# Pick the right env for this role - must match what test_00_bake used.
import os
env = os.environ.get("MESHTASTIC_MCP_ENV_ESP32S3", "t-beam-1w")
@@ -2,7 +2,7 @@
Real operator concern: "if someone resets my fleet device, will it come back
on my private mesh or on Meshtastic defaults?" A baked USERPREFS recipe
should be the factory floor for the device reset goes back to THAT state,
should be the factory floor for the device - reset goes back to THAT state,
not to stock Meshtastic.
"""
@@ -51,12 +51,12 @@ def test_baked_prefs_survive_factory_reset(
# Trigger non-full factory reset
admin.factory_reset(port=port, confirm=True, full=False)
# Device re-enumerates rediscover its port before probing. nRF52's
# Device re-enumerates - rediscover its port before probing. nRF52's
# CDC endpoint drops and comes back with a new `/dev/cu.usbmodem*`
# path on macOS; ESP32-S3 usually keeps the same path but the helper
# works either way (it just returns the current path for this role).
# Early sleep lets the USB kernel driver settle before we start
# polling list_devices during a transient re-enumeration can return
# polling - list_devices during a transient re-enumeration can return
# an empty list and the helper's poll-with-backoff handles that too,
# so the sleep is optimization not correctness.
time.sleep(10.0)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Recovery tier exercises `uhubctl` power control end-to-end.
"""Recovery tier - exercises `uhubctl` power control end-to-end.
Requires `uhubctl` installed AND at least one connected device on a
PPPS-capable hub. The whole tier skips cleanly via
+2 -2
View File
@@ -25,7 +25,7 @@ def _recovery_tier_guard() -> None:
)
# Probe: can we even list hubs? (A macOS user without sudo gets a
# permission error here we'd rather find out once at tier-start than
# permission error here - we'd rather find out once at tier-start than
# 6 tests later.)
from meshtastic_mcp import uhubctl
@@ -39,6 +39,6 @@ def _recovery_tier_guard() -> None:
if not any(h["ppps"] for h in hubs):
pytest.skip(
"no PPPS-capable hubs detected recovery tier has nothing to exercise.",
"no PPPS-capable hubs detected - recovery tier has nothing to exercise.",
allow_module_level=True,
)
+3 -3
View File
@@ -13,7 +13,7 @@ from meshtastic_mcp import uhubctl
@pytest.mark.timeout(30)
def test_list_hubs_returns_at_least_one_ppps_hub() -> None:
hubs = uhubctl.list_hubs()
assert hubs, "uhubctl found no hubs at all is a USB hub connected?"
assert hubs, "uhubctl found no hubs at all - is a USB hub connected?"
assert any(h["ppps"] for h in hubs), (
"no PPPS-capable hubs detected; power control won't work. "
"Check that the hub supports Per-Port Power Switching."
@@ -31,13 +31,13 @@ def test_list_hubs_structure(hub_devices: dict[str, str]) -> None:
assert "status" in port
# At least one of the detected Meshtastic roles should show up in some
# port's device_vid otherwise the recovery tier can't drive them.
# port's device_vid - otherwise the recovery tier can't drive them.
seen_vids = {
p["device_vid"] for h in hubs for p in h["ports"] if p["device_vid"] is not None
}
expected_any = {0x239A, 0x303A, 0x10C4} & seen_vids
assert expected_any or not hub_devices, (
f"hub_devices detected roles {list(hub_devices)} but uhubctl sees "
f"VIDs {sorted(hex(v) for v in seen_vids)} the devices may be on "
f"VIDs {sorted(hex(v) for v in seen_vids)} - the devices may be on "
"a hub that uhubctl can't see (e.g. built-in laptop ports)."
)
@@ -2,7 +2,7 @@
Two-path verification:
1. Listen on TX's pubsub for inbound telemetry packets originating from
RX's node_num if one arrives within the window, telemetry works.
RX's node_num - if one arrives within the window, telemetry works.
2. Fall back to checking TX's node DB for a populated `deviceMetrics`
block on the RX record (which the firmware writes on receipt).
@@ -30,7 +30,7 @@ from ..mesh._receive import ReceiveCollector
@pytest.mark.timeout(600)
def test_device_telemetry_broadcast(mesh_pair: dict[str, Any]) -> None:
"""Runs for every directed pair. Waits up to ~8 minutes for TX to see
RX's device telemetry either as a live inbound pubsub packet or as
RX's device telemetry - either as a live inbound pubsub packet or as
a populated deviceMetrics on RX's node-DB record.
Firmware default telemetry interval is 900s; after a fresh boot the
@@ -56,14 +56,14 @@ def test_device_telemetry_broadcast(mesh_pair: dict[str, Any]) -> None:
# Path 1: wait for a telemetry packet from RX on TX's pubsub.
got = tx_rx.wait_for(
lambda pkt: pkt.get("from") == rx_node_num,
timeout=420, # 7 min well above the 30-120s typical first broadcast
timeout=420, # 7 min - well above the 30-120s typical first broadcast
)
if got is not None:
return # Path 1 confirmed delivery.
# Path 2: re-query TX's node DB for a populated deviceMetrics on RX.
# Device may have reported telemetry before we subscribed, or the
# pubsub delivery might race with our window re-check nodesByNum.
# pubsub delivery might race with our window - re-check nodesByNum.
with connect(port=tx_port) as iface:
rec = (iface.nodesByNum or {}).get(rx_node_num, {})
metrics = rec.get("deviceMetrics") or {}
@@ -1,6 +1,6 @@
"""Telemetry: on-demand device-metrics request gets a prompt reply.
Complementary to ``test_device_telemetry_broadcast`` that one witnesses the
Complementary to ``test_device_telemetry_broadcast`` - that one witnesses the
firmware's *periodic* broadcast (900 s default interval, up to ~7 min worst
case). This one exercises the *request/reply* path: TX sends a
``meshtastic_Telemetry`` with the ``device_metrics`` variant-tag set and
@@ -13,14 +13,14 @@ Validates:
* ``sendData(portNum=TELEMETRY_APP, want_response=True)`` encodes + routes
to RX (directed, PKI-encrypted to RX's pubkey)
* RX's ``DeviceTelemetryModule::handleReceivedProtobuf`` dispatches to
``allocReply`` which is only invoked by the framework when
``allocReply`` - which is only invoked by the framework when
``want_response`` is set on the incoming packet
* The reply carries a ``DeviceMetrics`` sub-message with at least one
non-zero field (uptime_seconds is guaranteed non-zero a few seconds
after boot, so it reliably survives protobuf's default-value
serialization stripping)
* The reply routes back to TX and gets matched against the original
request via ``request_id`` using the library's ``onResponse``
request via ``request_id`` - using the library's ``onResponse``
callback mechanism, which stores the handler at
``responseHandlers[sent_packet.id]`` and dispatches when a packet
arrives with ``decoded.request_id == sent_packet.id``. This is more
@@ -74,7 +74,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
# onResponse, not pubsub), but keeping a concrete topic avoids the
# surprise of a pubsub wildcard receiving every packet type.
with ReceiveCollector(tx_port, topic="meshtastic.receive.telemetry") as tx_listener:
# Bilateral PKI warmup nudge BOTH sides to rebroadcast their
# Bilateral PKI warmup - nudge BOTH sides to rebroadcast their
# NodeInfo (with current pubkey) before the directed send.
# * Nudging only RX gets RX's key → TX, but leaves RX with a
# potentially stale TX pubkey → RX NAKs our request with
@@ -92,7 +92,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
if last_rec.get("user", {}).get("publicKey"):
break
if time.monotonic() - last_nudge > 15.0:
# Re-nudge both sides LoRa collisions can drop either
# Re-nudge both sides - LoRa collisions can drop either
# direction's NodeInfo broadcast independently.
nudge_nodeinfo_port(rx_port)
tx_listener.broadcast_nodeinfo_ping()
@@ -113,14 +113,14 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
# An empty `Telemetry()` has `which_variant = UNSET (0)`, so we MUST
# explicitly set the variant. `CopyFrom(DeviceMetrics())` with a
# default-constructed sub-message is the canonical Python-protobuf
# idiom for "set the oneof tag without populating fields" matching
# idiom for "set the oneof tag without populating fields" - matching
# how `MeshInterface.sendTelemetry()` constructs requests for the
# other variants.
#
# Matching the reply: the meshtastic client's `onResponse` callback
# mechanism fires ONLY for packets whose `decoded.request_id` equals
# the original outgoing packet's `id`. That's exactly the semantic
# we want rejects periodic broadcasts (no request_id), rejects
# we want - rejects periodic broadcasts (no request_id), rejects
# stale replies to prior requests (different request_id), and
# tolerates the firmware's reply_id/request_id naming quirk
# (firmware's `setReplyTo` writes the original packet's id into
@@ -160,7 +160,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
f"{[hex(p.get('from') or 0) for p in tx_listener.snapshot()]!r}"
)
# Sanity: the reply's origin matches a firmware bug that routed
# Sanity: the reply's origin matches - a firmware bug that routed
# the response to the wrong sender would make onResponse fire on
# the wrong packet.
assert got.get("from") == rx_node_num, (
@@ -177,7 +177,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
# A populated reply must contain at least one DeviceMetrics field.
# Protobuf's JSON serializer strips default-valued (zero) fields,
# so a bare `deviceMetrics: {}` would mean the firmware wrote the
# sub-message but every field was zero plausible right at boot
# sub-message but every field was zero - plausible right at boot
# but not for a device that's been running long enough for a test
# session's warmup + NodeInfo exchange (~10-30 s uptime minimum).
populated = [k for k in _DEVICE_METRICS_FIELDS if k in dm]
+12 -12
View File
@@ -1,8 +1,8 @@
"""Session-bake module runs first in the tier order to flash both hub roles
"""Session-bake module - runs first in the tier order to flash both hub roles
with the session `test_profile`.
Ordered first by `pytest_collection_modifyitems` in `conftest.py` (bucket
-1) because `baked_mesh` only *verifies* state it does not reflash. Without
-1) because `baked_mesh` only *verifies* state - it does not reflash. Without
the explicit order pin, the top-level path `tests/test_00_bake.py` falls
into the fallback bucket and sorts AFTER every tier, silently turning
`--force-bake` into a no-op for the tier tests.
@@ -52,9 +52,9 @@ def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> No
Root cause for the retry loop: esptool / nrfutil / pio all take an
*exclusive* serial port lock (fcntl LOCK_EX on macOS, EAGAIN otherwise).
Anything that held the port recently the TUI's startup `DevicePollerWorker._poll_once()`,
Anything that held the port recently - the TUI's startup `DevicePollerWorker._poll_once()`,
a prior `device_info` call, a lingering `meshtastic-mcp` subprocess
spawned by the operator's MCP host, or a stale `pio device monitor`
spawned by the operator's MCP host, or a stale `pio device monitor` -
can still be holding it when `test_00_bake` reaches the flash step. The
result is esptool exiting 2 in ~0.1s with `[Errno 35] Resource
temporarily unavailable`.
@@ -84,7 +84,7 @@ def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> No
pass
return
raise AssertionError(
f"{role_prefix}port {port} still busy after {timeout_s:.0f}s "
f"{role_prefix}port {port} still busy after {timeout_s:.0f}s - "
f"something else holds an exclusive lock. Last error: {last_exc!r}. "
f"Identify the holder with `lsof {port}` and kill it; common "
f"culprits are a lingering `meshtastic-mcp` subprocess from the "
@@ -102,7 +102,7 @@ def _prepare_nrf52_for_upload(port: str) -> str:
`touch_1200bps` does the heavy lifting: bounded open/close, polls for the
Adafruit-bootloader PID specifically, retries the touch up to twice.
Fails loudly if the device doesn't enter DFU no point trying pio
Fails loudly if the device doesn't enter DFU - no point trying pio
upload against an app-mode device, it'll just hang.
"""
result = flash.touch_1200bps(port=port, settle_ms=500, retries=2)
@@ -162,14 +162,14 @@ def _bake_role(
# If we can't query, fall through and bake anyway.
pass
# All architectures go through `pio run -t upload` pio knows the right
# All architectures go through `pio run -t upload` - pio knows the right
# protocol per variant (esptool for ESP32, adafruit-nrfutil for nRF52,
# picotool for RP2040). We don't use `bin/device-install.sh` for ESP32
# because it requires the external `mt-esp32s3-ota.bin` helper that's
# downloaded from releases, not generated by the build.
#
# IMPORTANT: `pio run -t upload` on ESP32 only overwrites the APP
# partition the LittleFS partition (config + NodeDB) survives. That
# partition - the LittleFS partition (config + NodeDB) survives. That
# means USERPREFS-baked defaults never take effect on a device that was
# already provisioned, because NodeDB init prefers the saved config. To
# force USERPREFS to apply cleanly, we erase the full chip first on
@@ -184,7 +184,7 @@ def _bake_role(
if arch in _NRF52_ARCHES:
upload_port = _prepare_nrf52_for_upload(port)
elif arch in _ESP32_ARCHES:
# Full chip erase wipes NVS + LittleFS so USERPREFS defaults apply.
# Full chip erase - wipes NVS + LittleFS so USERPREFS defaults apply.
erase_result = hw_tools.esptool_erase_flash(port=port, confirm=True)
assert erase_result["exit_code"] == 0, (
f"{role}: esptool erase_flash failed:\n"
@@ -196,7 +196,7 @@ def _bake_role(
# Post-erase, pre-upload: full chip erase on ESP32 drops the CDC
# endpoint for a moment while the bootloader re-enters download mode.
# Wait for the port to settle before pio reopens it for upload
# Wait for the port to settle before pio reopens it for upload -
# otherwise a fast machine can race and hit the same errno 35.
if arch in _ESP32_ARCHES:
_wait_port_free(upload_port, role=role, timeout_s=10.0)
@@ -207,7 +207,7 @@ def _bake_role(
# and will restore the original file at session end. A local
# `temporary_overrides` here would be a no-op (file is already baked)
# AND would cause the session fixture's teardown to see different
# stat / mtime than it snapshotted keep the mutation in one place.
# stat / mtime than it snapshotted - keep the mutation in one place.
result = flash.flash(
env=env,
port=upload_port,
@@ -220,7 +220,7 @@ def _bake_role(
)
# Post-flash: for nRF52, the DFU process only overwrites the app
# partition the NVS region holding the existing NodeDB/config is
# partition - the NVS region holding the existing NodeDB/config is
# untouched, so the firmware will prefer the saved config over the
# baked USERPREFS defaults. Trigger a full factory reset to wipe NVS
# so USERPREFS takes effect on the next boot.
+6 -6
View File
@@ -2,7 +2,7 @@
This is NOT line coverage (that's `coverage.py`). This measures which of the
38 public MCP tools in `meshtastic_mcp.server` got invoked during a pytest
run a quick signal for "where are the test-coverage gaps".
run - a quick signal for "where are the test-coverage gaps".
Approach: introspect `meshtastic_mcp.server.app` for registered tools, find
the underlying handler functions in their source modules, and wrap each with
@@ -40,7 +40,7 @@ def _wrap(module: Any, attr: str, tool_name: str) -> None:
# Mapping: MCP tool name → (module, function name). Mirrors the wiring in
# `meshtastic_mcp.server`. Keep synchronized manually adding a tool without
# `meshtastic_mcp.server`. Keep synchronized manually - adding a tool without
# updating this map means it shows as count=0 in reports even if exercised.
_TOOL_MAP: dict[str, tuple[str, str]] = {
# Discovery & metadata
@@ -54,7 +54,7 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
"erase_and_flash": ("meshtastic_mcp.flash", "erase_and_flash"),
"update_flash": ("meshtastic_mcp.flash", "update_flash"),
"touch_1200bps": ("meshtastic_mcp.flash", "touch_1200bps"),
# Serial log sessions module-level functions on serial_session
# Serial log sessions - module-level functions on serial_session
"serial_open": ("meshtastic_mcp.serial_session", "open_session"),
"serial_read": ("meshtastic_mcp.serial_session", "read_session"),
"serial_list": ("meshtastic_mcp.registry", "all_sessions"),
@@ -74,7 +74,7 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
"shutdown": ("meshtastic_mcp.admin", "shutdown"),
"factory_reset": ("meshtastic_mcp.admin", "factory_reset"),
"send_input_event": ("meshtastic_mcp.admin", "send_input_event"),
# `capture_screen` in server.py calls camera.get_camera instrument that.
# `capture_screen` in server.py calls camera.get_camera - instrument that.
"capture_screen": ("meshtastic_mcp.camera", "get_camera"),
# USB power control via uhubctl.
"uhubctl_list": ("meshtastic_mcp.uhubctl", "list_hubs"),
@@ -106,8 +106,8 @@ def install() -> None:
import importlib
# Whitelist the exact module paths this function is ever allowed to
# import. `module_path` below is iterated from `_TOOL_MAP` a file-
# local, hardcoded dict literal but a static whitelist makes the
# import. `module_path` below is iterated from `_TOOL_MAP` - a file-
# local, hardcoded dict literal - but a static whitelist makes the
# "no untrusted input here" invariant legible to reviewers and to
# the Semgrep `non-literal-import` audit rule.
_allowed_modules = frozenset(path for path, _attr in _TOOL_MAP.values())
+1 -1
View File
@@ -1,4 +1,4 @@
"""UI tier input-broker-driven screen navigation tests.
"""UI tier - input-broker-driven screen navigation tests.
Only runs when a screen-bearing role (esp32s3/heltec-v3) is present on the
hub AND the firmware was baked with `enable_ui_log=True` (so the
+1 -1
View File
@@ -122,7 +122,7 @@ def wait_for_reason(
) -> FrameEvent:
"""Wait for a frame event with `reason=<reason>` after call-start.
Matches only on `reason` useful when the caller knows *why* a
Matches only on `reason` - useful when the caller knows *why* a
transition should happen (e.g. `fn_f1`, `rebuild`) but not which named
frame the firmware will land on for this particular board.
"""
+15 -15
View File
@@ -24,11 +24,11 @@ from meshtastic_mcp.input_events import InputEventCode
from ._screen_log import FrameEvent, get_current_frame, wait_for_frame
# Roles that carry a screen the UI tier can drive. Only esp32s3 (heltec-v3
# SSD1306) qualifies today nrf52 (rak4631) has no display.
# SSD1306) qualifies today - nrf52 (rak4631) has no display.
UI_CAPABLE_ROLES = ("esp32s3",)
# Where per-test captures land. One subdirectory per session seed, then per
# sanitized test nodeid identical pattern to other pytest artifacts.
# sanitized test nodeid - identical pattern to other pytest artifacts.
CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures"
@@ -104,11 +104,11 @@ def _ocr_warm() -> None:
"""Pay easyocr's ~100 MB / cold-start cost ONCE per session.
Subsequent `ocr_text()` calls hit the cached reader and return quickly.
Swallows errors if OCR isn't installed, warm is a no-op.
Swallows errors - if OCR isn't installed, warm is a no-op.
"""
try:
ocr_mod.warm()
except Exception: # noqa: BLE001 belt: never block the suite on OCR init
except Exception: # noqa: BLE001 - belt: never block the suite on OCR init
pass
@@ -119,7 +119,7 @@ def _ui_screen_kept_on(
"""Keep the OLED on throughout the UI tier so input events aren't dropped.
Why: `InputBroker::handleInputEvent` (src/input/InputBroker.cpp:118-122)
silently DROPS any event that arrives while the screen is off it just
silently DROPS any event that arrives while the screen is off - it just
wakes the screen and returns. Every first event in each test would
disappear. We set `display.screen_on_secs = 86400` at session start
(effectively "always on" for the test window) and restore the prior
@@ -156,7 +156,7 @@ def _ui_screen_kept_on(
time.sleep(1.5) # Let the screen finish its wake transition.
except (
Exception
): # noqa: BLE001 best-effort; ui_home_state surfaces the real error
): # noqa: BLE001 - best-effort; ui_home_state surfaces the real error
pass
try:
@@ -193,7 +193,7 @@ class FrameCapture:
self._transcript_path = dir_path / "transcript.md"
self._dir.mkdir(parents=True, exist_ok=True)
self._transcript_path.write_text(
f"# {nodeid} {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
f"# {nodeid} - {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
encoding="utf-8",
)
@@ -242,7 +242,7 @@ class FrameCapture:
)
ocr_summary = (ocr_str or "").replace("\n", " / ")[:80]
fh.write(
f"{self._step}. **{label}** {frame_str} OCR: `{ocr_summary}`\n"
f"{self._step}. **{label}** - {frame_str} - OCR: `{ocr_summary}`\n"
)
return entry
@@ -273,7 +273,7 @@ def _send_event(port: str, event: InputEventCode) -> None:
try:
admin_mod.send_input_event(event_code=int(event), port=port)
except Exception: # noqa: BLE001
# Treat a failed event as soft the subsequent frame-log assertion
# Treat a failed event as soft - the subsequent frame-log assertion
# surfaces the real problem with better context.
pass
@@ -298,7 +298,7 @@ def ui_home_state(
instead of letting every test body fail with a confusing assertion.
Autouse scope is restricted to `tests/ui/` by virtue of this fixture
living in that directory's conftest.py no explicit nodeid guard
living in that directory's conftest.py - no explicit nodeid guard
needed (and earlier attempts at one were wrong, matching `/tests/ui/`
against a nodeid that has no leading slash).
"""
@@ -313,7 +313,7 @@ def ui_home_state(
start_len = len(lines)
# First: a wake event. The screen should already be kept on by
# `_ui_screen_kept_on`, but belt + suspenders if it somehow
# `_ui_screen_kept_on`, but belt + suspenders - if it somehow
# powered off (sleep after factory_reset, etc.), this first FN_F1
# gets dropped by InputBroker's screenWasOff guard. That's fine;
# the second FN_F1 below lands cleanly.
@@ -322,14 +322,14 @@ def ui_home_state(
_send_event(port, InputEventCode.FN_F1)
# Wait for the fn_f1 transition log. Any new `reason=fn_f1` line
# after call-start counts we don't care about the name (it should
# after call-start counts - we don't care about the name (it should
# be `home` or `deviceFocused` depending on board-specific frame order).
from ._screen_log import wait_for_reason
try:
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
except TimeoutError:
# One more try FreeRTOS queue may be draining slowly.
# One more try - FreeRTOS queue may be draining slowly.
_send_event(port, InputEventCode.FN_F1)
try:
wait_for_reason(lines, "fn_f1", timeout_s=5.0)
@@ -345,12 +345,12 @@ def ui_home_state(
f"ui_home_state: events fire but none reach Screen "
f"(saw {len(frame_lines)} frame line(s), "
f"{len(processing_lines)} admin inject(s)). "
f"Device may be in an unusual state try `--force-bake`."
f"Device may be in an unusual state - try `--force-bake`."
)
else:
pytest.skip(
"ui_home_state: no `Screen: frame` log after FN_F1. "
"Firmware not baked with USERPREFS_UI_TEST_LOG "
"Firmware not baked with USERPREFS_UI_TEST_LOG - "
"run with `--force-bake` to reflash, or verify the "
"macro is active in the bake."
)
+1 -1
View File
@@ -39,7 +39,7 @@ def test_fn_jump_direct_frame(
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None, "no frame log yet USERPREFS_UI_TEST_LOG not wired?"
assert start is not None, "no frame log yet - USERPREFS_UI_TEST_LOG not wired?"
assert start.name in (
"home",
"deviceFocused",
+5 -5
View File
@@ -3,7 +3,7 @@
`Screen::handleInputEvent` dispatches FN_F5 unconditionally to
`ui->switchToFrame(4)`. The OLEDDisplayUi library typically clamps or
silently ignores out-of-range indices, but firmware bugs have existed
here this test protects against a regression that would wedge the UI.
here - this test protects against a regression that would wedge the UI.
If this test fails, first check: did the device actually crash (Guru
Meditation in the log)? Or did switchToFrame accept an OOB index and
@@ -33,7 +33,7 @@ def test_fn_f5_out_of_bounds(
if start.count > 5:
pytest.skip(
f"device has {start.count} frames; FN_F5 is in-bounds not testing OOB here"
f"device has {start.count} frames; FN_F5 is in-bounds - not testing OOB here"
)
frame_capture("initial-home")
@@ -43,11 +43,11 @@ def test_fn_f5_out_of_bounds(
try:
wait_for_reason(lines, "fn_f5", timeout_s=3.0)
except TimeoutError:
# Firmware may have ignored the event entirely acceptable.
# Firmware may have ignored the event entirely - acceptable.
pass
# Capture whatever is on screen (OCR will tell us if something weird
# happened). Device must remain responsive subsequent events should
# happened). Device must remain responsive - subsequent events should
# still land.
frame_capture("after-fn_f5-oob")
@@ -57,5 +57,5 @@ def test_fn_f5_out_of_bounds(
post = wait_for_reason(lines, "next", timeout_s=5.0)
assert (
post is not None
), "UI wedged after OOB FN_F5 RIGHT no longer produces frame log"
), "UI wedged after OOB FN_F5 - RIGHT no longer produces frame log"
frame_capture("after-recovery-right")
+5 -5
View File
@@ -1,7 +1,7 @@
"""SELECT on the home frame opens the home menu; BACK closes it.
The home menu is an overlay (menuHandler::homeBaseMenu), not a frame
transition so we verify via OCR difference between before/after
transition - so we verify via OCR difference between before/after
captures rather than a `Screen: frame` log line. The underlying
mechanism is still InputBroker → Screen::handleInputEvent → menu
callback.
@@ -39,25 +39,25 @@ def test_select_opens_home_menu(
opened = frame_capture("after-select")
# The menu is an overlay (not a frame change). We cannot use log
# assertion instead, OCR should differ because a menu list is now
# assertion - instead, OCR should differ because a menu list is now
# drawn on top.
initial_text = (initial.get("ocr_text") or "").strip()
opened_text = (opened.get("ocr_text") or "").strip()
if initial_text and opened_text:
# When OCR is available, require *some* difference between the two
# frames even a single menu title changes the transcribed text.
# frames - even a single menu title changes the transcribed text.
assert initial_text != opened_text, (
f"expected OCR diff after SELECT; both read {initial_text!r}. "
"If both are empty, check camera alignment + OCR backend."
)
# Back out the menu dismisses on BACK.
# Back out - the menu dismisses on BACK.
send_event(ui_port, InputEventCode.BACK)
time.sleep(0.8)
closed = frame_capture("after-back")
# Soft check: OCR after BACK should look different from the menu
# (either back to home or onto a previous frame BACK's exact
# (either back to home or onto a previous frame - BACK's exact
# behavior when the menu is up vs. not-up varies). We don't assert
# equality because OLED rendering is pixel-stable but camera sampling
# introduces noise.
@@ -3,7 +3,7 @@ message-scroll path (or opens CannedMessages on empty devices).
Weaker than a "no frame change" assertion because on a fresh bench
device the message store is usually empty, and the firmware's UP
handler in that case launches CannedMessage which DOES rebuild
handler in that case launches CannedMessage - which DOES rebuild
frames. We just verify the path doesn't crash + produce captures for
visual inspection.
"""
@@ -28,7 +28,7 @@ def test_up_down_on_textmessage_survives(
lines: list[str] = request.node._debug_log_buffer
frame_capture("initial")
# Walk RIGHT until we land on textMessage up to 15 hops.
# Walk RIGHT until we land on textMessage - up to 15 hops.
for _i in range(15):
send_event(ui_port, InputEventCode.RIGHT)
time.sleep(0.3)
@@ -37,7 +37,7 @@ def test_up_down_on_textmessage_survives(
break
else:
pytest.skip(
"couldn't reach textMessage frame within 15 RIGHTs not present on this board"
"couldn't reach textMessage frame within 15 RIGHTs - not present on this board"
)
wait_for_frame(lines, "textMessage", timeout_s=5.0)
@@ -57,4 +57,4 @@ def test_up_down_on_textmessage_survives(
# The next test's `ui_home_state` will error out if the device is
# unresponsive, so we don't need a stricter guarantee here.
final = get_current_frame(lines)
assert final is not None, "no frame log after UP/DOWN event path broke"
assert final is not None, "no frame log after UP/DOWN - event path broke"
+2 -2
View File
@@ -25,9 +25,9 @@ def test_input_right_cycles_frames(
) -> None:
lines: list[str] = request.node._debug_log_buffer
start = get_current_frame(lines)
assert start is not None, "no frame log yet USERPREFS_UI_TEST_LOG not wired?"
assert start is not None, "no frame log yet - USERPREFS_UI_TEST_LOG not wired?"
# FN_F1 in ui_home_state lands on frame 0. The name at frame 0 varies
# by board (home on heltec-v3, deviceFocused on others) accept either.
# by board (home on heltec-v3, deviceFocused on others) - accept either.
assert start.name in (
"home",
"deviceFocused",
@@ -38,7 +38,7 @@ def test_up_down_on_nodelist_no_frame_change(
frame_capture("on-nodelist")
# UP/DOWN on nodelist scroll internally + `return 0` before
# notifyObservers no frame-change log. Verify.
# notifyObservers - no frame-change log. Verify.
send_event(ui_port, InputEventCode.UP)
assert_no_frame_change(lines, wait_s=1.5)
send_event(ui_port, InputEventCode.DOWN)
+1 -1
View File
@@ -1,6 +1,6 @@
"""`boards.py` filter and enumeration correctness.
Runs against the real `pio project config` output of this firmware repo
Runs against the real `pio project config` output of this firmware repo -
validates that filter predicates match expected envs and don't drift if
variants get reorganized.
"""
+1 -1
View File
@@ -1,6 +1,6 @@
"""Unit tests for the `build_flags` injection on `flash.build()`.
We don't actually run pio here too slow, requires hardware-aware envs.
We don't actually run pio here - too slow, requires hardware-aware envs.
We test the translation layer (`_build_flags_env`) and that the env vars
are threaded through pio.run correctly via mock.
"""
+7 -7
View File
@@ -1,6 +1,6 @@
"""TCP transport plumbing in connection.py + devices.py.
Pure-Python tests no real device or daemon required. Mocks `TCPInterface`
Pure-Python tests - no real device or daemon required. Mocks `TCPInterface`
when exercising `connect()`.
"""
@@ -171,7 +171,7 @@ class TestResolvePort:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
# Don't patch list_devices let the real env-var path run, but stub
# Don't patch list_devices - let the real env-var path run, but stub
# the USB enumeration to keep the test hermetic.
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
assert connection.resolve_port(None) == "tcp://localhost:4403"
@@ -208,7 +208,7 @@ class TestDevicesTcpEntry:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# `list_devices` is the diagnostic tool reached for when an env var
# isn't working it must not throw on misconfiguration.
# isn't working - it must not throw on misconfiguration.
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
ds = devices.list_devices(include_unknown=True)
@@ -222,7 +222,7 @@ class TestDevicesTcpEntry:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# `likely_meshtastic=False` keeps the bad TCP entry out of the
# auto-select path `resolve_port(None)` should still report
# auto-select path - `resolve_port(None)` should still report
# "no Meshtastic devices" rather than picking a broken endpoint.
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
@@ -274,7 +274,7 @@ class TestDevicesTcpEntry:
ds = devices.list_devices(include_unknown=True)
assert ds, "expected at least the USB + TCP entries"
# Real USB candidate must be at position 0 it's likely_meshtastic.
# Real USB candidate must be at position 0 - it's likely_meshtastic.
assert ds[0]["port"] == "/dev/cu.usbmodem4201"
assert ds[0]["likely_meshtastic"] is True
# The malformed TCP entry exists but lands among the unlikely entries.
@@ -287,7 +287,7 @@ class TestDevicesTcpEntry:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Conversely, a *valid* TCP env var should sort ahead of USB
# candidates of equal likely_meshtastic rank explicit env-var
# candidates of equal likely_meshtastic rank - explicit env-var
# configuration is a precedence signal.
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403")
@@ -375,7 +375,7 @@ class TestConnectRoutesTcp:
with connection.connect(port="tcp://locktest:4403"):
pass
# Lock should be released a second connect attempt must not fail
# Lock should be released - a second connect attempt must not fail
# with "busy".
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
mock_tcp.return_value.close.return_value = None
@@ -1,7 +1,7 @@
"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py
+ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb).
Lives under tests/unit/ because none of these touch real hardware they
Lives under tests/unit/ because none of these touch real hardware - they
shell out to the bin/ scripts and decode the resulting protobufs in-process.
"""
@@ -42,7 +42,7 @@ def _require_v25_bindings() -> None:
)
if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name:
pytest.skip(
"Loaded NodeDatabase predates v25 run `./bin/regen-py-protos.sh`."
"Loaded NodeDatabase predates v25 - run `./bin/regen-py-protos.sh`."
)
@@ -170,7 +170,7 @@ def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path)
proto = tmp_path / "out.proto"
jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl"
if not jsonl.is_file():
pytest.skip(f"{jsonl} not present run ./bin/regen-fake-nodedbs.sh")
pytest.skip(f"{jsonl} not present - run ./bin/regen-fake-nodedbs.sh")
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)])
db = NodeDatabase()
@@ -199,7 +199,7 @@ def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None:
_require_v25_bindings()
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not jsonl.is_file():
pytest.skip("250-node seed not present run ./bin/regen-fake-nodedbs.sh")
pytest.skip("250-node seed not present - run ./bin/regen-fake-nodedbs.sh")
a = tmp_path / "a.proto"
b = tmp_path / "b.proto"
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)])
@@ -1,7 +1,7 @@
"""Pin `InputEventCode` values to the firmware `input_broker_event` enum.
If this test fails, someone changed the firmware enum (or this Python
mirror) and they must stay in sync the admin RPC sends these as u8
mirror) and they must stay in sync - the admin RPC sends these as u8
wire values directly.
Also exercises `coerce_event_code` for the happy + error paths.
+7 -7
View File
@@ -75,7 +75,7 @@ class TestParseLogLine:
assert out["msg"] == "raw message body"
def test_bare_message(self) -> None:
# LogRecord.message path no level prefix at all.
# LogRecord.message path - no level prefix at all.
out = parse_log_line("just a bare message")
assert "level" not in out or out.get("level") is None
assert out["line"] == "just a bare message"
@@ -155,7 +155,7 @@ class TestRecorderDebugHeapSynthesis:
assert synth[-1]["fields"]["heap_total_bytes"] == 200000
def test_no_heap_no_synthesis(self, recorder: "Recorder") -> None:
# Plain log line (no [heap N], no Heap status) telemetry.jsonl
# Plain log line (no [heap N], no Heap status) - telemetry.jsonl
# should NOT gain a synth row.
before = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
recorder._on_log_line("INFO | 00:00:00 1 [Main] just a message", _FakeIface())
@@ -234,7 +234,7 @@ class TestSerialTap:
def test_serial_line_handler_swallows_exceptions(
self, recorder: "Recorder"
) -> None:
# Hostile input should not raise.
# Hostile input - should not raise.
recorder._on_serial_line(None, port="/dev/cu.tap") # type: ignore[arg-type]
recorder._on_serial_line(b"\x00\x01\x02\x03", port="/dev/cu.tap") # type: ignore[arg-type]
# Survived.
@@ -320,11 +320,11 @@ class TestRecorderWrites:
recorder._on_log_line("INFO | 12:34:56 99 [T] hi", _FakeIface())
path = recorder.base_dir / "logs.jsonl"
rows = [json.loads(line) for line in path.read_text().splitlines() if line]
# First row is recorder_start_event mirror? No that's events.jsonl only.
# First row is recorder_start_event mirror? No - that's events.jsonl only.
assert any(r.get("level") == "INFO" and r.get("tag") == "T" for r in rows)
def test_telemetry_recorded_and_packet_double(self, recorder: Recorder) -> None:
# _on_telemetry alone only telemetry.jsonl
# _on_telemetry alone - only telemetry.jsonl
recorder._on_telemetry(
{
"fromId": "!abc",
@@ -367,13 +367,13 @@ class TestRecorderWrites:
assert "kept" in post_resume
def test_pubsub_handler_swallows_exceptions(self, recorder: Recorder) -> None:
# If the writer dies, the pubsub callback must NOT raise that
# If the writer dies, the pubsub callback must NOT raise - that
# would crash the meshtastic receive thread.
bad_packet = object() # not a dict
recorder._on_receive(bad_packet, _FakeIface()) # type: ignore[arg-type]
recorder._on_telemetry(bad_packet, _FakeIface()) # type: ignore[arg-type]
recorder._on_log_line(None, _FakeIface()) # type: ignore[arg-type]
# No assertion needed survival is the test.
# No assertion needed - survival is the test.
# -- log_query read side ---------------------------------------------
@@ -113,7 +113,7 @@ def test_owner_names_included_when_provided() -> None:
def test_psk_seed_isolation_across_ci_runs() -> None:
"""The core claim: two test labs running concurrently with different
session seeds produce different PSKs their meshes cannot decode each
session seeds produce different PSKs - their meshes cannot decode each
other's traffic."""
lab_a = userprefs.build_testing_profile(psk_seed="lab-A-nightly")
lab_b = userprefs.build_testing_profile(psk_seed="lab-B-nightly")
+2 -2
View File
@@ -5,9 +5,9 @@ new hub-descriptor fields (e.g. the `, ppps` marker). The parser uses loose
regexes to tolerate additions; this test keeps us honest.
Samples captured from:
- v2.6.0 on macOS (Homebrew) two USB2 hubs, one populated with an
- v2.6.0 on macOS (Homebrew) - two USB2 hubs, one populated with an
nRF52 and a CP2102, plus chained USB3 hubs.
- v2.5.0 on Linux (hypothetical reconstructed from the project README).
- v2.5.0 on Linux (hypothetical - reconstructed from the project README).
"""
from __future__ import annotations
+3 -3
View File
@@ -1,8 +1,8 @@
"""Pin the `Screen: frame N/M name=X reason=Y` regex + FrameEvent dataclass.
The firmware-side format lives in `src/graphics/Screen.cpp::logFrameChange`;
if the format string changes, this test and the parser in
`tests/ui/_screen_log.py` have to be updated together.
if the format string changes, this test - and the parser in
`tests/ui/_screen_log.py` - have to be updated together.
"""
from __future__ import annotations
@@ -23,7 +23,7 @@ class TestFrameEventParse:
def test_with_log_prefix(self) -> None:
"""Log lines may be preamble-wrapped by the firmware LOG_INFO macro
(timestamp, severity, etc.) the regex uses .search() not .match()
(timestamp, severity, etc.) - the regex uses .search() not .match()
so prefixes are fine."""
raw = "[INFO] 00:12:34 567 Screen: frame 4/12 name=nodelist_nodes reason=fn_f3 "
evt = FrameEvent.parse(raw)
@@ -2,7 +2,7 @@
write, and the `temporary_overrides` context manager's byte-for-byte restore.
None of these require hardware. They validate the contract that the flash/
testing-profile tools rely on if these fail, the provisioning tier will
testing-profile tools rely on - if these fail, the provisioning tier will
produce confusing mismatches.
"""
@@ -63,7 +63,7 @@ def test_infer_type_matches_platformio_custom_py() -> None:
def test_temporary_overrides_restores_byte_for_byte(sample_jsonc: Path) -> None:
"""The context manager MUST leave the file bit-identical on exit, even on
exception this is the safety guarantee build/flash tools rely on."""
exception - this is the safety guarantee build/flash tools rely on."""
original = sample_jsonc.read_bytes()
with userprefs.temporary_overrides({"USERPREFS_CHANNEL_0_NAME": "OverrideTest"}):
@@ -104,7 +104,7 @@ def test_build_manifest_surfaces_all_keys(sample_jsonc: Path) -> None:
"""Manifest should union the jsonc set with firmware-src consumers.
In the sample tmpdir there's no `src/` so `consumed_by` is empty for all
entries; that's fine the manifest still lists every jsonc key.
entries; that's fine - the manifest still lists every jsonc key.
"""
manifest = userprefs.build_manifest()
keys = {e["key"] for e in manifest["entries"]}