"""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_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 NETWORK_KEY = 0xDFD34F0F05CAD978EC4E32B0413038FF PSKC = 0xC23A76E98F1A6483639B1AC1271E2E27 TLV = "0e080000000000010000000300001435060004001fffe0" @pytest.mark.parametrize( ("config", "field", "expected"), [ ({"network_key": hex(NETWORK_KEY)}, "network_key", NETWORK_KEY), ({"network_key": hex(NETWORK_KEY), "pskc": hex(PSKC)}, "pskc", PSKC), ({"tlv": TLV}, "tlv", TLV), ], ) def test_credentials_are_sensitive( config: dict[str, str], field: str, expected: int | str, set_core_config: SetCoreConfigCallable, ) -> None: """Thread credentials are tagged for redaction without changing their value or the hexadecimal integer type used to build sdkconfig options.""" set_core_config( PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} ) validated = CONFIG_SCHEMA(config) 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"}})