From 417ab4d2e940d54fa2d9b11eaa79946e196df1d9 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Tue, 13 Oct 2026 15:00:45 -0400 Subject: [PATCH] [openthread] Add core_options for OpenThread compile-time options (#20595) Co-authored-by: Claude Opus 5.5 --- esphome/components/openthread/__init__.py | 68 +++++++ esphome/components/openthread/const.py | 3 + .../openthread/config/custom_header.yaml | 19 ++ .../openthread/config/no_custom_header.yaml | 16 ++ tests/component_tests/openthread/test_init.py | 171 +++++++++++++++++- .../openthread/test.esp32-c6-idf.yaml | 2 + 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/openthread/config/custom_header.yaml create mode 100644 tests/component_tests/openthread/config/no_custom_header.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 6fca8bfdd9a..2c278f12f49 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,4 @@ +import re from typing import Any from esphome import automation @@ -44,9 +45,12 @@ from esphome.core import ( coroutine_with_priority, ) import esphome.final_validate as fv +from esphome.helpers import write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_build from .const import ( + CONF_CORE_OPTIONS, CONF_DEVICE_TYPE, CONF_EXT_PAN_ID, CONF_FORCE_DATASET, @@ -59,6 +63,7 @@ from .const import ( CONF_PSKC, CONF_SRP_ID, CONF_TLV, + OPENTHREAD_CONFIG_PREFIX, ) CODEOWNERS = ["@mrene"] @@ -84,6 +89,10 @@ CONF_DEVICE_TYPES = [ "MTD", ] +OT_HEADER_DIR = "openthread" +OT_HEADER_FILE = "esphome_ot_config.h" +OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") + def _validate_txpower(value: Any) -> int | float: if CORE.is_esp32: @@ -158,6 +167,31 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) +def _write_custom_header(config: ConfigType) -> None: + """Write core_options to the OpenThread custom header.""" + if not (options := config.get(CONF_CORE_OPTIONS)): + return + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_HEADER_CUSTOM", True) + # IDF resolves the path relative to the project (build) directory. + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH", OT_HEADER_DIR) + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME", OT_HEADER_FILE + ) + content = ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + + "".join( + f"#define {name} {value}\n" for name, value in sorted(options.items()) + ) + ) + # PlatformIO does not track this header as an OpenThread input; the esp-idf + # toolchain does. + header_path = CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE) + if write_file_if_changed(header_path, content) and not CORE.using_toolchain_esp_idf: + clean_build(clear_pio_cache=False) + + openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) @@ -220,6 +254,34 @@ def _validate_platform(config: ConfigType) -> ConfigType: )(config) +def _validate_core_option_names(options: dict[str, str]) -> dict[str, str]: + for name in options: + if ( + not name.startswith(OPENTHREAD_CONFIG_PREFIX) + or name == OPENTHREAD_CONFIG_PREFIX + ): + raise cv.Invalid( + f"'{name}' is not an OpenThread core option; names must be " + f"{OPENTHREAD_CONFIG_PREFIX} followed by the option name", + path=[name], + ) + if not OT_CORE_OPTION_NAME.fullmatch(name): + raise cv.Invalid(f"'{name}' may only contain A-Z, 0-9 and _", path=[name]) + return options + + +def _validate_core_option_value(value: Any) -> str: + value = cv.string_strict(value) + if not value.strip(): + raise cv.Invalid("OpenThread core option value must not be empty") + if "\n" in value or "\r" in value: + raise cv.Invalid("OpenThread core option value must be a single line") + # GCC treats a backslash followed by spaces as a line continuation too. + if value.rstrip().endswith("\\"): + raise cv.Invalid("OpenThread core option value must not end with \\") + return value + + def _validate_tlv_hex(value: Any) -> str: s = cv.string_strict(value) if len(s) % 2 != 0: @@ -252,6 +314,11 @@ CONFIG_SCHEMA = cv.All( _validate_txpower, ), cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CORE_OPTIONS): cv.All( + cv.only_on_esp32, + {cv.string_strict: _validate_core_option_value}, + _validate_core_option_names, + ), } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -325,6 +392,7 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) + _write_custom_header(config) elif CORE.using_zephyr: # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 include_west_project("mbedtls") diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index f0274a8c9e5..bd9c8cfa1c7 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -1,3 +1,4 @@ +CONF_CORE_OPTIONS = "core_options" CONF_DEVICE_TYPE = "device_type" CONF_EXT_PAN_ID = "ext_pan_id" CONF_FORCE_DATASET = "force_dataset" @@ -10,3 +11,5 @@ CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" + +OPENTHREAD_CONFIG_PREFIX = "OPENTHREAD_CONFIG_" diff --git a/tests/component_tests/openthread/config/custom_header.yaml b/tests/component_tests/openthread/config/custom_header.yaml new file mode 100644 index 00000000000..767497e9b2f --- /dev/null +++ b/tests/component_tests/openthread/config/custom_header.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + core_options: + OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE: "1" + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" diff --git a/tests/component_tests/openthread/config/no_custom_header.yaml b/tests/component_tests/openthread/config/no_custom_header.yaml new file mode 100644 index 00000000000..df0634e98d7 --- /dev/null +++ b/tests/component_tests/openthread/config/no_custom_header.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index 7ba9bdd4bba..c0e155ec7e3 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -1,11 +1,22 @@ -"""Tests for the openthread component schema.""" +"""Tests for the openthread component.""" + +from collections.abc import Callable +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest -from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6 -from esphome.components.openthread import CONFIG_SCHEMA -from esphome.const import PlatformFramework -from esphome.core import HexInt +from esphome.components.esp32 import ( + KEY_ESP32, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, + VARIANT_ESP32C6, +) +from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE +from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX +import esphome.config_validation as cv +from esphome.const import PlatformFramework, Toolchain +from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -37,3 +48,153 @@ def test_credentials_are_sensitive( assert isinstance(validated[field], Sensitive) assert validated[field] == expected assert isinstance(validated[field], HexInt) == isinstance(expected, int) + + +def _generate_in( + tmp_path: Path, yaml_text: str, generate_main: Callable[[str | Path], str] +) -> tuple[Path, MagicMock]: + """Generate from ``yaml_text`` written to tmp_path, so the build directory lands there. + + Returns the header path and the mocked ``clean_build``. + """ + config_path = tmp_path / "test.yaml" + config_path.write_text(yaml_text, encoding="utf-8") + with patch("esphome.components.openthread.clean_build") as clean_build: + generate_main(config_path) + return CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE), clean_build + + +def test_core_options_written_to_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, _ = _generate_in( + tmp_path, + component_config_path("custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_HEADER_CUSTOM"] is True + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH"] == OT_HEADER_DIR + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME"] == OT_HEADER_FILE + assert header.read_text(encoding="utf-8") == ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + "#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1\n" + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 5\n" + ) + + +def test_no_header_options_no_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, clean_build = _generate_in( + tmp_path, + component_config_path("no_custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_OPENTHREAD_HEADER_CUSTOM" not in sdkconfig + assert not header.exists() + clean_build.assert_not_called() + + +@pytest.mark.parametrize( + ("toolchain", "cleans"), + [(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)], +) +def test_header_change_cleans_only_on_platformio( + toolchain: Toolchain, + cleans: bool, + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = component_config_path("custom_header.yaml").read_text(encoding="utf-8") + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + assert clean_build.called is cleans + # Unchanged content: no clean on either toolchain. + CORE.reset() + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + clean_build.assert_not_called() + # A changed value leaves sdkconfig as it was, so only this clean rebuilds OpenThread. + changed = source.replace('RETRIES_DIRECT: "5"', 'RETRIES_DIRECT: "7"') + assert changed != source + CORE.reset() + CORE.toolchain = toolchain + header, clean_build = _generate_in(tmp_path, changed, generate_main) + assert clean_build.called is cleans + assert ( + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 7\n" + in header.read_text(encoding="utf-8") + ) + + +@pytest.mark.parametrize( + "name", + [ + "CONFIG_OPENTHREAD_JOINER", + "openthread_config_x", + "OT_CONFIG_X", + OPENTHREAD_CONFIG_PREFIX, + ], +) +def test_core_option_without_prefix_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=OPENTHREAD_CONFIG_PREFIX): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + "name", + ["OPENTHREAD_CONFIG_a", "OPENTHREAD_CONFIG_A B", "OPENTHREAD_CONFIG_A-B"], +) +def test_core_option_bad_characters_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match="may only contain A-Z, 0-9 and _"): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("", "must not be empty"), + (" ", "must not be empty"), + ("1\n#define X 2", "must be a single line"), + ("1\r2", "must be a single line"), + ("1 \\", "must not end with"), + ("1 \\ ", "must not end with"), + ], +) +def test_core_option_bad_value_rejected( + value: str, message: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=message): + CONFIG_SCHEMA( + { + "network_key": hex(NETWORK_KEY), + "core_options": {"OPENTHREAD_CONFIG_X": value}, + } + ) + + +def test_core_options_only_on_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="ESP32"): + CONFIG_SCHEMA({"tlv": TLV, "core_options": {"OPENTHREAD_CONFIG_X": "1"}}) diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 92d120e5d19..746b7151f30 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -9,3 +9,5 @@ openthread: pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 output_power: 1dBm + core_options: + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5"