From ca08803425d5c1baeb0642f2dd3aa9502a35e315 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 27 Jul 2026 22:03:22 +0200 Subject: [PATCH] [nrf52] add OTA for Adafruit_nRF52_Bootloader (#17381) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 54 +++++++++-- esphome/components/zephyr/__init__.py | 27 +++++- .../components/zephyr_mcumgr/ota/__init__.py | 90 +++++++++++++++++-- tests/components/ota/test.nrf52-adafruit.yaml | 4 + 4 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 tests/components/ota/test.nrf52-adafruit.yaml diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2edc988ff8..4002d1cc04 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -877,6 +877,21 @@ def run_compile(args, config: ConfigType) -> bool: zephyr_dir = build_dir / "zephyr" framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + bootloader = zephyr_data()[KEY_BOOTLOADER] + + # (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"), + } + # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant + _UF2_FAMILY_IDS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: "0x7EAED30A", + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: "0xADA52840", + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: "0xADA52840", + } + # SDK < 2.9.2 places artifacts directly in build_dir/zephyr/. # SDK >= 2.9.2 nests them one level deeper (build_dir/zephyr/zephyr/); # copy files to match get_download_types layout. @@ -888,20 +903,43 @@ def run_compile(args, config: ConfigType) -> bool: _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex") - # (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] + # For Adafruit bootloader builds, regenerate the UF2 from merged.hex, + # whose records carry the correct flash addresses. The build's own + # zephyr.uf2 uses the board's default offset, which is wrong in some cases. + merged_hex = zephyr_dir / "merged.hex" + if bootloader in _UF2_FAMILY_IDS and merged_hex.is_file(): + # Drop the build's own wrong-offset UF2 so it isn't shipped alongside. + app_uf2 = west_out / "zephyr.uf2" + if app_uf2.is_file(): + app_uf2.unlink() + uf2conv = ( + paths["framework_path"] / "zephyr" / "scripts" / "build" / "uf2conv.py" + ) + if not run_command_ok( + [ + str(paths["python_executable"]), + str(uf2conv), + "-f", + _UF2_FAMILY_IDS[bootloader], + "-c", + "-o", + str(zephyr_dir / "zephyr.uf2"), + str(merged_hex), + ], + env=env, + stream_output=True, + ): + raise EsphomeError("Failed to generate UF2 from merged hex") + 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" + # no fallback is needed for adafruit case. merged merged.hex is always generated. + # get_download_types needs fallback for mcuboot (non adafruit) + hex_file = zephyr_dir / "merged.hex" dfu_package = build_dir / "firmware.zip" genpkg_cmd = [ str(paths["python_executable"]), diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index b98f94d37a..524dc55a13 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -26,7 +26,26 @@ from .const import ( CODEOWNERS = ["@tomaszduda23"] -PrjConfValueType = bool | str | int + +class HexValue: + """Wrap an integer so it is written as 0x... in prj.conf (required for hex Kconfig types).""" + + def __init__(self, value: int) -> None: + self.value = value + + def __eq__(self, other: object) -> bool: + if isinstance(other, HexValue): + return self.value == other.value + return NotImplemented + + def __repr__(self) -> str: + return f"HexValue(0x{self.value:X})" + + def __str__(self) -> str: + return f"0x{self.value:X}" + + +PrjConfValueType = bool | str | int | HexValue class Section: @@ -164,6 +183,8 @@ def zephyr_setup_preferences(): def _format_prj_conf_val(value: PrjConfValueType) -> str: if isinstance(value, bool): return "y" if value else "n" + if isinstance(value, HexValue): + return hex(value.value) if isinstance(value, int): return str(value) if isinstance(value, str): @@ -249,7 +270,7 @@ def copy_files() -> None: ) if image: - path = CORE.relative_build_path(f"sysbuild/{image}.conf") + path = CORE.relative_build_path(f"zephyr/sysbuild/{image}.conf") else: path = CORE.relative_build_path("zephyr/prj.conf") @@ -257,7 +278,7 @@ def copy_files() -> None: for image, content in zephyr_data()[KEY_OVERLAY].items(): if image: - path = CORE.relative_build_path(f"sysbuild/{image}.overlay") + path = CORE.relative_build_path(f"zephyr/sysbuild/{image}.overlay") else: path = CORE.relative_build_path("zephyr/app.overlay") changed |= write_file_if_changed(path, content) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 0ff1825bd1..1503c94274 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,6 +1,8 @@ import esphome.codegen as cg +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( + HexValue, zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, @@ -72,12 +74,6 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_mcumgr_bootloader(config: ConfigType) -> None: - bootloader = zephyr_data()[KEY_BOOTLOADER] - if bootloader != BOOTLOADER_MCUBOOT: - raise cv.Invalid(f"'{bootloader}' bootloader does not support OTA") - - KEY_ZEPHYR_BLE_SERVER = "zephyr_ble_server" @@ -89,9 +85,22 @@ def _validate_ble_server(config: ConfigType) -> None: raise cv.Invalid(f"'{KEY_ZEPHYR_BLE_SERVER}' component is required for BLE OTA") +def _validate_bootloader(config: ConfigType) -> None: + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader == BOOTLOADER_MCUBOOT: + return + if bootloader not in BOOTLOADER_CONFIG: + raise cv.Invalid(f"{bootloader} does not support OTA") + framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver < cv.Version(2, 9, 2): + raise cv.Invalid( + "OTA with Adafruit_nRF52_Bootloader requires at least SDK 2.9.2" + ) + + def _final_validate(config: ConfigType) -> None: - _validate_mcumgr_bootloader(config) _validate_ble_server(config) + _validate_bootloader(config) FINAL_VALIDATE_SCHEMA = _final_validate @@ -152,3 +161,70 @@ async def to_code(config: ConfigType) -> None: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver >= cv.Version(2, 9, 2): zephyr_data()[KEY_SYSBUILD] = True + + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader != BOOTLOADER_MCUBOOT: + sections = BOOTLOADER_CONFIG[bootloader] + # Derive partition addresses from the SoftDevice and bootloader sections so + # that the DTS flash map matches what the Partition Manager produces: + # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + mcuboot_size = 0x9000 + sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) + bl_start = next(s.address for s in sections if "Adafruit" in s.name) + slot0_start = sd_end + mcuboot_size + # Align slot size down to a 4 KB sector boundary + slot_size = ((bl_start - slot0_start) // 2 // 0x1000) * 0x1000 + slot1_start = slot0_start + slot_size + + def _mcuboot_partition_overlay() -> str: + def part(name, start, size): + return f""" + {name}: partition@{start:x} {{ + reg = <0x{start:x} 0x{size:x}>; + }};""" + + return f""" + /delete-node/ &boot_partition; + /delete-node/ &storage_partition; + /delete-node/ &code_partition; + /delete-node/ &reserved_partition_0; + + &flash0 {{ + partitions {{ + compatible = "fixed-partitions"; + #address-cells = <1>; + #size-cells = <1>; + {part("slot0_partition", slot0_start, slot_size)} + {part("slot1_partition", slot1_start, slot_size)} + }}; + }}; + """ + + def _code_partition_overlay() -> str: + return """ + / { + chosen { + zephyr,code-partition = &slot0_partition; + }; + }; + """ + + zephyr_add_overlay(_mcuboot_partition_overlay()) + zephyr_add_overlay(_mcuboot_partition_overlay(), "mcuboot") + zephyr_add_overlay(_code_partition_overlay()) + zephyr_add_overlay(_code_partition_overlay(), "mcuboot") + # mcuboot is second bootloader. It's only task is to swap partitions. + # recovery can be done by first bootloader. Keep it small. + zephyr_add_overlay( + """ + &zephyr_udc0 { + status = "disabled"; + }; + """, + "mcuboot", + ) + zephyr_add_prj_conf("USB_DEVICE_STACK", False, image="mcuboot") + zephyr_add_prj_conf("CONSOLE", False, image="mcuboot") + zephyr_add_prj_conf( + "PM_PARTITION_SIZE_MCUBOOT", HexValue(mcuboot_size), image="mcuboot" + ) diff --git a/tests/components/ota/test.nrf52-adafruit.yaml b/tests/components/ota/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..e8ac96f051 --- /dev/null +++ b/tests/components/ota/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr