From d96ad02b9f8bab72888895ebde115532c0d409ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 May 2026 23:07:04 -0500 Subject: [PATCH] [nrf52] Honor --prebuilt-dir for mcumgr/BLE OTA uploads nRF52 has its own upload_program that runs before the default dispatch in esphome.__main__. The mcumgr/BLE OTA path reads the MCUboot-signed update image from CORE.relative_pioenvs_path(name, 'zephyr', 'app_update.bin'), which assumes a local Zephyr build tree. Consult CORE.prebuilt_artifact_path('app_update.bin') first so the dashboard's transparent BLE install on a Bluetooth proxy can flash a prebuilt update image without compiling locally. Serial uploads on non-MCUboot bootloaders still go through adafruit-nrfutil via _upload_using_platformio and are out of scope here; they need their own bypass to work with --prebuilt-dir (tracked separately). Issue: esphome/device-builder#572 --- esphome/components/nrf52/__init__.py | 14 ++++-- tests/unit_tests/components/test_nrf52.py | 52 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/components/test_nrf52.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index d2ed3b15e9c..2335e947fdc 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -436,9 +436,17 @@ def upload_program(config: ConfigType, args, host: str) -> bool: mcumgr_device = host if mcumgr_device: - firmware = Path( - CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin") - ).resolve() + # `esphome upload --prebuilt-dir ` ships the MCUboot-signed + # update image at the root of the prebuilt directory; prefer that so + # the dashboard's transparent BLE install on a Bluetooth proxy works + # without a local Zephyr build tree. + prebuilt = CORE.prebuilt_artifact_path("app_update.bin") + if prebuilt is not None: + firmware = prebuilt.resolve() + else: + firmware = Path( + CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin") + ).resolve() asyncio.run(smpmgr_upload(mcumgr_device, firmware)) return True # Handled: mcumgr OTA upload diff --git a/tests/unit_tests/components/test_nrf52.py b/tests/unit_tests/components/test_nrf52.py new file mode 100644 index 00000000000..5864b6e0e20 --- /dev/null +++ b/tests/unit_tests/components/test_nrf52.py @@ -0,0 +1,52 @@ +"""Tests for the nrf52 component's custom upload_program.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from esphome.core import CORE + + +def test_nrf52_upload_program_prebuilt_dir_for_ble_ota(tmp_path: Path) -> None: + """`esphome upload --prebuilt-dir ` for an nRF52 Bluetooth proxy + ships the MCUboot-signed update at /app_update.bin so the + dashboard's transparent BLE install doesn't need a local Zephyr build + tree. Verify the nrf52 upload_program picks up that path instead of + `CORE.relative_pioenvs_path(..., "zephyr", "app_update.bin")`. + """ + prebuilt = tmp_path / "prebuilt" + prebuilt.mkdir() + expected_firmware = prebuilt / "app_update.bin" + expected_firmware.write_bytes(b"signed-mcuboot-image") + CORE.prebuilt_dir = prebuilt + CORE.name = "bt-proxy" + + captured_firmware: list[Path] = [] + + async def fake_upload(device: str, firmware: Path) -> None: + captured_firmware.append(firmware) + + # Patch the heavy nrf52 surface so we exercise only the firmware-path + # resolution: BLE scan returns a fake device, smpmgr_upload records the + # firmware path it would have flashed. + with ( + patch( + "esphome.components.nrf52.ota.smpmgr_scan", + new=AsyncMock(return_value="AA:BB:CC:DD:EE:FF"), + ), + patch( + "esphome.components.nrf52.ota.smpmgr_upload", + new=fake_upload, + ), + patch( + "esphome.components.nrf52.ble_logger.is_mac_address", + return_value=False, + ), + ): + from esphome.components.nrf52 import upload_program + + handled = upload_program({}, MagicMock(), "BLE") + + assert handled is True + assert captured_firmware == [expected_firmware.resolve()]