mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[openthread] Add core_options for OpenThread compile-time options (#20595)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0821c84b7a
commit
417ab4d2e9
@@ -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")
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user