[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
This commit is contained in:
J. Nick Koston
2026-05-10 23:07:04 -05:00
parent a6a0a404ae
commit d96ad02b9f
2 changed files with 63 additions and 3 deletions
+11 -3
View File
@@ -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 <path>` 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
+52
View File
@@ -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 <path>` for an nRF52 Bluetooth proxy
ships the MCUboot-signed update at <prebuilt-dir>/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()]