"""Session-bake module — runs first in the tier order to flash both hub roles with the session `test_profile`. Ordered first by `pytest_collection_modifyitems` in `conftest.py` (bucket -1) because `baked_mesh` only *verifies* state — it does not reflash. Without the explicit order pin, the top-level path `tests/test_00_bake.py` falls into the fallback bucket and sorts AFTER every tier, silently turning `--force-bake` into a no-op for the tier tests. Skipped entirely when `--assume-baked` is passed. All downstream hardware tests either depend on `baked_mesh` (which verifies state) or do their own per-test bake (provisioning/fleet tiers), so failing here gives one clear actionable failure instead of a cascade of mismatches. Hardware-specific env names live in a small role→env map at the top of this file; override by setting `MESHTASTIC_MCP_ENV_` env vars (e.g. `MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114`). """ from __future__ import annotations import os import time from typing import Any import pytest import serial # type: ignore[import-untyped] from meshtastic_mcp import admin, boards, flash, hw_tools, info # Default envs for a common lab setup. Override per-role via env var. _DEFAULT_ENVS = { "nrf52": "rak4631", "esp32s3": "heltec-v3", } _ESP32_ARCHES = { "esp32", "esp32-s2", "esp32s2", "esp32-s3", "esp32s3", "esp32-c3", "esp32c3", "esp32-c6", "esp32c6", } _NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"} def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None: """Block until `port` can be exclusively opened, or raise after `timeout_s`. Root cause for the retry loop: esptool / nrfutil / pio all take an *exclusive* serial port lock (fcntl LOCK_EX on macOS, EAGAIN otherwise). Anything that held the port recently — the TUI's startup `DevicePollerWorker._poll_once()`, a prior `device_info` call, a lingering `meshtastic-mcp` subprocess spawned by the operator's MCP host, or a stale `pio device monitor` — can still be holding it when `test_00_bake` reaches the flash step. The result is esptool exiting 2 in ~0.1s with `[Errno 35] Resource temporarily unavailable`. `pyserial.Serial(exclusive=True)` probes the same lock esptool takes; a brief open/close cycle is the cleanest way to verify the port is genuinely free before handing it to a subprocess we can't easily retry. 200 ms poll interval keeps the failure fast while giving the kernel time to release a just-closed descriptor. Raises AssertionError (rather than a generic TimeoutError) so the pytest summary shows the role + port + a hint at `lsof`. """ role_prefix = f"{role}: " if role else "" deadline = time.monotonic() + timeout_s last_exc: BaseException | None = None while time.monotonic() < deadline: try: s = serial.Serial(port=port, exclusive=True, timeout=0.5) except Exception as exc: last_exc = exc time.sleep(0.2) continue try: s.close() except Exception: pass return raise AssertionError( f"{role_prefix}port {port} still busy after {timeout_s:.0f}s — " f"something else holds an exclusive lock. Last error: {last_exc!r}. " f"Identify the holder with `lsof {port}` and kill it; common " f"culprits are a lingering `meshtastic-mcp` subprocess from the " f"MCP host (.mcp.json) or a stale `pio device monitor`." ) def _prepare_nrf52_for_upload(port: str) -> str: """Kick the RAK4631 (or similar nRF52 USB-DFU board) into bootloader mode via 1200bps touch, then return the port where pio should upload. Adafruit bootloader on RAK4631 interprets 1200bps-open-close as 'enter DFU'. The device re-enumerates with a distinct USB VID/PID (0x239A/0x0029) at a different `/dev/cu.usbmodem*` path. `touch_1200bps` does the heavy lifting: bounded open/close, polls for the Adafruit-bootloader PID specifically, retries the touch up to twice. Fails loudly if the device doesn't enter DFU — no point trying pio upload against an app-mode device, it'll just hang. """ result = flash.touch_1200bps(port=port, settle_ms=500, retries=2) if not result.get("ok"): raise AssertionError( f"nRF52 at {port} did not enter DFU bootloader after " f"{result.get('attempts')} 1200bps touches. Manual recovery: " f"double-tap the reset button on the board, then re-run. " f"Detected port set before/after touch was unchanged." ) new_port = result["new_port"] # Small settle so pio/nrfutil sees a fully-ready CDC endpoint. time.sleep(1.0) return new_port def _env_for(role: str) -> str: override = os.environ.get(f"MESHTASTIC_MCP_ENV_{role.upper()}") if override: return override if role not in _DEFAULT_ENVS: pytest.fail( f"no default PlatformIO env for role {role!r}. " f"Set MESHTASTIC_MCP_ENV_{role.upper()} to the env name." ) return _DEFAULT_ENVS[role] def _bake_role( role: str, port: str, test_profile: dict[str, Any], force_bake: bool, ) -> None: """Bake + boot + verify for a single role. Skips if already baked unless `--force-bake` was passed.""" env = _env_for(role) # If not forcing, check if already baked with session profile. if not force_bake: try: live = info.device_info(port=port, timeout_s=8.0) # Quick heuristic: region matches and primary channel matches. expected_region_short = test_profile["USERPREFS_CONFIG_LORA_REGION"].rsplit( "_", 1 )[-1] if ( live.get("region") == expected_region_short and live.get("primary_channel") == test_profile["USERPREFS_CHANNEL_0_NAME"] ): pytest.skip( f"{role} at {port} already baked with session profile " f"(pass --force-bake to reflash)" ) except Exception: # If we can't query, fall through and bake anyway. pass # All architectures go through `pio run -t upload` — pio knows the right # protocol per variant (esptool for ESP32, adafruit-nrfutil for nRF52, # picotool for RP2040). We don't use `bin/device-install.sh` for ESP32 # because it requires the external `mt-esp32s3-ota.bin` helper that's # downloaded from releases, not generated by the build. # # IMPORTANT: `pio run -t upload` on ESP32 only overwrites the APP # partition — the LittleFS partition (config + NodeDB) survives. That # means USERPREFS-baked defaults never take effect on a device that was # already provisioned, because NodeDB init prefers the saved config. To # force USERPREFS to apply cleanly, we erase the full chip first on # ESP32 boards. nRF52 DFU naturally wipes the user partition, so no # erase needed there. rec = boards.get_board(env) arch = rec.get("architecture") or "" # Make sure nothing else (TUI startup poll, MCP-host zombie, pio monitor) # is holding the port before we hand it to a subprocess. Self-heals the # [Errno 35] port-busy flake that otherwise fails the bake in ~0.1s. _wait_port_free(port, role=role) if arch in _NRF52_ARCHES: upload_port = _prepare_nrf52_for_upload(port) elif arch in _ESP32_ARCHES: # Full chip erase — wipes NVS + LittleFS so USERPREFS defaults apply. erase_result = hw_tools.esptool_erase_flash(port=port, confirm=True) assert erase_result["exit_code"] == 0, ( f"{role}: esptool erase_flash failed:\n" f"{erase_result.get('stderr_tail', '')}" ) upload_port = port else: upload_port = port # Post-erase, pre-upload: full chip erase on ESP32 drops the CDC # endpoint for a moment while the bootloader re-enters download mode. # Wait for the port to settle before pio reopens it for upload — # otherwise a fast machine can race and hit the same errno 35. if arch in _ESP32_ARCHES: _wait_port_free(upload_port, role=role, timeout_s=10.0) # NOTE: no `userprefs_overrides=` here. The session-scoped # `_session_userprefs` autouse fixture in conftest.py has already baked # the test profile into userPrefs.jsonc for the duration of the session # and will restore the original file at session end. A local # `temporary_overrides` here would be a no-op (file is already baked) # AND would cause the session fixture's teardown to see different # stat / mtime than it snapshotted — keep the mutation in one place. result = flash.flash( env=env, port=upload_port, confirm=True, ) assert result["exit_code"] == 0, ( f"{role} bake failed: exit={result['exit_code']}\n" f"stdout tail:\n{result.get('stdout_tail', '')}\n" f"stderr tail:\n{result.get('stderr_tail', '')}" ) # Post-flash: for nRF52, the DFU process only overwrites the app # partition — the NVS region holding the existing NodeDB/config is # untouched, so the firmware will prefer the saved config over the # baked USERPREFS defaults. Trigger a full factory reset to wipe NVS # so USERPREFS takes effect on the next boot. # # ESP32 devices had their full flash erased BEFORE upload via # esptool_erase_flash, so they don't need this post-flash reset. if arch in _NRF52_ARCHES: # Give the device time to come up from DFU. time.sleep(8.0) # Wait for meshtastic to be responsive; `device_info` may take a # few seconds on the first post-flash boot. for _ in range(20): try: info.device_info(port=port, timeout_s=6.0) break except Exception: time.sleep(1.5) else: raise AssertionError(f"{role}: device didn't respond after DFU flash") # Trigger full factory reset (wipes NVS + identity) admin.factory_reset(port=port, confirm=True, full=True) # Wait for the device to reboot and come back with fresh config # populated from USERPREFS defaults. time.sleep(10.0) for _ in range(30): try: live = info.device_info(port=port, timeout_s=6.0) if live.get("my_node_num"): break except Exception: pass time.sleep(2.0) else: raise AssertionError(f"{role}: device didn't return after factory_reset") @pytest.mark.timeout(600) def test_bake_nrf52( hub_devices: dict[str, str], test_profile: dict[str, Any], request: pytest.FixtureRequest, ) -> None: """Flash the nRF52840 role with the session test profile.""" if "nrf52" not in hub_devices: pytest.skip("nRF52 not detected on hub") _bake_role( role="nrf52", port=hub_devices["nrf52"], test_profile=test_profile, force_bake=request.config.getoption("--force-bake"), ) @pytest.mark.timeout(600) def test_bake_esp32s3( hub_devices: dict[str, str], test_profile: dict[str, Any], request: pytest.FixtureRequest, ) -> None: """Flash the ESP32-S3 role with the session test profile.""" if "esp32s3" not in hub_devices: pytest.skip("ESP32-S3 not detected on hub") _bake_role( role="esp32s3", port=hub_devices["esp32s3"], test_profile=test_profile, force_bake=request.config.getoption("--force-bake"), )