Files
esphome/esphome/components/nrf52/__init__.py
T
tomaszduda23andpre-commit-ci-lite[bot] 26abc1325e [nrf52] disable USB if not configured by esphome to save power (#19493)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-23 14:48:39 +02:00

971 lines
33 KiB
Python

from __future__ import annotations
import asyncio
import logging
from pathlib import Path
import re
import shutil
import subprocess
from esphome import pins
import esphome.codegen as cg
from esphome.components.zephyr import (
add_extra_script,
copy_files as zephyr_copy_files,
zephyr_add_overlay,
zephyr_add_pm_static,
zephyr_add_prj_conf,
zephyr_data,
zephyr_set_core_data,
zephyr_setup_preferences,
zephyr_to_code,
)
from esphome.components.zephyr.const import (
BOOTLOADER_MCUBOOT,
CONF_CDC_ACM,
KEY_BOARD,
KEY_BOOTLOADER,
KEY_ZEPHYR,
CdcAcm,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_ADVANCED,
CONF_BOARD,
CONF_DISABLED,
CONF_ENABLE_OTA_ROLLBACK,
CONF_FRAMEWORK,
CONF_ID,
CONF_OTA,
CONF_RESET_PIN,
CONF_SAFE_MODE,
CONF_TOOLCHAIN,
CONF_VERSION,
CONF_VOLTAGE,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_NRF52,
ThreadModel,
Toolchain,
)
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.core.config import BOARD_MAX_LENGTH
import esphome.final_validate as fv
from esphome.framework_helpers import (
get_project_compile_flags,
get_project_link_flags,
run_command_ok,
)
from esphome.helpers import rmtree, write_file_if_changed
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
from .boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG
from .const import (
BOOTLOADER_ADAFRUIT,
BOOTLOADER_ADAFRUIT_NRF52_SD132,
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
)
from .framework import (
check_and_install,
get_build_env,
get_build_paths,
setup_platformio_python_env,
)
# force import gpio to register pin schema
from .gpio import nrf52_pin_to_code # noqa: F401
CODEOWNERS = ["@tomaszduda23"]
AUTO_LOAD = ["zephyr", "preferences"]
IS_TARGET_PLATFORM = True
_LOGGER = logging.getLogger(__name__)
# Default framework versions per toolchain. The sdk-nrf one also keys the CI
# sdk-nrf install cache and pins the clang-tidy project's SDK.
RECOMMENDED_PLATFORMIO_VERSION = "2.6.1-b"
RECOMMENDED_SDK_NRF_VERSION = "2.9.2"
FAKE_BOARD_MANIFEST = """
{
"frameworks": [
"zephyr"
],
"name": "esphome nrf52",
"upload": {
"maximum_ram_size": 248832,
"maximum_size": 815104,
"speed": 115200
},
"url": "https://esphome.io/",
"vendor": "esphome",
"build": {
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_fwid": "0x00B6"
}
}
}
"""
def set_core_data(config: ConfigType) -> ConfigType:
zephyr_set_core_data(config)
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR
if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]])
return config
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF)
_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF)
def set_framework(config: ConfigType) -> ConfigType:
if CONF_VERSION not in config[CONF_FRAMEWORK]:
default_version = (
RECOMMENDED_PLATFORMIO_VERSION
if CORE.using_toolchain_platformio
else RECOMMENDED_SDK_NRF_VERSION
)
config = {
**config,
CONF_FRAMEWORK: {**config[CONF_FRAMEWORK], CONF_VERSION: default_version},
}
framework_ver = cv.Version.parse(
cv.version_number(config[CONF_FRAMEWORK][CONF_VERSION])
)
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver
if not CORE.using_toolchain_platformio:
return config
if framework_ver < cv.Version(2, 9, 2):
return cv.require_framework_version(
nrf52_zephyr=cv.Version(2, 6, 1, "a"),
)(config)
if framework_ver < cv.Version(3, 2, 0):
return cv.require_framework_version(
nrf52_zephyr=cv.Version(2, 9, 2, "2"),
)(config)
return cv.require_framework_version(
nrf52_zephyr=cv.Version(3, 2, 0, "1"),
)(config)
BOOTLOADERS = [
BOOTLOADER_ADAFRUIT,
BOOTLOADER_ADAFRUIT_NRF52_SD132,
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
BOOTLOADER_MCUBOOT,
]
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
def _detect_bootloader(config: ConfigType) -> ConfigType:
"""Detect the bootloader for the given board."""
config = config.copy()
bootloaders: list[str] = []
board = config[CONF_BOARD]
if board in BOARDS_ZEPHYR and KEY_BOOTLOADER in BOARDS_ZEPHYR[board]:
# this board have bootloaders config available
bootloaders = BOARDS_ZEPHYR[board][KEY_BOOTLOADER]
if KEY_BOOTLOADER not in config:
if bootloaders:
# there is no bootloader in config -> take first one
config[KEY_BOOTLOADER] = bootloaders[0]
else:
# make mcuboot as default if there is no configuration for that board
config[KEY_BOOTLOADER] = BOOTLOADER_MCUBOOT
elif bootloaders and config[KEY_BOOTLOADER] not in bootloaders:
raise cv.Invalid(
f"{board} does not support {config[KEY_BOOTLOADER]}, select one of: {', '.join(bootloaders)}"
)
return config
nrf52_ns = cg.esphome_ns.namespace("nrf52")
DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component)
CONF_DFU = "dfu"
CONF_DCDC = "dcdc"
CONF_LIBC_NANO = "libc_nano"
CONF_REG0 = "reg0"
CONF_UICR_ERASE = "uicr_erase"
VOLTAGE_LEVELS = [1.8, 2.1, 2.4, 2.7, 3.0, 3.3]
_DFU_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(DeviceFirmwareUpdate),
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
def _dfu_schema(value: bool | ConfigType) -> ConfigType:
if isinstance(value, bool):
if not value:
raise cv.Invalid("Use 'dfu: true' or specify a configuration dict")
return _DFU_SCHEMA({})
return _DFU_SCHEMA(value)
CONFIG_SCHEMA = cv.All(
_detect_bootloader,
set_core_data,
cv.Schema(
{
cv.Required(CONF_BOARD): cv.All(
cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH)
),
cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True),
cv.Optional(CONF_DFU): _dfu_schema,
cv.Optional(CONF_DCDC): cv.boolean,
cv.Optional(CONF_REG0): cv.Schema(
{
cv.Required(CONF_VOLTAGE): cv.All(
cv.voltage,
cv.one_of(*VOLTAGE_LEVELS, float=True),
),
cv.Optional(CONF_UICR_ERASE, default=False): cv.boolean,
}
),
cv.Optional(
CONF_FRAMEWORK,
default={},
): cv.Schema(
{
cv.Optional(CONF_VERSION): cv.string_strict,
cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean,
cv.Optional(
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
): cv.Schema(
{
cv.Optional(
CONF_ENABLE_OTA_ROLLBACK, default=True
): cv.boolean,
}
),
}
),
cv.Optional(CONF_TOOLCHAIN): _validate_toolchain,
cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm),
}
),
_resolve_toolchain,
set_framework,
)
def _validate_mcumgr(config):
bootloader = zephyr_data()[KEY_BOOTLOADER]
if bootloader == BOOTLOADER_MCUBOOT:
raise cv.Invalid(f"'{bootloader}' bootloader does not support DFU")
def _final_validate(config):
# Remove before 2027.2.0
if CORE.using_toolchain_platformio:
_LOGGER.warning(
"The 'platformio' toolchain for nRF52 is deprecated and will be removed in ESPHome 2027.2.0. "
"Please use 'toolchain: sdk-nrf' instead."
)
if CONF_DFU in config:
_validate_mcumgr(config)
if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT:
_LOGGER.warning(
"Selected generic Adafruit bootloader. The board might crash. Consider settings `bootloader:`"
)
full_config = fv.full_config.get()
conf = config[CONF_FRAMEWORK]
advanced = conf[CONF_ADVANCED]
if conf[CONF_LIBC_NANO] and "logger" in CORE.loaded_integrations:
_LOGGER.warning(
"Logger is enabled with newlib-nano (libc_nano: true). Some format specifiers "
"such as %%zu are not supported and will print incorrectly. "
"Set 'libc_nano: false' under 'framework:' to use the full newlib."
)
if advanced[CONF_ENABLE_OTA_ROLLBACK]:
# "disabled: false" means safe mode *is* enabled.
safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True})
safe_mode_enabled = not safe_mode_config[CONF_DISABLED]
ota_enabled = CONF_OTA in full_config
# Both need to be enabled for rollback to work
if not (ota_enabled and safe_mode_enabled):
# But only warn if ota is even possible
if ota_enabled:
_LOGGER.warning(
"OTA rollback requires safe_mode, disabling rollback support"
)
# disable the rollback feature anyway since it can't be used.
advanced[CONF_ENABLE_OTA_ROLLBACK] = False
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config: ConfigType) -> None:
"""Convert the configuration to code."""
cg.add_build_flag("-DUSE_NRF52")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
cg.add_define("ESPHOME_VARIANT", "NRF52")
# nRF52 processors are single-core
cg.add_define(ThreadModel.SINGLE)
if CORE.using_toolchain_platformio:
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option(
CONF_FRAMEWORK, CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK]
)
cg.add_platformio_option(
"platform",
"https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-5.zip",
)
cg.add_platformio_option(
"platform_packages",
[
f"platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v{CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}.zip",
],
)
if config[KEY_BOOTLOADER] != BOOTLOADER_MCUBOOT:
# make sure that firmware.zip is created
# for Adafruit_nRF52_Bootloader
cg.add_platformio_option("board_upload.protocol", "nrfutil")
cg.add_platformio_option("board_upload.use_1200bps_touch", "true")
cg.add_platformio_option("board_upload.require_upload_port", "true")
cg.add_platformio_option("board_upload.wait_for_upload_port", "true")
add_extra_script(
"pre",
"pre_build.py",
Path(__file__).parent / "pre_build.py.script",
)
# build is done by west so bypass board checking in platformio
cg.add_platformio_option("boards_dir", CORE.relative_build_path("boards"))
if config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT:
cg.add_define("USE_BOOTLOADER_MCUBOOT")
elif "_sd" in config[KEY_BOOTLOADER]:
bootloader = config[KEY_BOOTLOADER].split("_")
sd_id = bootloader[2][2:]
cg.add_define("USE_SOFTDEVICE_ID", int(sd_id))
if (len(bootloader)) > 3:
sd_version = bootloader[3][1:]
cg.add_define("USE_SOFTDEVICE_VERSION", int(sd_version))
zephyr_setup_preferences()
zephyr_to_code(config)
if dfu_config := config.get(CONF_DFU):
CORE.add_job(_dfu_to_code, dfu_config)
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
if CONF_DCDC in config:
if framework_ver < cv.Version(2, 9, 2):
zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC])
else:
zephyr_add_overlay(
f"""
&reg1 {{
regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>;
}};
"""
)
if reg0_config := config.get(CONF_REG0):
value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE])
cg.add_define("USE_NRF52_REG0_VOUT", value)
if reg0_config[CONF_UICR_ERASE]:
cg.add_define("USE_NRF52_UICR_ERASE")
conf = config[CONF_FRAMEWORK]
advanced = conf[CONF_ADVANCED]
# Enable OTA rollback support
if advanced[CONF_ENABLE_OTA_ROLLBACK]:
cg.add_define("USE_OTA_ROLLBACK")
zephyr_add_prj_conf("NEWLIB_LIBC", True)
zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True)
zephyr_add_prj_conf("NEWLIB_LIBC_NANO", conf[CONF_LIBC_NANO])
# c++ support
if framework_ver < cv.Version(2, 9, 2):
zephyr_add_prj_conf("CPLUSPLUS", True)
zephyr_add_prj_conf("LIB_CPLUSPLUS", True)
else:
zephyr_add_prj_conf("CPP", True)
zephyr_add_prj_conf("REQUIRES_FULL_LIBCPP", True)
# watchdog
zephyr_add_prj_conf("WATCHDOG", True)
zephyr_add_prj_conf("WDT_DISABLE_AT_BOOT", False)
# disable console
zephyr_add_prj_conf("UART_CONSOLE", False)
zephyr_add_prj_conf("CONSOLE", False, False)
# use NFC pins as GPIO
if framework_ver < cv.Version(2, 9, 2):
zephyr_add_prj_conf("NFCT_PINS_AS_GPIOS", True)
else:
zephyr_add_overlay(
"""
&uicr {
nfct-pins-as-gpios;
};
"""
)
zephyr_add_prj_conf("REBOOT", True)
# some boards enable USB by default.
# disable it to prevent extra current consumption.
zephyr_add_prj_conf("USB_DEVICE_STACK", False, False)
@coroutine_with_priority(CoroPriority.DIAGNOSTICS)
async def _dfu_to_code(dfu_config):
cg.add_define("USE_NRF52_DFU")
var = cg.new_Pvariable(dfu_config[CONF_ID])
if CONF_RESET_PIN in dfu_config:
pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN])
cg.add(var.set_reset_pin(pin))
# DFU uses cdc rate callback to enter bootloader which was disabled explicitly to save power.
zephyr_add_prj_conf("USB_DEVICE_STACK", True)
zephyr_add_prj_conf("USB_CDC_ACM", True)
zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True)
await cg.register_component(var, dfu_config)
def copy_files() -> None:
"""Copy files to the build directory."""
# Library conversion to Zephyr modules is wired into the sdk-nrf
# CMakeLists only; the PlatformIO toolchain's forked platform package
# cannot compile external libraries at all, so the build would fail at
# link time anyway. Fail fast with a clear message instead.
if CORE.using_toolchain_platformio and CORE.platformio_libraries:
raise EsphomeError(
f"Libraries ({', '.join(sorted(CORE.platformio_libraries))}) are "
"not supported on the nRF52 'platformio' toolchain; use toolchain "
"'sdk-nrf' to build them as Zephyr modules."
)
if CORE.using_toolchain_platformio and (
zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT
or zephyr_data()[KEY_BOARD] == "xiao_ble"
):
write_file_if_changed(
CORE.relative_build_path(f"boards/{zephyr_data()[KEY_BOARD]}.json"),
FAKE_BOARD_MANIFEST,
)
zephyr_copy_files()
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
"""Get the download types for the firmware."""
# No recorded firmware path means nothing was built; no downloads.
if storage_json.firmware_bin_path is None:
return []
types = []
UF2_PATH = "zephyr/zephyr.uf2"
DFU_PATH = "firmware.zip"
HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1, only generated when OTA is disabled
HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2, always generated
APP_IMAGE_PATH = "zephyr/app_update.bin"
build_dir = Path(storage_json.firmware_bin_path).parent
if (build_dir / UF2_PATH).is_file():
types = [
{
"title": "UF2 package (recommended)",
"description": "For flashing via Adafruit nRF52 Bootloader as a flash drive.",
"file": UF2_PATH,
"download": f"{storage_json.name}.uf2",
},
{
"title": "DFU package",
"description": "For flashing via adafruit-nrfutil using USB CDC.",
"file": DFU_PATH,
"download": f"dfu-{storage_json.name}.zip",
},
]
else:
types = [
{
"title": "HEX package",
"description": "For flashing via pyocd using SWD.",
"file": (
HEX_MERGED_PATH
if (build_dir / HEX_MERGED_PATH).is_file()
else HEX_PATH
),
"download": f"{storage_json.name}.hex",
},
]
if (build_dir / APP_IMAGE_PATH).is_file():
types += [
{
"title": "App update package",
"description": "For flashing via mcumgr-web using BLE or smpclient using USB CDC.",
"file": APP_IMAGE_PATH,
"download": f"app-{storage_json.name}.img",
},
]
return types
def _upload_using_platformio(
config: ConfigType, port: str, upload_args: list[str]
) -> int | str:
from esphome.platformio import toolchain
setup_platformio_python_env()
if port is not None:
upload_args += ["--upload-port", port]
return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args)
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:
check_permissions(host)
if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT:
mcumgr_device = host
else:
if not CORE.using_toolchain_platformio:
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":
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
from .ota import smpmgr_scan, smpmgr_upload
if host == "BLE":
mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))
if is_mac_address(host):
mcumgr_device = host
if mcumgr_device:
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
return False # Not handled: let caller try default upload methods
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
address = devices[0]
from .ble_logger import is_mac_address, logger_connect, logger_scan
if devices[0] == "BLE":
ble_device = asyncio.run(logger_scan(CORE.name))
if ble_device:
address = ble_device.address
else:
return True
if is_mac_address(address):
asyncio.run(logger_connect(address))
return True
return False
def _addr2line(addr2line: str, elf: Path, addr: str) -> str:
try:
result = subprocess.run(
[addr2line, "-e", elf, addr],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip().splitlines()[0]
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.error("Running command failed: %s", err)
return ""
# The PC bound matches the gate in platform_hooks.STACKTRACE_GATES;
# the logger prints both registers with %08x, so a real PC is always
# 8 digits. tests/unit_tests/test_stacktrace.py guards against drift.
STACKTRACE_NRF52_PC_LR_RE = re.compile(r"PC=(0x[0-9a-fA-F]{3,})\s+LR=(0x[0-9a-fA-F]+)")
def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool:
if "Last crash:" in line:
return True
if backtrace_state:
match = STACKTRACE_NRF52_PC_LR_RE.search(line)
if match:
pc = match.group(1)
lr = match.group(2)
from esphome.analyze_memory.toolchain import find_tool
addr2line = find_tool("addr2line")
if addr2line is None:
return False
candidates = [
CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf"),
CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf"),
CORE.relative_pioenvs_path(CORE.name, "firmware.elf"),
]
elf = next((path for path in candidates if path.exists()), None)
if elf is None:
_LOGGER.warning(
"None of the expected ELF files exist:\n%s",
"\n".join(str(p) for p in candidates),
)
return False
_LOGGER.error("=== CRASH ===")
_LOGGER.error("PC: %s", _addr2line(addr2line, elf, pc))
_LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr))
return False
def _generate_cmake_lists() -> bool:
"""Write the project CMakeLists.txt, returning True if it changed."""
compile_flags = get_project_compile_flags()
link_flags = get_project_link_flags()
# Convert any PlatformIO libraries added via cg.add_library() into Zephyr
# modules and discover them through EXTRA_ZEPHYR_MODULES (a CMake list, set
# before find_package(Zephyr) so the modules are picked up). Only
# framework-agnostic libraries actually compile under Zephyr.
from esphome.components.zephyr.library import generate_zephyr_modules
module_dirs = generate_zephyr_modules(list(CORE.platformio_libraries.values()))
lines = [
"cmake_minimum_required(VERSION 3.20.0)",
"",
'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")',
"",
]
if module_dirs:
modules = ";".join(str(d).replace("\\", "/") for d in module_dirs)
lines += [f'set(EXTRA_ZEPHYR_MODULES "{modules}")', ""]
lines += [
"find_package(Zephyr REQUIRED)",
"",
f"project({CORE.name})",
"",
'file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../src/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/../src/*.c")',
"",
"target_sources(app PRIVATE ${APP_SOURCES})",
'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")',
]
if compile_flags:
lines += [
"",
"target_compile_options(app PRIVATE",
*[f' "{flag}"' for flag in compile_flags],
")",
]
if link_flags:
lines += [
"",
"zephyr_ld_options(",
*[f' "{flag}"' for flag in link_flags],
")",
]
return write_file_if_changed(
CORE.relative_build_path("zephyr", "CMakeLists.txt"),
"\n".join(lines) + "\n",
)
def _copy_if_exists(src: Path, dst: Path) -> None:
if src.is_file():
shutil.copy2(src, dst)
def run_compile(args, config: ConfigType) -> bool:
if CORE.using_toolchain_platformio:
# The actual build is done by PlatformIO (the caller falls through to
# it when this returns False); prepare the Python environment its
# Zephyr build script expects first.
setup_platformio_python_env()
return False
if not CORE.using_toolchain_sdk_nrf:
raise EsphomeError(
"Unsupported toolchain for nRF52. "
"Supported toolchains are 'platformio' and 'sdk-nrf'."
)
check_and_install()
paths = get_build_paths()
env = get_build_env()
cmake_lists_changed = _generate_cmake_lists()
board = zephyr_data()[KEY_BOARD]
build_dir = CORE.relative_pioenvs_path(CORE.name)
source_dir = CORE.relative_build_path("zephyr")
# A missing CMake cache (dropped by zephyr's copy_files() on config
# change) or a changed CMakeLists.txt requires a pristine build: Zephyr
# caches Kconfig/devicetree state that survives a plain cmake re-run.
# West can't do the wipe — its pristine modes only recognize a build dir
# by reading ZEPHYR_BASE from the very cache that was dropped.
if (
cmake_lists_changed or not (build_dir / "CMakeCache.txt").is_file()
) and build_dir.is_dir():
_LOGGER.info("Build inputs changed, cleaning %s", build_dir)
rmtree(build_dir)
west_cmd = [
str(paths["python_executable"]),
"-m",
"west",
"build",
"--pristine=auto",
"-b",
board,
"-d",
str(build_dir),
str(source_dir),
]
if not run_command_ok(
west_cmd,
env=env,
stream_output=True,
cwd=str(paths["framework_path"]),
):
raise EsphomeError("nRF52 native build failed")
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.
if framework_ver < cv.Version(2, 9, 2):
west_out = zephyr_dir
else:
west_out = zephyr_dir / "zephyr"
_copy_if_exists(west_out / "zephyr.uf2", zephyr_dir / "zephyr.uf2")
_copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin")
_copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex")
# 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,
):
# 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"]),
"-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