diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33534f1f63a..d97352f65f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1003,7 +1003,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 # yamllint disable-line rule:line-length - options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM --grep USE_OPENTHREAD tidy_environment: esp32s3-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 49f55ab47f0..dcf40d2a07c 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BLUE_CHANNEL = "blue_channel" +CONF_BORDER_ROUTER = "border_router" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1568d37b114..d0df293fd4e 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ETHERNET) && defined(USE_ESP32) +#include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -731,14 +732,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() { addresses[0] = network::IPAddress(&ip.ip); } #if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } + network::fill_ip6_addresses(this->eth_netif_, addresses); #endif /* USE_NETWORK_IPV6 */ return addresses; diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index bc431599248..8c28603e78c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.const import CONF_BORDER_ROUTER from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv @@ -188,10 +189,16 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def _openthread_srp_registers_services() -> bool: + """Without border_router, the OpenThread SRP client registers the services instead of mDNS.""" + openthread_config = CORE.config.get(CONF_OPENTHREAD) + return openthread_config is not None and CONF_BORDER_ROUTER not in openthread_config + + def request_service_enable_disable() -> bool: """Request MDNSComponent::set_service_enabled() support. - ESP32 only, not with OpenThread. Returns True when the + ESP32 only, not with the OpenThread SRP client. Returns True when the USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. Public API for external components. Do not remove. @@ -201,7 +208,7 @@ def request_service_enable_disable() -> bool: mdns_config is None or mdns_config[CONF_DISABLED] or not CORE.is_esp32 - or CONF_OPENTHREAD in CORE.config + or _openthread_srp_registers_services() ): return False cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") @@ -288,13 +295,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) # With both wifi and ethernet, the _esphomelib "network" TXT value follows the - # connected interface. OpenThread hands its services to the SRP client once, + # connected interface. The OpenThread SRP client registers the services once, # so it keeps the fixed value. if ( CONF_API in CORE.config and CONF_WIFI in CORE.config and CONF_ETHERNET in CORE.config - and CONF_OPENTHREAD not in CORE.config + and not _openthread_srp_registers_services() ): from esphome.components import network diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 06d10d2d818..1ff92f07f34 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -126,8 +126,8 @@ class MDNSComponent final : public Component #ifndef USE_MDNS_STORE_SERVICES #error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" #endif -#ifdef USE_OPENTHREAD -#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#ifdef USE_OPENTHREAD_SRP +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with the OpenThread SRP client" #endif /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index d84a2551202..7953eea3091 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { ESPHOME_LOG_TAG(TAG, "mdns"); -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP static esp_err_t add_service(const MDNSService &service) { // Stack buffer for up to 16 txt records, heap fallback for more SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); @@ -30,7 +30,7 @@ static esp_err_t add_service(const MDNSService &service) { #endif static void register_esp32(MDNSComponent *comp, StaticVector &services) { -#ifdef USE_OPENTHREAD +#ifdef USE_OPENTHREAD_SRP // OpenThread handles service registration via SRP client // Services are compiled by MDNSComponent::compile_records_() and consumed by OpenThreadSrpComponent #else @@ -62,7 +62,7 @@ static void register_esp32(MDNSComponent *comp, StaticVectoris_ready()) { @@ -87,7 +87,7 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr ESP_LOGW(TAG, "Service %s not found", service_type); return false; } -#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD_SRP #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT bool MDNSComponent::publish_network_txt_(const char *network) { @@ -110,7 +110,7 @@ void MDNSComponent::setup() { } void MDNSComponent::on_shutdown() { -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 11485fdcf03..f0a4c59dca8 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -2,10 +2,40 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #ifdef USE_NETWORK namespace esphome::network { +#if defined(USE_ESP32) && USE_NETWORK_IPV6 +ESPHOME_LOG_TAG(TAG, "network"); + +void fill_ip6_addresses(esp_netif_t *netif, IPAddresses &addresses) { + esp_ip6_addr_t ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + size_t count = esp_netif_get_all_ip6(netif, ip6s); + // Slot 0 holds the IPv4 address + if (count > addresses.size() - 1) { + // Warn once per interface + static esp_netif_t *warned[3]{}; + for (auto &slot : warned) { + if (slot == netif) { + break; + } + if (slot == nullptr) { + slot = netif; + ESP_LOGW(TAG, "%s has %zu IPv6 addresses, only %zu are reported", esp_netif_get_desc(netif), count, + addresses.size() - 1); + break; + } + } + count = addresses.size() - 1; + } + for (size_t i = 0; i < count; i++) { + addresses[i + 1] = IPAddress(&ip6s[i]); + } +} +#endif + // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that uses a previous interface for NAT). diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 57c5a66833b..7b3cceaea21 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -18,9 +18,17 @@ #ifdef USE_OPENTHREAD #include "esphome/components/openthread/openthread.h" #endif +#ifdef USE_ESP32 +#include "esp_netif.h" +#endif namespace esphome::network { +#if defined(USE_ESP32) && USE_NETWORK_IPV6 +/// Copies the IPv6 addresses of an esp_netif into `addresses[1..]`, warning once when some do not fit. +void fill_ip6_addresses(esp_netif_t *netif, IPAddresses &addresses); +#endif + // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that uses a previous interface for NAT). @@ -44,7 +52,8 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { return true; #endif -#ifdef USE_OPENTHREAD +#if defined(USE_OPENTHREAD) && !defined(USE_OPENTHREAD_BORDER_ROUTER) + // A border router is reached over its Wi-Fi backbone, so the Thread link alone does not count if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) return true; #endif diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 2c278f12f49..181bcea1d55 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,22 +1,33 @@ import re from typing import Any -from esphome import automation +from esphome import automation, pins import esphome.codegen as cg +from esphome.components import wifi +from esphome.components.const import CONF_BORDER_ROUTER from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32H4, VARIANT_ESP32H21, + VARIANT_ESP32S2, + VARIANT_ESP32S3, VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, include_builtin_idf_component, only_on_variant, + request_software_coexistence, require_mbedtls_ecp, + require_mbedtls_tls, require_mbedtls_tls_extras, require_mbedtls_tls_server, + require_vfs_dir, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage @@ -26,13 +37,25 @@ from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( + CONF_AP, + CONF_BAUD_RATE, CONF_CHANNEL, CONF_ENABLE_IPV6, + CONF_ENABLE_ON_BOOT, CONF_FRAMEWORK, + CONF_HARDWARE_UART, CONF_ID, + CONF_INVERTED, CONF_LOG_LEVEL, + CONF_LOGGER, + CONF_NETWORKS, + CONF_NUMBER, CONF_OUTPUT_POWER, + CONF_RESET_PIN, + CONF_RX_PIN, + CONF_TX_PIN, CONF_USE_ADDRESS, + CONF_WIFI, KEY_CORE, KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, @@ -61,6 +84,7 @@ from .const import ( CONF_PAN_ID, CONF_POLL_PERIOD, CONF_PSKC, + CONF_RCP, CONF_SRP_ID, CONF_TLV, OPENTHREAD_CONFIG_PREFIX, @@ -71,10 +95,6 @@ DOMAIN = "openthread" AUTO_LOAD = ["network"] -# Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time -# TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet -CONFLICTS_WITH = ["wifi"] - IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", "ERROR": "CRIT", @@ -89,6 +109,43 @@ CONF_DEVICE_TYPES = [ "MTD", ] +# Variants with both Wi-Fi and an 802.15.4 radio +NATIVE_BORDER_ROUTER_VARIANTS = { + VARIANT_ESP32C5, + VARIANT_ESP32C6, +} +# Variants with Wi-Fi, for an external RCP +RCP_BORDER_ROUTER_VARIANTS = { + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, +} +# ESP-IDF options a border router needs on top of the plain OpenThread ones +BORDER_ROUTER_SDKCONFIG_OPTIONS: dict[str, bool | int] = { + "CONFIG_OPENTHREAD_PLATFORM_NETIF": True, + "CONFIG_OPENTHREAD_BORDER_ROUTER": True, + # The advertising proxy registers the Thread devices' services with mDNS; the SDK default of 10 fills up fast + "CONFIG_MDNS_MAX_SERVICES": 20, + "CONFIG_LWIP_IPV6_FORWARD": True, + "CONFIG_LWIP_IPV6_NUM_ADDRESSES": 12, + "CONFIG_LWIP_MULTICAST_PING": True, + "CONFIG_LWIP_NETIF_STATUS_CALLBACK": True, + "CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT": True, + "CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT": True, + "CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM": True, + "CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM": True, + "CONFIG_MDNS_MULTIPLE_INSTANCE": True, + "CONFIG_MBEDTLS_CMAC_C": True, + "CONFIG_MBEDTLS_SSL_PROTO_DTLS": True, + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE": True, + "CONFIG_MBEDTLS_ECJPAKE_C": True, +} OT_HEADER_DIR = "openthread" OT_HEADER_FILE = "esphome_ot_config.h" OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") @@ -108,10 +165,18 @@ def _validate_txpower(value: Any) -> int | float: return value # Unsupported, fail later with clear error +def _get_rcp(config: ConfigType) -> ConfigType | None: + return config.get(CONF_BORDER_ROUTER, {}).get(CONF_RCP) + + def set_sdkconfig_options(config: ConfigType) -> None: - # and expose options for using SPI/UART RCPs - add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", True) + border_router = CONF_BORDER_ROUTER in config + rcp = _get_rcp(config) is not None + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", not rcp) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_SPINEL_UART", rcp) + if not rcp: + add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) # There is a conflict if the logger's uart also uses the default UART, which is seen as a watchdog failure on "ot_cli" add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CLI", False) @@ -123,45 +188,55 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - # Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked; - # setting one under sdkconfig_options keeps TLS in the build automatically. - # The crypto platform uses AES-CCM and deterministic ECDSA directly. - require_mbedtls_tls_server() + # OpenThread's SRP and border-router code uses these mbedTLS crypto features directly. require_mbedtls_tls_extras( ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") ) + # Widens the TLS role only where TLS is compiled in: a commissioner or border agent enabled + # through sdkconfig_options needs the DTLS server side too + require_mbedtls_tls_server() + if border_router: + require_mbedtls_tls() if not config.get(CONF_TLV): - if pan_id := config.get(CONF_PAN_ID): + if (pan_id := config.get(CONF_PAN_ID)) is not None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) if channel := config.get(CONF_CHANNEL): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_CHANNEL", channel) - if network_key := config.get(CONF_NETWORK_KEY): + if (network_key := config.get(CONF_NETWORK_KEY)) is not None: add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:X}".lower() + "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:032x}" ) - if network_name := config.get(CONF_NETWORK_NAME): + if (network_name := config.get(CONF_NETWORK_NAME)) is not None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_NAME", network_name) if (ext_pan_id := config.get(CONF_EXT_PAN_ID)) is not None: add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:X}".lower() + "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:016x}" ) if (mesh_local_prefix := config.get(CONF_MESH_LOCAL_PREFIX)) is not None: add_idf_sdkconfig_option( "CONFIG_OPENTHREAD_MESH_LOCAL_PREFIX", f"{mesh_local_prefix}".lower() ) if (pskc := config.get(CONF_PSKC)) is not None: - add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() - ) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:032x}") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", not border_router) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", not border_router) + if not border_router: + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) + + if border_router: + # Border routing follows the Wi-Fi link + wifi.request_wifi_connect_state_listener() + for name, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): + add_idf_sdkconfig_option(name, value) + if not rcp: + # The native 802.15.4 radio shares the RF frontend with Wi-Fi + request_software_coexistence() # TODO: Add support for synchronized sleepy end devices (SSED) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) @@ -196,6 +271,30 @@ openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) + +def _validate_reset_pin(value: object) -> ConfigType: + value = {CONF_NUMBER: value} if not isinstance(value, dict) else dict(value) + value.setdefault(CONF_INVERTED, True) + return pins.internal_gpio_output_pin_schema(value) + + +# The pin registry rejects a pin used twice, so RX, TX and reset need no check of their own +_RCP_SCHEMA = cv.Schema( + { + cv.Required(CONF_RX_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_TX_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_BAUD_RATE, default=460800): cv.int_range(min=9600), + cv.Optional(CONF_RESET_PIN): _validate_reset_pin, + } +) + +_BORDER_ROUTER_SCHEMA = cv.Schema({cv.Optional(CONF_RCP): _RCP_SCHEMA}) + + +def _validate_border_router(value: object) -> ConfigType: + return _BORDER_ROUTER_SCHEMA({} if value is None else value) + + _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, @@ -227,21 +326,44 @@ def _validate(config: ConfigType) -> ConfigType: raise cv.Invalid( f"{CONF_POLL_PERIOD} can only be used with {CONF_DEVICE_TYPE}: MTD" ) + if CONF_BORDER_ROUTER in config and device_type != "FTD": + raise cv.Invalid("OpenThread Border Router requires 'device_type: FTD'.") return config -def _require_vfs_select(config: ConfigType) -> ConfigType: - """Register VFS select requirement during config validation.""" +def _require_vfs(config: ConfigType) -> ConfigType: + """Register VFS requirements during config validation.""" # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) if CORE.is_esp32: require_vfs_select() + if _get_rcp(config) is not None: + require_vfs_dir() return config def _validate_platform(config: ConfigType) -> ConfigType: if CORE.using_zephyr: return config + # Older 5.5 releases keep the first lock mutex when a timed wait for the second one runs out + cv.require_framework_version( + esp_idf=cv.Version(5, 5, 5), + extra_message="OpenThread requires ESP-IDF 5.5.5 or newer; older 5.5 releases can deadlock the OpenThread lock", + )(config) + if CONF_BORDER_ROUTER in config: + variant = get_esp32_variant() + if _get_rcp(config) is not None: + if variant not in RCP_BORDER_ROUTER_VARIANTS: + raise cv.Invalid( + "OpenThread Border Router RCP mode requires a Wi-Fi-capable ESP32 variant." + ) + elif variant not in NATIVE_BORDER_ROUTER_VARIANTS: + raise cv.Invalid( + "OpenThread Border Router with a native radio requires an ESP32 variant " + "with both Wi-Fi and IEEE 802.15.4 support. " + "Configure 'rcp:' to use an external OpenThread Radio Co-Processor." + ) + return config return only_on_variant( supported=[ VARIANT_ESP32C5, @@ -288,8 +410,8 @@ def _validate_tlv_hex(value: Any) -> str: raise cv.Invalid("TLV must have an even number of hex characters") try: raw = bytes.fromhex(s) - except ValueError as e: - raise cv.Invalid(f"TLV must be valid hex: {e}") from e + except ValueError as err: + raise cv.Invalid(f"TLV must be valid hex: {err}") from err if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") return s @@ -301,6 +423,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(OpenThreadComponent), cv.GenerateID(CONF_SRP_ID): cv.declare_id(OpenThreadSrpComponent), cv.GenerateID(CONF_MDNS_ID): cv.use_id(MDNSComponent), + cv.Optional(CONF_BORDER_ROUTER): _validate_border_router, cv.Optional(CONF_DEVICE_TYPE, default="FTD"): cv.one_of( *CONF_DEVICE_TYPES, upper=True ), @@ -324,11 +447,11 @@ CONFIG_SCHEMA = cv.All( cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), _validate_platform, _validate, - _require_vfs_select, + _require_vfs, ) -def _final_validate(_: ConfigType) -> None: +def _final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() network_config = full_config.get(NETWORK_DOMAIN, {}) if not network_config.get(CONF_ENABLE_IPV6, False): @@ -337,6 +460,43 @@ def _final_validate(_: ConfigType) -> None: "Please set `enable_ipv6: true` in the `network` configuration." ) + wifi_config = full_config.get(CONF_WIFI) + if CONF_BORDER_ROUTER not in config: + if wifi_config is not None: + raise cv.Invalid( + "OpenThread can only be used with Wi-Fi when 'border_router:' is configured." + ) + else: + if wifi_config is None: + raise cv.Invalid("OpenThread Border Router requires a Wi-Fi STA backbone.") + if CONF_AP in wifi_config: + raise cv.Invalid( + "OpenThread Border Router does not support Wi-Fi AP or fallback AP mode." + ) + if not wifi_config.get(CONF_NETWORKS): + raise cv.Invalid( + "OpenThread Border Router requires at least one configured Wi-Fi STA network." + ) + if not wifi_config[CONF_ENABLE_ON_BOOT]: + raise cv.Invalid( + "OpenThread Border Router requires Wi-Fi 'enable_on_boot: true'." + ) + if _get_rcp(config) is not None: + if full_config.get("uart") is not None: + raise cv.Invalid( + "OpenThread RCP uses ESP-IDF's dedicated Spinel UART driver and " + "cannot currently be combined with the ESPHome UART component." + ) + if ( + (logger_config := full_config.get(CONF_LOGGER)) is not None + and logger_config.get(CONF_HARDWARE_UART) == "UART1" + and logger_config.get(CONF_BAUD_RATE, 0) > 0 + ): + raise cv.Invalid( + "OpenThread RCP reserves UART1; configure the logger to use USB " + "or another UART." + ) + if ( (esp32_config := full_config.get(PLATFORM_ESP32)) is not None and (fw_config := esp32_config.get(CONF_FRAMEWORK)) is not None @@ -346,6 +506,8 @@ def _final_validate(_: ConfigType) -> None: ot_log_level = IDF_TO_OT_LOG_LEVEL.get(log_level, log_level) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_LOG_LEVEL_{ot_log_level}", True) + return config + FINAL_VALIDATE_SCHEMA = _final_validate @@ -361,6 +523,9 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config: ConfigType) -> None: + border_router = CONF_BORDER_ROUTER in config + rcp = _get_rcp(config) + # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") @@ -368,24 +533,47 @@ async def to_code(config: ConfigType) -> None: require_mbedtls_ecp() cg.add_define("USE_OPENTHREAD") + if border_router: + cg.add_define("USE_OPENTHREAD_BORDER_ROUTER") + else: + # The SRP client registers the mDNS services with the Thread network + cg.add_define("USE_OPENTHREAD_SRP") + if rcp is not None: + cg.add_define("USE_OPENTHREAD_RCP_UART") if config.get(CONF_FORCE_DATASET): cg.add_define("USE_OPENTHREAD_FORCE_DATASET") if tlv := config.get(CONF_TLV): cg.add_define("USE_OPENTHREAD_TLVS", tlv) - # OpenThread SRP needs access to mDNS services after setup - enable_mdns_storage() + if not border_router: + # OpenThread SRP needs access to mDNS services after setup + enable_mdns_storage() - ot = cg.new_Pvariable(config[CONF_ID]) + if rcp is None: + ot = cg.new_Pvariable(config[CONF_ID]) + else: + reset_pin = ( + await cg.gpio_pin_expression(rcp[CONF_RESET_PIN]) + if CONF_RESET_PIN in rcp + else cg.nullptr + ) + ot = cg.new_Pvariable( + config[CONF_ID], + rcp[CONF_BAUD_RATE], + rcp[CONF_RX_PIN], + rcp[CONF_TX_PIN], + reset_pin, + ) add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) - srp = cg.new_Pvariable(config[CONF_SRP_ID]) - mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) - cg.add(srp.set_mdns(mdns_component)) - await cg.register_component(srp, config) + if not border_router: + mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) + srp = cg.new_Pvariable(config[CONF_SRP_ID]) + cg.add(srp.set_mdns(mdns_component)) + await cg.register_component(srp, config) if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index bd9c8cfa1c7..b5481e7b7aa 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -9,6 +9,7 @@ CONF_NETWORK_KEY = "network_key" CONF_PAN_ID = "pan_id" CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" +CONF_RCP = "rcp" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index c4ab27689e7..92599603bef 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -16,6 +16,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esp_err.h" +#include "esp_openthread_border_router.h" +#endif + ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { @@ -25,6 +30,14 @@ OpenThreadComponent *global_openthread_component = // NOLINT(cppcoreguidelines- OpenThreadComponent::OpenThreadComponent() { global_openthread_component = this; } +#ifdef USE_OPENTHREAD_RCP_UART +OpenThreadComponent::OpenThreadComponent(uint32_t rcp_baud_rate, uint8_t rcp_rx_pin, uint8_t rcp_tx_pin, + GPIOPin *rcp_reset_pin) + : rcp_baud_rate_(rcp_baud_rate), rcp_rx_pin_(rcp_rx_pin), rcp_tx_pin_(rcp_tx_pin), rcp_reset_pin_(rcp_reset_pin) { + global_openthread_component = this; +} +#endif + void OpenThreadComponent::dump_config() { ESP_LOGCONFIG(TAG, "Open Thread:"); #if CONFIG_OPENTHREAD_FTD @@ -43,6 +56,24 @@ void OpenThreadComponent::dump_config() { if (this->output_power_.has_value()) { ESP_LOGCONFIG(TAG, " Output power: %" PRId8 "dBm", *this->output_power_); } +#ifdef USE_OPENTHREAD_RCP_UART + ESP_LOGCONFIG(TAG, + " Radio: External RCP\n" + " Transport: UART1 at %" PRIu32 " baud\n" + " RX Pin: GPIO%u\n" + " TX Pin: GPIO%u", + this->rcp_baud_rate_, this->rcp_rx_pin_, this->rcp_tx_pin_); + if (this->rcp_reset_pin_ != nullptr) { + LOG_PIN(" RCP Reset Pin: ", this->rcp_reset_pin_); + } +#else + ESP_LOGCONFIG(TAG, " Radio: Native 802.15.4"); +#endif +#ifdef USE_OPENTHREAD_BORDER_ROUTER + ESP_LOGCONFIG(TAG, " Border Router: ENABLED\n" + " Backbone: Wi-Fi STA\n" + " Experimental: YES"); +#endif } void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) { @@ -87,6 +118,7 @@ std::optional OpenThreadComponent::get_omr_address_(InstanceLock & return {}; } +#ifdef USE_OPENTHREAD_SRP void OpenThreadComponent::defer_factory_reset_external_callback() { ESP_LOGD(TAG, "Defer factory_reset_external_callback_"); this->defer([this]() { this->factory_reset_external_callback_(); }); @@ -125,7 +157,13 @@ void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrp } void OpenThreadSrpComponent::setup() { - if (global_openthread_component->is_failed()) { + // A start-up failure on the OpenThread task reaches the component as a deferred mark_failed(); + // the task itself may already be gone, and acquire() would abort instead of waiting + if (global_openthread_component->is_failed() +#ifdef USE_ESP32 + || global_openthread_component->is_task_exited() +#endif + ) { this->mark_failed(); return; } @@ -234,10 +272,11 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } +#endif + bool OpenThreadComponent::teardown() { - // Nothing was started - if (this->is_failed()) - return true; + // A setup() failure marks the stage complete itself; a failure with the task running, such as a + // border router that did not come up or an RCP that stays silent, still needs the stop below switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { { @@ -248,31 +287,48 @@ bool OpenThreadComponent::teardown() { ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown"); return false; } - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS; - ESP_LOGV(TAG, "Clear SRP"); + // The OpenThread task may have completed on its own in the meantime; never move back from there + TeardownStage expected = TeardownStage::TEARDOWN_STAGE_NOT_STARTED; + if (!this->teardown_stage_.compare_exchange_strong(expected, TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS)) { + break; + } otInstance *instance = lock.get_instance(); + // Stop mirroring address changes onto the netif. Every multicast group and address the + // stack drops from here on would otherwise become an MLD message that is sent, through + // the event loop, after the clean-up has deleted the glue's packet queue, which aborts. + otIp6SetAddressCallback(instance, nullptr, nullptr); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + if (this->border_router_started_) { + if (const esp_err_t err = esp_openthread_border_router_deinit(); err != ESP_OK) { + ESP_LOGW(TAG, "Failed to deinitialize OpenThread Border Router: %s", esp_err_to_name(err)); + } + this->border_router_started_ = false; + } +#endif +#ifdef USE_OPENTHREAD_SRP + ESP_LOGV(TAG, "Clear SRP"); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); +#endif if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) { ESP_LOGW(TAG, "Failed to disable Thread during teardown"); } if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) { ESP_LOGW(TAG, "Failed to disable IPv6 during teardown"); } - // Release the lock before stopping -- openthread_stop_() (esp_openthread_stop() on - // ESP32) acquires it internally, and the lock is not recursive. } // Stop OpenThread global_openthread_component = nullptr; ESP_LOGV(TAG, "Stop OpenThread"); - int error = this->openthread_stop_(); - if (error != 0) { + if (int error = this->openthread_stop_(); error != 0) { + // Nothing will mark the stage complete for us; do not wait out the teardown timeout ESP_LOGW(TAG, "Failed attempt to stop OpenThread %d", error); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } } break; case TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS: - // Unreachable today; teardown is synchronous on both platforms. Kept for a future - // graceful-exit path, or a platform whose teardown() cannot be made synchronous. + // On ESP32 the OpenThread task is leaving its main loop and cleaning up; it marks the + // stage complete when done. Zephyr completes synchronously in openthread_stop_(). break; case TeardownStage::TEARDOWN_STAGE_COMPLETED: ESP_LOGV(TAG, "OpenThreadComponent Teardown Complete"); @@ -281,13 +337,17 @@ bool OpenThreadComponent::teardown() { return this->teardown_stage_ == TeardownStage::TEARDOWN_STAGE_COMPLETED; } -void OpenThreadComponent::on_factory_reset(std::function callback) { +void OpenThreadComponent::on_factory_reset(const std::function &callback) { +#ifndef USE_OPENTHREAD_SRP + // Nothing registered with an SRP server to remove first + callback(); +#else if (this->is_failed()) { // No SRP registration to remove; reset right away callback(); return; } - this->factory_reset_external_callback_ = std::move(callback); + this->factory_reset_external_callback_ = callback; ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; InstanceLock lock = InstanceLock::acquire(); @@ -299,6 +359,7 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { return; } ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); +#endif } void OpenThreadComponent::apply_poll_period(uint32_t poll_period) { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 8599304c89a..4041e21894e 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -4,7 +4,11 @@ #include "esphome/components/mdns/mdns_component.h" #include "esphome/components/network/ip_address.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esphome/components/wifi/wifi_component.h" +#endif #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include #include @@ -25,22 +29,47 @@ enum class TeardownStage : uint8_t { TEARDOWN_STAGE_COMPLETED, }; +#ifdef USE_OPENTHREAD_BORDER_ROUTER +class OpenThreadComponent final : public Component, public wifi::WiFiConnectStateListener { +#else class OpenThreadComponent final : public Component { +#endif public: OpenThreadComponent(); +#ifdef USE_OPENTHREAD_RCP_UART + OpenThreadComponent(uint32_t rcp_baud_rate, uint8_t rcp_rx_pin, uint8_t rcp_tx_pin, GPIOPin *rcp_reset_pin); +#endif ~OpenThreadComponent(); void dump_config() override; void setup() override; +#ifdef USE_OPENTHREAD_BORDER_ROUTER + void loop() override; + /// Border routing follows the Wi-Fi backbone; loop() does the work and sleeps in between + void on_wifi_connect_state(StringRef ssid, std::span bssid) override { this->enable_loop(); } +#endif bool teardown() override; - float get_setup_priority() const override { return setup_priority::WIFI; } + float get_setup_priority() const override { +#ifdef USE_OPENTHREAD_BORDER_ROUTER + // After every network interface, whichever order network.priority gives them: the backbone netif has to exist + return setup_priority::BEFORE_CONNECTION; +#else + return setup_priority::WIFI; +#endif + } bool is_connected() const { return this->connected_; } - /// Returns true once esp_openthread_start() has completed and the OT lock is usable. + /// Returns true once the OpenThread stack is initialized and the OT lock is usable. bool is_lock_initialized() const { return this->lock_initialized_; } +#ifdef USE_ESP32 + /// True once the OpenThread task has torn the stack down; its lock never comes back + bool is_task_exited() const { return this->task_exited_; } +#endif network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); - void on_factory_reset(std::function callback); + void on_factory_reset(const std::function &callback); +#ifdef USE_OPENTHREAD_SRP void defer_factory_reset_external_callback(); +#endif /// Returns nullptr when no explicit use_address is configured and the address is /// derived at runtime from the device name (see network::get_use_address_to()). @@ -55,25 +84,54 @@ class OpenThreadComponent final : public Component { void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); +#ifdef USE_OPENTHREAD_RCP_UART + static void rcp_failure_handler(); +#endif protected: /** Apply Link Mode settings (incl poll period). * Callers running outside the OpenThread task must hold InstanceLock. */ void apply_linkmode_(otInstance *instance); +#ifdef USE_ESP32 + void ot_main_(); + void mark_task_failed_(const LogString *reason); +#endif +#ifdef USE_OPENTHREAD_RCP_UART + void reset_rcp_(bool recovery_attempt); +#endif std::optional get_omr_address_(InstanceLock &lock); otInstance *get_openthread_instance_(); int openthread_stop_(); +#ifdef USE_OPENTHREAD_SRP std::function factory_reset_external_callback_; +#endif #if CONFIG_OPENTHREAD_MTD uint32_t poll_period_{0}; #endif std::optional output_power_{}; std::atomic lock_initialized_{false}; - // Only ever written from teardown(), on the main task -- no atomic needed. - TeardownStage teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; + std::atomic teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; std::atomic connected_{false}; +#ifdef USE_ESP32 + std::atomic task_exited_{false}; +#endif +#ifdef USE_OPENTHREAD_RCP_UART + uint32_t rcp_baud_rate_{0}; + uint8_t rcp_rx_pin_{0}; + uint8_t rcp_tx_pin_{0}; + GPIOPin *rcp_reset_pin_{nullptr}; + // Both only touched on the OpenThread task, by the failure handler + uint8_t rcp_reset_attempts_{0}; + uint32_t last_rcp_reset_ms_{0}; + static constexpr uint32_t RCP_RESET_FORGET_MS = 10 * 60 * 1000; +#endif +#ifdef USE_OPENTHREAD_BORDER_ROUTER + std::atomic mainloop_running_{false}; + bool border_router_started_{false}; + bool backbone_up_{false}; // what the stack was told about the infra interface last +#endif private: // Stores a pointer to a string literal (static storage duration). @@ -83,6 +141,7 @@ class OpenThreadComponent final : public Component { extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_OPENTHREAD_SRP class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } @@ -101,6 +160,7 @@ class OpenThreadSrpComponent final : public Component { std::vector> memory_pool_; void *pool_alloc_(size_t size); }; +#endif // RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the // guard may or may not own the lock (try_acquire can fail), so check it with diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index de1295fa26a..e1194272973 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -1,12 +1,29 @@ #include "esphome/core/defines.h" #if defined(USE_OPENTHREAD) && defined(USE_ESP32) +#include +#include +#include +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include +#endif #include "openthread.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esp_openthread_border_router.h" +#include "esphome/components/wifi/wifi_component.h" +#ifndef USE_OPENTHREAD_RCP_UART +#include "esp_coexist.h" +#endif +#endif +#ifdef USE_OPENTHREAD_RCP_UART +#include "esp_openthread_spinel.h" +#endif #include "esp_log.h" #include "esp_openthread.h" #include "esp_openthread_lock.h" - +#include "esp_openthread_types.h" #include "esp_task_wdt.h" +#include "esphome/components/network/util.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -16,77 +33,170 @@ #include "esp_netif.h" #include "esp_openthread_netif_glue.h" #include "esp_vfs_eventfd.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { void OpenThreadComponent::setup() { - // Used eventfds: - // * netif - // * ot task queue - // * radio driver +#ifdef USE_OPENTHREAD_BORDER_ROUTER + esp_netif_t *backbone_netif = wifi::global_wifi_component->get_esp_netif_sta(); + if (backbone_netif == nullptr) { + ESP_LOGE(TAG, "Wi-Fi STA backbone netif is not initialized"); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + return; + } + esp_openthread_set_backbone_netif(backbone_netif); + wifi::global_wifi_component->add_connect_state_listener(this); +#endif + + // Used eventfds: netif, ot task queue, radio driver, and one more for a border router on the + // native radio (the UART Spinel transport has none) +#if defined(USE_OPENTHREAD_BORDER_ROUTER) && !defined(USE_OPENTHREAD_RCP_UART) + constexpr size_t max_eventfds = 4; +#else + constexpr size_t max_eventfds = 3; +#endif esp_vfs_eventfd_config_t eventfd_config = { - .max_fds = 3, + .max_fds = max_eventfds, }; // Network interface setup handled by network component. - // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_start() + // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_init() // aborts without it, so fail this component instead. if (global_preferences->nvs_handle == 0) { ESP_LOGE(TAG, "NVS unavailable"); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; this->mark_failed(); return; } ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); - esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(), - .platform_config = { - .radio_config = - { - .radio_mode = RADIO_MODE_NATIVE, - .radio_uart_config = {}, - }, - .host_config = - { - // There is a conflict between esphome's logger which also - // claims the usb serial jtag device. - // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, - // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), - }, - .port_config = - { - .storage_partition_name = "nvs", - .netif_queue_size = 10, - .task_queue_size = 10, - }, - }}; + // Own task instead of esp_openthread_start(): the dataset activation has to run on the task + // that holds the OpenThread task-switching lock, and a border router must not become the + // default netif + if (xTaskCreate( + [](void *arg) { + static_cast(arg)->ot_main_(); + vTaskDelete(nullptr); + }, + CONFIG_OPENTHREAD_TASK_NAME, CONFIG_OPENTHREAD_TASK_SIZE, this, CONFIG_OPENTHREAD_TASK_PRIORITY, + nullptr) != pdPASS) { + ESP_LOGE(TAG, "Failed to create OpenThread task"); + esp_vfs_eventfd_unregister(); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + } +} + +void OpenThreadComponent::ot_main_() { + esp_openthread_platform_config_t config = { + .radio_config = + { +#ifdef USE_OPENTHREAD_RCP_UART + .radio_mode = RADIO_MODE_UART_RCP, + .radio_uart_config = + { + .port = UART_NUM_1, + .uart_config = + { + .baud_rate = static_cast(this->rcp_baud_rate_), + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .rx_flow_ctrl_thresh = 0, + .source_clk = UART_SCLK_DEFAULT, + }, + .rx_pin = static_cast(this->rcp_rx_pin_), + .tx_pin = static_cast(this->rcp_tx_pin_), + }, +#else + .radio_mode = RADIO_MODE_NATIVE, + .radio_uart_config = {}, +#endif + }, + .host_config = + { + // There is a conflict between esphome's logger which also + // claims the usb serial jtag device. + // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, + // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), + }, + .port_config = + { + .storage_partition_name = "nvs", + .netif_queue_size = 10, + .task_queue_size = 10, + }, + }; + +#ifdef USE_OPENTHREAD_RCP_UART + // Without a reset pin there is nothing to do on a failure beyond the Spinel driver's own recovery + if (this->rcp_reset_pin_ != nullptr) { + this->rcp_reset_pin_->setup(); + esp_openthread_register_rcp_failure_handler(OpenThreadComponent::rcp_failure_handler); + esp_openthread_set_coprocessor_reset_failure_callback(OpenThreadComponent::rcp_failure_handler); + this->reset_rcp_(false); + } +#endif + + ESP_ERROR_CHECK(esp_openthread_init(&config)); + + // Initialize the esp_netif bindings + esp_netif_config_t netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(); + esp_netif_t *netif = esp_netif_new(&netif_config); + assert(netif != nullptr); + ESP_ERROR_CHECK(esp_netif_attach(netif, esp_openthread_netif_glue_init(&config))); +#ifndef USE_OPENTHREAD_BORDER_ROUTER + esp_netif_set_default_netif(netif); +#endif + + ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - ESP_ERROR_CHECK(esp_openthread_start(&config)); - // Mark lock as initialized so InstanceLock callers know it's safe to acquire. - // Must be set after esp_openthread_start() which creates the internal semaphore. - this->lock_initialized_ = true; - // Fetch OT instance once to avoid repeated call into OT stack otInstance *instance = esp_openthread_get_instance(); - { - InstanceLock lock = InstanceLock::acquire(); + bool launch_mainloop = true; +#if defined(USE_OPENTHREAD_BORDER_ROUTER) && !defined(USE_OPENTHREAD_RCP_UART) + // The native 802.15.4 radio shares the RF frontend with Wi-Fi. Coexistence has to be on before + // the Thread network starts below, or Wi-Fi scanning and association can fail outright. + if (const esp_err_t err = esp_coex_wifi_i154_enable(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable Wi-Fi/802.15.4 coexistence: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Coexistence could not be enabled")); + launch_mainloop = false; + } +#endif + { + // One lock for the whole bring-up, so the main loop (actions, teardown, the SRP component) + // cannot call into the stack half way through. The lock is recursive: hold it before + // telling the other tasks that it exists, then let the guard take over. + esp_openthread_lock_acquire(portMAX_DELAY); + this->lock_initialized_ = true; + InstanceLock lock = InstanceLock::acquire(); + esp_openthread_lock_release(); + +#ifndef USE_OPENTHREAD_BORDER_ROUTER this->apply_linkmode_(instance); +#endif if (this->output_power_.has_value()) { if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { ESP_LOGE(TAG, "Failed to set power: %s", otThreadErrorToString(err)); } } + ESP_LOGI(TAG, "Activating dataset..."); otOperationalDatasetTlvs dataset = {}; - + bool stored_dataset = false; #ifndef USE_OPENTHREAD_FORCE_DATASET // Check if openthread has a valid dataset from a previous execution - otError error = otDatasetGetActiveTlvs(instance, &dataset); - if (error != OT_ERROR_NONE) { + if (otDatasetGetActiveTlvs(instance, &dataset) != OT_ERROR_NONE) { // Make sure the length is 0 so we fallback to the configuration dataset.mLength = 0; } else { + stored_dataset = true; ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); } #endif @@ -94,54 +204,219 @@ void OpenThreadComponent::setup() { #ifdef USE_OPENTHREAD_TLVS if (dataset.mLength == 0) { // If we didn't have an active dataset, and we have tlvs, parse it and pass it to esp_openthread_auto_start - size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; - if (len > sizeof(dataset.mTlvs)) { - ESP_LOGW(TAG, "TLV buffer too small, truncating"); - len = sizeof(dataset.mTlvs); - } + constexpr size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; + static_assert(len <= sizeof(dataset.mTlvs), "OpenThread dataset TLV is too long"); parse_hex(USE_OPENTHREAD_TLVS, sizeof(USE_OPENTHREAD_TLVS) - 1, dataset.mTlvs, len); dataset.mLength = len; } #endif - // Pass the existing dataset, or NULL which will use the preprocessor definitions - ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); + if (dataset.mLength > 0 && !otDatasetIsValid(&dataset, true)) { + if (stored_dataset) { + ESP_LOGE(TAG, "Stored OpenThread Active Operational Dataset is invalid; set force_dataset: true to replace it"); + } else { + ESP_LOGE(TAG, "Configured OpenThread Active Operational Dataset TLV is invalid"); + } + this->mark_task_failed_(LOG_STR("Invalid dataset")); + launch_mainloop = false; + } else if (launch_mainloop) { + // esp_openthread_auto_start() enables IPv6, which (for Border Router builds) opens a UDP socket + // via otPlatUdpSocket(). That path releases and re-acquires the OpenThread task-switching lock, + // which asserts unless this task holds the OpenThread lock, as it does here. + if (const esp_err_t err = esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to start OpenThread: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Start failed")); + launch_mainloop = false; + } + } - // Register state change callback to update connected_ reactively instead of polling - otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); - if (ot_err != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + if (launch_mainloop) { + // Register state change callback to update connected_ reactively instead of polling + otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); + if (ot_err != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to register state change callback: %d", ot_err); + // Without this callback connected_ would never update, so the component would look + // healthy forever while never actually reporting the device as connected. + this->mark_task_failed_(LOG_STR("State callback could not be registered")); + launch_mainloop = false; + } } } - ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); + if (launch_mainloop) { +#ifdef USE_OPENTHREAD_BORDER_ROUTER + this->mainloop_running_ = true; +#endif + const esp_err_t err = esp_openthread_launch_mainloop(); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + this->mainloop_running_ = false; +#endif + if (err != ESP_OK) { + ESP_LOGE(TAG, "OpenThread main loop failed: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Main loop failed")); + } + } + + // Clean up. Taking the lock once more lets every guard finish; the stack is stopped and the + // lock withdrawn while it is held. + esp_openthread_lock_acquire(portMAX_DELAY); + // Best effort: after a normal teardown all of this is done already + otIp6SetAddressCallback(instance, nullptr, nullptr); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + if (this->border_router_started_) { + (void) esp_openthread_border_router_deinit(); + this->border_router_started_ = false; + } +#endif + (void) otThreadSetEnabled(instance, false); + (void) otIp6SetEnabled(instance, false); + // Nothing runs the main loop any more, so the stack's reaction to the disables (the border + // agent closing its sockets, for one) is run here, with the lock held + for (uint8_t i = 0; i < 16 && otTaskletsArePending(instance); i++) { + otTaskletsProcess(instance); + } + this->lock_initialized_ = false; + this->connected_ = false; + this->task_exited_ = true; + esp_openthread_lock_release(); + esp_openthread_netif_glue_deinit(); + esp_netif_destroy(netif); + // Finalizing the instance closes the sockets the stack still holds, which hands the task + // switching lock back and forth, so it has to be held here; deinit destroys it at the end + esp_openthread_lock_acquire(portMAX_DELAY); + esp_openthread_deinit(); + esp_vfs_eventfd_unregister(); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } -int OpenThreadComponent::openthread_stop_() { - // Clean up - reset lock flag before deinit destroys the semaphore - this->lock_initialized_ = false; - int error = esp_openthread_stop(); - // Mark complete even on failure: we're already mid-shutdown/reboot, so there's no - // recovery path to retry into -- leaving the stage stuck would only burn the full - // teardown timeout for no benefit. - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; - return error; +// Runs on the OpenThread task; the component state belongs to the main loop, and the log goes +// out from there too, where an abort that follows on the task cannot swallow it +void OpenThreadComponent::mark_task_failed_(const LogString *reason) { + this->defer([this, reason]() { + ESP_LOGE(TAG, "OpenThread task failed: %s", LOG_STR_ARG(reason)); + this->mark_failed(); + }); } +#ifdef USE_OPENTHREAD_RCP_UART +void OpenThreadComponent::rcp_failure_handler() { + if (global_openthread_component == nullptr) { + ESP_LOGE(TAG, "RCP failure reported after OpenThread component was torn down"); + return; + } + global_openthread_component->reset_rcp_(true); +} + +void OpenThreadComponent::reset_rcp_(bool recovery_attempt) { + if (recovery_attempt) { + static constexpr uint8_t MAX_RESET_ATTEMPTS = 5; + uint8_t attempt = this->rcp_reset_attempts_; + if (attempt > MAX_RESET_ATTEMPTS) { + return; // Gave up earlier; the driver keeps reporting the failure + } + // An RCP that ran long enough since its last reset gets its attempts back; one that keeps failing does not + const uint32_t now = millis(); + if (attempt != 0 && now - this->last_rcp_reset_ms_ > RCP_RESET_FORGET_MS) { + attempt = 0; + } + this->rcp_reset_attempts_ = ++attempt; + if (attempt > MAX_RESET_ATTEMPTS) { + ESP_LOGE(TAG, "RCP failed to recover after %u reset attempts", MAX_RESET_ATTEMPTS); + this->mark_task_failed_(LOG_STR("RCP does not recover")); + return; + } + ESP_LOGW(TAG, "RCP failure detected, reset attempt %u of %u", attempt, MAX_RESET_ATTEMPTS); + this->last_rcp_reset_ms_ = now; + } + this->rcp_reset_pin_->digital_write(true); + vTaskDelay(pdMS_TO_TICKS(10)); + this->rcp_reset_pin_->digital_write(false); + vTaskDelay(pdMS_TO_TICKS(30)); +} +#endif + +int OpenThreadComponent::openthread_stop_() { return esp_openthread_mainloop_exit(); } + network::IPAddresses OpenThreadComponent::get_ip_addresses() { network::IPAddresses addresses; - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - esp_netif_t *netif = esp_netif_get_default_netif(); - count = esp_netif_get_all_ip6(netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); +#if USE_NETWORK_IPV6 + // The task's clean-up destroys the netif under the lock, so the netif is only read while it is held; + // the OpenThread task releases the lock between events, so the wait is short + auto lock = InstanceLock::try_acquire(10); + if (!lock) { + ESP_LOGV(TAG, "Instance lock busy, no addresses this time"); + return addresses; } + if (esp_netif_t *netif = esp_openthread_get_netif(); netif != nullptr) { + network::fill_ip6_addresses(netif, addresses); + } +#endif return addresses; } +#ifdef USE_OPENTHREAD_BORDER_ROUTER +static constexpr uint32_t BORDER_ROUTER_RETRY_MS = 10000; + +void OpenThreadComponent::loop() { + // Thread starts on its own. Border routing starts once the Wi-Fi backbone has an address. When the + // backbone goes away the stack is told so it pauses routing on that interface; when it comes back + // the border router is created afresh, since the port's infra interface keeps the socket and the + // addresses of the backbone as it was. The Wi-Fi listener enables the loop on every link change + if (!this->mainloop_running_) { + return; + } + const bool backbone_up = wifi::global_wifi_component->is_connected(); + if (backbone_up == this->backbone_up_) { + this->disable_loop(); + return; + } + // Never wait for the lock on the main loop; the OpenThread task releases it between events + auto lock = InstanceLock::try_acquire(0); + if (!lock) { + return; + } + auto retry_later = [this](const LogString *reason) { + this->status_set_error(reason); + this->disable_loop(); + this->set_timeout(BORDER_ROUTER_RETRY_MS, [this]() { this->enable_loop(); }); + }; + if (!backbone_up) { + if (this->border_router_started_) { + ESP_LOGI(TAG, "Wi-Fi backbone lost, border routing pauses"); + esp_netif_t *backbone = esp_openthread_get_backbone_netif(); + // A refused report is not retried: the border router is created afresh when the backbone returns + if (otPlatInfraIfStateChanged(lock.get_instance(), esp_netif_get_netif_impl_index(backbone), false) != + OT_ERROR_NONE) { + ESP_LOGW(TAG, "Stack did not take the backbone loss, routes stay until Wi-Fi returns"); + } + } + this->backbone_up_ = false; + this->disable_loop(); + return; + } + if (this->border_router_started_) { + ESP_LOGI(TAG, "Wi-Fi backbone back, border routing restarts"); + // The old router stays marked as started until it is gone, so a retry tears it down first + if (const esp_err_t err = esp_openthread_border_router_deinit(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to deinitialize OpenThread Border Router: %s", esp_err_to_name(err)); + retry_later(LOG_STR("Border router deinit failed")); + return; + } + this->border_router_started_ = false; + } + if (const esp_err_t err = esp_openthread_border_router_init(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize OpenThread Border Router: %s", esp_err_to_name(err)); + retry_later(LOG_STR("Border router init failed")); + return; + } + this->status_clear_error(); + this->border_router_started_ = true; + this->backbone_up_ = true; + ESP_LOGI(TAG, "Border routing started"); + this->disable_loop(); +} +#endif + // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } @@ -149,7 +424,23 @@ InstanceLock InstanceLock::try_acquire(int delay) { if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } - return InstanceLock(esp_openthread_lock_acquire(delay)); + if (!esp_openthread_lock_acquire(delay)) { + return InstanceLock(false); + } + // The clean-up may have released the lock to a waiter just before deinit destroys it + if (global_openthread_component->is_task_exited()) { + esp_openthread_lock_release(); + return InstanceLock(false); + } + return InstanceLock(true); +} + +// The lock went away with the task, so waiting for it could never end +static void abort_if_task_exited() { + if (global_openthread_component->is_task_exited()) { + ESP_LOGE(TAG, "OpenThread task has exited, cannot acquire instance lock"); + abort(); + } } InstanceLock InstanceLock::acquire() { @@ -160,15 +451,16 @@ InstanceLock InstanceLock::acquire() { abort(); } // A failed setup() never creates the lock; say so now instead of after the wait below - if (global_openthread_component->is_failed()) { + if (global_openthread_component->is_failed() && !global_openthread_component->is_lock_initialized()) { ESP_LOGE(TAG, "Setup failed, cannot acquire instance lock"); abort(); } // Wait for the lock to be created before attempting to acquire it. - // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start(). + // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_init(). constexpr uint32_t lock_init_timeout_ms = 10000; uint32_t start = millis(); while (!global_openthread_component->is_lock_initialized()) { + abort_if_task_exited(); if (millis() - start > lock_init_timeout_ms) { ESP_LOGE(TAG, "OpenThread lock not initialized after %" PRIu32 "ms, aborting", lock_init_timeout_ms); abort(); @@ -177,8 +469,14 @@ InstanceLock InstanceLock::acquire() { esp_task_wdt_reset(); } while (!esp_openthread_lock_acquire(100)) { + abort_if_task_exited(); esp_task_wdt_reset(); } + // The clean-up may have released the lock to this waiter just before deinit destroys it + if (global_openthread_component->is_task_exited()) { + esp_openthread_lock_release(); + abort_if_task_exited(); + } return InstanceLock(true); } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d308ad2b977..c8e1df12f31 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1,4 +1,5 @@ #include "wifi_component.h" +#include "esphome/components/network/util.h" #ifdef USE_WIFI #ifdef USE_ESP32 @@ -863,14 +864,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { addresses[0] = network::IPAddress(&ip.ip); } #if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(s_sta_netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } + network::fill_ip6_addresses(s_sta_netif, addresses); #endif /* USE_NETWORK_IPV6 */ return addresses; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0e020975e84..79ea459bad8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -574,13 +574,20 @@ #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD +#define USE_OPENTHREAD_SRP #define USE_ZIGBEE #endif +#if defined(USE_ESP32_VARIANT_ESP32S3) +// Border router with a UART RCP; a native-radio border router (C5/C6) adds only the coexistence call +#define USE_OPENTHREAD +#define USE_OPENTHREAD_BORDER_ROUTER +#define USE_OPENTHREAD_RCP_UART +#endif #if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #define USE_ESPECTRE #endif -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP #define USE_MDNS_SUPPORTS_ENABLE_DISABLE #ifdef USE_ETHERNET #define USE_MDNS_ACTIVE_INTERFACE_LISTENER diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 index 63dbeffd772..8d5ddfc39f8 100644 --- a/sdkconfig.defaults.esp32c6 +++ b/sdkconfig.defaults.esp32c6 @@ -7,6 +7,7 @@ CONFIG_IEEE802154_ENABLED=y CONFIG_OPENTHREAD_ENABLED=y CONFIG_OPENTHREAD_RADIO_NATIVE=y +CONFIG_OPENTHREAD_FTD=y # zigbee CONFIG_ZB_ENABLED=y diff --git a/sdkconfig.defaults.esp32s3 b/sdkconfig.defaults.esp32s3 index 15b97eb1b41..5204d95db52 100644 --- a/sdkconfig.defaults.esp32s3 +++ b/sdkconfig.defaults.esp32s3 @@ -10,3 +10,28 @@ CONFIG_TINYUSB_CDC_ENABLED=y CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 + +# OpenThread Border Router with external UART RCP. +CONFIG_OPENTHREAD_ENABLED=y +CONFIG_OPENTHREAD_FTD=y +CONFIG_OPENTHREAD_PLATFORM_NETIF=y +CONFIG_OPENTHREAD_BORDER_ROUTER=y +CONFIG_OPENTHREAD_RADIO_SPINEL_UART=y +CONFIG_OPENTHREAD_CLI=n +CONFIG_OPENTHREAD_CONSOLE_ENABLE=n +CONFIG_OPENTHREAD_DIAG=n +CONFIG_OPENTHREAD_DNS64_CLIENT=n +CONFIG_OPENTHREAD_SRP_CLIENT=n +CONFIG_LWIP_IPV6_FORWARD=y +CONFIG_LWIP_IPV6_NUM_ADDRESSES=12 +CONFIG_LWIP_MULTICAST_PING=y +CONFIG_LWIP_NETIF_STATUS_CALLBACK=y +CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT=y +CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT=y +CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM=y +CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM=y +CONFIG_MDNS_MULTIPLE_INSTANCE=y +CONFIG_MBEDTLS_CMAC_C=y +CONFIG_MBEDTLS_SSL_PROTO_DTLS=y +CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE=y +CONFIG_MBEDTLS_ECJPAKE_C=y diff --git a/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml b/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml new file mode 100644 index 00000000000..a6f8877e31b --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: {} + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd8c099c09f..3cde877f7ad 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2240,9 +2240,10 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" - generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + """The Border Router needs TLS/DTLS server support and OpenThread crypto extras.""" + generate_main(component_config_path("mbedtls_tls_openthread_border_router.yaml")) mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] + assert mbedtls.tls_required is True assert mbedtls.tls_server_required is True assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py index eacf722f177..ecd3cdeb92a 100644 --- a/tests/component_tests/mdns/test_service_enable_disable.py +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -38,6 +38,19 @@ def test_esp32_adds_define_and_keeps_services_stored( assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() +def test_esp32_border_router_adds_define( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_config( + set_core_config, + PlatformFramework.ESP32_IDF, + {"mdns": {CONF_DISABLED: False}, "openthread": {"border_router": {}}}, + ) + + assert mdns.request_service_enable_disable() is True + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + @pytest.mark.parametrize( "platform_framework", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], diff --git a/tests/component_tests/openthread/config/border_router.yaml b/tests/component_tests/openthread/config/border_router.yaml new file mode 100644 index 00000000000..04e033f55ab --- /dev/null +++ b/tests/component_tests/openthread/config/border_router.yaml @@ -0,0 +1,24 @@ +esphome: + name: openthread-border-router + +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: {} + network_name: TestNetwork + channel: 15 + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x0123 + ext_pan_id: 0x00000000000000AB + pskc: 0x000000000000000000000000000000CD + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/component_tests/openthread/config/border_router_rcp.yaml b/tests/component_tests/openthread/config/border_router_rcp.yaml new file mode 100644 index 00000000000..26204f9719b --- /dev/null +++ b/tests/component_tests/openthread/config/border_router_rcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: openthread-border-router-rcp + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + network_name: TestNetwork + channel: 15 + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x0123 + ext_pan_id: 0x00000000000000AB + pskc: 0x000000000000000000000000000000CD + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index c0e155ec7e3..96aeb92ffaa 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -15,7 +15,7 @@ from esphome.components.esp32 import ( 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.const import KEY_FRAMEWORK_VERSION, PlatformFramework, Toolchain from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -42,7 +42,9 @@ def test_credentials_are_sensitive( """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} + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)}, + platform_data={KEY_VARIANT: VARIANT_ESP32C6}, ) validated = CONFIG_SCHEMA(config) assert isinstance(validated[field], Sensitive) diff --git a/tests/component_tests/openthread/test_openthread.py b/tests/component_tests/openthread/test_openthread.py new file mode 100644 index 00000000000..11b6854b5a9 --- /dev/null +++ b/tests/component_tests/openthread/test_openthread.py @@ -0,0 +1,324 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from esphome.components.const import CONF_BORDER_ROUTER +from esphome.components.esp32 import ( + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32S3, +) +from esphome.components.esp32.const import ( + KEY_BOARD, + KEY_ESP32, + KEY_IDF_VERSION, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) +from esphome.components.openthread import ( + _RCP_SCHEMA, + BORDER_ROUTER_SDKCONFIG_OPTIONS, + RCP_BORDER_ROUTER_VARIANTS, + _final_validate, + _validate, + _validate_platform, +) +from esphome.components.openthread.const import CONF_DEVICE_TYPE, CONF_RCP +import esphome.config_validation as cv +from esphome.const import ( + CONF_AP, + CONF_BAUD_RATE, + CONF_ENABLE_IPV6, + CONF_ENABLE_ON_BOOT, + CONF_ID, + CONF_INVERTED, + CONF_LOGGER, + CONF_NETWORKS, + CONF_NUMBER, + CONF_OPENTHREAD, + CONF_RX_PIN, + CONF_TX_PIN, + CONF_USE_ADDRESS, + CONF_WIFI, + KEY_FRAMEWORK_VERSION, + PlatformFramework, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONFIG_DIR = Path(__file__).parent / "config" +CONF_NETWORK = "network" +DEVICE_TYPE_FTD = "FTD" +DEVICE_TYPE_MTD = "MTD" +RCP_PINS = {CONF_RX_PIN: 18, CONF_TX_PIN: 17} + + +@pytest.fixture +def otbr_config() -> ConfigType: + return { + CONF_ID: "openthread_component", + CONF_BORDER_ROUTER: {}, + CONF_DEVICE_TYPE: DEVICE_TYPE_FTD, + } + + +@pytest.fixture +def full_config(otbr_config: ConfigType) -> ConfigType: + return { + CONF_OPENTHREAD: otbr_config, + CONF_NETWORK: {CONF_ENABLE_IPV6: True}, + CONF_WIFI: { + CONF_ENABLE_ON_BOOT: True, + CONF_NETWORKS: [{"ssid": "test"}], + }, + } + + +def _set_esp32_idf_core( + set_core_config: SetCoreConfigCallable, + variant: str = VARIANT_ESP32C6, + idf_version: cv.Version | None = None, +) -> None: + if idf_version is None: + idf_version = cv.Version(5, 5, 5) + set_core_config( + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: idf_version}, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_IDF_VERSION: idf_version, + KEY_VARIANT: variant, + KEY_SDKCONFIG_OPTIONS: {}, + }, + ) + + +def _run_final_validation( + otbr_config: ConfigType, + full_config: ConfigType, +) -> None: + token = fv.full_config.set(full_config) + try: + _final_validate(otbr_config) + finally: + fv.full_config.reset(token) + + +def test_plain_openthread_uses_the_srp_client( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_DIR / "no_custom_header.yaml") + + defines = {define.name for define in CORE.defines} + assert "USE_OPENTHREAD_SRP" in defines + assert "USE_OPENTHREAD_BORDER_ROUTER" not in defines + assert ( + CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_OPENTHREAD_SRP_CLIENT"] + is True + ) + + +def test_border_router_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_DIR / "border_router.yaml") + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + for option, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): + assert sdkconfig[option] == value + assert sdkconfig["CONFIG_SW_COEXIST_ENABLE"] is True + assert sdkconfig["CONFIG_OPENTHREAD_SRP_CLIENT"] is False + assert sdkconfig["CONFIG_OPENTHREAD_DNS64_CLIENT"] is False + assert ( + sdkconfig["CONFIG_OPENTHREAD_NETWORK_MASTERKEY"] + == "00112233445566778899aabbccddeeff" + ) + assert sdkconfig["CONFIG_OPENTHREAD_NETWORK_EXTPANID"] == "00000000000000ab" + assert ( + sdkconfig["CONFIG_OPENTHREAD_NETWORK_PSKC"] + == "000000000000000000000000000000cd" + ) + defines = {define.name for define in CORE.defines} + assert "USE_OPENTHREAD_BORDER_ROUTER" in defines + assert "USE_OPENTHREAD_SRP" not in defines + + +def test_border_router_rcp_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + cpp_main = generate_main(CONFIG_DIR / "border_router_rcp.yaml") + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_RADIO_NATIVE"] is False + assert sdkconfig["CONFIG_OPENTHREAD_RADIO_SPINEL_UART"] is True + assert sdkconfig["CONFIG_VFS_SUPPORT_DIR"] is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is not True + assert any(define.name == "USE_OPENTHREAD_RCP_UART" for define in CORE.defines) + assert "OpenThreadComponent(460800, 18, 17, nullptr)" in cpp_main + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda config, _: config.pop(CONF_WIFI), + "requires a Wi-Fi STA backbone", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_NETWORKS: []}), + "requires at least one configured", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_ENABLE_ON_BOOT: False}), + "enable_on_boot: true", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_AP: {}}), + "does not support Wi-Fi AP", + ), + ( + lambda config, _: config[CONF_NETWORK].update({CONF_ENABLE_IPV6: False}), + "requires IPv6 to be enabled", + ), + ], +) +def test_border_router_rejects_incompatible_config( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, + mutate: Any, + message: str, +) -> None: + _set_esp32_idf_core(set_core_config) + mutate(full_config, otbr_config) + + with pytest.raises(cv.Invalid, match=message): + _run_final_validation(otbr_config, full_config) + + +def test_border_router_requires_ftd( + set_core_config: SetCoreConfigCallable, otbr_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config) + otbr_config[CONF_DEVICE_TYPE] = DEVICE_TYPE_MTD + otbr_config[CONF_USE_ADDRESS] = "test.local" + + with pytest.raises(cv.Invalid, match="device_type: FTD"): + _validate(otbr_config) + + +def test_native_border_router_rejects_variant_without_ieee802154( + set_core_config: SetCoreConfigCallable, otbr_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + + with pytest.raises(cv.Invalid, match="both Wi-Fi and IEEE 802.15.4"): + _validate_platform(otbr_config) + + +def test_requires_idf_5_5_5(set_core_config: SetCoreConfigCallable) -> None: + _set_esp32_idf_core(set_core_config, idf_version=cv.Version(5, 5, 2)) + + with pytest.raises(cv.Invalid, match="at least framework version 5.5.5"): + _validate_platform({CONF_DEVICE_TYPE: DEVICE_TYPE_FTD}) + + +def test_rejects_arduino(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_ARDUINO) + + with pytest.raises(cv.Invalid, match="incompatible with ESP32 using arduino"): + _validate_platform({CONF_DEVICE_TYPE: DEVICE_TYPE_FTD}) + + +@pytest.mark.parametrize("variant", sorted(RCP_BORDER_ROUTER_VARIANTS)) +def test_border_router_rcp_accepts_wifi_variants( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + + assert _validate_platform(otbr_config) is otbr_config + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32H2, VARIANT_ESP32H4]) +def test_border_router_rcp_rejects_variants_without_wifi( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + + with pytest.raises(cv.Invalid, match="requires a Wi-Fi-capable ESP32 variant"): + _validate_platform(otbr_config) + + +@pytest.mark.parametrize( + ("extra_config", "message"), + [ + ({"uart": [{}]}, "cannot currently be combined"), + ( + {CONF_LOGGER: {"hardware_uart": "UART1", "baud_rate": 115200}}, + "reserves UART1", + ), + ], +) +def test_border_router_rcp_rejects_uart_conflicts( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, + extra_config: ConfigType, + message: str, +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + full_config.update(extra_config) + + with pytest.raises(cv.Invalid, match=message): + _run_final_validation(otbr_config, full_config) + + +@pytest.mark.parametrize( + "rcp_config", + [ + {**RCP_PINS, CONF_BAUD_RATE: 0}, + {**RCP_PINS, CONF_BAUD_RATE: 9599}, + {CONF_RX_PIN: {CONF_NUMBER: 18, CONF_INVERTED: True}, CONF_TX_PIN: 17}, + ], +) +def test_rcp_schema_rejects_bad_options( + set_core_config: SetCoreConfigCallable, rcp_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + + with pytest.raises(cv.Invalid): + _RCP_SCHEMA(rcp_config) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C5, VARIANT_ESP32C6]) +def test_native_border_router_accepts_wifi_ieee802154_variants( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + + assert _validate_platform(otbr_config) is otbr_config + + +def test_border_router_accepts_supported_config( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, +) -> None: + _set_esp32_idf_core(set_core_config) + + _run_final_validation(otbr_config, full_config) diff --git a/tests/components/openthread/test-border-router.esp32-c5-idf.yaml b/tests/components/openthread/test-border-router.esp32-c5-idf.yaml new file mode 100644 index 00000000000..c3558b16f9d --- /dev/null +++ b/tests/components/openthread/test-border-router.esp32-c5-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR Test" + password: "test-password" + +openthread: + border_router: {} + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 + +button: + - platform: factory_reset + name: Factory Reset diff --git a/tests/components/openthread/test-border-router.esp32-c6-idf.yaml b/tests/components/openthread/test-border-router.esp32-c6-idf.yaml new file mode 100644 index 00000000000..c3558b16f9d --- /dev/null +++ b/tests/components/openthread/test-border-router.esp32-c6-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR Test" + password: "test-password" + +openthread: + border_router: {} + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 + +button: + - platform: factory_reset + name: Factory Reset diff --git a/tests/components/openthread/test-rcp.esp32-idf.yaml b/tests/components/openthread/test-rcp.esp32-idf.yaml new file mode 100644 index 00000000000..b784f54ff9d --- /dev/null +++ b/tests/components/openthread/test-rcp.esp32-idf.yaml @@ -0,0 +1,19 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR RCP Test" + password: "test-password" + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/components/openthread/test-rcp.esp32-s3-idf.yaml b/tests/components/openthread/test-rcp.esp32-s3-idf.yaml new file mode 100644 index 00000000000..6d643ff91e6 --- /dev/null +++ b/tests/components/openthread/test-rcp.esp32-s3-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR RCP Test" + password: "test-password" + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + reset_pin: GPIO16 + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64