mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 11:07:33 +00:00
[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:
@@ -10,6 +10,8 @@ from esphome.components.esp32 import (
|
||||
require_vfs_select,
|
||||
)
|
||||
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
|
||||
from esphome.const import (
|
||||
CONF_CHANNEL,
|
||||
@@ -20,6 +22,7 @@ from esphome.const import (
|
||||
CONF_OUTPUT_POWER,
|
||||
CONF_USE_ADDRESS,
|
||||
PLATFORM_ESP32,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
@@ -52,7 +55,6 @@ 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"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
|
||||
IDF_TO_OT_LOG_LEVEL = {
|
||||
"NONE": "NONE",
|
||||
@@ -98,9 +100,7 @@ def set_sdkconfig_options(config):
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True)
|
||||
|
||||
if tlv := config.get(CONF_TLV):
|
||||
cg.add_define("USE_OPENTHREAD_TLVS", tlv)
|
||||
else:
|
||||
if not config.get(CONF_TLV):
|
||||
if pan_id := config.get(CONF_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()
|
||||
)
|
||||
|
||||
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_SRP_CLIENT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5)
|
||||
@@ -159,6 +156,11 @@ _CONNECTION_SCHEMA = cv.Schema(
|
||||
def _validate(config: ConfigType) -> ConfigType:
|
||||
if CONF_USE_ADDRESS not in config:
|
||||
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)
|
||||
poll_period = config.get(CONF_POLL_PERIOD)
|
||||
if (
|
||||
@@ -175,11 +177,33 @@ def _validate(config: ConfigType) -> ConfigType:
|
||||
|
||||
def _require_vfs_select(config):
|
||||
"""Register VFS select requirement during config validation."""
|
||||
# OpenThread uses esp_vfs_eventfd which requires VFS select support
|
||||
require_vfs_select()
|
||||
# OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only)
|
||||
if CORE.is_esp32:
|
||||
require_vfs_select()
|
||||
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(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -190,7 +214,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
*CONF_DEVICE_TYPES, upper=True
|
||||
),
|
||||
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_POLL_PERIOD): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_OUTPUT_POWER): cv.All(
|
||||
@@ -200,7 +224,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(_CONNECTION_SCHEMA),
|
||||
cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV),
|
||||
only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]),
|
||||
_validate_platform,
|
||||
_validate,
|
||||
_require_vfs_select,
|
||||
)
|
||||
@@ -227,13 +251,27 @@ def _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)
|
||||
async def to_code(config):
|
||||
# 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")
|
||||
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()
|
||||
@@ -252,4 +290,12 @@ async def to_code(config):
|
||||
if (output_power := config.get(CONF_OUTPUT_POWER)) is not None:
|
||||
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)
|
||||
|
||||
@@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() {
|
||||
char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size);
|
||||
const auto &host_name = App.get_name();
|
||||
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");
|
||||
return;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ void OpenThreadSrpComponent::setup() {
|
||||
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();
|
||||
ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size());
|
||||
for (const auto &service : mdns_services) {
|
||||
@@ -164,7 +164,7 @@ void OpenThreadSrpComponent::setup() {
|
||||
// Set service name
|
||||
char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size);
|
||||
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());
|
||||
continue;
|
||||
}
|
||||
@@ -172,7 +172,7 @@ void OpenThreadSrpComponent::setup() {
|
||||
|
||||
// Set instance name (using host_name)
|
||||
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());
|
||||
continue;
|
||||
}
|
||||
@@ -189,11 +189,21 @@ void OpenThreadSrpComponent::setup() {
|
||||
for (size_t i = 0; i < service.txt_records.size(); 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_
|
||||
// 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);
|
||||
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].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.mNumTxtEntries = service.txt_records.size();
|
||||
@@ -233,7 +243,7 @@ bool OpenThreadComponent::teardown() {
|
||||
global_openthread_component = nullptr;
|
||||
ESP_LOGD(TAG, "Exit main loop ");
|
||||
int error = this->openthread_stop_();
|
||||
if (error != ESP_OK) {
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error);
|
||||
this->teardown_complete_ = true;
|
||||
}
|
||||
|
||||
@@ -43,10 +43,11 @@ class OpenThreadComponent : public Component {
|
||||
void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; }
|
||||
#endif
|
||||
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:
|
||||
std::optional<otIp6Address> get_omr_address_(InstanceLock &lock);
|
||||
static void on_state_changed(otChangedFlags flags, void *context);
|
||||
otInstance *get_openthread_instance_();
|
||||
int openthread_stop_();
|
||||
std::function<void()> factory_reset_external_callback_;
|
||||
|
||||
@@ -217,7 +217,7 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() {
|
||||
otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); }
|
||||
|
||||
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(esp_openthread_lock_acquire(delay));
|
||||
|
||||
141
esphome/components/openthread/openthread_zephyr.cpp
Normal file
141
esphome/components/openthread/openthread_zephyr.cpp
Normal 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
|
||||
@@ -76,7 +76,10 @@ def zephyr_data() -> ZephyrData:
|
||||
|
||||
|
||||
def zephyr_add_prj_conf(
|
||||
name: str, value: PrjConfValueType, required: bool = True, image: str = ""
|
||||
name: str,
|
||||
value: PrjConfValueType,
|
||||
required: bool = True,
|
||||
image: str = "",
|
||||
) -> None:
|
||||
"""Set an zephyr prj conf value."""
|
||||
if not name.startswith("CONFIG_"):
|
||||
@@ -133,7 +136,7 @@ def zephyr_to_code(config: ConfigType) -> None:
|
||||
|
||||
# <err> os: ***** USAGE FAULT *****
|
||||
# <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)
|
||||
|
||||
|
||||
5
tests/components/openthread/test.nrf52-adafruit.yaml
Normal file
5
tests/components/openthread/test.nrf52-adafruit.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
openthread:
|
||||
tlv: 0E080000000000010000
|
||||
Reference in New Issue
Block a user