emdashes begone (#10847)
This commit is contained in:
+16
-16
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,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.
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
Reference in New Issue
Block a user