diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 2fc09330cd..a3ffd7722e 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 @@ -124,6 +126,11 @@ 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 dfb5b72292..2c98a3eaf0 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -36,6 +36,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..8f640d22a3 --- /dev/null +++ b/esphome/components/openthread/__init__.py @@ -0,0 +1,89 @@ +# from esphome.components.zephyr import ZEPHYR_CORE_KEY +from esphome.const import (KEY_CORE, KEY_TARGET_PLATFORM, CONF_ID, CONF_MAC_ADDRESS) + +from esphome.core import CORE, EsphomeError, coroutine_with_priority +import esphome.config_validation as cv +import esphome.codegen as cg + +from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant, add_idf_component +from esphome.components.mdns import MDNSComponent + +AUTO_LOAD = ["network"] #"openthread_srp" + +# 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) +CONFLICTS_WITH = ["wifi"] +DEPENDENCIES = ["esp32"] + +CONF_NETWORK_NAME = "network_name" +CONF_CHANNEL = "channel" +CONF_NETWORK_KEY = "network_key" +CONF_PSKC = "pskc" +CONF_PANID = "panid" +CONF_EXTPANID = "extpanid" +CONF_MDNS_ID = "mdns_id" + + +def set_sdkconfig_options(config): + + if not (CORE.is_esp32 and CORE.using_esp_idf): + raise cv.Invalid("OpenThread is only supported on ESP32 with ESP-IDF") + + # TODO: Check that the board supports 802.15.4 + # 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) + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", config[CONF_PANID]) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_CHANNEL", config[CONF_CHANNEL]) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f'{config[CONF_NETWORK_KEY]}') + + if config[CONF_NETWORK_NAME]: + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_NAME", f'{config[CONF_NETWORK_NAME]}') + if config[CONF_EXTPANID]: + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_EXTPANID", f'{config[CONF_EXTPANID]}') + if config[CONF_PSKC]: + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PSKC", f'{config[CONF_PSKC]}') + + 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_FTD", True) # Full Thread Device + + +openthread_ns = cg.esphome_ns.namespace("openthread") +OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(OpenThreadComponent), + cv.GenerateID(CONF_MDNS_ID): cv.use_id(MDNSComponent), + cv.Required(CONF_NETWORK_NAME): cv.string_strict, + cv.Required(CONF_CHANNEL): cv.int_, + cv.Required(CONF_NETWORK_KEY): cv.string_strict, + cv.Required(CONF_PSKC): cv.string_strict, + cv.Required(CONF_PANID): cv.int_, + cv.Required(CONF_EXTPANID): cv.string_strict, + } + ), +) + + +async def to_code(config): + cg.add_define("USE_OPENTHREAD") + + var = cg.new_Pvariable(config[CONF_ID]) + cg.add(var.set_host_name(cg.RawExpression(f'"{CORE.name}"'))) + mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) + cg.add(var.set_mdns(mdns_component)) + await cg.register_component(var, config) + cg.add_global(cg.RawStatement('#include "esp_openthread.h"')) + cg.add_global(cg.RawStatement('#include "esp_openthread_lock.h"')) + cg.add_global(cg.RawStatement('#include "esp_task_wdt.h"')) + cg.add_global(cg.RawStatement('#include ')) + + set_sdkconfig_options(config) + +# file /home/mrene/dev/esp/thread/.esphome/build/ott-h2/.pioenvs/ott-h2/firmware.elf diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp new file mode 100644 index 0000000000..d3dadcacc5 --- /dev/null +++ b/esphome/components/openthread/openthread.cpp @@ -0,0 +1,322 @@ +#include "openthread.h" + +#ifdef USE_ESP_IDF +#include "openthread_esp.h" +#else +#error "OpenThread is not supported on this platform" +#endif + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#define TAG "openthread" + +namespace esphome { +namespace openthread { + +OpenThreadComponent *global_openthread_component = nullptr; + +OpenThreadComponent::OpenThreadComponent() { + global_openthread_component = this; +} + +OpenThreadComponent::~OpenThreadComponent() { + auto lock = EspOpenThreadLockGuard::TryAcquire(100); + if (!lock) { + ESP_LOGW(TAG, "Failed to acquire OpenThread lock in destructor, leaking memory"); + return; + } + otInstance *instance = esp_openthread_get_instance(); + otSrpClientClearHostAndServices(instance); + otSrpClientBuffersFreeAllServices(instance); + + global_openthread_component = nullptr; +} + +void OpenThreadComponent::setup() { + ESP_LOGI("openthread", "Setting up OpenThread..."); + // Used eventfds: + // * netif + // * ot task queue + // * radio driver + // TODO: Does anything else in esphome set this up? + 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(NULL); + }, "ot_main", 10240, this, 5, nullptr); + + xTaskCreate([](void* arg) { + static_cast(arg)->srp_setup(); + vTaskDelete(NULL); + }, "ot_srp_setup", 10240, this, 5, nullptr); + ESP_LOGI("openthread", "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 != NULL); + 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 + 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; + otError error = otDatasetGetActiveTlvs(esp_openthread_get_instance(), &dataset); + ESP_ERROR_CHECK(esp_openthread_auto_start((error == OT_ERROR_NONE) ? &dataset : NULL)); + + esp_openthread_launch_mainloop(); + + // Clean up + esp_openthread_netif_glue_deinit(); + esp_netif_destroy(openthread_netif); + + esp_vfs_eventfd_unregister(); +} + +bool OpenThreadComponent::is_connected() { + otInstance *instance = esp_openthread_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; +} + +// TODO: This gets used by mqtt in order to register the device's IP. Likely it doesn't +// make sense to return thread-local addresses, since they can't be reached from outside the thread network. +// It could make more sense to return the off-mesh-routable address instead. +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; +} + +// Gets the off-mesh routable address +std::optional OpenThreadComponent::get_omr_address() { + auto lock = EspOpenThreadLockGuard::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 = esp_openthread_get_instance(); + + otBorderRouterConfig aConfig; + while (otNetDataGetNextOnMeshPrefix(instance, &iterator, &aConfig) != OT_ERROR_NONE) { + lock.reset(); + vTaskDelay(100); + lock = EspOpenThreadLockGuard::TryAcquire(portMAX_DELAY); + if (!lock) { + ESP_LOGW("OT SRP", "Could not re-acquire lock"); + return {}; + } + }; + const otIp6Prefix * omrPrefix = &aConfig.mPrefix; + + char addressAsString[40]; + otIp6PrefixToString(omrPrefix, addressAsString, 40); + ESP_LOGW("OT SRP", "USING omr prefix %s", addressAsString); + + const otNetifAddress *unicastAddrs = otIp6GetUnicastAddresses(instance); + for (const otNetifAddress *addr = unicastAddrs; addr; addr = addr->mNext){ + const otIp6Address *localIp = &addr->mAddress; + if (otIp6PrefixMatch(&omrPrefix->mPrefix, localIp)) { + otIp6AddressToString(localIp, addressAsString, 40); + ESP_LOGW("OT SRP", "USING %s for SRP address", addressAsString); + return *localIp; + } + } + ESP_LOGW("OT SRP", "Could not find the OMR address"); + return {}; +} + +void OpenThreadComponent::srp_setup(){ + otError error; + otInstance *instance = nullptr; + auto lock = EspOpenThreadLockGuard::Acquire(); + instance = esp_openthread_get_instance(); + + // set the host name + uint16_t size; + char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); + uint16_t len = host_name.size(); + if (len > size) { + ESP_LOGW("OT SRP", "Hostname is too long, choose a shorter project name"); + return; + } + memcpy(existing_host_name, host_name.c_str(), len + 1); + + error = otSrpClientSetHostName(instance, existing_host_name); + if (error != 0) { + ESP_LOGW("OT SRP", "Could not set host name with srp server"); + return; + } + + uint8_t arrayLength; + otIp6Address *hostAddressArray = otSrpClientBuffersGetHostAddressesArray(instance, &arrayLength); + + const std::optional localIp = this->get_omr_address(lock); + if (!localIp) { + ESP_LOGW("OT SRP", "Could not get local IP address"); + return; + } + memcpy(hostAddressArray, &*localIp, sizeof(localIp)); + + error = otSrpClientSetHostAddresses(instance, hostAddressArray, 1); + if (error != 0){ + ESP_LOGW("OT SRP", "Could not set ip address with srp server"); + 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(); + for (const auto& service : this->mdns_services_) { + otSrpClientBuffersServiceEntry *entry = otSrpClientBuffersAllocateService(instance); + if (!entry) { + ESP_LOGW("OT SRP", "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("OT SRP", "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("OT SRP", "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 = service.port; + + 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]; + mTxtEntries[i].mKey = txt.key.c_str(); + mTxtEntries[i].mValue = reinterpret_cast(txt.value.c_str()); + mTxtEntries[i].mValueLength = txt.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("OT SRP", "Failed to add service: %s", otThreadErrorToString(error)); + } + } + + otSrpClientEnableAutoStartMode(instance, nullptr, nullptr); +} + +void *OpenThreadComponent::pool_alloc(size_t size) { + uint8_t* ptr = new uint8_t[size]; + if (ptr) { + this->_memory_pool.emplace_back(std::unique_ptr(ptr)); + } + return ptr; +} + +void OpenThreadComponent::set_host_name(std::string host_name){ + this->host_name = host_name; +} + +void OpenThreadComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { + this->mdns_ = mdns; +} + + + +} // namespace openthread +} // namespace esphome diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h new file mode 100644 index 0000000000..e16348b234 --- /dev/null +++ b/esphome/components/openthread/openthread.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/mdns/mdns_component.h" +#include "esphome/components/network/ip_address.h" + +#include + +#ifdef USE_ESP_IDF +#include "openthread_esp.h" +#endif + +#include + +namespace esphome { +namespace openthread { + +class OpenThreadComponent : public Component { + public: + OpenThreadComponent(); + ~OpenThreadComponent(); + void setup() override; + float get_setup_priority() const override { + return setup_priority::WIFI; + } + + void set_host_name(std::string host_name); + void set_mdns(esphome::mdns::MDNSComponent *mdns); + bool is_connected(); + network::IPAddresses get_ip_addresses(); + std::optional get_omr_address(); + void ot_main(); + protected: + void srp_setup(); + std::optional get_omr_address(std::optional &lock); + std::string host_name; + void *pool_alloc(size_t size); + + private: + esphome::mdns::MDNSComponent *mdns_{nullptr}; + std::vector mdns_services_; + std::vector> _memory_pool; + +}; + +extern OpenThreadComponent *global_openthread_component; + +} // namespace openthread +} // namespace esphome diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/openthread/openthread_esp.h b/esphome/components/openthread/openthread_esp.h new file mode 100644 index 0000000000..42dde5cdab --- /dev/null +++ b/esphome/components/openthread/openthread_esp.h @@ -0,0 +1,46 @@ +#pragma once + +#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" +namespace esphome { +namespace openthread { + +class EspOpenThreadLockGuard { + public: + static std::optional TryAcquire(TickType_t delay) { + if (esp_openthread_lock_acquire(delay)) { + return EspOpenThreadLockGuard(); + } + return {}; + } + static std::optional Acquire() { + while (!esp_openthread_lock_acquire(100)) { + esp_task_wdt_reset(); + } + return EspOpenThreadLockGuard(); + } + ~EspOpenThreadLockGuard() { esp_openthread_lock_release(); } + private: + // Use a private constructor in order to force thehandling + // of acquisition failure + EspOpenThreadLockGuard() {} +}; +} +} diff --git a/tests/components/openthread/test-ot.yaml b/tests/components/openthread/test-ot.yaml new file mode 100644 index 0000000000..c4155f6c86 --- /dev/null +++ b/tests/components/openthread/test-ot.yaml @@ -0,0 +1,75 @@ +esphome: + name: openthread + +esp32: + board: esp32-c6-devkitm-1 + variant: esp32c6 + framework: + type: esp-idf + version: "5.3.0" + platform_version: "https://github.com/pioarduino/platform-espressif32/releases/download/53.03.10%%2Brc1/platform-espressif32.zip" + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: dfd34f0f05cad978ec4e32b0413038ff + panid: 0x8f28 + extpanid: d63e8e3e495ebbc3 + pskc: c23a76e98f1a6483639b1ac1271e2e27 + +# The web server will cause the HA integration to fail, see note in the README +# web_server: +# port: 80 + +api: + encryption: + key: CXybYc0oErjlPMacNX70d2rHshyLv/FPDrJO4Yrs+Ho= + +# This is the "Boot" button on my dev board +binary_sensor: + - platform: gpio + id: button1 + name: "Button" + publish_initial_state: true + pin: + number: GPIO9 + mode: INPUT_PULLUP + inverted: true + +light: + - platform: esp32_rmt_led_strip + chipset: WS2812 + pin: GPIO8 + num_leds: 1 + rmt_channel: 0 + name: "Status LED RGB" + id: statusledlight + icon: "mdi:led-outline" + rgb_order: GRB + +# text_sensor: +# - platform: template +# name: "OTT Thread RLOC16" +# lambda: |- +# if (!esp_openthread_lock_acquire(1)) { +# return {"Unknown"}; +# } +# auto instance = esp_openthread_get_instance(); +# auto rloc16 = otThreadGetRloc16(instance); +# esp_openthread_lock_release(); +# char buf[10]; +# snprintf(buf, sizeof(buf), "%04x", rloc16); +# return {buf}; +# - platform: template +# name: "OTT Thread Role" +# lambda: |- +# if (!esp_openthread_lock_acquire(1)) { +# return {"Unknown"}; +# } +# auto instance = esp_openthread_get_instance(); +# auto role = otThreadDeviceRoleToString(otThreadGetDeviceRole(instance)); +# esp_openthread_lock_release(); +# return {role};