diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index d87318b03d..00271c97c7 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -4,6 +4,7 @@ import asyncio import logging from pathlib import Path import re +import shutil import subprocess from esphome import pins @@ -486,6 +487,16 @@ def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type + if KEY_ZEPHYR not in CORE.data: + platform_config = config.get(CORE.target_platform) + if not platform_config: + raise EsphomeError( + "nRF52 platform configuration is missing; " + "please re-validate and recompile." + ) + set_core_data(platform_config) + set_framework(platform_config) + mcumgr_device: str | None = None if get_port_type(host) == PortType.SERIAL: @@ -494,17 +505,122 @@ def upload_program(config: ConfigType, args, host: str) -> bool: mcumgr_device = host else: if not CORE.using_toolchain_platformio: - raise EsphomeError("Not implemented yet") - result = _upload_using_platformio(config, host, ["-t", "upload"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio serial upload + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader not in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + raise EsphomeError("Not implemented yet") + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + if not dfu_package.is_file(): + raise EsphomeError("Firmware not found. Please compile first.") + import time as _time + + import serial as _serial + import serial.tools.list_ports as _list_ports + + try: + ser = _serial.Serial(host, baudrate=1200, timeout=1) + ser.close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + + # Wait for device to reset (port disappears) + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host not in {p.device for p in _list_ports.comports()}: + break + else: + _LOGGER.warning( + "Device did not leave %s within 5 s; " + "it may not have entered bootloader mode", + host, + ) + + # Wait for DFU port to reappear + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host in {p.device for p in _list_ports.comports()}: + break + else: + raise EsphomeError( + f"DFU port {host!r} did not reappear within 10 s. " + "Check that the device entered DFU mode." + ) + + # Wait for udev to finish setting up device permissions + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + try: + check_permissions(host) + break + except EsphomeError: + _time.sleep(0.05) + else: + check_permissions(host) # raises with helpful message + + python = str(paths["python_executable"]) + if not run_command_ok( + [ + python, + "-m", + "nordicsemi.__main__", + "dfu", + "serial", + "-pkg", + str(dfu_package), + "-p", + host, + "-b", + "115200", + "--singlebank", + ], + env=env, + stream_output=True, + ): + raise EsphomeError("nRF52 serial DFU upload failed") + else: + result = _upload_using_platformio(config, host, ["-t", "upload"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: serial upload if host == "PYOCD": - result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio PYOCD upload + if not CORE.using_toolchain_platformio: + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "flash", + "--runner", + "pyocd", + "-d", + str(build_dir), + ] + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 pyocd flash failed") + else: + result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths from .ble_logger import is_mac_address @@ -662,4 +778,43 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError("nRF52 native build failed") + # Zephyr's cmake places kernel artifacts in build_dir/zephyr/zephyr/ and + # merged.hex at build_dir/. Normalize to build_dir/zephyr/ so paths match + # get_download_types (which mirrors the platformio build output layout). + zephyr_dir = build_dir / "zephyr" + west_out = zephyr_dir / "zephyr" + for filename in ["zephyr.uf2"]: + src = west_out / filename + if src.is_file(): + shutil.copy2(src, zephyr_dir / filename) + + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + _GENPKG_PARAMS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + } + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + hex_file = west_out / "zephyr.hex" + dfu_package = build_dir / "firmware.zip" + genpkg_cmd = [ + str(paths["python_executable"]), + "-m", + "nordicsemi.__main__", + "dfu", + "genpkg", + ] + if bootloader in _GENPKG_PARAMS: + dev_type, sd_req = _GENPKG_PARAMS[bootloader] + genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req] + genpkg_cmd += ["--application", str(hex_file), str(dfu_package)] + if not run_command_ok(genpkg_cmd, env=env, stream_output=True): + raise EsphomeError("Failed to create adafruit DFU package") + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index a35ba3ef85..05feadb001 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -111,10 +111,9 @@ def _get_version_str() -> str: def get_build_paths() -> dict: version = _get_version_str() + env_path = _get_python_env_path(version) return { - "python_executable": get_python_env_executable_path( - _get_python_env_path(version), "python" - ), + "python_executable": get_python_env_executable_path(env_path, "python"), "framework_path": _get_framework_path(version), } diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt index 250d3a29cf..c55d35b2b1 100644 --- a/esphome/components/nrf52/requirements.txt +++ b/esphome/components/nrf52/requirements.txt @@ -1,3 +1,4 @@ west==1.5.0 ninja==1.13.0 cmake==4.3.2 +adafruit-nrfutil @ git+https://github.com/adafruit/Adafruit_nRF52_nrfutil.git@7fdfe15feee5f304fb7d9b031721dcefa1f72b58 diff --git a/esphome/storage_json.py b/esphome/storage_json.py index f754673b79..9d662df8f8 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_DISABLED, CONF_MDNS, KEY_CORE, + KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, Toolchain, @@ -179,6 +180,8 @@ class StorageJSON: hardware = esp32.get_esp32_variant(esph) framework_version = str(esp32.idf_version()) + elif esph.is_nrf52: + framework_version = str(esph.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) return StorageJSON( storage_version=1, name=esph.name, @@ -334,6 +337,19 @@ class StorageJSON: f"Please clean the build files and recompile." ) from err CORE.data[KEY_ESP32] = esp32_data + elif target_platform == const.PLATFORM_NRF52 and self.framework_version: + import esphome.config_validation as cv + + try: + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py new file mode 100644 index 0000000000..a60e23a337 --- /dev/null +++ b/tests/unit_tests/test_nrf52_upload.py @@ -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) diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 7ba56b05f4..01683507c1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -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"