"""Tests for esphome.components.nrf52 upload_program and run_compile.""" from contextlib import ExitStack from pathlib import Path from unittest.mock import MagicMock, patch import pytest from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 from esphome.components.zephyr.const import ( KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, KEY_OVERLAY, KEY_OVERLAY_BUILDER, KEY_PM_STATIC, KEY_PRJ_CONF, KEY_ZEPHYR, ) import esphome.config_validation as cv from esphome.const import ( KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_NRF52, Toolchain, ) from esphome.core import CORE, EsphomeError # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _setup_nrf52_core( bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, toolchain: Toolchain = Toolchain.SDK_NRF, build_path: Path | None = None, ) -> None: CORE.name = "test_device" if build_path is not None: CORE.build_path = build_path CORE.data[KEY_CORE] = { KEY_TARGET_PLATFORM: PLATFORM_NRF52, KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2), } CORE.toolchain = toolchain CORE.data[KEY_ZEPHYR] = { KEY_BOARD: "adafruit_feather_nrf52840", KEY_BOOTLOADER: bootloader, KEY_PRJ_CONF: {}, KEY_OVERLAY: {"": ""}, KEY_OVERLAY_BUILDER: [], KEY_EXTRA_BUILD_FILES: {}, KEY_PM_STATIC: [], KEY_KCONFIG: "", } def _make_paths(tmp_path: Path) -> dict: return { "python_executable": tmp_path / "penv" / "python", "framework_path": tmp_path / "framework", } # --------------------------------------------------------------------------- # Config-reconstruction guard # --------------------------------------------------------------------------- class TestUploadProgramConfigGuard: def test_missing_platform_config_raises(self, setup_core: Path) -> None: """upload_program raises EsphomeError when the platform config section is absent.""" from esphome.components.nrf52 import upload_program CORE.data[KEY_CORE] = { KEY_TARGET_PLATFORM: PLATFORM_NRF52, KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, } # KEY_ZEPHYR absent → reconstruction branch is entered assert KEY_ZEPHYR not in CORE.data with pytest.raises(EsphomeError, match="platform configuration"): upload_program(config={}, args=None, host="PYOCD") # --------------------------------------------------------------------------- # PYOCD upload path # --------------------------------------------------------------------------- class TestUploadProgramPyocd: def test_pyocd_assembles_west_command( self, setup_core: Path, tmp_path: Path ) -> None: """West flash command must include --runner pyocd and the build dir.""" from esphome.components.nrf52 import upload_program _setup_nrf52_core(build_path=tmp_path / "build") CORE.config_path = tmp_path / "test.yaml" paths = _make_paths(tmp_path) build_dir = CORE.relative_pioenvs_path(CORE.name) with ( patch("esphome.components.nrf52.check_and_install"), patch("esphome.components.nrf52.get_build_paths", return_value=paths), patch("esphome.components.nrf52.get_build_env", return_value={}), patch( "esphome.components.nrf52.run_command_ok", return_value=True ) as mock_run, ): result = upload_program(config={}, args=None, host="PYOCD") assert result is True mock_run.assert_called_once() cmd = mock_run.call_args[0][0] assert str(paths["python_executable"]) == cmd[0] assert "west" in cmd assert "flash" in cmd assert "--runner" in cmd assert "pyocd" in cmd assert "-d" in cmd assert str(build_dir) in cmd def test_pyocd_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: """A failed west flash must raise EsphomeError.""" from esphome.components.nrf52 import upload_program _setup_nrf52_core(build_path=tmp_path / "build") CORE.config_path = tmp_path / "test.yaml" with ( patch("esphome.components.nrf52.check_and_install"), patch( "esphome.components.nrf52.get_build_paths", return_value=_make_paths(tmp_path), ), patch("esphome.components.nrf52.get_build_env", return_value={}), patch("esphome.components.nrf52.run_command_ok", return_value=False), pytest.raises(EsphomeError, match="pyocd"), ): upload_program(config={}, args=None, host="PYOCD") # --------------------------------------------------------------------------- # PlatformIO toolchain paths # --------------------------------------------------------------------------- class TestRunCompilePlatformio: def test_prepares_python_env_and_delegates_to_platformio( self, setup_core: Path, tmp_path: Path ) -> None: """The PlatformIO toolchain prepares the env, then returns False so PlatformIO builds.""" from esphome.components.nrf52 import run_compile _setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build") with patch( "esphome.components.nrf52.setup_platformio_python_env" ) as mock_setup: assert run_compile(args=None, config={}) is False mock_setup.assert_called_once_with() class TestUploadProgramSerialPlatformio: def _upload(self, host: str, tmp_path: Path, run_result: int) -> tuple: from esphome.components.nrf52 import upload_program from esphome.upload_targets import PortType _setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build") CORE.config_path = tmp_path / "test.yaml" with ( patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), patch("esphome.__main__.check_permissions"), patch("esphome.components.nrf52.setup_platformio_python_env") as mock_setup, patch( "esphome.platformio.toolchain.run_platformio_cli_run", return_value=run_result, ) as mock_run, ): result = upload_program(config={}, args=None, host=host) return result, mock_setup, mock_run def test_serial_upload_prepares_env_and_runs_platformio( self, setup_core: Path, tmp_path: Path ) -> None: """Serial upload with the PlatformIO toolchain runs pio with -t upload.""" host = "/dev/ttyACM0" result, mock_setup, mock_run = self._upload(host, tmp_path, run_result=0) assert result is True mock_setup.assert_called_once_with() mock_run.assert_called_once() run_args = mock_run.call_args[0] assert "-t" in run_args assert "upload" in run_args assert "--upload-port" in run_args assert host in run_args def test_serial_upload_failure_raises( self, setup_core: Path, tmp_path: Path ) -> None: """A non-zero PlatformIO result must raise EsphomeError.""" with pytest.raises(EsphomeError, match="Upload failed"): self._upload("/dev/ttyACM0", tmp_path, run_result=1) # --------------------------------------------------------------------------- # Serial DFU upload path # --------------------------------------------------------------------------- def _enter_serial_dfu_patches( stack: ExitStack, host: str, tmp_path: Path, paths: dict ) -> MagicMock: """Enter all context managers needed for the serial DFU happy path. Returns the mock for ``run_command_ok`` so callers can inspect calls. comports() returns [] on the first call (port disappeared) and a list containing the host on every subsequent call (port reappeared). Patches are applied directly on the real pyserial module attributes so they are visible to the deferred ``import serial[.tools.list_ports] as _x`` statements inside upload_program. """ import serial import serial.tools.list_ports from esphome.upload_targets import PortType _comports_calls = [0] def _comports(): _comports_calls[0] += 1 if _comports_calls[0] == 1: return [] # port disappeared → disappear loop breaks return [MagicMock(device=host)] # port back → reappear loop breaks stack.enter_context( patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL) ) stack.enter_context(patch("esphome.__main__.check_permissions")) stack.enter_context(patch("esphome.components.nrf52.check_and_install")) stack.enter_context( patch("esphome.components.nrf52.get_build_paths", return_value=paths) ) stack.enter_context( patch("esphome.components.nrf52.get_build_env", return_value={}) ) stack.enter_context(patch("time.sleep")) # Patch directly on the real pyserial module so the deferred imports inside # upload_program see our mocks regardless of how sys.modules is cached. stack.enter_context(patch.object(serial, "Serial")) stack.enter_context( patch.object(serial.tools.list_ports, "comports", side_effect=_comports) ) return stack.enter_context( patch("esphome.components.nrf52.run_command_ok", return_value=True) ) class TestUploadProgramSerialDfu: def test_unsupported_bootloader_raises( self, setup_core: Path, tmp_path: Path ) -> None: """An unknown bootloader must raise EsphomeError before touching the port.""" from esphome.components.nrf52 import upload_program from esphome.upload_targets import PortType _setup_nrf52_core( bootloader="unknown_bootloader", build_path=tmp_path / "build" ) CORE.config_path = tmp_path / "test.yaml" with ( patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), patch("esphome.__main__.check_permissions"), pytest.raises(EsphomeError, match="Not implemented"), ): upload_program(config={}, args=None, host="/dev/ttyACM0") def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None: """Missing firmware.zip must raise EsphomeError before opening the serial port.""" from esphome.components.nrf52 import upload_program from esphome.upload_targets import PortType _setup_nrf52_core(build_path=tmp_path / "build") CORE.config_path = tmp_path / "test.yaml" with ( patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), patch("esphome.__main__.check_permissions"), patch("esphome.components.nrf52.check_and_install"), patch( "esphome.components.nrf52.get_build_paths", return_value=_make_paths(tmp_path), ), patch("esphome.components.nrf52.get_build_env", return_value={}), pytest.raises(EsphomeError, match="Firmware not found"), ): # firmware.zip does not exist on disk → is_file() returns False upload_program(config={}, args=None, host="/dev/ttyACM0") def test_serial_dfu_assembles_nordicsemi_command( self, setup_core: Path, tmp_path: Path ) -> None: """Nordicsemi DFU command must include pkg path, port, and --singlebank.""" from esphome.components.nrf52 import upload_program _setup_nrf52_core(build_path=tmp_path / "build") CORE.config_path = tmp_path / "test.yaml" paths = _make_paths(tmp_path) build_dir = CORE.relative_pioenvs_path(CORE.name) dfu_package = build_dir / "firmware.zip" dfu_package.parent.mkdir(parents=True, exist_ok=True) dfu_package.touch() host = "/dev/ttyACM0" with ExitStack() as stack: mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) result = upload_program(config={}, args=None, host=host) assert result is True mock_run.assert_called_once() cmd = mock_run.call_args[0][0] assert "nordicsemi.__main__" in cmd assert "dfu" in cmd assert "serial" in cmd assert "-pkg" in cmd assert str(dfu_package) in cmd assert "-p" in cmd assert host in cmd assert "--singlebank" in cmd def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: """A failed nordicsemi DFU must raise EsphomeError.""" from esphome.components.nrf52 import upload_program _setup_nrf52_core(build_path=tmp_path / "build") CORE.config_path = tmp_path / "test.yaml" paths = _make_paths(tmp_path) build_dir = CORE.relative_pioenvs_path(CORE.name) dfu_package = build_dir / "firmware.zip" dfu_package.parent.mkdir(parents=True, exist_ok=True) dfu_package.touch() host = "/dev/ttyACM0" with ExitStack() as stack: mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) mock_run.return_value = False with pytest.raises(EsphomeError, match="serial DFU upload failed"): upload_program(config={}, args=None, host=host)