From 9d9d21017698e661157c3ab6ff4f8ee9a1aa697f Mon Sep 17 00:00:00 2001 From: Mathieu Rene Date: Wed, 11 Jun 2025 01:27:58 -0400 Subject: [PATCH] Add OpenThread support on ESP-IDF (#7506) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/mdns/mdns_component.cpp | 4 + esphome/components/mdns/mdns_component.h | 2 + esphome/components/network/util.cpp | 13 + esphome/components/openthread/__init__.py | 148 ++++++++++++ esphome/components/openthread/const.py | 10 + esphome/components/openthread/openthread.cpp | 201 +++++++++++++++ esphome/components/openthread/openthread.h | 72 ++++++ .../components/openthread/openthread_esp.cpp | 166 +++++++++++++ esphome/components/openthread/tlv.py | 58 +++++ .../components/openthread_info/__init__.py | 0 .../openthread_info_text_sensor.cpp | 24 ++ .../openthread_info_text_sensor.h | 228 ++++++++++++++++++ .../components/openthread_info/text_sensor.py | 105 ++++++++ esphome/core/defines.h | 3 + .../openthread/test.esp32-c6-idf.yaml | 11 + .../openthread_info/test.esp32-c6-idf.yaml | 30 +++ 17 files changed, 1076 insertions(+) create mode 100644 esphome/components/openthread/__init__.py create mode 100644 esphome/components/openthread/const.py create mode 100644 esphome/components/openthread/openthread.cpp create mode 100644 esphome/components/openthread/openthread.h create mode 100644 esphome/components/openthread/openthread_esp.cpp create mode 100644 esphome/components/openthread/tlv.py create mode 100644 esphome/components/openthread_info/__init__.py create mode 100644 esphome/components/openthread_info/openthread_info_text_sensor.cpp create mode 100644 esphome/components/openthread_info/openthread_info_text_sensor.h create mode 100644 esphome/components/openthread_info/text_sensor.py create mode 100644 tests/components/openthread/test.esp32-c6-idf.yaml create mode 100644 tests/components/openthread_info/test.esp32-c6-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9e4202f2d4..66ea80f8d6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -322,6 +322,7 @@ esphome/components/number/* @esphome/core esphome/components/one_wire/* @ssieb esphome/components/online_image/* @clydebarrow @guillempages esphome/components/opentherm/* @olegtarasov +esphome/components/openthread/* @mrene esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 2a7c524f0f..06ca99b402 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -59,6 +59,8 @@ void MDNSComponent::compile_records_() { service.txt_records.push_back({"network", "wifi"}); #elif defined(USE_ETHERNET) service.txt_records.push_back({"network", "ethernet"}); +#elif defined(USE_OPENTHREAD) + service.txt_records.push_back({"network", "thread"}); #endif #ifdef USE_API_NOISE @@ -132,6 +134,8 @@ void MDNSComponent::dump_config() { } } +std::vector MDNSComponent::get_services() { return this->services_; } + } // namespace mdns } // namespace esphome #endif diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 3f3f663900..93a16f40d2 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -37,6 +37,8 @@ class MDNSComponent : public Component { void add_extra_service(MDNSService service) { services_extra_.push_back(std::move(service)); } + std::vector get_services(); + void on_shutdown() override; protected: diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index ed519f738a..a8e792a2d7 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -9,6 +9,10 @@ #include "esphome/components/ethernet/ethernet_component.h" #endif +#ifdef USE_OPENTHREAD +#include "esphome/components/openthread/openthread.h" +#endif + namespace esphome { namespace network { @@ -23,6 +27,11 @@ bool is_connected() { return wifi::global_wifi_component->is_connected(); #endif +#ifdef USE_OPENTHREAD + if (openthread::global_openthread_component != nullptr) + return openthread::global_openthread_component->is_connected(); +#endif + #ifdef USE_HOST return true; // Assume its connected #endif @@ -45,6 +54,10 @@ network::IPAddresses get_ip_addresses() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) return wifi::global_wifi_component->get_ip_addresses(); +#endif +#ifdef USE_OPENTHREAD + if (openthread::global_openthread_component != nullptr) + return openthread::global_openthread_component->get_ip_addresses(); #endif return {}; } diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py new file mode 100644 index 0000000000..9643239cb0 --- /dev/null +++ b/esphome/components/openthread/__init__.py @@ -0,0 +1,148 @@ +import esphome.codegen as cg +from esphome.components.esp32 import ( + VARIANT_ESP32C6, + VARIANT_ESP32H2, + add_idf_sdkconfig_option, + only_on_variant, +) +from esphome.components.mdns import MDNSComponent +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ENABLE_IPV6, CONF_ID +from esphome.core import CORE +import esphome.final_validate as fv + +from .const import ( + CONF_EXT_PAN_ID, + CONF_FORCE_DATASET, + CONF_MDNS_ID, + CONF_MESH_LOCAL_PREFIX, + CONF_NETWORK_KEY, + CONF_NETWORK_NAME, + CONF_PAN_ID, + CONF_PSKC, + CONF_SRP_ID, + CONF_TLV, +) +from .tlv import parse_tlv + +CODEOWNERS = ["@mrene"] + +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"] + + +def set_sdkconfig_options(config): + # 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) + + # 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) + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", config[CONF_PAN_ID]) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_CHANNEL", config[CONF_CHANNEL]) + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{config[CONF_NETWORK_KEY]:X}" + ) + + if network_name := config.get(CONF_NETWORK_NAME): + 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}" + ) + 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:X}" + ) + if (pskc := config.get(CONF_PSKC)) is not None: + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}") + + if CONF_FORCE_DATASET in config: + if config[CONF_FORCE_DATASET]: + cg.add_define("CONFIG_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) + + # TODO: Add suport for sleepy end devices + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_FTD", True) # Full Thread Device + + +openthread_ns = cg.esphome_ns.namespace("openthread") +OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) +OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) + + +def _convert_tlv(config): + if tlv := config.get(CONF_TLV): + config = config.copy() + parsed_tlv = parse_tlv(tlv) + validated = _CONNECTION_SCHEMA(parsed_tlv) + config.update(validated) + del config[CONF_TLV] + return config + + +_CONNECTION_SCHEMA = cv.Schema( + { + cv.Inclusive(CONF_PAN_ID, "manual"): cv.hex_int, + cv.Inclusive(CONF_CHANNEL, "manual"): cv.int_, + cv.Inclusive(CONF_NETWORK_KEY, "manual"): cv.hex_int, + cv.Optional(CONF_EXT_PAN_ID): cv.hex_int, + cv.Optional(CONF_NETWORK_NAME): cv.string_strict, + cv.Optional(CONF_PSKC): cv.hex_int, + cv.Optional(CONF_MESH_LOCAL_PREFIX): cv.hex_int, + } +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + 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_FORCE_DATASET): cv.boolean, + cv.Optional(CONF_TLV): cv.string_strict, + } + ).extend(_CONNECTION_SCHEMA), + cv.has_exactly_one_key(CONF_PAN_ID, CONF_TLV), + _convert_tlv, + cv.only_with_esp_idf, + only_on_variant(supported=[VARIANT_ESP32C6, VARIANT_ESP32H2]), +) + + +def _final_validate(_): + full_config = fv.full_config.get() + network_config = full_config.get("network", {}) + if not network_config.get(CONF_ENABLE_IPV6, False): + raise cv.Invalid( + "OpenThread requires IPv6 to be enabled in the network component. " + "Please set `enable_ipv6: true` in the `network` configuration." + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + cg.add_define("USE_OPENTHREAD") + + ot = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(ot, config) + + srp = cg.new_Pvariable(config[CONF_SRP_ID]) + cg.add(srp.set_host_name(cg.RawExpression(f'"{CORE.name}"'))) + mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) + cg.add(srp.set_mdns(mdns_component)) + await cg.register_component(srp, config) + + set_sdkconfig_options(config) diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py new file mode 100644 index 0000000000..7837e69eea --- /dev/null +++ b/esphome/components/openthread/const.py @@ -0,0 +1,10 @@ +CONF_EXT_PAN_ID = "ext_pan_id" +CONF_FORCE_DATASET = "force_dataset" +CONF_MDNS_ID = "mdns_id" +CONF_MESH_LOCAL_PREFIX = "mesh_local_prefix" +CONF_NETWORK_NAME = "network_name" +CONF_NETWORK_KEY = "network_key" +CONF_PAN_ID = "pan_id" +CONF_PSKC = "pskc" +CONF_SRP_ID = "srp_id" +CONF_TLV = "tlv" diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp new file mode 100644 index 0000000000..c42d1fb4e1 --- /dev/null +++ b/esphome/components/openthread/openthread.cpp @@ -0,0 +1,201 @@ +#include "esphome/core/defines.h" +#ifdef USE_OPENTHREAD +#include "openthread.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#define TAG "openthread" + +namespace esphome { +namespace openthread { + +OpenThreadComponent *global_openthread_component = nullptr; + +OpenThreadComponent::OpenThreadComponent() { global_openthread_component = this; } + +OpenThreadComponent::~OpenThreadComponent() { + auto lock = InstanceLock::try_acquire(100); + if (!lock) { + ESP_LOGW(TAG, "Failed to acquire OpenThread lock in destructor, leaking memory"); + return; + } + otInstance *instance = lock->get_instance(); + otSrpClientClearHostAndServices(instance); + otSrpClientBuffersFreeAllServices(instance); + global_openthread_component = nullptr; +} + +bool OpenThreadComponent::is_connected() { + auto lock = InstanceLock::try_acquire(100); + if (!lock) { + ESP_LOGW(TAG, "Failed to acquire OpenThread lock in is_connected"); + return false; + } + + otInstance *instance = lock->get_instance(); + if (instance == nullptr) { + return false; + } + + otDeviceRole role = otThreadGetDeviceRole(instance); + + // TODO: If we're a leader, check that there is at least 1 known peer + return role >= OT_DEVICE_ROLE_CHILD; +} + +// Gets the off-mesh routable address +std::optional OpenThreadComponent::get_omr_address() { + auto lock = InstanceLock::acquire(); + return this->get_omr_address_(lock); +} + +std::optional OpenThreadComponent::get_omr_address_(std::optional &lock) { + otNetworkDataIterator iterator = OT_NETWORK_DATA_ITERATOR_INIT; + otInstance *instance = nullptr; + + instance = lock->get_instance(); + + otBorderRouterConfig aConfig; + if (otNetDataGetNextOnMeshPrefix(instance, &iterator, &aConfig) != OT_ERROR_NONE) { + return std::nullopt; + } + + const otIp6Prefix *omrPrefix = &aConfig.mPrefix; + const otNetifAddress *unicastAddrs = otIp6GetUnicastAddresses(instance); + for (const otNetifAddress *addr = unicastAddrs; addr; addr = addr->mNext) { + const otIp6Address *localIp = &addr->mAddress; + if (otIp6PrefixMatch(&omrPrefix->mPrefix, localIp)) { + return *localIp; + } + } + return {}; +} + +void srpCallback(otError aError, const otSrpClientHostInfo *aHostInfo, const otSrpClientService *aServices, + const otSrpClientService *aRemovedServices, void *aContext) { + if (aError != 0) { + ESP_LOGW(TAG, "SRP client reported an error: %s", otThreadErrorToString(aError)); + for (const otSrpClientHostInfo *host = aHostInfo; host; host = nullptr) { + ESP_LOGW(TAG, " Host: %s", host->mName); + } + for (const otSrpClientService *service = aServices; service; service = service->mNext) { + ESP_LOGW(TAG, " Service: %s", service->mName); + } + } +} + +void srpStartCallback(const otSockAddr *aServerSockAddr, void *aContext) { ESP_LOGI(TAG, "SRP client has started"); } + +void OpenThreadSrpComponent::setup() { + otError error; + auto lock = InstanceLock::acquire(); + otInstance *instance = lock->get_instance(); + + otSrpClientSetCallback(instance, srpCallback, nullptr); + + // set the host name + uint16_t size; + char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); + uint16_t len = this->host_name_.size(); + if (len > size) { + ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name"); + return; + } + memcpy(existing_host_name, this->host_name_.c_str(), len + 1); + + error = otSrpClientSetHostName(instance, existing_host_name); + if (error != 0) { + ESP_LOGW(TAG, "Could not set host name"); + return; + } + + error = otSrpClientEnableAutoHostAddress(instance); + if (error != 0) { + ESP_LOGW(TAG, "Could not enable auto host address"); + return; + } + + // Copy the mdns services to our local instance so that the c_str pointers remain valid for the lifetime of this + // component + this->mdns_services_ = this->mdns_->get_services(); + ESP_LOGW(TAG, "Setting up SRP services. count = %d\n", this->mdns_services_.size()); + for (const auto &service : this->mdns_services_) { + otSrpClientBuffersServiceEntry *entry = otSrpClientBuffersAllocateService(instance); + if (!entry) { + ESP_LOGW(TAG, "Failed to allocate service entry"); + continue; + } + + // Set service name + char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size); + std::string full_service = service.service_type + "." + service.proto; + if (full_service.size() > size) { + ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str()); + continue; + } + memcpy(string, full_service.c_str(), full_service.size() + 1); + + // Set instance name (using host_name) + string = otSrpClientBuffersGetServiceEntryInstanceNameString(entry, &size); + if (this->host_name_.size() > size) { + ESP_LOGW(TAG, "Instance name too long: %s", this->host_name_.c_str()); + continue; + } + memcpy(string, this->host_name_.c_str(), this->host_name_.size() + 1); + + // Set port + entry->mService.mPort = const_cast &>(service.port).value(); + + otDnsTxtEntry *mTxtEntries = + reinterpret_cast(this->pool_alloc_(sizeof(otDnsTxtEntry) * service.txt_records.size())); + // Set TXT records + entry->mService.mNumTxtEntries = service.txt_records.size(); + for (size_t i = 0; i < service.txt_records.size(); i++) { + const auto &txt = service.txt_records[i]; + auto value = const_cast &>(txt.value).value(); + mTxtEntries[i].mKey = txt.key.c_str(); + mTxtEntries[i].mValue = reinterpret_cast(value.c_str()); + mTxtEntries[i].mValueLength = value.size(); + } + entry->mService.mTxtEntries = mTxtEntries; + entry->mService.mNumTxtEntries = service.txt_records.size(); + + // Add service + error = otSrpClientAddService(instance, &entry->mService); + if (error != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to add service: %s", otThreadErrorToString(error)); + } + ESP_LOGW(TAG, "Added service: %s", full_service.c_str()); + } + + otSrpClientEnableAutoStartMode(instance, srpStartCallback, nullptr); + ESP_LOGW(TAG, "Finished SRP setup **** "); +} + +void *OpenThreadSrpComponent::pool_alloc_(size_t size) { + uint8_t *ptr = new uint8_t[size]; + this->memory_pool_.emplace_back(std::unique_ptr(ptr)); + return ptr; +} + +void OpenThreadSrpComponent::set_host_name(std::string host_name) { this->host_name_ = host_name; } + +void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } + +} // namespace openthread +} // namespace esphome + +#endif diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h new file mode 100644 index 0000000000..32728521ae --- /dev/null +++ b/esphome/components/openthread/openthread.h @@ -0,0 +1,72 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_OPENTHREAD + +#include "esphome/core/component.h" +#include "esphome/components/mdns/mdns_component.h" +#include "esphome/components/network/ip_address.h" + +#include + +#include +#include + +namespace esphome { +namespace openthread { + +class InstanceLock; + +class OpenThreadComponent : public Component { + public: + OpenThreadComponent(); + ~OpenThreadComponent(); + void setup() override; + float get_setup_priority() const override { return setup_priority::WIFI; } + + bool is_connected(); + network::IPAddresses get_ip_addresses(); + std::optional get_omr_address(); + void ot_main(); + + protected: + std::optional get_omr_address_(std::optional &lock); +}; + +extern OpenThreadComponent *global_openthread_component; + +class OpenThreadSrpComponent : public Component { + public: + void set_mdns(esphome::mdns::MDNSComponent *mdns); + void set_host_name(std::string host_name); + // This has to run after the mdns component or else no services are available to advertise + float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0; } + + protected: + void setup() override; + + private: + std::string host_name_; + esphome::mdns::MDNSComponent *mdns_{nullptr}; + std::vector mdns_services_; + std::vector> memory_pool_; + void *pool_alloc_(size_t size); +}; + +class InstanceLock { + public: + static std::optional try_acquire(int delay); + static std::optional acquire(); + ~InstanceLock(); + + // Returns the global openthread instance guarded by this lock + otInstance *get_instance(); + + private: + // Use a private constructor in order to force thehandling + // of acquisition failure + InstanceLock() {} +}; + +} // namespace openthread +} // namespace esphome +#endif diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp new file mode 100644 index 0000000000..f7601c69f5 --- /dev/null +++ b/esphome/components/openthread/openthread_esp.cpp @@ -0,0 +1,166 @@ +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_ESP_IDF) +#include "openthread.h" +#include + +#include "esp_openthread.h" +#include "esp_openthread_lock.h" +#include "esp_log.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esp_task_wdt.h" + +#include "esp_openthread_cli.h" +#include "esp_openthread_netif_glue.h" +#include "esp_event.h" +#include "nvs_flash.h" +#include "esp_vfs_eventfd.h" +#include "esp_netif.h" +#include "esp_netif_types.h" +#include "esp_err.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define TAG "openthread" + +namespace esphome { +namespace openthread { + +void OpenThreadComponent::setup() { + ESP_LOGI(TAG, "Setting up OpenThread..."); + // Used eventfds: + // * netif + // * ot task queue + // * radio driver + esp_vfs_eventfd_config_t eventfd_config = { + .max_fds = 3, + }; + ESP_ERROR_CHECK(nvs_flash_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); + + xTaskCreate( + [](void *arg) { + static_cast(arg)->ot_main(); + vTaskDelete(nullptr); + }, + "ot_main", 10240, this, 5, nullptr); + + ESP_LOGI(TAG, "OpenThread started"); +} + +static esp_netif_t *init_openthread_netif(const esp_openthread_platform_config_t *config) { + esp_netif_config_t cfg = ESP_NETIF_DEFAULT_OPENTHREAD(); + esp_netif_t *netif = esp_netif_new(&cfg); + assert(netif != nullptr); + ESP_ERROR_CHECK(esp_netif_attach(netif, esp_openthread_netif_glue_init(config))); + + return netif; +} + +void OpenThreadComponent::ot_main() { + esp_openthread_platform_config_t 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, + }, + }; + + // Initialize the OpenThread stack + // otLoggingSetLevel(OT_LOG_LEVEL_DEBG); + ESP_ERROR_CHECK(esp_openthread_init(&config)); + +#if CONFIG_OPENTHREAD_STATE_INDICATOR_ENABLE + ESP_ERROR_CHECK(esp_openthread_state_indicator_init(esp_openthread_get_instance())); +#endif + +#if CONFIG_OPENTHREAD_LOG_LEVEL_DYNAMIC + // The OpenThread log level directly matches ESP log level + (void) otLoggingSetLevel(CONFIG_LOG_DEFAULT_LEVEL); +#endif + // Initialize the OpenThread cli +#if CONFIG_OPENTHREAD_CLI + esp_openthread_cli_init(); +#endif + + esp_netif_t *openthread_netif; + // Initialize the esp_netif bindings + openthread_netif = init_openthread_netif(&config); + esp_netif_set_default_netif(openthread_netif); + +#if CONFIG_OPENTHREAD_CLI_ESP_EXTENSION + esp_cli_custom_command_init(); +#endif // CONFIG_OPENTHREAD_CLI_ESP_EXTENSION + + // Run the main loop +#if CONFIG_OPENTHREAD_CLI + esp_openthread_cli_create_task(); +#endif + ESP_LOGI(TAG, "Activating dataset..."); + otOperationalDatasetTlvs dataset; + +#ifdef CONFIG_OPENTHREAD_FORCE_DATASET + ESP_ERROR_CHECK(esp_openthread_auto_start(NULL)); +#else + otError error = otDatasetGetActiveTlvs(esp_openthread_get_instance(), &dataset); + ESP_ERROR_CHECK(esp_openthread_auto_start((error == OT_ERROR_NONE) ? &dataset : NULL)); +#endif + esp_openthread_launch_mainloop(); + + // Clean up + esp_openthread_netif_glue_deinit(); + esp_netif_destroy(openthread_netif); + + esp_vfs_eventfd_unregister(); +} + +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); + for (int i = 0; i < count; i++) { + addresses[i + 1] = network::IPAddress(&if_ip6s[i]); + } + return addresses; +} + +std::optional InstanceLock::try_acquire(int delay) { + if (esp_openthread_lock_acquire(delay)) { + return InstanceLock(); + } + return {}; +} + +std::optional InstanceLock::acquire() { + while (!esp_openthread_lock_acquire(100)) { + esp_task_wdt_reset(); + } + return InstanceLock(); +} + +otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } + +InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } + +} // namespace openthread +} // namespace esphome +#endif diff --git a/esphome/components/openthread/tlv.py b/esphome/components/openthread/tlv.py new file mode 100644 index 0000000000..45c8c47227 --- /dev/null +++ b/esphome/components/openthread/tlv.py @@ -0,0 +1,58 @@ +# Sourced from https://gist.github.com/agners/0338576e0003318b63ec1ea75adc90f9 +import binascii + +from esphome.const import CONF_CHANNEL + +from . import ( + CONF_EXT_PAN_ID, + CONF_MESH_LOCAL_PREFIX, + CONF_NETWORK_KEY, + CONF_NETWORK_NAME, + CONF_PAN_ID, + CONF_PSKC, +) + +TLV_TYPES = { + 0: CONF_CHANNEL, + 1: CONF_PAN_ID, + 2: CONF_EXT_PAN_ID, + 3: CONF_NETWORK_NAME, + 4: CONF_PSKC, + 5: CONF_NETWORK_KEY, + 7: CONF_MESH_LOCAL_PREFIX, +} + + +def parse_tlv(tlv) -> dict: + data = binascii.a2b_hex(tlv) + output = {} + pos = 0 + while pos < len(data): + tag = data[pos] + pos += 1 + _len = data[pos] + pos += 1 + val = data[pos : pos + _len] + pos += _len + if tag in TLV_TYPES: + if tag == 3: + output[TLV_TYPES[tag]] = val.decode("utf-8") + else: + output[TLV_TYPES[tag]] = int.from_bytes(val) + return output + + +def main(): + import sys + + args = sys.argv[1:] + parsed = parse_tlv(args[0]) + # print the parsed TLV data + for key, value in parsed.items(): + if isinstance(value, bytes): + value = value.hex() + print(f"{key}: {value}") + + +if __name__ == "__main__": + main() diff --git a/esphome/components/openthread_info/__init__.py b/esphome/components/openthread_info/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp new file mode 100644 index 0000000000..6570ed9d59 --- /dev/null +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -0,0 +1,24 @@ + +#include "openthread_info_text_sensor.h" +#ifdef USE_OPENTHREAD +#include "esphome/core/log.h" + +namespace esphome { +namespace openthread_info { + +static const char *const TAG = "openthread_info"; + +void IPAddressOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo IPAddress", this); } +void RoleOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo Role", this); } +void ChannelOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo Channel", this); } +void Rloc16OpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo Rloc16", this); } +void ExtAddrOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo ExtAddr", this); } +void Eui64OpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo Eui64", this); } +void NetworkNameOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo Network Name", this); } +void NetworkKeyOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo Network Key", this); } +void PanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo PAN ID", this); } +void ExtPanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "OpenThreadInfo Extended PAN ID", this); } + +} // namespace openthread_info +} // namespace esphome +#endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h new file mode 100644 index 0000000000..d6a32181d8 --- /dev/null +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -0,0 +1,228 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/components/openthread/openthread.h" +#ifdef USE_OPENTHREAD + +namespace esphome { +namespace openthread_info { + +using esphome::openthread::InstanceLock; + +class OpenThreadInstancePollingComponent : public PollingComponent { + public: + void update() override { + auto lock = InstanceLock::try_acquire(10); + if (!lock) { + return; + } + + this->update_instance_(lock->get_instance()); + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + protected: + virtual void update_instance_(otInstance *instance) = 0; +}; + +class IPAddressOpenThreadInfo : public PollingComponent, public text_sensor::TextSensor { + public: + void update() override { + std::optional address = openthread::global_openthread_component->get_omr_address(); + if (!address) { + return; + } + + char addressAsString[40]; + otIp6AddressToString(&*address, addressAsString, 40); + std::string ip = addressAsString; + + if (this->last_ip_ != ip) { + this->last_ip_ = ip; + this->publish_state(this->last_ip_); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-ip"; } + void dump_config() override; + + protected: + std::string last_ip_; +}; + +class RoleOpenThreadInfo : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance_(otInstance *instance) override { + otDeviceRole role = otThreadGetDeviceRole(instance); + + if (this->last_role_ != role) { + this->last_role_ = role; + this->publish_state(otThreadDeviceRoleToString(this->last_role_)); + } + } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-role"; } + void dump_config() override; + + protected: + otDeviceRole last_role_; +}; + +class Rloc16OpenThreadInfo : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance_(otInstance *instance) override { + uint16_t rloc16 = otThreadGetRloc16(instance); + if (this->last_rloc16_ != rloc16) { + this->last_rloc16_ = rloc16; + char buf[5]; + snprintf(buf, sizeof(buf), "%04x", rloc16); + this->publish_state(buf); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-rloc16"; } + void dump_config() override; + + protected: + uint16_t last_rloc16_; +}; + +class ExtAddrOpenThreadInfo : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance_(otInstance *instance) override { + auto extaddr = otLinkGetExtendedAddress(instance); + if (!std::equal(this->last_extaddr_.begin(), this->last_extaddr_.end(), extaddr->m8)) { + std::copy(extaddr->m8, extaddr->m8 + 8, this->last_extaddr_.begin()); + this->publish_state(format_hex(extaddr->m8, 8)); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-extaddr"; } + void dump_config() override; + + protected: + std::array last_extaddr_{}; +}; + +class Eui64OpenThreadInfo : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance_(otInstance *instance) override { + otExtAddress addr; + otLinkGetFactoryAssignedIeeeEui64(instance, &addr); + + if (!std::equal(this->last_eui64_.begin(), this->last_eui64_.end(), addr.m8)) { + std::copy(addr.m8, addr.m8 + 8, this->last_eui64_.begin()); + this->publish_state(format_hex(this->last_eui64_.begin(), 8)); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-extaddr"; } + void dump_config() override; + + protected: + std::array last_eui64_{}; +}; + +class ChannelOpenThreadInfo : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance_(otInstance *instance) override { + uint8_t channel = otLinkGetChannel(instance); + if (this->last_channel_ != channel) { + this->last_channel_ = channel; + this->publish_state(std::to_string(this->last_channel_)); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-extaddr"; } + void dump_config() override; + + protected: + uint8_t last_channel_; +}; + +class DatasetOpenThreadInfo : public OpenThreadInstancePollingComponent { + public: + void update_instance_(otInstance *instance) override { + otOperationalDataset dataset; + if (otDatasetGetActive(instance, &dataset) != OT_ERROR_NONE) { + return; + } + + this->update_dataset_(&dataset); + } + + protected: + virtual void update_dataset_(otOperationalDataset *dataset) = 0; +}; + +class NetworkNameOpenThreadInfo : public DatasetOpenThreadInfo, public text_sensor::TextSensor { + public: + void update_dataset_(otOperationalDataset *dataset) override { + if (this->last_network_name_ != dataset->mNetworkName.m8) { + this->last_network_name_ = dataset->mNetworkName.m8; + this->publish_state(this->last_network_name_); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-networkname"; } + void dump_config() override; + + protected: + std::string last_network_name_; +}; + +class NetworkKeyOpenThreadInfo : public DatasetOpenThreadInfo, public text_sensor::TextSensor { + public: + void update_dataset_(otOperationalDataset *dataset) override { + if (!std::equal(this->last_key_.begin(), this->last_key_.end(), dataset->mNetworkKey.m8)) { + std::copy(dataset->mNetworkKey.m8, dataset->mNetworkKey.m8 + 16, this->last_key_.begin()); + this->publish_state(format_hex(dataset->mNetworkKey.m8, 16)); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-networkkey"; } + void dump_config() override; + + protected: + std::array last_key_{}; +}; + +class PanIdOpenThreadInfo : public DatasetOpenThreadInfo, public text_sensor::TextSensor { + public: + void update_dataset_(otOperationalDataset *dataset) override { + uint16_t panid = dataset->mPanId; + if (this->last_panid_ != panid) { + this->last_panid_ = panid; + char buf[5]; + snprintf(buf, sizeof(buf), "%04x", panid); + this->publish_state(buf); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-panid"; } + void dump_config() override; + + protected: + uint16_t last_panid_; +}; + +class ExtPanIdOpenThreadInfo : public DatasetOpenThreadInfo, public text_sensor::TextSensor { + public: + void update_dataset_(otOperationalDataset *dataset) override { + if (!std::equal(this->last_extpanid_.begin(), this->last_extpanid_.end(), dataset->mExtendedPanId.m8)) { + std::copy(dataset->mExtendedPanId.m8, dataset->mExtendedPanId.m8 + 8, this->last_extpanid_.begin()); + this->publish_state(format_hex(this->last_extpanid_.begin(), 8)); + } + } + + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-openthreadinfo-extpanid"; } + void dump_config() override; + + protected: + std::array last_extpanid_{}; +}; + +} // namespace openthread_info +} // namespace esphome +#endif diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py new file mode 100644 index 0000000000..ddec8f264c --- /dev/null +++ b/esphome/components/openthread_info/text_sensor.py @@ -0,0 +1,105 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +from esphome.components.openthread.const import ( + CONF_EXT_PAN_ID, + CONF_NETWORK_KEY, + CONF_NETWORK_NAME, + CONF_PAN_ID, +) +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_IP_ADDRESS, ENTITY_CATEGORY_DIAGNOSTIC + +CONF_ROLE = "role" +CONF_RLOC16 = "rloc16" +CONF_EUI64 = "eui64" +CONF_EXT_ADDR = "ext_addr" + + +DEPENDENCIES = ["openthread"] + +openthread_info_ns = cg.esphome_ns.namespace("openthread_info") +IPAddressOpenThreadInfo = openthread_info_ns.class_( + "IPAddressOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) +RoleOpenThreadInfo = openthread_info_ns.class_( + "RoleOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) +Rloc16OpenThreadInfo = openthread_info_ns.class_( + "Rloc16OpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) +ExtAddrOpenThreadInfo = openthread_info_ns.class_( + "ExtAddrOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) +Eui64OpenThreadInfo = openthread_info_ns.class_( + "Eui64OpenThreadInfo", text_sensor.TextSensor, cg.Component +) +ChannelOpenThreadInfo = openthread_info_ns.class_( + "ChannelOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) +NetworkNameOpenThreadInfo = openthread_info_ns.class_( + "NetworkNameOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) +NetworkKeyOpenThreadInfo = openthread_info_ns.class_( + "NetworkKeyOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) +PanIdOpenThreadInfo = openthread_info_ns.class_( + "PanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) +ExtPanIdOpenThreadInfo = openthread_info_ns.class_( + "ExtPanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_IP_ADDRESS): text_sensor.text_sensor_schema( + IPAddressOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_ROLE): text_sensor.text_sensor_schema( + RoleOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_RLOC16): text_sensor.text_sensor_schema( + Rloc16OpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_EXT_ADDR): text_sensor.text_sensor_schema( + ExtAddrOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_EUI64): text_sensor.text_sensor_schema( + Eui64OpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1h")), + cv.Optional(CONF_CHANNEL): text_sensor.text_sensor_schema( + ChannelOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_NETWORK_NAME): text_sensor.text_sensor_schema( + NetworkNameOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_NETWORK_KEY): text_sensor.text_sensor_schema( + NetworkKeyOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_PAN_ID): text_sensor.text_sensor_schema( # noqa: F821 + PanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_EXT_PAN_ID): text_sensor.text_sensor_schema( + ExtPanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), + } +) + + +async def setup_conf(config: dict, key: str): + if conf := config.get(key): + var = await text_sensor.new_text_sensor(conf) + await cg.register_component(var, conf) + + +async def to_code(config): + await setup_conf(config, CONF_IP_ADDRESS) + await setup_conf(config, CONF_ROLE) + await setup_conf(config, CONF_RLOC16) + await setup_conf(config, CONF_EXT_ADDR) + await setup_conf(config, CONF_EUI64) + await setup_conf(config, CONF_CHANNEL) + await setup_conf(config, CONF_NETWORK_NAME) + await setup_conf(config, CONF_NETWORK_KEY) + await setup_conf(config, CONF_PAN_ID) + await setup_conf(config, CONF_EXT_PAN_ID) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9752ad0d78..f7a937c28d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -157,6 +157,9 @@ #define USE_ESP_IDF_VERSION_CODE VERSION_CODE(5, 3, 2) #define USE_MICRO_WAKE_WORD #define USE_MICRO_WAKE_WORD_VAD +#if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) +#define USE_OPENTHREAD +#endif #endif #if defined(USE_ESP32_VARIANT_ESP32S2) diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..482fd1a453 --- /dev/null +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -0,0 +1,11 @@ +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + force_dataset: true diff --git a/tests/components/openthread_info/test.esp32-c6-idf.yaml b/tests/components/openthread_info/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..ded0f17611 --- /dev/null +++ b/tests/components/openthread_info/test.esp32-c6-idf.yaml @@ -0,0 +1,30 @@ +network: + enable_ipv6: true + +openthread: + channel: 13 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + +text_sensor: + - platform: openthread_info + ip_address: + name: "Off-mesh routable IP Address" + channel: + name: "Channel" + role: + name: "Device Role" + rloc16: + name: "RLOC16" + ext_addr: + name: "Extended Address" + eui64: + name: "EUI64" + network_name: + name: "Network Name" + network_key: + name: "Network Key" + pan_id: + name: "PAN ID" + ext_pan_id: + name: "Extended PAN ID"