mirror of
https://github.com/esphome/esphome.git
synced 2026-10-15 18:52:51 +00:00
[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:
co-authored by
Copilot App
pre-commit-ci-lite[bot]
J. Nick Koston
J. Nick Koston
parent
ea6d5499b3
commit
8d963bde5b
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user