[nrf52] add upload for native build (#17100)

Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
tomaszduda23
2026-06-27 15:50:28 -04:00
committed by GitHub
co-authored by Jonathan Swoboda
parent a0742a9535
commit 690e8c3fb9
6 changed files with 571 additions and 12 deletions
+292
View File
@@ -0,0 +1,292 @@
"""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_PM_STATIC,
KEY_PRJ_CONF,
KEY_USER,
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_EXTRA_BUILD_FILES: {},
KEY_PM_STATIC: [],
KEY_USER: {},
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")
# ---------------------------------------------------------------------------
# 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)
+96
View File
@@ -352,6 +352,7 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
mock_core.web_port = None
mock_core.target_platform = "esp8266"
mock_core.is_esp32 = False
mock_core.is_nrf52 = False
mock_core.build_path = "/build"
mock_core.firmware_bin = "/build/firmware.bin"
mock_core.loaded_integrations = set()
@@ -366,6 +367,34 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
assert result.toolchain is None
def test_storage_json_from_esphome_core_nrf52(setup_core: Path) -> None:
"""Test from_esphome_core captures the framework version on nRF52."""
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
mock_core = MagicMock()
mock_core.name = "nrf_device"
mock_core.friendly_name = "nRF Device"
mock_core.comment = None
mock_core.address = "nrf.local"
mock_core.web_port = None
mock_core.target_platform = "nrf52"
mock_core.is_esp32 = False
mock_core.is_nrf52 = True
mock_core.data = {KEY_CORE: {KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2)}}
mock_core.build_path = "/build/nrf_device"
mock_core.firmware_bin = "/build/nrf_device/firmware.bin"
mock_core.loaded_integrations = set()
mock_core.loaded_platforms = set()
mock_core.config = {}
mock_core.target_framework = "zephyr"
mock_core.toolchain = None
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
assert result.target_platform == "NRF52"
assert result.framework_version == "2.9.2"
def test_storage_json_load_valid_file(tmp_path: Path) -> None:
"""Test StorageJSON.load with valid JSON file."""
storage_data = {
@@ -787,6 +816,73 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None:
assert result.esphome_version == "1.14.0" # Should map to esphome_version
def _make_nrf52_storage(
framework_version: str | None = None,
) -> storage_json.StorageJSON:
return storage_json.StorageJSON(
storage_version=1,
name="dev",
friendly_name=None,
comment=None,
esphome_version="2024.1.0",
src_version=1,
address="dev.local",
web_port=None,
target_platform="NRF52",
build_path=Path("/build"),
firmware_bin_path=Path("/build/zephyr/zephyr.bin"),
loaded_integrations=set(),
loaded_platforms=set(),
no_mdns=False,
framework="zephyr",
core_platform="nrf52",
framework_version=framework_version,
)
def test_storage_json_nrf52_framework_version_round_trip(setup_core: Path) -> None:
"""Sidecar framework_version restores CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]."""
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
storage = _make_nrf52_storage("2.9.2")
path = setup_core / "storage.json"
path.write_text(storage.to_json())
assert json.loads(path.read_text())["framework_version"] == "2.9.2"
loaded = storage_json.StorageJSON.load(path)
assert loaded is not None
assert loaded.framework_version == "2.9.2"
loaded.apply_to_core()
assert CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] == cv.Version(2, 9, 2)
def test_storage_json_nrf52_apply_to_core_without_framework_version(
setup_core: Path,
) -> None:
"""Older sidecars lacking framework_version don't populate KEY_FRAMEWORK_VERSION."""
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
loaded = _make_nrf52_storage(framework_version=None)
assert loaded.framework_version is None
loaded.apply_to_core()
assert KEY_FRAMEWORK_VERSION not in CORE.data[KEY_CORE]
def test_storage_json_nrf52_apply_to_core_raises_on_invalid_framework_version(
setup_core: Path,
) -> None:
"""A malformed version string fails with an actionable error at parse time."""
from esphome.core import EsphomeError
loaded = _make_nrf52_storage(framework_version="not-a-version")
with pytest.raises(EsphomeError, match="clean the build"):
loaded.apply_to_core()
def test_storage_json_load_area(tmp_path: Path) -> None:
"""``area`` round-trips through load; absence loads as None."""
file_path = tmp_path / "with_area.json"