[openthread] Add basic Openthread support to Zephyr/nRF52 platform (#16854)

Co-authored-by: Copilot Autofix powered by AI <175728472+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: tomaszduda23 <tomaszduda23@gmail.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
Ardumine
2026-06-18 02:58:51 +01:00
committed by GitHub
parent 4b8568e948
commit f76dfd579c
7 changed files with 229 additions and 23 deletions

View File

@@ -10,6 +10,8 @@ from esphome.components.esp32 import (
require_vfs_select, require_vfs_select,
) )
from esphome.components.mdns import MDNSComponent, enable_mdns_storage from esphome.components.mdns import MDNSComponent, enable_mdns_storage
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 import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_CHANNEL, CONF_CHANNEL,
@@ -20,6 +22,7 @@ from esphome.const import (
CONF_OUTPUT_POWER, CONF_OUTPUT_POWER,
CONF_USE_ADDRESS, CONF_USE_ADDRESS,
PLATFORM_ESP32, PLATFORM_ESP32,
PlatformFramework,
) )
from esphome.core import ( from esphome.core import (
CORE, CORE,
@@ -52,7 +55,6 @@ AUTO_LOAD = ["network"]
# Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time # 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 # 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"] CONFLICTS_WITH = ["wifi"]
DEPENDENCIES = ["esp32"]
IDF_TO_OT_LOG_LEVEL = { IDF_TO_OT_LOG_LEVEL = {
"NONE": "NONE", "NONE": "NONE",
@@ -98,9 +100,7 @@ def set_sdkconfig_options(config):
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True)
if tlv := config.get(CONF_TLV): if not config.get(CONF_TLV):
cg.add_define("USE_OPENTHREAD_TLVS", tlv)
else:
if pan_id := config.get(CONF_PAN_ID): if pan_id := config.get(CONF_PAN_ID):
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id)
@@ -128,9 +128,6 @@ def set_sdkconfig_options(config):
"CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower()
) )
if config.get(CONF_FORCE_DATASET):
cg.add_define("USE_OPENTHREAD_FORCE_DATASET")
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) 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", True)
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5)
@@ -159,6 +156,11 @@ _CONNECTION_SCHEMA = cv.Schema(
def _validate(config: ConfigType) -> ConfigType: def _validate(config: ConfigType) -> ConfigType:
if CONF_USE_ADDRESS not in config: if CONF_USE_ADDRESS not in config:
config[CONF_USE_ADDRESS] = f"{CORE.name}.local" config[CONF_USE_ADDRESS] = f"{CORE.name}.local"
if CORE.using_zephyr and CONF_TLV not in config:
raise cv.Invalid(
"On nRF52, OpenThread credentials must be provided via 'tlv'. "
"Individual parameters (network_key, pan_id, channel, etc.) are not yet supported on this platform."
)
device_type = config.get(CONF_DEVICE_TYPE) device_type = config.get(CONF_DEVICE_TYPE)
poll_period = config.get(CONF_POLL_PERIOD) poll_period = config.get(CONF_POLL_PERIOD)
if ( if (
@@ -175,11 +177,33 @@ def _validate(config: ConfigType) -> ConfigType:
def _require_vfs_select(config): def _require_vfs_select(config):
"""Register VFS select requirement during config validation.""" """Register VFS select requirement during config validation."""
# OpenThread uses esp_vfs_eventfd which requires VFS select support # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only)
require_vfs_select() if CORE.is_esp32:
require_vfs_select()
return config return config
def _validate_platform(config):
if CORE.using_zephyr:
return config
return only_on_variant(
supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]
)(config)
def _validate_tlv_hex(value):
s = cv.string_strict(value)
if len(s) % 2 != 0:
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
if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs)
raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)")
return s
CONFIG_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All(
cv.Schema( cv.Schema(
{ {
@@ -190,7 +214,7 @@ CONFIG_SCHEMA = cv.All(
*CONF_DEVICE_TYPES, upper=True *CONF_DEVICE_TYPES, upper=True
), ),
cv.Optional(CONF_FORCE_DATASET): cv.boolean, cv.Optional(CONF_FORCE_DATASET): cv.boolean,
cv.Optional(CONF_TLV): cv.string_strict, cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex),
cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds,
cv.Optional(CONF_OUTPUT_POWER): cv.All( cv.Optional(CONF_OUTPUT_POWER): cv.All(
@@ -200,7 +224,7 @@ CONFIG_SCHEMA = cv.All(
} }
).extend(_CONNECTION_SCHEMA), ).extend(_CONNECTION_SCHEMA),
cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV),
only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]), _validate_platform,
_validate, _validate,
_require_vfs_select, _require_vfs_select,
) )
@@ -227,13 +251,27 @@ def _final_validate(_):
FINAL_VALIDATE_SCHEMA = _final_validate FINAL_VALIDATE_SCHEMA = _final_validate
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"openthread_esp.cpp": {
PlatformFramework.ESP32_IDF,
},
"openthread_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR},
}
)
@coroutine_with_priority(CoroPriority.COMMUNICATION) @coroutine_with_priority(CoroPriority.COMMUNICATION)
async def to_code(config): async def to_code(config):
# Re-enable openthread IDF component (excluded by default) # Re-enable openthread IDF component (excluded by default)
include_builtin_idf_component("openthread") if CORE.is_esp32:
include_builtin_idf_component("openthread")
cg.add_define("USE_OPENTHREAD") cg.add_define("USE_OPENTHREAD")
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 # OpenThread SRP needs access to mDNS services after setup
enable_mdns_storage() enable_mdns_storage()
@@ -252,4 +290,12 @@ async def to_code(config):
if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: if (output_power := config.get(CONF_OUTPUT_POWER)) is not None:
cg.add(ot.set_output_power(output_power)) cg.add(ot.set_output_power(output_power))
set_sdkconfig_options(config) if CORE.is_esp32:
set_sdkconfig_options(config)
elif CORE.using_zephyr:
zephyr_add_prj_conf("NET_L2_OPENTHREAD", True)
zephyr_add_prj_conf(
f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True
)
zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True)
zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096)

View File

@@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() {
char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size);
const auto &host_name = App.get_name(); const auto &host_name = App.get_name();
uint16_t host_name_len = host_name.size(); uint16_t host_name_len = host_name.size();
if (host_name_len > size) { if (host_name_len >= size) {
ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name"); ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name");
return; return;
} }
@@ -151,7 +151,7 @@ void OpenThreadSrpComponent::setup() {
return; return;
} }
// Get mdns services and copy their data (strings are copied with strdup below) // Get mdns services and copy their data (strdup on ESP32, pool_alloc_ on Zephyr)
const auto &mdns_services = this->mdns_->get_services(); const auto &mdns_services = this->mdns_->get_services();
ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size()); ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size());
for (const auto &service : mdns_services) { for (const auto &service : mdns_services) {
@@ -164,7 +164,7 @@ void OpenThreadSrpComponent::setup() {
// Set service name // Set service name
char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size); char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size);
std::string full_service = std::string(MDNS_STR_ARG(service.service_type)) + "." + MDNS_STR_ARG(service.proto); std::string full_service = std::string(MDNS_STR_ARG(service.service_type)) + "." + MDNS_STR_ARG(service.proto);
if (full_service.size() > size) { if (full_service.size() >= size) {
ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str()); ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str());
continue; continue;
} }
@@ -172,7 +172,7 @@ void OpenThreadSrpComponent::setup() {
// Set instance name (using host_name) // Set instance name (using host_name)
string = otSrpClientBuffersGetServiceEntryInstanceNameString(entry, &size); string = otSrpClientBuffersGetServiceEntryInstanceNameString(entry, &size);
if (host_name_len > size) { if (host_name_len >= size) {
ESP_LOGW(TAG, "Instance name too long: %s", host_name.c_str()); ESP_LOGW(TAG, "Instance name too long: %s", host_name.c_str());
continue; continue;
} }
@@ -189,11 +189,21 @@ void OpenThreadSrpComponent::setup() {
for (size_t i = 0; i < service.txt_records.size(); i++) { for (size_t i = 0; i < service.txt_records.size(); i++) {
const auto &txt = service.txt_records[i]; const auto &txt = service.txt_records[i];
// Value is either a compile-time string literal in flash or a pointer to dynamic_txt_values_ // Value is either a compile-time string literal in flash or a pointer to dynamic_txt_values_
// OpenThread SRP client expects the data to persist, so we strdup it // OpenThread SRP client expects the data to persist, so we copy it
const char *value_str = MDNS_STR_ARG(txt.value); const char *value_str = MDNS_STR_ARG(txt.value);
txt_entries[i].mKey = MDNS_STR_ARG(txt.key); txt_entries[i].mKey = MDNS_STR_ARG(txt.key);
#ifndef USE_ZEPHYR
txt_entries[i].mValue = reinterpret_cast<const uint8_t *>(strdup(value_str)); txt_entries[i].mValue = reinterpret_cast<const uint8_t *>(strdup(value_str));
txt_entries[i].mValueLength = strlen(value_str); txt_entries[i].mValueLength = strlen(value_str);
#else
// strdup is not available on zephyr
// https:// github.com/zephyrproject-rtos/zephyr/issues/22464
size_t value_len = strlen(value_str);
char *value_copy = reinterpret_cast<char *>(this->pool_alloc_(value_len + 1));
memcpy(value_copy, value_str, value_len + 1);
txt_entries[i].mValue = reinterpret_cast<const uint8_t *>(value_copy);
txt_entries[i].mValueLength = value_len;
#endif
} }
entry->mService.mTxtEntries = txt_entries; entry->mService.mTxtEntries = txt_entries;
entry->mService.mNumTxtEntries = service.txt_records.size(); entry->mService.mNumTxtEntries = service.txt_records.size();
@@ -233,7 +243,7 @@ bool OpenThreadComponent::teardown() {
global_openthread_component = nullptr; global_openthread_component = nullptr;
ESP_LOGD(TAG, "Exit main loop "); ESP_LOGD(TAG, "Exit main loop ");
int error = this->openthread_stop_(); int error = this->openthread_stop_();
if (error != ESP_OK) { if (error != 0) {
ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error); ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error);
this->teardown_complete_ = true; this->teardown_complete_ = true;
} }

View File

@@ -43,10 +43,11 @@ class OpenThreadComponent : public Component {
void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; }
#endif #endif
void set_output_power(int8_t output_power) { this->output_power_ = output_power; } 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);
protected: protected:
std::optional<otIp6Address> get_omr_address_(InstanceLock &lock); std::optional<otIp6Address> get_omr_address_(InstanceLock &lock);
static void on_state_changed(otChangedFlags flags, void *context);
otInstance *get_openthread_instance_(); otInstance *get_openthread_instance_();
int openthread_stop_(); int openthread_stop_();
std::function<void()> factory_reset_external_callback_; std::function<void()> factory_reset_external_callback_;

View File

@@ -217,7 +217,7 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() {
otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); }
InstanceLock InstanceLock::try_acquire(int delay) { InstanceLock InstanceLock::try_acquire(int delay) {
if (!global_openthread_component->is_lock_initialized()) { if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) {
return InstanceLock(false); return InstanceLock(false);
} }
return InstanceLock(esp_openthread_lock_acquire(delay)); return InstanceLock(esp_openthread_lock_acquire(delay));

View File

@@ -0,0 +1,141 @@
#include "esphome/core/defines.h"
#if defined(USE_OPENTHREAD) && defined(USE_NRF52)
#include <openthread/dataset.h>
#include <openthread/thread.h>
#include <openthread/logging.h>
#include "openthread.h"
#include "esphome/core/helpers.h"
#include <zephyr/net/openthread.h>
static const char *const TAG = "openthread";
namespace esphome::openthread {
static void on_thread_state_changed(otChangedFlags flags, struct openthread_context *ot_context, void *user_data) {
// Delegate connection status tracking to common callback
if (global_openthread_component != nullptr) {
OpenThreadComponent::on_state_changed(flags, global_openthread_component);
}
if (flags & OT_CHANGED_THREAD_ROLE) {
otDeviceRole role = otThreadGetDeviceRole(ot_context->instance);
ESP_LOGI(TAG, "Thread role changed to %s", otThreadDeviceRoleToString(role));
}
if (flags & OT_CHANGED_THREAD_NETDATA) {
ESP_LOGI(TAG, "Thread network data updated");
}
if (flags & (OT_CHANGED_THREAD_ROLE | OT_CHANGED_THREAD_NETDATA)) {
char buf[NET_IPV6_ADDR_LEN];
for (const otNetifAddress *addr = otIp6GetUnicastAddresses(ot_context->instance); addr != nullptr;
addr = addr->mNext) {
ESP_LOGI(TAG, " Address: %s", net_addr_ntop(AF_INET6, &addr->mAddress, buf, sizeof(buf)));
}
}
}
static struct openthread_state_changed_cb ot_state_changed_cb = {.state_changed_cb = on_thread_state_changed};
void OpenThreadComponent::setup() {
struct openthread_context *context = openthread_get_default_context();
this->lock_initialized_ = true;
otOperationalDatasetTlvs dataset = {};
#ifndef USE_OPENTHREAD_FORCE_DATASET
otError error = otDatasetGetActiveTlvs(context->instance, &dataset);
if (error != OT_ERROR_NONE) {
dataset.mLength = 0;
} else {
ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)");
}
#endif
#ifdef USE_OPENTHREAD_TLVS
if (dataset.mLength == 0) {
const size_t tlv_chars = sizeof(USE_OPENTHREAD_TLVS) - 1;
if ((tlv_chars % 2) != 0) {
ESP_LOGE(TAG, "Invalid OpenThread TLV hex string length (must be even, got %zu)", tlv_chars);
this->mark_failed();
return;
}
size_t len = tlv_chars / 2;
if (len > sizeof(dataset.mTlvs)) {
ESP_LOGE(TAG, "OpenThread TLV too long (max %zu bytes, got %zu bytes)", sizeof(dataset.mTlvs), len);
this->mark_failed();
return;
}
size_t parsed = parse_hex(USE_OPENTHREAD_TLVS, tlv_chars, dataset.mTlvs, len);
if (parsed != tlv_chars) {
ESP_LOGE(TAG, "Invalid OpenThread TLV hex string (expected %zu hex chars, got %zu)", tlv_chars, parsed);
this->mark_failed();
return;
}
dataset.mLength = len;
}
#endif
if (dataset.mLength > 0) {
otError error = otDatasetSetActiveTlvs(context->instance, &dataset);
if (error != OT_ERROR_NONE) {
ESP_LOGE(TAG, "Failed to set active dataset: %s", otThreadErrorToString(error));
this->mark_failed();
return;
}
}
openthread_state_changed_cb_register(context, &ot_state_changed_cb);
openthread_start(context);
}
void OpenThreadComponent::ot_main() {}
otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); }
int OpenThreadComponent::openthread_stop_() {
// OT stack is intentionally left running — no Zephyr stop API. The state callback stays
// registered but is safe (null-checks global_openthread_component). nRF52840 never
// re-enters setup() after teardown so this is functionally correct.
this->teardown_complete_ = true;
return 0;
}
network::IPAddresses OpenThreadComponent::get_ip_addresses() {
network::IPAddresses addresses;
auto lock = InstanceLock::acquire();
size_t addr_count = 0;
for (const otNetifAddress *addr = otIp6GetUnicastAddresses(openthread_get_default_instance());
addr != nullptr && addr_count + 1 < addresses.size(); addr = addr->mNext) {
struct in6_addr ip6;
memcpy(&ip6, addr->mAddress.mFields.m8, sizeof(ip6));
addresses[addr_count + 1] = network::IPAddress(&ip6);
addr_count++;
}
return addresses;
}
InstanceLock InstanceLock::try_acquire(int delay) {
if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) {
return InstanceLock(false);
}
struct openthread_context *ot_context = openthread_get_default_context();
if (k_mutex_lock(&ot_context->api_lock, K_MSEC(delay)) == 0) {
return InstanceLock(true);
}
return InstanceLock(false);
}
InstanceLock InstanceLock::acquire() {
struct openthread_context *ot_context = openthread_get_default_context();
k_mutex_lock(&ot_context->api_lock, K_FOREVER);
return InstanceLock(true);
}
otInstance *InstanceLock::get_instance() { return openthread_get_default_instance(); }
InstanceLock::~InstanceLock() {
if (this->owns_) {
struct openthread_context *ot_context = openthread_get_default_context();
k_mutex_unlock(&ot_context->api_lock);
}
}
} // namespace esphome::openthread
#endif

View File

@@ -76,7 +76,10 @@ def zephyr_data() -> ZephyrData:
def zephyr_add_prj_conf( def zephyr_add_prj_conf(
name: str, value: PrjConfValueType, required: bool = True, image: str = "" name: str,
value: PrjConfValueType,
required: bool = True,
image: str = "",
) -> None: ) -> None:
"""Set an zephyr prj conf value.""" """Set an zephyr prj conf value."""
if not name.startswith("CONFIG_"): if not name.startswith("CONFIG_"):
@@ -133,7 +136,7 @@ def zephyr_to_code(config: ConfigType) -> None:
# <err> os: ***** USAGE FAULT ***** # <err> os: ***** USAGE FAULT *****
# <err> os: Illegal load of EXC_RETURN into PC # <err> os: Illegal load of EXC_RETURN into PC
zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048) zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048, required=False)
CORE.add_job(_cdc_acm_to_code, config) CORE.add_job(_cdc_acm_to_code, config)

View File

@@ -0,0 +1,5 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000