[openthread] Add ESP32 OpenThread Border Router support (#18104)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Pawel Puterla
2026-10-14 20:02:33 -10:00
committed by GitHub
co-authored by Copilot App pre-commit-ci-lite[bot] J. Nick Koston J. Nick Koston
parent ea6d5499b3
commit 8d963bde5b
28 changed files with 1329 additions and 159 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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"
@@ -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;
+11 -4
View File
@@ -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
+2 -2
View File
@@ -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.
+5 -5
View File
@@ -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<MDNSService, MDNS_SERVICE_COUNT> &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, StaticVector<MDNSService, MDNS_S
#endif
}
#if defined(USE_MDNS_SUPPORTS_ENABLE_DISABLE) && !defined(USE_OPENTHREAD)
#if defined(USE_MDNS_SUPPORTS_ENABLE_DISABLE) && !defined(USE_OPENTHREAD_SRP)
bool MDNSComponent::set_service_enabled(const char *service_type, const char *proto, bool enabled) {
// services_ is compiled in setup()
if (!this->is_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
+30
View File
@@ -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).
+10 -1
View File
@@ -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
+224 -36
View File
@@ -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))
+1
View File
@@ -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"
+75 -14
View File
@@ -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<otIp6Address> 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<void()> callback) {
void OpenThreadComponent::on_factory_reset(const std::function<void()> &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<void()> callback) {
return;
}
ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services");
#endif
}
void OpenThreadComponent::apply_poll_period(uint32_t poll_period) {
+65 -5
View File
@@ -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 <openthread/srp_client.h>
#include <openthread/srp_client_buffers.h>
@@ -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<const uint8_t, 6> 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<otIp6Address> get_omr_address();
void on_factory_reset(std::function<void()> callback);
void on_factory_reset(const std::function<void()> &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<otIp6Address> get_omr_address_(InstanceLock &lock);
otInstance *get_openthread_instance_();
int openthread_stop_();
#ifdef USE_OPENTHREAD_SRP
std::function<void()> factory_reset_external_callback_;
#endif
#if CONFIG_OPENTHREAD_MTD
uint32_t poll_period_{0};
#endif
std::optional<int8_t> output_power_{};
std::atomic<bool> 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<TeardownStage> teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED};
std::atomic<bool> connected_{false};
#ifdef USE_ESP32
std::atomic<bool> 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<bool> 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<std::unique_ptr<uint8_t[]>> 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
+368 -70
View File
@@ -1,12 +1,29 @@
#include "esphome/core/defines.h"
#if defined(USE_OPENTHREAD) && defined(USE_ESP32)
#include <openthread/dataset.h>
#include <openthread/ip6.h>
#include <openthread/tasklet.h>
#ifdef USE_OPENTHREAD_BORDER_ROUTER
#include <openthread/platform/infra_if.h>
#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<OpenThreadComponent *>(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<int>(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<gpio_num_t>(this->rcp_rx_pin_),
.tx_pin = static_cast<gpio_num_t>(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);
}
@@ -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;
}
+8 -1
View File
@@ -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
+1
View File
@@ -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
+25
View File
@@ -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
@@ -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
+3 -2
View File
@@ -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
@@ -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],
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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