From 50c181671cc886457fd8c62dc376d97a087874aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:47:16 -0500 Subject: [PATCH 1/8] [ci] Better explain too-big bot review message (#15939) --- .github/scripts/auto-label-pr/reviews.js | 30 ++++++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index 7ac136515d..e9e848da6f 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -41,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; + message += + `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` + + `this PR is on the large side `; + if (tooManyLabels && tooManyChanges) { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`; + message += + `(${nonTestChanges} line changes excluding tests, across ` + + `${originalLabelCount} different components/areas)`; } else if (tooManyLabels) { - message += `This PR affects ${originalLabelCount} different components/areas.`; + message += + `(it touches ${originalLabelCount} different components/areas)`; } else { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; + message += `(${nonTestChanges} line changes excluding tests)`; } - message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`; - message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`; + message += `, which makes it harder for maintainers to review.\n\n`; + message += + `Smaller, focused PRs tend to be reviewed much faster since they ` + + `fit into the short gaps between other maintainer work; large ones ` + + `often have to wait for a rare long uninterrupted block of time. ` + + `If you can break this up into smaller pieces that can be reviewed ` + + `independently, it will almost certainly land faster overall.\n\n`; + message += + `Before putting more time in, it's also worth popping into ` + + `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` + + `you scope things and flag anything already in flight.\n\n`; + message += + `For more details (including how to split the work up), see: ` + + `https://developers.esphome.io/contributing/submitting-your-work/` + + `#how-to-approach-large-submissions`; messages.push(message); } From 13fe881f70a142d1f2888c6b1141590a607445ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:20:31 -0500 Subject: [PATCH 2/8] [scheduler][core] Lock-free fast-path on ESPHOME_THREAD_MULTI_NO_ATOMICS via __atomic builtins (#15947) --- esphome/core/scheduler.cpp | 20 ++++---- esphome/core/scheduler.h | 100 +++++++++++++++++++++---------------- esphome/core/time_64.cpp | 23 ++++++--- 3 files changed, 82 insertions(+), 61 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index b0eaa670ac..a6f1558e4a 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -235,11 +235,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } target->push_back(item); if (target == &this->to_add_) { - this->to_add_count_increment_(); + this->to_add_count_increment_locked_(); } #ifndef ESPHOME_THREAD_SINGLE else { - this->defer_count_increment_(); + this->defer_count_increment_locked_(); } #endif } @@ -452,7 +452,7 @@ void Scheduler::full_cleanup_removed_items_() { this->items_.erase(this->items_.begin() + write, this->items_.end()); // Rebuild the heap structure since items are no longer in heap order std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - this->to_remove_clear_(); + this->to_remove_clear_locked_(); } #ifndef ESPHOME_THREAD_SINGLE @@ -501,7 +501,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { this->lock_.lock(); // Reset counter and snapshot queue end under lock - this->defer_count_clear_(); + this->defer_count_clear_locked_(); size_t defer_queue_end = this->defer_queue_.size(); if (this->defer_queue_front_ >= defer_queue_end) { this->lock_.unlock(); @@ -621,7 +621,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; if (is_item_removed_locked_(item)) { this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); continue; } } @@ -630,7 +630,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { if (is_item_removed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); continue; } #endif @@ -658,7 +658,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { if (this->is_item_removed_locked_(executed_item)) { // We were removed/cancelled in the function call, recycle and continue - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(executed_item); continue; } @@ -721,7 +721,7 @@ void HOT Scheduler::process_to_add_slow_path_() { std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } this->to_add_.clear(); - this->to_add_count_clear_(); + this->to_add_count_clear_locked_(); } bool HOT Scheduler::cleanup_slow_path_() { // We must hold the lock for the entire cleanup operation because: @@ -737,7 +737,7 @@ bool HOT Scheduler::cleanup_slow_path_() { SchedulerItem *item = this->items_[0]; if (!this->is_item_removed_locked_(item)) break; - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(this->pop_raw_locked_()); } return !this->items_.empty(); @@ -825,7 +825,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, hash_or_id, type, match_retry, find_first); total_cancelled += heap_cancelled; - this->to_remove_add_(heap_cancelled); + this->to_remove_add_locked_(heap_cancelled); if (find_first && total_cancelled > 0) return true; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b7e99d4603..46b19855c3 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -524,11 +524,13 @@ class Scheduler { std::vector to_add_; #ifndef ESPHOME_THREAD_SINGLE - // Fast-path counter for process_to_add() to skip taking the lock when there is - // nothing to add. Uses std::atomic on platforms that support it, plain uint32_t - // otherwise. On non-atomic platforms, callers must hold the scheduler lock when - // mutating this counter. Not needed on single-threaded platforms where we can - // check to_add_.empty() directly. + // Fast-path counter for process_to_add() to skip taking the lock when there + // is nothing to add. std::atomic on ATOMICS; plain uint32_t on NO_ATOMICS + // (BK72xx — ARMv5TE single-core, lacks LDREX/STREX so std::atomic RMW would + // require libatomic). Reads use __atomic_load_n(__ATOMIC_RELAXED) on + // NO_ATOMICS — compiles to a plain LDR (aligned 32-bit load is naturally + // atomic on ARMv5TE) but expresses the concurrent-access intent in the C++ + // memory model. Writes live behind *_locked_ helpers and must hold lock_. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_add_count_{0}; #else @@ -536,40 +538,41 @@ class Scheduler { #endif #endif /* ESPHOME_THREAD_SINGLE */ - // Fast-path helper for process_to_add() to decide if it can try the lock-free path. - // - On ESPHOME_THREAD_SINGLE: direct container check is safe (no concurrent writers). - // - On ESPHOME_THREAD_MULTI_ATOMICS: performs a lock-free check via to_add_count_. - // - On ESPHOME_THREAD_MULTI_NO_ATOMICS: always returns false to force the caller - // down the locked path; this is NOT a lock-free emptiness check on that platform. + // Fast-path helper for process_to_add() to decide if it can skip the lock. bool to_add_empty_() const { #ifdef ESPHOME_THREAD_SINGLE return this->to_add_.empty(); #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_add_count_.load(std::memory_order_relaxed) == 0; #else - return false; + return __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED) == 0; #endif } - // Increment to_add_count_ (no-op on single-threaded platforms) - void to_add_count_increment_() { -#ifdef ESPHOME_THREAD_SINGLE + // Increment to_add_count_ (no-op on single-threaded platforms). + // On NO_ATOMICS the caller must hold lock_; both load and store go through + // __atomic_*_n with __ATOMIC_RELAXED to keep every access to the counter + // explicitly atomic in the C++ memory model (same ARMv5TE codegen as + // plain LDR+STR). + void to_add_count_increment_locked_() { +#if defined(ESPHOME_THREAD_SINGLE) // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.fetch_add(1, std::memory_order_relaxed); #else - this->to_add_count_++; + uint32_t v = __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_add_count_, v + 1, __ATOMIC_RELAXED); #endif } // Reset to_add_count_ (no-op on single-threaded platforms) - void to_add_count_clear_() { -#ifdef ESPHOME_THREAD_SINGLE + void to_add_count_clear_locked_() { +#if defined(ESPHOME_THREAD_SINGLE) // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.store(0, std::memory_order_relaxed); #else - this->to_add_count_ = 0; + __atomic_store_n(&this->to_add_count_, 0, __ATOMIC_RELAXED); #endif } @@ -580,7 +583,8 @@ class Scheduler { std::vector defer_queue_; // FIFO queue for defer() calls size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) - // Fast-path counter for process_defer_queue_() to skip lock when nothing to process. + // Fast-path counter for process_defer_queue_() to skip lock when nothing to + // process. See to_add_count_ above for the NO_ATOMICS rationale. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic defer_count_{0}; #else @@ -589,35 +593,35 @@ class Scheduler { bool defer_empty_() const { // defer_queue_ only exists on multi-threaded platforms, so no ESPHOME_THREAD_SINGLE path - // ESPHOME_THREAD_MULTI_NO_ATOMICS: always take the lock #ifdef ESPHOME_THREAD_MULTI_ATOMICS return this->defer_count_.load(std::memory_order_relaxed) == 0; #else - return false; + return __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED) == 0; #endif } - void defer_count_increment_() { + void defer_count_increment_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.fetch_add(1, std::memory_order_relaxed); #else - this->defer_count_++; + uint32_t v = __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED); + __atomic_store_n(&this->defer_count_, v + 1, __ATOMIC_RELAXED); #endif } - void defer_count_clear_() { + void defer_count_clear_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.store(0, std::memory_order_relaxed); #else - this->defer_count_ = 0; + __atomic_store_n(&this->defer_count_, 0, __ATOMIC_RELAXED); #endif } #endif /* ESPHOME_THREAD_SINGLE */ - // Counter for items marked for removal. Incremented cross-thread in cancel_item_locked_(). - // On ESPHOME_THREAD_MULTI_ATOMICS this is read without a lock in the cleanup_() fast path; - // on ESPHOME_THREAD_MULTI_NO_ATOMICS the fast path is disabled so cleanup_() always takes the lock. + // Counter for items marked for removal. Incremented cross-thread in + // cancel_item_locked_(). See to_add_count_ above for the NO_ATOMICS + // rationale. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_remove_{0}; #else @@ -626,44 +630,54 @@ class Scheduler { // Lock-free check if there are items to remove (for fast-path in cleanup_) bool to_remove_empty_() const { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_remove_.load(std::memory_order_relaxed) == 0; -#elif defined(ESPHOME_THREAD_SINGLE) - return this->to_remove_ == 0; +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) == 0; #else - return false; // Always take the lock path + return this->to_remove_ == 0; #endif } - void to_remove_add_(uint32_t count) { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_add_locked_(uint32_t count) { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.fetch_add(count, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_remove_, v + count, __ATOMIC_RELAXED); #else - this->to_remove_ += count; + this->to_remove_ += count; #endif } - void to_remove_decrement_() { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_decrement_locked_() { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.fetch_sub(1, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_remove_, v - 1, __ATOMIC_RELAXED); #else - this->to_remove_--; + this->to_remove_--; #endif } - void to_remove_clear_() { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_clear_locked_() { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.store(0, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, 0, __ATOMIC_RELAXED); #else - this->to_remove_ = 0; + this->to_remove_ = 0; #endif } uint32_t to_remove_count_() const { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_remove_.load(std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); #else - return this->to_remove_; + return this->to_remove_; #endif } diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index b8a299ff7e..cf651c3e91 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -74,8 +74,8 @@ uint64_t Millis64Impl::compute(uint32_t now) { // 2. Always locks when detecting a large backwards jump // 3. Updates without lock in normal forward progression (accepting minor races) // This is less efficient but necessary without atomic operations. - uint16_t major = millis_major; - uint32_t last = last_millis; + uint16_t major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); + uint32_t last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); // Define a safe window around the rollover point (10 seconds) // This covers any reasonable scheduler delays or thread preemption @@ -87,19 +87,26 @@ uint64_t Millis64Impl::compute(uint32_t now) { if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) { // Near rollover or detected a rollover - need lock for safety LockGuard guard{lock}; - // Re-read with lock held - last = last_millis; + // Re-read both values with lock held. last_millis can be updated + // unlocked from the forward-progression branch below, so use an atomic + // load. millis_major can only be updated under this lock, but another + // thread may have completed a rollover between our unlocked loads above + // and the lock acquisition — reload or we'd return a stale high word. + last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); + major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); if (now < last && (last - now) > HALF_MAX_UINT32) { - // True rollover detected (happens every ~49.7 days) - millis_major++; + // True rollover detected (happens every ~49.7 days). + // Use the already-loaded `major` local; avoids a second read of the + // global (equivalent under the held lock). major++; + __atomic_store_n(&millis_major, major, __ATOMIC_RELAXED); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); #endif /* ESPHOME_DEBUG_SCHEDULER */ } // Update last_millis while holding lock - last_millis = now; + __atomic_store_n(&last_millis, now, __ATOMIC_RELAXED); } else if (now > last) { // Normal case: Not near rollover and time moved forward // Update without lock. While this may cause minor races (microseconds of @@ -107,7 +114,7 @@ uint64_t Millis64Impl::compute(uint32_t now) { // 1. The scheduler operates at millisecond resolution, not microsecond // 2. We've already prevented the critical rollover race condition // 3. Any backwards movement is orders of magnitude smaller than scheduler delays - last_millis = now; + __atomic_store_n(&last_millis, now, __ATOMIC_RELAXED); } // If now <= last and we're not near rollover, don't update // This minimizes backwards time movement From b38db617a2f5da489f8160ad02d80c9561be1622 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:21:05 -0500 Subject: [PATCH 3/8] [core] Clean up stale includes and inline yield_with_select_ in application (#15945) --- esphome/components/libretiny/core.cpp | 2 +- esphome/core/application.cpp | 9 +-------- esphome/core/application.h | 19 ++++--------------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 1b74e3addb..ca46bcb899 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -56,7 +56,7 @@ void arch_init() { // // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. - // This is safe because ESPHome yields voluntarily via yield_with_select_() and + // This is safe because ESPHome yields voluntarily via wakeable_delay() and // the Arduino mainTask yield() after each loop() iteration. static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 11381030a3..d03696fbb6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -12,9 +12,6 @@ #include #include #endif -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif // USE_LWIP_FAST_SELECT #include "esphome/core/version.h" #include "esphome/core/hal.h" #include @@ -24,10 +21,6 @@ #include "esphome/components/status_led/status_led.h" #endif -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) -#include "esphome/components/socket/socket.h" -#endif - namespace esphome { static const char *const TAG = "app"; @@ -366,7 +359,7 @@ void Application::teardown_components(uint32_t timeout_ms) { // Give some time for I/O operations if components are still pending if (pending_count > 0) { - this->yield_with_select_(1); + esphome::internal::wakeable_delay(1); } // Update time for next iteration diff --git a/esphome/core/application.h b/esphome/core/application.h index 8280b3bd4b..b700415681 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,9 +24,6 @@ #include "esphome/core/area.h" #endif -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif @@ -423,10 +420,6 @@ class Application { void service_status_led_slow_(uint32_t time); #endif - /// Sleep for up to delay_ms, returning early if a wake event arrives. - /// Thin wrapper over the platform wake primitive in wake.h. - inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms); - // === Member variables ordered by size to minimize padding === // Pointer-sized members first @@ -664,18 +657,14 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } - this->yield_with_select_(delay_time); + // All platforms route loop yields through the platform wake primitive. + // On host this drains the loopback wake socket via select(); on FreeRTOS + // targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. + esphome::internal::wakeable_delay(delay_time); if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); } } -// All platforms route loop yields through the platform wake primitive. -// On host this drains the loopback wake socket via select(); on FreeRTOS -// targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. -inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { - esphome::internal::wakeable_delay(delay_ms); -} - } // namespace esphome From 3ca86fc3fc6c41c2c51f15eadb2a1536e4955b3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:21:46 -0500 Subject: [PATCH 4/8] [core] Raise WDT_FEED_INTERVAL_MS to 2000ms on BK72xx (#15943) --- esphome/core/application.h | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b700415681..e9b386038e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -216,11 +216,19 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~16x - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x + /// - ESP32 task WDT default (5 s): ~16x + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below +#ifdef USE_BK72XX + // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny + // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: + // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; +#endif /// Feed the task watchdog. Cold entry — callers without a millis() /// timestamp in hand. Out of line to keep call sites tiny. From 8f9b91eecea69ab12f8afd7d408010daad2ced5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:22:17 -0500 Subject: [PATCH 5/8] [wifi] Avoid BDK 3.0.78 wifi_event_sta_disconnected_t collision on BK72xx (#15942) --- esphome/components/wifi/wifi_component_libretiny.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index cdd11ceaef..6588e93e16 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -12,7 +12,12 @@ #ifdef USE_BK72XX extern "C" { +// BDK 3.0.78 (required for BK7238) redeclares wifi_event_sta_disconnected_t, +// which LibreTiny's Arduino WiFi API already defines. ESPHome doesn't use the +// BDK version, so rename it across this include to avoid the collision. +#define wifi_event_sta_disconnected_t bdk_wifi_event_sta_disconnected_t #include +#undef wifi_event_sta_disconnected_t } #endif From 70ae614abd9c34cdf0be53feceb9c6f0624b39c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 08:23:38 -0500 Subject: [PATCH 6/8] [api] Fall back to plaintext for logger connections (#15938) --- esphome/components/api/client.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 0c6c569c7d..312d937f01 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -93,7 +93,24 @@ async def async_run_logs( config, raw_line, backtrace_state=backtrace_state ) - stop = await async_run(cli, on_log, name=name, subscribe_states=subscribe_states) + # Safe to fall back to plaintext here only for this diagnostics use + # case: the stream is one-way from device to client, and this code + # never accepts commands or acts on any message the device sends. + # An on-path attacker could still both inject fabricated log lines + # and passively read the device's log output (and any state data + # delivered when subscribe_states is enabled), so this does lose + # confidentiality as well as authentication/integrity. That tradeoff + # is acceptable for operator-visible logs, which aioesphomeapi also + # warns may come from an unverified device. Never mirror this opt-in + # for any connection that sends data to the device or uses Home + # Assistant actions. + stop = await async_run( + cli, + on_log, + name=name, + subscribe_states=subscribe_states, + allow_plaintext_fallback=True, + ) try: await asyncio.Event().wait() finally: From 9b45b046a8992e65ff19b7610f2cc72e238ac760 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Apr 2026 09:43:32 -0400 Subject: [PATCH 7/8] [core] Allow finding all devices as target that match mac suffix (#13135) --- esphome/__main__.py | 120 +++++- esphome/address_cache.py | 11 + esphome/async_thread.py | 56 +++ esphome/resolver.py | 48 +-- esphome/zeroconf.py | 181 ++++++++- tests/unit_tests/test_address_cache.py | 20 + tests/unit_tests/test_main.py | 513 ++++++++++++++++++++++++- tests/unit_tests/test_resolver.py | 33 +- 8 files changed, 912 insertions(+), 70 deletions(-) create mode 100644 esphome/async_thread.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 7879cdad0c..8c80dab90a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -39,6 +39,7 @@ from esphome.const import ( CONF_MDNS, CONF_MQTT, CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, @@ -71,6 +72,7 @@ from esphome.util import ( run_external_process, safe_print, ) +from esphome.zeroconf import discover_mdns_devices _LOGGER = logging.getLogger(__name__) @@ -204,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]: return [address] +def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None: + """Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``. + + Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so + the downstream resolution path (``resolve_ip_address``) can skip opening a + second Zeroconf client. + """ + from esphome.address_cache import AddressCache + + if CORE.address_cache is None: + CORE.address_cache = AddressCache() + for host, addresses in hosts_to_addresses.items(): + if addresses: + _LOGGER.debug("Caching mDNS result %s -> %s", host, addresses) + CORE.address_cache.add_mdns_addresses(host, addresses) + + +def _discover_mac_suffix_devices() -> list[str] | None: + """Discover ``-.local`` devices and cache their IPs. + + Returns: + - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off, + mDNS disabled, or ``CORE.address`` is already an IP). Callers should + then fall back to whatever default OTA address they normally use. + - ``[]`` when discovery ran but found nothing. Callers should NOT fall + back to the base name: with ``name_add_mac_suffix`` enabled, the base + name by definition doesn't exist on the network. + - A non-empty sorted list of ``.local`` hostnames on success. + + Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or + ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we + already have without opening a second Zeroconf client. + """ + if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): + return None + _LOGGER.info("Discovering devices...") + if not (discovered := discover_mdns_devices(CORE.name)): + _LOGGER.warning( + "No devices matching '%s-.local' were discovered.", CORE.name + ) + return [] + _populate_mdns_cache(discovered) + return list(discovered) + + +def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: + """Return OTA hostname(s) for the ``--device OTA`` / default-resolve path. + + When ``name_add_mac_suffix`` is enabled, returns discovered + ``-.local`` hostnames (possibly empty — in which case the + caller should not fall back to the base name). Otherwise falls back to + the cache-resolved ``CORE.address``. + """ + if (discovered := _discover_mac_suffix_devices()) is not None: + return discovered + return _resolve_with_cache(CORE.address, purpose) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -242,14 +302,14 @@ def choose_upload_log_host( resolved.append("MQTT") if has_api() and has_non_ip_address() and has_resolvable_address(): - resolved.extend(_resolve_with_cache(CORE.address, purpose)) + resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: if has_ota() and has_mqtt_ip_lookup(): resolved.append("MQTTIP") if has_ota() and has_non_ip_address() and has_resolvable_address(): - resolved.extend(_resolve_with_cache(CORE.address, purpose)) + resolved.extend(_ota_hostnames_for_default(purpose)) else: resolved.append(device) if not resolved: @@ -281,22 +341,29 @@ def choose_upload_log_host( elif bootsel.permission_error: bootsel_permission_error = True + def add_ota_options() -> None: + """Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled.""" + if (discovered := _discover_mac_suffix_devices()) is not None: + # Discovery was applicable. Use whatever we found — on empty, + # intentionally skip the base-name fallback since with + # name_add_mac_suffix on, the base name doesn't exist on the net. + for host in discovered: + options.append((f"Over The Air ({host})", host)) + elif has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + if purpose == Purpose.LOGGING: if has_mqtt_logging(): mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) if has_api(): - if has_resolvable_address(): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if has_mqtt_ip_lookup(): - options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): - if has_resolvable_address(): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if has_mqtt_ip_lookup(): - options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + add_ota_options() # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( @@ -407,7 +474,17 @@ def has_resolvable_address() -> bool: return not CORE.address.endswith(".local") -def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str): +def has_name_add_mac_suffix() -> bool: + """Check if name_add_mac_suffix is enabled in the config.""" + if CORE.config is None: + return False + esphome_config = CORE.config.get(CONF_ESPHOME, {}) + return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False) + + +def mqtt_get_ip( + config: ConfigType, username: str, password: str, client_id: str +) -> list[str]: from esphome import mqtt return mqtt.get_esphome_device_ip(config, username, password, client_id) @@ -420,6 +497,9 @@ def _resolve_network_devices( This function filters the devices list to: - Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup + - Expand hostnames that are already in ``CORE.address_cache`` to their + cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second + Zeroconf client to resolve them - Deduplicate addresses while preserving order - Only resolve MQTT once even if multiple MQTT strings are present - If MQTT resolution fails, log a warning and continue with other devices @@ -444,13 +524,29 @@ def _resolve_network_devices( mqtt_ips = mqtt_get_ip( config, args.username, args.password, args.client_id ) - network_devices.extend(mqtt_ips) + # pylint can't infer mqtt_get_ip's return through its + # lazy ``from esphome import mqtt`` import, so it flags + # the genexpr below. + network_devices.extend( + addr + for addr in mqtt_ips # pylint: disable=not-an-iterable + if addr not in network_devices + ) except EsphomeError as err: _LOGGER.warning( "MQTT IP discovery failed (%s), will try other devices if available", err, ) mqtt_resolved = True + continue + + # If the hostname is already in the address cache (e.g. populated by + # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't + # open its own Zeroconf to re-resolve it. + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend( + addr for addr in cached if addr not in network_devices + ) elif device not in network_devices: # Regular network address or IP - add if not already present network_devices.append(device) diff --git a/esphome/address_cache.py b/esphome/address_cache.py index 7c20be90f0..4fb3689818 100644 --- a/esphome/address_cache.py +++ b/esphome/address_cache.py @@ -101,6 +101,17 @@ class AddressCache: """Check if any cache entries exist.""" return bool(self.mdns_cache or self.dns_cache) + def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None: + """Store resolved mDNS addresses for ``hostname`` in the cache. + + Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use + this to avoid a second resolution round-trip during the upload path. + No-op when ``addresses`` is empty. + """ + if not addresses: + return + self.mdns_cache[normalize_hostname(hostname)] = addresses + @classmethod def from_cli_args( cls, mdns_args: Iterable[str], dns_args: Iterable[str] diff --git a/esphome/async_thread.py b/esphome/async_thread.py new file mode 100644 index 0000000000..7be3c83a9a --- /dev/null +++ b/esphome/async_thread.py @@ -0,0 +1,56 @@ +"""Helpers for running an async coroutine from sync code via a daemon thread. + +``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup +cycle finishes, which can add hundreds of milliseconds before the caller +receives the result. Running the loop in a daemon thread lets the caller +observe the result as soon as the coroutine completes while cleanup finishes +in the background. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import threading +from typing import Generic, TypeVar + +_T = TypeVar("_T") + + +class AsyncThreadRunner(threading.Thread, Generic[_T]): + """Run an async coroutine in a daemon thread and expose its result. + + The runner catches all exceptions from the coroutine and stores them in + ``exception`` so ``event`` is always set — this prevents callers waiting + on ``event`` from hanging forever when the coroutine crashes. + + Typical usage:: + + runner = AsyncThreadRunner(lambda: my_coro(arg)) + runner.start() + if not runner.event.wait(timeout=5.0): + ... # timed out + if runner.exception is not None: + raise runner.exception + result = runner.result + """ + + def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None: + super().__init__(daemon=True) + self._coro_factory = coro_factory + self.result: _T | None = None + self.exception: BaseException | None = None + self.event = threading.Event() + + async def _runner(self) -> None: + try: + self.result = await self._coro_factory() + except Exception as exc: # pylint: disable=broad-except + # Capture all exceptions so ``event`` is always set — otherwise a + # crash would hang the waiter forever. + self.exception = exc + finally: + self.event.set() + + def run(self) -> None: + asyncio.run(self._runner()) diff --git a/esphome/resolver.py b/esphome/resolver.py index 99482aa20e..9fb596ce7b 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -2,66 +2,52 @@ from __future__ import annotations -import asyncio -import threading - from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError import aioesphomeapi.host_resolver as hr +from esphome.async_thread import AsyncThreadRunner from esphome.core import EsphomeError RESOLVE_TIMEOUT = 10.0 # seconds -class AsyncResolver(threading.Thread): +class AsyncResolver: """Resolver using aioesphomeapi that runs in a thread for faster results. - This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, - including proper .local domain fallback. Running in a thread allows us to get - the result immediately without waiting for asyncio.run() to complete its - cleanup cycle, which can take significant time. + This resolver uses aioesphomeapi's async_resolve_host to handle DNS + resolution, including proper .local domain fallback. Running in a thread + (via :class:`AsyncThreadRunner`) allows us to get the result immediately + without waiting for ``asyncio.run()`` to complete its cleanup cycle, which + can take significant time. """ def __init__(self, hosts: list[str], port: int) -> None: """Initialize the resolver.""" - super().__init__(daemon=True) self.hosts = hosts self.port = port - self.result: list[hr.AddrInfo] | None = None - self.exception: Exception | None = None - self.event = threading.Event() - async def _resolve(self) -> None: + async def _resolve(self) -> list[hr.AddrInfo]: """Resolve hostnames to IP addresses.""" - try: - self.result = await hr.async_resolve_host( - self.hosts, self.port, timeout=RESOLVE_TIMEOUT - ) - except Exception as e: # pylint: disable=broad-except - # We need to catch all exceptions to ensure the event is set - # Otherwise the thread could hang forever - self.exception = e - finally: - self.event.set() - - def run(self) -> None: - """Run the DNS resolution.""" - asyncio.run(self._resolve()) + return await hr.async_resolve_host( + self.hosts, self.port, timeout=RESOLVE_TIMEOUT + ) def resolve(self) -> list[hr.AddrInfo]: """Start the thread and wait for the result.""" - self.start() + runner: AsyncThreadRunner[list[hr.AddrInfo]] = AsyncThreadRunner(self._resolve) + runner.start() - if not self.event.wait( + if not runner.event.wait( timeout=RESOLVE_TIMEOUT + 1.0 ): # Give it 1 second more than the resolver timeout raise EsphomeError("Timeout resolving IP address") - if exc := self.exception: + if exc := runner.exception: if isinstance(exc, ResolveTimeoutAPIError): raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc if isinstance(exc, ResolveAPIError): raise EsphomeError(f"Error resolving IP address: {exc}") from exc raise exc - return self.result + assert runner.result is not None # guaranteed when event set and no exception + return runner.result diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index dd45b58a6c..6f5d33c808 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -14,8 +14,13 @@ from zeroconf import ( ) from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf +from esphome.async_thread import AsyncThreadRunner from esphome.storage_json import StorageJSON, ext_storage_path +# Length of the MAC suffix appended when name_add_mac_suffix is enabled. +MAC_SUFFIX_LEN = 6 +_HEX_CHARS = frozenset("0123456789abcdef") + _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10.0 @@ -188,15 +193,177 @@ class EsphomeZeroconf(Zeroconf): return None +async def async_resolve_hosts( + zeroconf: Zeroconf, hosts: list[str], timeout: float = DEFAULT_TIMEOUT +) -> dict[str, list[str]]: + """Resolve ``hosts`` to IPs using a shared ``Zeroconf`` instance. + + Tries the cache synchronously first (so hosts already primed by a recent + browse return immediately with no network round-trip), then issues + ``async_request`` for the remaining misses in parallel via + ``asyncio.gather``. Returns a dict mapping each host to its list of + addresses (empty list when unresolved). Only ``.local`` form is + queried, matching the name scheme the resolvers below expect. + """ + resolvers: dict[str, AddressResolver] = {} + pending: list[str] = [] + for host in hosts: + resolver = AddressResolver(f"{host.partition('.')[0]}.local.") + resolvers[host] = resolver + if not resolver.load_from_cache(zeroconf): + pending.append(host) + + if pending and timeout: + results = await asyncio.gather( + *( + resolvers[host].async_request(zeroconf, timeout * 1000) + for host in pending + ), + return_exceptions=True, + ) + for host, result in zip(pending, results): + if isinstance(result, BaseException): + _LOGGER.debug("Failed to resolve %s: %s", host, result) + + return { + host: resolver.parsed_scoped_addresses(IPVersion.All) + for host, resolver in resolvers.items() + } + + class AsyncEsphomeZeroconf(AsyncZeroconf): async def async_resolve_host( self, host: str, timeout: float = DEFAULT_TIMEOUT ) -> list[str] | None: """Resolve a host name to an IP address.""" - info = AddressResolver(f"{host.partition('.')[0]}.local.") - if ( - info.load_from_cache(self.zeroconf) - or (timeout and await info.async_request(self.zeroconf, timeout * 1000)) - ) and (addresses := info.parsed_scoped_addresses(IPVersion.All)): - return addresses - return None + addresses = (await async_resolve_hosts(self.zeroconf, [host], timeout))[host] + return addresses or None + + +def _is_mac_suffix_match(device_name: str, prefix: str) -> bool: + """Return True if ``device_name`` is ``prefix`` followed by a 6-char hex MAC.""" + if not device_name.startswith(prefix): + return False + suffix = device_name[len(prefix) :] + return len(suffix) == MAC_SUFFIX_LEN and all(c in _HEX_CHARS for c in suffix) + + +async def async_discover_mdns_devices( + base_name: str, timeout: float = 5.0 +) -> dict[str, list[str]]: + """Discover ESPHome devices via mDNS that match the base name + MAC suffix. + + When ``name_add_mac_suffix`` is enabled, devices advertise as + ``-<6-hex-mac>.local``. This function uses a single + ``AsyncEsphomeZeroconf`` lifecycle to both browse for matching services and + resolve their IP addresses, so callers get resolved addresses without + opening a second Zeroconf client. + + Args: + base_name: The base device name (without MAC suffix). + timeout: How long to wait for mDNS responses (default 5 seconds). + + Returns: + Mapping of ``.local`` hostnames to their resolved IP addresses + (may be empty for a device if resolution failed within the timeout). + """ + prefix = f"{base_name}-" + # Preserves insertion order for stable output and deduplicates + discovered: dict[str, list[str]] = {} + + def on_service_state_change( + zeroconf: Zeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: + if state_change not in (ServiceStateChange.Added, ServiceStateChange.Updated): + return + device_name = name.partition(".")[0] + if not _is_mac_suffix_match(device_name, prefix): + _LOGGER.debug( + "Ignoring %s (%s): does not match '%s<6-hex>'", + device_name, + state_change.name, + prefix, + ) + return + host = f"{device_name}.local" + if host in discovered: + return + discovered[host] = [] + _LOGGER.debug("Discovered %s (%s)", host, state_change.name) + + _LOGGER.debug( + "Starting mDNS discovery for '%s.local' (timeout=%.1fs)", + prefix, + timeout, + ) + try: + aiozc = AsyncEsphomeZeroconf() + except Exception as err: # pylint: disable=broad-except + # Zeroconf init can raise OSError, NonUniqueNameException, etc. + # Any failure here just means we can't discover — log and move on. + _LOGGER.warning("mDNS discovery failed to initialize: %s", err) + return {} + + try: + browser = AsyncServiceBrowser( + aiozc.zeroconf, + ESPHOME_SERVICE_TYPE, + handlers=[on_service_state_change], + ) + try: + await asyncio.sleep(timeout) + finally: + await browser.async_cancel() + _LOGGER.debug( + "Browse finished: %d device(s) matched '%s'", + len(discovered), + prefix, + ) + + # Resolve each discovered hostname on the SAME Zeroconf instance so + # we don't spin up a second client. ``async_resolve_hosts`` tries the + # cache synchronously (the browse usually primes it) before issuing + # any ``async_request`` in parallel for misses. + resolved = await async_resolve_hosts(aiozc.zeroconf, list(discovered)) + for host, addresses in resolved.items(): + if addresses: + discovered[host] = addresses + _LOGGER.debug("Resolved %s -> %s", host, addresses) + else: + _LOGGER.debug("No addresses returned for %s", host) + finally: + await aiozc.async_close() + + return dict(sorted(discovered.items())) + + +def _await_discovery( + runner: AsyncThreadRunner[dict[str, list[str]]], timeout: float +) -> dict[str, list[str]]: + """Wait for ``runner`` to finish and return its discovery result. + + Split out of :func:`discover_mdns_devices` so the timeout branch is + testable without patching ``asyncio`` or ``threading`` internals — a test + passes a stub whose ``event.wait`` returns ``False``. + """ + # Give the discovery an extra second over the browse timeout for the + # resolution + cleanup pass. + if not runner.event.wait(timeout=timeout + 2.0): + _LOGGER.warning("mDNS discovery timed out after %.1fs", timeout) + return {} + if runner.exception is not None: + _LOGGER.warning("mDNS discovery failed: %s", runner.exception) + return {} + return runner.result or {} + + +def discover_mdns_devices(base_name: str, timeout: float = 5.0) -> dict[str, list[str]]: + """Synchronous wrapper around :func:`async_discover_mdns_devices`.""" + runner = AsyncThreadRunner( + lambda: async_discover_mdns_devices(base_name, timeout=timeout) + ) + runner.start() + return _await_discovery(runner, timeout) diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py index de43830d53..1ca28c4f02 100644 --- a/tests/unit_tests/test_address_cache.py +++ b/tests/unit_tests/test_address_cache.py @@ -121,6 +121,26 @@ def test_get_addresses_auto_detection() -> None: assert cache.get_addresses("unknown.com") is None +def test_add_mdns_addresses_stores_and_normalizes() -> None: + """add_mdns_addresses inserts entries under the normalized hostname.""" + cache = AddressCache() + cache.add_mdns_addresses("Device.Local.", ["192.168.1.10", "192.168.1.11"]) + + assert cache.mdns_cache == { + normalize_hostname("Device.Local."): ["192.168.1.10", "192.168.1.11"] + } + # Overwrites on subsequent calls for the same host + cache.add_mdns_addresses("device.local", ["10.0.0.1"]) + assert cache.mdns_cache[normalize_hostname("device.local")] == ["10.0.0.1"] + + +def test_add_mdns_addresses_empty_is_noop() -> None: + """Passing an empty address list must not create an entry.""" + cache = AddressCache() + cache.add_mdns_addresses("device.local", []) + assert cache.mdns_cache == {} + + def test_has_cache() -> None: """Test checking if cache has entries.""" # Empty cache diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e07b4accf2..8ec9e70cf8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import Callable, Generator from dataclasses import dataclass import json import logging @@ -12,16 +12,18 @@ import re import sys import time from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from pytest import CaptureFixture +from zeroconf import ServiceStateChange from esphome import platformio_api from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _resolve_network_devices, choose_upload_log_host, command_analyze_memory, command_bundle, @@ -36,6 +38,7 @@ from esphome.__main__ import ( has_mqtt, has_mqtt_ip_lookup, has_mqtt_logging, + has_name_add_mac_suffix, has_non_ip_address, has_ota, has_resolvable_address, @@ -48,6 +51,7 @@ from esphome.__main__ import ( upload_using_picotool, upload_using_platformio, ) +from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( @@ -62,6 +66,7 @@ from esphome.const import ( CONF_MDNS, CONF_MQTT, CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, @@ -79,6 +84,7 @@ from esphome.const import ( ) from esphome.core import CORE, EsphomeError from esphome.util import BootselResult +from esphome.zeroconf import _await_discovery, discover_mdns_devices def strip_ansi_codes(text: str) -> str: @@ -2218,6 +2224,509 @@ def test_has_resolvable_address() -> None: assert has_resolvable_address() is False +def test_has_name_add_mac_suffix() -> None: + """Test has_name_add_mac_suffix function.""" + + # Test with name_add_mac_suffix enabled + setup_core(config={CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}}) + assert has_name_add_mac_suffix() is True + + # Test with name_add_mac_suffix disabled + setup_core(config={CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: False}}) + assert has_name_add_mac_suffix() is False + + # Test with name_add_mac_suffix not set (defaults to False) + setup_core(config={CONF_ESPHOME: {}}) + assert has_name_add_mac_suffix() is False + + # Test with no esphome config + setup_core(config={}) + assert has_name_add_mac_suffix() is False + + # Test with no config at all + CORE.config = None + assert has_name_add_mac_suffix() is False + + +@pytest.fixture +def mock_mdns_discovery() -> Generator[MagicMock]: + """Fixture to mock the async mDNS discovery infrastructure. + + Patches ``AsyncEsphomeZeroconf``, ``AsyncServiceBrowser`` and + ``AddressResolver`` in ``esphome.zeroconf`` and exposes hooks for tests to + stage browser events and control resolution results. The default + ``AddressResolver`` stub simulates a cache hit returning no addresses, so + matched hosts appear in the discovery output with empty address lists + unless the test overrides ``_resolver_setup``. + """ + with ( + patch("esphome.zeroconf.AsyncEsphomeZeroconf") as mock_aiozc_class, + patch("esphome.zeroconf.AsyncServiceBrowser") as mock_browser_class, + patch("esphome.zeroconf.AddressResolver") as mock_resolver_class, + ): + mock_aiozc = MagicMock() + mock_aiozc.zeroconf = MagicMock() + mock_aiozc.async_close = AsyncMock(return_value=None) + mock_aiozc_class.return_value = mock_aiozc + + mock_browser = MagicMock() + mock_browser.async_cancel = AsyncMock(return_value=None) + + # Default: each host gets a fresh resolver that hits the cache and + # returns no addresses. Tests can override via ``_resolver_setup``. + def default_resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver._name = name + resolver.load_from_cache.return_value = True + resolver.async_request = AsyncMock(return_value=True) + resolver.parsed_scoped_addresses.return_value = [] + return resolver + + mock_resolver_class.side_effect = default_resolver_factory + + # Store references for test access + mock_aiozc._mock_browser_class = mock_browser_class + mock_aiozc._mock_browser = mock_browser + mock_aiozc._mock_class = mock_aiozc_class + mock_aiozc._mock_resolver_class = mock_resolver_class + yield mock_aiozc + + +@pytest.mark.parametrize( + ("discovered_services", "base_name", "expected_hosts"), + [ + # Matching devices; different-prefix device is filtered out + ( + [ + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Added), + ("mydevice-def456._esphomelib._tcp.local.", ServiceStateChange.Added), + ( + "otherdevice-abcdef._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + ], + "mydevice", + ["mydevice-abc123.local", "mydevice-def456.local"], + ), + # No matches at all + ( + [ + ( + "otherdevice-abcdef._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + ], + "mydevice", + [], + ), + # Deduplication (same device Added then Updated) + ( + [ + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Added), + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Updated), + ], + "mydevice", + ["mydevice-abc123.local"], + ), + # Suffix must be exactly 6 hex chars: wrong length and non-hex are rejected + ( + [ + # too short + ("mydevice-abcd._esphomelib._tcp.local.", ServiceStateChange.Added), + # too long + ( + "mydevice-abcdef1._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + # non-hex + ("mydevice-xyz123._esphomelib._tcp.local.", ServiceStateChange.Added), + # valid + ("mydevice-012345._esphomelib._tcp.local.", ServiceStateChange.Added), + ], + "mydevice", + ["mydevice-012345.local"], + ), + # Prefix-collision: base "foo" must not match "foo-bar-abc123" + ( + [ + ("foo-abcdef._esphomelib._tcp.local.", ServiceStateChange.Added), + ("foo-bar-abcdef._esphomelib._tcp.local.", ServiceStateChange.Added), + ], + "foo", + ["foo-abcdef.local"], + ), + ], + ids=[ + "matching_with_filter", + "no_matches", + "deduplication", + "hex_suffix_filter", + "prefix_collision", + ], +) +def test_discover_mdns_devices( + mock_mdns_discovery: MagicMock, + discovered_services: list[tuple[str, ServiceStateChange]], + base_name: str, + expected_hosts: list[str], +) -> None: + """Test discover_mdns_devices filtering and deduplication.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + callback = handlers[0] + for service_name, state_change in discovered_services: + callback( + mock_mdns_discovery.zeroconf, service_type, service_name, state_change + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + # Each discovered host gets a resolver that returns a unique IP string + # derived from its server name so we can assert per-host. + def resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver._name = name + resolver.load_from_cache.return_value = True + resolver.async_request = AsyncMock(return_value=True) + resolver.parsed_scoped_addresses.return_value = [f"10.0.0.1#{name}"] + return resolver + + mock_mdns_discovery._mock_resolver_class.side_effect = resolver_factory + + result = discover_mdns_devices(base_name, timeout=0) + + assert sorted(result) == expected_hosts + # Resolved addresses should be stored for matched hosts. AddressResolver + # receives the fully-qualified name (``.local.``). + for host in expected_hosts: + short = host.partition(".")[0] + assert result[host] == [f"10.0.0.1#{short}.local."] + mock_browser.async_cancel.assert_awaited_once() + mock_mdns_discovery.async_close.assert_awaited_once() + + +def test_discover_mdns_devices_init_failure(caplog: pytest.LogCaptureFixture) -> None: + """If AsyncEsphomeZeroconf fails to init, return empty dict and log warning.""" + with ( + patch( + "esphome.zeroconf.AsyncEsphomeZeroconf", + side_effect=OSError("no network"), + ), + caplog.at_level(logging.WARNING, logger="esphome.zeroconf"), + ): + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {} + assert "mDNS discovery failed to initialize" in caplog.text + + +def test_discover_mdns_devices_resolution_failure( + mock_mdns_discovery: MagicMock, +) -> None: + """If resolution raises, the host is still listed with an empty address list.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Added, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + # Resolver misses the cache, then async_request raises. + def failing_resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver.load_from_cache.return_value = False + resolver.async_request = AsyncMock(side_effect=OSError("boom")) + resolver.parsed_scoped_addresses.return_value = [] + return resolver + + mock_mdns_discovery._mock_resolver_class.side_effect = failing_resolver_factory + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {"mydevice-abc123.local": []} + + +def test_discover_mdns_devices_ignores_removed_state( + mock_mdns_discovery: MagicMock, +) -> None: + """``Removed`` state changes are ignored and do not appear in the result.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Removed, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {} + # No AddressResolver should have been constructed since no host matched. + mock_mdns_discovery._mock_resolver_class.assert_not_called() + + +def test_discover_mdns_devices_empty_resolution( + mock_mdns_discovery: MagicMock, +) -> None: + """Host is listed with empty addresses when resolver returns no addresses.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Added, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + # Default fixture resolver is a cache-hit with no addresses — simulates + # the "browse found it but no A/AAAA records are available" case. + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {"mydevice-abc123.local": []} + + +def test_resolve_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded to their cached IPs.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + result = _resolve_network_devices( + ["device-abc123.local", "192.168.1.50", "device-abc123.local"], + CORE.config, + MockArgs(), + ) + + # Cached hostname is replaced with its IPs (deduplicated across repeats) + # and the literal IP is preserved after. + assert result == ["10.0.0.1", "10.0.0.2", "192.168.1.50"] + + +def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: + """Hostnames not in the cache pass through unchanged.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache() + + result = _resolve_network_devices( + ["unknown.local", "192.168.1.50"], + CORE.config, + MockArgs(), + ) + + assert result == ["unknown.local", "192.168.1.50"] + + +def test_await_discovery_timeout_returns_empty( + caplog: pytest.LogCaptureFixture, +) -> None: + """If the discovery runner never sets its event, return {} and warn.""" + stub = MagicMock() + stub.event.wait.return_value = False + stub.exception = None + stub.result = {"should_not_be_read": ["1.2.3.4"]} + + with caplog.at_level(logging.WARNING, logger="esphome.zeroconf"): + result = _await_discovery(stub, timeout=0.01) + + assert result == {} + assert "mDNS discovery timed out after 0.0s" in caplog.text + stub.event.wait.assert_called_once_with(timeout=pytest.approx(2.01)) + + +def test_await_discovery_propagates_exception_as_empty( + caplog: pytest.LogCaptureFixture, +) -> None: + """If the coroutine raised, log and return {} rather than re-raise.""" + stub = MagicMock() + stub.event.wait.return_value = True + stub.exception = RuntimeError("boom") + stub.result = None + + with caplog.at_level(logging.WARNING, logger="esphome.zeroconf"): + result = _await_discovery(stub, timeout=5.0) + + assert result == {} + assert "mDNS discovery failed: boom" in caplog.text + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_discovers_mac_suffix_devices(tmp_path: Path) -> None: + """Interactive mode discovers MAC-suffixed devices and populates the cache.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + CORE.address_cache = None + + discovered = { + "mydevice-abc123.local": ["10.0.0.1"], + "mydevice-def456.local": ["10.0.0.2"], + } + with ( + patch( + "esphome.__main__.discover_mdns_devices", return_value=discovered + ) as mock_discover, + patch( + "esphome.__main__.choose_prompt", return_value="mydevice-abc123.local" + ) as mock_prompt, + ): + result = choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert result == ["mydevice-abc123.local"] + mock_discover.assert_called_once_with("mydevice") + mock_prompt.assert_called_once_with( + [ + ("Over The Air (mydevice-abc123.local)", "mydevice-abc123.local"), + ("Over The Air (mydevice-def456.local)", "mydevice-def456.local"), + ], + purpose=Purpose.UPLOADING, + ) + # Resolved IPs should be cached so downstream resolution skips a second + # Zeroconf lookup. + assert CORE.address_cache is not None + assert CORE.address_cache.get_mdns_addresses("mydevice-abc123.local") == [ + "10.0.0.1" + ] + assert CORE.address_cache.get_mdns_addresses("mydevice-def456.local") == [ + "10.0.0.2" + ] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_mac_suffix_no_devices_found( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """When discovery finds nothing, no OTA option is offered and a warning logs.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + + with ( + patch("esphome.__main__.discover_mdns_devices", return_value={}), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + pytest.raises(EsphomeError), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "No devices matching 'mydevice-.local'" in caplog.text + + +def test_choose_upload_log_host_default_ota_discovers_mac_suffix( + tmp_path: Path, +) -> None: + """``--device OTA`` also runs mDNS discovery when name_add_mac_suffix is on.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + CORE.address_cache = None + + discovered = { + "mydevice-abc123.local": ["10.0.0.1"], + "mydevice-def456.local": ["10.0.0.2"], + } + with patch( + "esphome.__main__.discover_mdns_devices", return_value=discovered + ) as mock_discover: + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + + # Both discovered hostnames are returned so aioesphomeapi / espota2 can + # try each in turn with the cached IPs. + assert result == ["mydevice-abc123.local", "mydevice-def456.local"] + mock_discover.assert_called_once_with("mydevice") + assert CORE.address_cache is not None + assert CORE.address_cache.get_mdns_addresses("mydevice-abc123.local") == [ + "10.0.0.1" + ] + + +def test_choose_upload_log_host_default_ota_no_suffix_discovery( + tmp_path: Path, +) -> None: + """``--device OTA`` without name_add_mac_suffix uses CORE.address as-is.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, + address="192.168.1.100", + tmp_path=tmp_path, + name="mydevice", + ) + + with patch("esphome.__main__.discover_mdns_devices") as mock_discover: + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert result == ["192.168.1.100"] + # Discovery must NOT run when name_add_mac_suffix is disabled. + mock_discover.assert_not_called() + + def test_command_wizard(tmp_path: Path) -> None: """Test command_wizard function.""" config_file = tmp_path / "test.yaml" diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index b4cca05d9f..7862c268ca 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -4,7 +4,7 @@ from __future__ import annotations import re import socket -from unittest.mock import patch +from unittest.mock import MagicMock, patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr @@ -115,24 +115,21 @@ def test_async_resolver_generic_exception() -> None: def test_async_resolver_thread_timeout() -> None: - """Test timeout when thread doesn't complete in time.""" - # Mock the start method to prevent actual thread execution - with ( - patch.object(AsyncResolver, "start"), - patch("esphome.resolver.hr.async_resolve_host"), - ): - resolver = AsyncResolver(["test.local"], 6053) - # Override event.wait to simulate timeout (return False = timeout occurred) - with ( - patch.object(resolver.event, "wait", return_value=False), - pytest.raises( - EsphomeError, match=re.escape("Timeout resolving IP address") - ), - ): - resolver.resolve() + """Test timeout when the runner thread doesn't complete in time.""" + # Patch AsyncThreadRunner inside esphome.resolver so we never actually + # start a thread and can control the wait return value directly. + fake_runner = MagicMock() + fake_runner.start = MagicMock() + fake_runner.event.wait.return_value = False # simulate timeout - # Verify thread start was called - resolver.start.assert_called_once() + with ( + patch("esphome.resolver.AsyncThreadRunner", return_value=fake_runner), + patch("esphome.resolver.hr.async_resolve_host"), + pytest.raises(EsphomeError, match=re.escape("Timeout resolving IP address")), + ): + AsyncResolver(["test.local"], 6053).resolve() + + fake_runner.start.assert_called_once() def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: From f757cd1210447b6145bdd87cf50bdb4bcab164fd Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:46:56 +0200 Subject: [PATCH 8/8] [zigbee][core] Add support for Zigbee binary sensors on ESP32 H2 and C6 (#11553) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .clang-tidy.hash | 2 +- CODEOWNERS | 2 +- esphome/components/zigbee/__init__.py | 109 +++++- esphome/components/zigbee/automation.h | 3 + esphome/components/zigbee/const.py | 32 ++ esphome/components/zigbee/const_esp32.py | 35 ++ esphome/components/zigbee/const_zephyr.py | 21 -- esphome/components/zigbee/time/__init__.py | 3 +- .../zigbee/zigbee_attribute_esp32.cpp | 89 +++++ .../zigbee/zigbee_attribute_esp32.h | 90 +++++ esphome/components/zigbee/zigbee_ep_esp32.py | 70 ++++ esphome/components/zigbee/zigbee_esp32.cpp | 313 ++++++++++++++++++ esphome/components/zigbee/zigbee_esp32.h | 134 ++++++++ esphome/components/zigbee/zigbee_esp32.py | 274 +++++++++++++++ .../components/zigbee/zigbee_helpers_esp32.c | 74 +++++ .../components/zigbee/zigbee_helpers_esp32.h | 27 ++ esphome/components/zigbee/zigbee_zephyr.py | 27 +- esphome/core/defines.h | 1 + esphome/idf_component.yml | 8 + sdkconfig.defaults | 5 + tests/components/zigbee/common.yaml | 10 - tests/components/zigbee/common_esp32.yaml | 14 + tests/components/zigbee/common_nrf52.yaml | 12 + .../components/zigbee/test.esp32-c6-idf.yaml | 1 + .../zigbee/test.nrf52-adafruit.yaml | 2 +- .../components/zigbee/test.nrf52-mcumgr.yaml | 2 +- .../zigbee/test.nrf52-xiao-ble.yaml | 2 +- 27 files changed, 1295 insertions(+), 67 deletions(-) create mode 100644 esphome/components/zigbee/const.py create mode 100644 esphome/components/zigbee/const_esp32.py create mode 100644 esphome/components/zigbee/zigbee_attribute_esp32.cpp create mode 100644 esphome/components/zigbee/zigbee_attribute_esp32.h create mode 100644 esphome/components/zigbee/zigbee_ep_esp32.py create mode 100644 esphome/components/zigbee/zigbee_esp32.cpp create mode 100644 esphome/components/zigbee/zigbee_esp32.h create mode 100644 esphome/components/zigbee/zigbee_esp32.py create mode 100644 esphome/components/zigbee/zigbee_helpers_esp32.c create mode 100644 esphome/components/zigbee/zigbee_helpers_esp32.h create mode 100644 tests/components/zigbee/common_esp32.yaml create mode 100644 tests/components/zigbee/common_nrf52.yaml create mode 100644 tests/components/zigbee/test.esp32-c6-idf.yaml diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 9b6b817633..41e1b7bd2f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -256216e144a626c8c9d1a458920a9db3de7dfc8c6a1b44b87946b9752e81026c +1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324 diff --git a/CODEOWNERS b/CODEOWNERS index 92efe4da4e..69f2cb1d17 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -600,6 +600,6 @@ esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 esphome/components/zhlt01/* @cfeenstra1024 -esphome/components/zigbee/* @tomaszduda23 +esphome/components/zigbee/* @luar123 @tomaszduda23 esphome/components/zio_ultrasonic/* @kahrendt esphome/components/zwave_proxy/* @kbx81 diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 280ff6b50c..126e3aa2cd 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -3,26 +3,42 @@ from typing import Any from esphome import automation, core import esphome.codegen as cg +from esphome.components.esp32 import only_on_variant +from esphome.components.esp32.const import ( + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, +) from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data from esphome.components.zephyr.const import KEY_BOOTLOADER import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_NAME +from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType +from .const import ( + CONF_ON_JOIN, + CONF_POWER_SOURCE, + CONF_REPORT, + CONF_ROUTER, + CONF_WIPE_ON_BOOT, + KEY_ZIGBEE, + POWER_SOURCE, + REPORT, + ZigbeeComponent, + zigbee_ns, +) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER, - CONF_ON_JOIN, - CONF_POWER_SOURCE, - CONF_WIPE_ON_BOOT, CONF_ZIGBEE_ID, KEY_EP_NUMBER, - KEY_ZIGBEE, - POWER_SOURCE, - ZigbeeComponent, - zigbee_ns, +) +from .zigbee_esp32 import ( + final_validate_esp32, + validate_binary_sensor_esp32, + zigbee_require_vfs_select, ) from .zigbee_zephyr import ( zephyr_binary_sensor, @@ -33,11 +49,11 @@ from .zigbee_zephyr import ( _LOGGER = logging.getLogger(__name__) -CODEOWNERS = ["@tomaszduda23"] +CODEOWNERS = ["@luar123", "@tomaszduda23"] def zigbee_set_core_data(config: ConfigType) -> ConfigType: - if zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: + if CORE.is_nrf52 and zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: zephyr_add_pm_static( [Section("empty_after_zboss_offset", 0xF4000, 0xC000, "flash_primary")] ) @@ -45,7 +61,15 @@ def zigbee_set_core_data(config: ConfigType) -> ConfigType: return config -BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor) +BINARY_SENSOR_SCHEMA = cv.Schema( + { + cv.Optional(CONF_REPORT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.enum(REPORT, lower=True), + ) + } +).extend(zephyr_binary_sensor) SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) @@ -54,16 +78,27 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), - cv.Optional(CONF_ON_JOIN): automation.validate_automation(single=True), - cv.Optional(CONF_WIPE_ON_BOOT, default=False): cv.All( + cv.Optional(CONF_MODEL, default=CORE.name): cv.All( + cv.string, cv.Length(max=31) + ), + cv.OnlyWith(CONF_ROUTER, "esp32", default=False): cv.All( + cv.requires_component("esp32"), + cv.boolean, + ), + cv.Optional(CONF_ON_JOIN): cv.All( + cv.requires_component("nrf52"), + automation.validate_automation(single=True), + ), + cv.OnlyWith(CONF_WIPE_ON_BOOT, "nrf52", default=False): cv.All( cv.Any( cv.boolean, cv.one_of(*["once"], lower=True), ), cv.requires_component("nrf52"), ), - cv.Optional(CONF_POWER_SOURCE, default="DC_SOURCE"): cv.enum( - POWER_SOURCE, upper=True + cv.OnlyWith(CONF_POWER_SOURCE, "nrf52", default="DC_SOURCE"): cv.All( + cv.enum(POWER_SOURCE, upper=True), + cv.requires_component("nrf52"), ), cv.Optional(CONF_IEEE802154_VENDOR_OUI): cv.All( cv.Any( @@ -74,12 +109,27 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), + zigbee_require_vfs_select, zigbee_set_core_data, - cv.only_with_framework("zephyr"), + cv.Any( + cv.All( + cv.only_on_esp32, + only_on_variant( + supported=[ + VARIANT_ESP32H2, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + ] + ), + ), + cv.only_with_framework("zephyr"), + ), ) -def validate_number_of_ep(config: ConfigType) -> None: +def validate_number_of_ep(config: ConfigType) -> ConfigType: + if not CORE.is_nrf52: + return config if KEY_ZIGBEE not in CORE.data: raise cv.Invalid("At least one zigbee device need to be included") count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) @@ -90,9 +140,12 @@ def validate_number_of_ep(config: ConfigType) -> None: if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode: raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") + return config + FINAL_VALIDATE_SCHEMA = cv.All( validate_number_of_ep, + final_validate_esp32, ) @@ -103,6 +156,10 @@ async def to_code(config: ConfigType) -> None: from .zigbee_zephyr import zephyr_to_code await zephyr_to_code(config) + if CORE.is_esp32: + from .zigbee_esp32 import esp32_to_code + + await esp32_to_code(config) async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: @@ -148,7 +205,7 @@ async def setup_number( def consume_endpoint(config: ConfigType) -> ConfigType: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if not config.get(CONF_ZIGBEE_ID): return config if CONF_NAME in config and " " in config[CONF_NAME]: _LOGGER.warning( @@ -163,18 +220,34 @@ def consume_endpoint(config: ConfigType) -> ConfigType: def validate_binary_sensor(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return validate_binary_sensor_esp32(config) return consume_endpoint(config) def validate_sensor(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) def validate_switch(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) def validate_number(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h index 1822e6a029..55ee9746ea 100644 --- a/esphome/components/zigbee/automation.h +++ b/esphome/components/zigbee/automation.h @@ -1,6 +1,9 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_ZIGBEE +#ifdef USE_ESP32 +#include "zigbee_esp32.h" +#endif #ifdef USE_NRF52 #include "zigbee_zephyr.h" #endif diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py new file mode 100644 index 0000000000..26ae2cc0ec --- /dev/null +++ b/esphome/components/zigbee/const.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg + +zigbee_ns = cg.esphome_ns.namespace("zigbee") +ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) +ZigbeeAttribute = zigbee_ns.class_("ZigbeeAttribute", cg.Component) +BinaryAttrs = zigbee_ns.struct("BinaryAttrs") +AnalogAttrs = zigbee_ns.struct("AnalogAttrs") +AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") + +report = zigbee_ns.enum("ZigbeeReportT") +REPORT = { + "coordinator": report.ZIGBEE_REPORT_COORDINATOR, + "enable": report.ZIGBEE_REPORT_ENABLE, + "force": report.ZIGBEE_REPORT_FORCE, +} + +CONF_ON_JOIN = "on_join" +CONF_WIPE_ON_BOOT = "wipe_on_boot" +CONF_REPORT = "report" +CONF_ROUTER = "router" +CONF_POWER_SOURCE = "power_source" +POWER_SOURCE = { + "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", + "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", + "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", + "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", + "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", + "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", + "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", +} + +KEY_ZIGBEE = "zigbee" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py new file mode 100644 index 0000000000..682638439e --- /dev/null +++ b/esphome/components/zigbee/const_esp32.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg + +DEVICE_TYPE = "device_type" +ROLE = "role" +CONF_MAX_EP_NUMBER = 239 +CONF_NUM = "num" +CONF_CLUSTERS = "clusters" +CONF_ATTRIBUTES = "attributes" +CONF_ENDPOINT = "endpoint" +CONF_CLUSTER = "cluster" +SCALE = "scale" +CONF_ATTRIBUTE_ID = "attribute_id" +KEY_BS_EP = "binary_sensor_ep" + +ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") +DEVICE_ID = { + "RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID, + "SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID, + "CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID, +} +cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") +CLUSTER_ID = { + "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, + "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, +} +cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") +CLUSTER_ROLE = { + "SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE, +} +attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t") +ATTR_TYPE = { + "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, + "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, + "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, +} diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 2d233755ac..103ef01a3d 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,33 +1,12 @@ -import esphome.codegen as cg - -zigbee_ns = cg.esphome_ns.namespace("zigbee") -ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) -BinaryAttrs = zigbee_ns.struct("BinaryAttrs") -AnalogAttrs = zigbee_ns.struct("AnalogAttrs") -AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") - CONF_MAX_EP_NUMBER = 8 CONF_ZIGBEE_ID = "zigbee_id" -CONF_ON_JOIN = "on_join" -CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" -CONF_POWER_SOURCE = "power_source" -POWER_SOURCE = { - "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", - "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", - "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", - "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", - "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", - "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", - "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", -} CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage -KEY_ZIGBEE = "zigbee" KEY_EP_NUMBER = "ep_number" # External ZBOSS SDK types (just strings for codegen) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 82f94c8372..3acab0076f 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -6,7 +6,8 @@ from esphome.core import CORE from esphome.types import ConfigType from .. import consume_endpoint -from ..const_zephyr import CONF_ZIGBEE_ID, zigbee_ns +from ..const import zigbee_ns +from ..const_zephyr import CONF_ZIGBEE_ID from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp new file mode 100644 index 0000000000..4d73600171 --- /dev/null +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -0,0 +1,89 @@ +#include "zigbee_attribute_esp32.h" +#include "esphome/core/log.h" +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee.attribute"; + +void ZigbeeAttribute::set_attr_() { + if (!this->zb_->is_connected()) { + return; + } + if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_, + this->attr_id_, this->value_p_, false); + if (this->force_report_) { + this->report_(true); + } + this->set_attr_requested_ = false; + // Check for error + if (state != ESP_ZB_ZCL_STATUS_SUCCESS) { + ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); + } + esp_zb_lock_release(); + } +} + +void ZigbeeAttribute::report_(bool has_lock) { + if (!this->zb_->is_connected()) { + return; + } + if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + esp_zb_zcl_report_attr_cmd_t cmd = { + .address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI, + }; + cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000; + cmd.zcl_basic_cmd.dst_endpoint = 1; + cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_; + cmd.clusterID = this->cluster_id_; + cmd.attributeID = this->attr_id_; + + esp_zb_zcl_report_attr_cmd_req(&cmd); + if (!has_lock) { + esp_zb_lock_release(); + } + } +} + +esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() { + esp_zb_zcl_reporting_info_t reporting_info = { + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV, + .ep = this->endpoint_id_, + .cluster_id = this->cluster_id_, + .cluster_role = this->role_, + .attr_id = this->attr_id_, + .manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC, + }; + reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID; + reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */ + reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */ + reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */ + reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */ + reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */ + + return reporting_info; +} + +void ZigbeeAttribute::set_report(bool force) { + this->report_enabled = true; + this->force_report_ = force; +} + +void ZigbeeAttribute::loop() { + if (this->set_attr_requested_) { + this->set_attr_(); + } + + if (!this->set_attr_requested_) { + this->disable_loop(); + } +} + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h new file mode 100644 index 0000000000..5a0cfc4fbd --- /dev/null +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -0,0 +1,90 @@ +#pragma once + +#include + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "esp_zigbee_core.h" +#include "zigbee_esp32.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::zigbee { + +enum ZigbeeReportT { + ZIGBEE_REPORT_COORDINATOR, + ZIGBEE_REPORT_ENABLE, + ZIGBEE_REPORT_FORCE, +}; + +class ZigbeeAttribute : public Component { + public: + ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t attr_type, float scale, uint8_t max_size) + : zb_(parent), + endpoint_id_(endpoint_id), + cluster_id_(cluster_id), + role_(role), + attr_id_(attr_id), + attr_type_(attr_type), + scale_(scale), + max_size_(max_size) {} + void loop() override; + template void add_attr(T value); + esp_zb_zcl_reporting_info_t get_reporting_info(); + template void set_attr(const T &value); + uint8_t attr_type() { return attr_type_; } + void set_report(bool force); +#ifdef USE_BINARY_SENSOR + template void connect(binary_sensor::BinarySensor *sensor); +#endif + bool report_enabled = false; + + protected: + void set_attr_(); + void report_(bool has_lock); + ZigbeeComponent *zb_; + uint8_t endpoint_id_; + uint16_t cluster_id_; + uint8_t role_; + uint16_t attr_id_; + uint8_t attr_type_; + uint8_t max_size_; + float scale_; + void *value_p_{nullptr}; + bool set_attr_requested_{false}; + bool force_report_{false}; +}; + +template void ZigbeeAttribute::add_attr(T value) { + // Attribute type does never change and add_attr is only called once during startup, so this is safe. + // For now we need to support only simple numeric/bool types for (binary) sensors. + // For strings and arrays we would need to allocate a buffer of the maximum size. + this->value_p_ = (void *) (new T); + this->zb_->add_attr(this, this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, this->max_size_, + std::move(value)); +} + +template void ZigbeeAttribute::set_attr(const T &value) { + *static_cast(this->value_p_) = value; + this->set_attr_requested_ = true; + this->enable_loop(); +} + +#ifdef USE_BINARY_SENSOR +template void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) { + sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); +} +#endif + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py new file mode 100644 index 0000000000..791232d463 --- /dev/null +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -0,0 +1,70 @@ +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE + +from .const import CONF_REPORT, REPORT +from .const_esp32 import ( + CLUSTER_ROLE, + CONF_ATTRIBUTE_ID, + CONF_ATTRIBUTES, + CONF_CLUSTERS, + CONF_MAX_EP_NUMBER, + CONF_NUM, + DEVICE_TYPE, + ROLE, +) + +# endpoint configs: +ep_configs: dict[str, dict[str, Any]] = { + "binary_input": { + DEVICE_TYPE: "SIMPLE_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "BINARY_INPUT", + ROLE: CLUSTER_ROLE["SERVER"], + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "BOOL", + CONF_REPORT: REPORT["enable"], + CONF_DEVICE: None, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "8BITMAP", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "CHAR_STRING", + }, + ], + }, + ], + }, +} + + +def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]: + # create dummy endpoint if list is empty + if not ep_list: + ep_type = "CUSTOM_ATTR" + if router: + ep_type = "RANGE_EXTENDER" + ep_list = [ + { + DEVICE_TYPE: ep_type, + } + ] + # enumerate endpoints + for i, ep in enumerate(ep_list, 1): + ep[CONF_NUM] = i + if len(ep_list) > CONF_MAX_EP_NUMBER: + raise cv.Invalid( + f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." + ) + return ep_list diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp new file mode 100644 index 0000000000..c16736236a --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -0,0 +1,313 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_check.h" +#include "nvs_flash.h" +#include "zigbee_attribute_esp32.h" +#include "zigbee_esp32.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include "zigbee_helpers_esp32.h" +#ifdef USE_WIFI +#include "esp_coexist.h" +#endif + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee"; + +static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { + uint8_t str_len = static_cast(strlen(str)); + uint8_t zcl_str_size = use_max_size ? max_size : std::min(max_size, str_len); + uint8_t *zcl_str = new uint8_t[zcl_str_size + 1]; // string + length octet + zcl_str[0] = zcl_str_size; + + // Initialize payload to avoid leaking uninitialized heap contents and clamp copy length + memset(zcl_str + 1, 0, zcl_str_size); + uint8_t copy_len = std::min(zcl_str_size, str_len); + if (copy_len > 0) { + memcpy(zcl_str + 1, str, copy_len); + } + return zcl_str; +} + +static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { + if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) { + ESP_LOGE(TAG, "Start network steering failed!"); + } +} + +void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { + static uint8_t steering_retry_count = 0; + uint32_t *p_sg_p = signal_struct->p_app_signal; + esp_err_t err_status = signal_struct->esp_err_status; + esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p; + esp_zb_zdo_signal_leave_params_t *leave_params = NULL; + switch (sig_type) { + case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP: + ESP_LOGD(TAG, "Zigbee stack initialized"); + esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION); + break; + case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START: + case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT: + if (err_status == ESP_OK) { + ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non "); + global_zigbee->started = true; + if (esp_zb_bdb_is_factory_new()) { + ESP_LOGD(TAG, "Start network steering"); + esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING); + } else { + ESP_LOGD(TAG, "Device rebooted"); + global_zigbee->connected = true; + } + } else { + ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)", + esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status)); + ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status)); + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION, + 1000); + } + break; + case ESP_ZB_BDB_SIGNAL_STEERING: + if (err_status == ESP_OK) { + steering_retry_count = 0; + ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(), + esp_zb_get_current_channel()); + global_zigbee->connected = true; + } else { + ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status)); + if (steering_retry_count < 10) { + steering_retry_count++; + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, + ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000); + } else { + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, + ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000); + } + } + break; + case ESP_ZB_ZDO_SIGNAL_LEAVE: + leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p); + if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) { + esp_zb_factory_reset(); + } + break; + default: + ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type, + esp_err_to_name(err_status)); + break; + } +} + +static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) { + esp_err_t ret = ESP_OK; + ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message"); + ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG, + "Received message: error status(%d)", message->info.status); + ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)", + message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size); + return ret; +} + +static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) { + esp_err_t ret = ESP_OK; + switch (callback_id) { + case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID: + ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message); + break; + default: + ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id); + break; + } + return ret; +} + +void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) { + esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create(); + this->endpoint_list_[endpoint_id] = + std::tuple(device_id, cluster_list); + // Add basic cluster + this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); + // Add identify cluster if not already present + if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) == + nullptr) { + this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); + } +} + +void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) { + esp_zb_attribute_list_t *attr_list; + if (cluster_id == 0) { + attr_list = create_basic_cluster_(); + } else { + attr_list = esphome_zb_default_attr_list_create(cluster_id); + } + this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list; +} + +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer) { + char date_buf[16]; + time_t time_val = App.get_build_time(); + struct tm *timeinfo = localtime(&time_val); + strftime(date_buf, sizeof(date_buf), "%Y%m%d %H%M%S", timeinfo); + this->basic_cluster_data_ = { + .model = get_zcl_string(model, 31), + .manufacturer = get_zcl_string(manufacturer, 31), + .date = get_zcl_string(date_buf, 15), + }; +} + +esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() { + esp_zb_basic_cluster_cfg_t basic_cluster_cfg = { + .zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, + .power_source = 0, + }; + esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, + this->basic_cluster_data_.manufacturer); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + return attr_list; +} + +esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, + esp_zb_cluster_list_t *esp_zb_cluster_list) { + esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, + .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0}; + return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); +} + +static void esp_zb_task_(void *pvParameters) { + if (esp_zb_start(false) != ESP_OK) { + ESP_LOGE(TAG, "Could not setup Zigbee"); + vTaskDelete(NULL); + } + esp_zb_set_node_descriptor_power_source(1); + esp_zb_stack_main_loop(); +} + +void ZigbeeComponent::setup() { + global_zigbee = this; + esp_zb_platform_config_t config = { + .radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(), + .host_config = ESP_ZB_DEFAULT_HOST_CONFIG(), + }; +#ifdef USE_WIFI + if (esp_coex_wifi_i154_enable() != ESP_OK) { + this->mark_failed(); + return; + } +#endif + if (esp_zb_platform_config(&config) != ESP_OK) { + this->mark_failed(); + return; + } + + esp_zb_zed_cfg_t zb_zed_cfg = { + .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; + esp_zb_zczr_cfg_t zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; + esp_zb_cfg_t zb_nwk_cfg = { + .esp_zb_role = this->device_role_, + .install_code_policy = false, + }; +#ifdef ZB_ROUTER_ROLE + zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; +#else + zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; +#endif + esp_zb_init(&zb_nwk_cfg); + + esp_err_t ret; + for (auto const &[key, val] : this->attribute_list_) { + esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]); + ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key)); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key), + esp_err_to_name(ret)); + } else { + ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key), + std::get<2>(key)); +#ifdef ESPHOME_LOG_HAS_VERBOSE + // Dump cluster attributes in verbose log + ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key)); + esp_zb_attribute_list_t *attr_list = val; + while (attr_list) { + esp_zb_zcl_attr_t *attr = &attr_list->attribute; + ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access); + attr_list = attr_list->next; + } +#endif + } + } + this->attribute_list_.clear(); + + for (auto const &[ep_id, dev_id] : this->endpoint_list_) { + if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) { + ESP_LOGE(TAG, "Could not create endpoint %u", ep_id); + } + } + this->endpoint_list_.clear(); + + if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) { + ESP_LOGE(TAG, "Could not register the endpoint list"); + this->mark_failed(); + return; + } + + esp_zb_core_action_handler_register(zb_action_handler); + + if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) { + ESP_LOGE(TAG, "Could not setup Zigbee"); + this->mark_failed(); + return; + } + for (auto &[_, attribute] : this->attributes_) { + if (attribute->report_enabled) { + esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info(); + ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id); + if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) { + ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u", + reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep); + } + } + } + xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL); +} + +void ZigbeeComponent::dump_config() { + if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %s\n" + " Router: %s\n" + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER), + YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(), + esp_zb_get_pan_id()); + esp_zb_lock_release(); + } else { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %s\n" + " Router: %s\n", + this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER)); + } +} +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h new file mode 100644 index 0000000000..80ecbfd639 --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -0,0 +1,134 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include +#include +#include + +#include "esp_zigbee_core.h" +#include "zboss_api.h" +#include "ha/esp_zigbee_ha_standard.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "zigbee_helpers_esp32.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::zigbee { + +/* Zigbee configuration */ +static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ +static const uint8_t MAX_CHILDREN = 10; + +#define ESP_ZB_DEFAULT_RADIO_CONFIG() \ + { .radio_mode = ZB_RADIO_MODE_NATIVE, } + +#define ESP_ZB_DEFAULT_HOST_CONFIG() \ + { .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, } + +uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); + +class ZigbeeAttribute; + +class ZigbeeComponent : public Component { + public: + void setup() override; + void dump_config() override; + esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, + esp_zb_cluster_list_t *esp_zb_cluster_list); + void set_basic_cluster(const char *model, const char *manufacturer); + void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); + void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id); + + template + void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t max_size, T value); + + template + void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value); + + void factory_reset() { + esp_zb_lock_acquire(portMAX_DELAY); + esp_zb_factory_reset(); // triggers a reboot + esp_zb_lock_release(); + } + + bool is_started() { return this->started; } + bool is_connected() { return this->connected; } + std::atomic connected = false; + std::atomic started = false; + + protected: + struct { + uint8_t *model; + uint8_t *manufacturer; + uint8_t *date; + } basic_cluster_data_; +#ifdef ZB_ED_ROLE + esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED; +#else + esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER; +#endif + esp_zb_attribute_list_t *create_basic_cluster_(); + template + void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + T *value_p); + // endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards + // value tuple could be replaced by struct + std::map> endpoint_list_; + // key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role + std::map, esp_zb_attribute_list_t *> attribute_list_; + // attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger + // automations + // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id + std::map, ZigbeeAttribute *> attributes_; + esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); +}; + +extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct); + +template +void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t max_size, T value) { + this->add_attr(nullptr, endpoint_id, cluster_id, role, attr_id, max_size, value); +} + +template +void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, + uint16_t attr_id, uint8_t max_size, T value) { + // The size byte of the zcl_str must be set to the maximum value, + // even though the initial string may be shorter. + if constexpr (std::is_same::value) { + auto zcl_str = get_zcl_string(value.c_str(), max_size, true); + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); + delete[] zcl_str; + } else if constexpr (std::is_convertible::value) { + auto zcl_str = get_zcl_string(value, max_size, true); + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); + delete[] zcl_str; + } else { + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, &value); + } +} + +template +void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, + uint16_t attr_id, T *value_p) { + esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; + esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } +} + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py new file mode 100644 index 0000000000..1b98df6c0a --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -0,0 +1,274 @@ +import copy +import logging +import re +from typing import Any + +import esphome.codegen as cg +from esphome.components.esp32 import ( + CONF_PARTITIONS, + add_idf_component, + add_idf_sdkconfig_option, + add_partition, + require_vfs_select, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_AP, + CONF_DEVICE, + CONF_ID, + CONF_MAX_LENGTH, + CONF_MODEL, + CONF_NAME, + CONF_TYPE, + CONF_VALUE, + CONF_WIFI, +) +from esphome.core import CORE +from esphome.coroutine import CoroPriority, coroutine_with_priority +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .const import CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, REPORT, ZigbeeAttribute +from .const_esp32 import ( + ATTR_TYPE, + CLUSTER_ID, + CONF_ATTRIBUTE_ID, + CONF_ATTRIBUTES, + CONF_CLUSTERS, + CONF_NUM, + DEVICE_ID, + DEVICE_TYPE, + KEY_BS_EP, + ROLE, + SCALE, +) +from .zigbee_ep_esp32 import create_ep, ep_configs + +_LOGGER = logging.getLogger(__name__) + + +def get_c_size(bits: str, options: list[int]) -> str: + return str([n for n in options if n >= int(bits)][0]) + + +def get_c_type(attr_type: str) -> Any | None: + if attr_type == "BOOL": + return cg.bool_ + if "STRING" in attr_type: + return cg.std_string + test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + if test and test.group(2): + return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) + return None + + +def get_cv_by_type(attr_type: str) -> Any | None: + if attr_type == "BOOL": + return cv.boolean + if "STRING" in attr_type: + return cv.string + test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + if test and test.group(2): + return cv.positive_int + return None + + +def get_default_by_type(attr_type: str) -> str | bool | int: + if attr_type == "CHAR_STRING": + return "" + if attr_type == "BOOL": + return False + return 0 + + +def validate_attributes(config: ConfigType) -> ConfigType: + if CONF_VALUE not in config: + config[CONF_VALUE] = get_default_by_type(config[CONF_TYPE]) + config[CONF_VALUE] = get_cv_by_type(config[CONF_TYPE])(config[CONF_VALUE]) + + return config + + +def final_validate_esp32(config: ConfigType) -> ConfigType: + if not CORE.is_esp32: + return config + if CONF_WIFI in fv.full_config.get(): + if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]: + raise cv.Invalid( + "Only Zigbee End Device can be used together with a Wifi Access Point." + ) + if CONF_AP in fv.full_config.get()[CONF_WIFI]: + _LOGGER.warning( + "Wifi Access Point might be unstable while Zigbee is active, use only as fallback." + ) + elif config[CONF_ROUTER]: + _LOGGER.warning( + "The Zigbee Router might miss packets while Wifi is active and could destabilize " + "your network. Use only if Wifi is off most of the time." + ) + if CONF_PARTITIONS in fv.full_config.get() and not isinstance( + fv.full_config.get()[CONF_PARTITIONS], list + ): + with open( + CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]), + encoding="utf8", + ) as f: + partitions_tab = f.read() + for partition, types in [ + ("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}), + ("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}), + ]: + if partition not in partitions_tab: + raise cv.Invalid( + f"Add '{partition}, {types['type']}, {types['subtype']}, , {types['size']},' to your custom partition table." + ) + if not re.search( + rf"^{partition},\s*{types['type']},\s*{types['subtype']}", + partitions_tab, + re.MULTILINE, + ): + raise cv.Invalid( + f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'" + ) + return config + + +def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: + ep = copy.deepcopy(ep_configs["binary_input"]) + for cl in ep.get(CONF_CLUSTERS, []): + for attr in cl[CONF_ATTRIBUTES]: + if ( + attr[CONF_ATTRIBUTE_ID] == 0x1C + and CONF_VALUE not in attr + and CONF_NAME in config + ): # set name + name = ( + config[CONF_NAME].encode("ascii", "ignore").decode() + ) # or use unidecode + attr[CONF_VALUE] = str(name) + attr[CONF_MAX_LENGTH] = len(str(name)) + if CONF_DEVICE in attr: # connect device + attr[CONF_DEVICE] = config[CONF_ID] + if CONF_REPORT in config: + attr[CONF_REPORT] = config[CONF_REPORT] + attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) + if "zb_attr_ids" not in config: + config["zb_attr_ids"] = [] + config["zb_attr_ids"].append(attr[CONF_ID]) + else: + attr[CONF_ID] = None + validate_attributes(attr) + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) + binary_sensor_ep.append(ep) + return config + + +def zigbee_require_vfs_select(config: ConfigType) -> ConfigType: + """Register VFS select requirement during config validation.""" + # Zigbee uses esp_vfs_eventfd which requires VFS select support + if CORE.is_esp32: + require_vfs_select() + return config + + +@coroutine_with_priority(CoroPriority.WORKAROUNDS) +async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: + """Add sdkconfigs late so they can overwrite esp32 defaults""" + add_idf_sdkconfig_option("CONFIG_ZB_ENABLED", True) + if config.get(CONF_ROUTER): + add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True) + else: + add_idf_sdkconfig_option("CONFIG_ZB_ZED", True) + add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True) + if CONF_WIFI in CORE.config: + add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096) + # The pre-built Zigbee library uses esp_log_default_level which requires + # dynamic log level control to be enabled + add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) + + +async def attributes_to_code( + var: cg.Pvariable, ep_num: int, cl: dict[str, Any] +) -> None: + for attr in cl.get(CONF_ATTRIBUTES, []): + if attr.get(CONF_ID) is None: + cg.add( + var.add_attr( + ep_num, + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + attr[CONF_ATTRIBUTE_ID], + attr.get(CONF_MAX_LENGTH, 0), + attr[CONF_VALUE], + ) + ) + continue + attr_var = cg.new_Pvariable( + attr[CONF_ID], + var, + ep_num, + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + attr[CONF_ATTRIBUTE_ID], + ATTR_TYPE[attr[CONF_TYPE]], + attr.get(SCALE, 1), + attr.get(CONF_MAX_LENGTH, 0), + ) + await cg.register_component(attr_var, attr) + + cg.add(attr_var.add_attr(attr[CONF_VALUE])) + if CONF_REPORT in attr and attr[CONF_REPORT] in [ + REPORT["enable"], + REPORT["force"], + ]: + cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"])) + + if CONF_DEVICE in attr: + device = await cg.get_variable(attr[CONF_DEVICE]) + template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) + cg.add(attr_var.connect(template_arg, device)) + + +async def esp32_to_code(config: ConfigType) -> None: + add_idf_component( + name="espressif/esp-zboss-lib", + ref="1.6.4", + ) + add_idf_component( + name="espressif/esp-zigbee-lib", + ref="1.6.8", + ) + + # add sdkconfigs later so they can overwrite esp32 defaults + CORE.add_job(_zigbee_add_sdkconfigs, config) + + # add partitions for zigbee + add_partition("zb_storage", "data", "fat", 0x4000) # 16KB + add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size + + # create endpoints + zb_data = CORE.data.get(KEY_ZIGBEE, {}) + binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) + ep_list = create_ep(binary_sensor_ep, config.get(CONF_ROUTER)) + + # setup zigbee components + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add( + var.set_basic_cluster( + config[CONF_MODEL], + "esphome", + ) + ) + for ep in ep_list: + cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]])) + for cl in ep.get(CONF_CLUSTERS, []): + cg.add( + var.add_cluster( + ep[CONF_NUM], + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + ) + ) + await attributes_to_code(var, ep[CONF_NUM], cl) diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c new file mode 100644 index 0000000000..4ba71ec609 --- /dev/null +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -0,0 +1,74 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "ha/esp_zigbee_ha_standard.h" +#include "zigbee_helpers_esp32.h" + +esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, + uint16_t attr_id, void *value_p) { + esp_err_t ret; + ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p); + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error"); + ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p); + } + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id, + esp_err_to_name(ret)); + } + return ret; +} + +esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, + esp_zb_attribute_list_t *attr_list, uint8_t role_mask) { + esp_err_t ret; + ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask); + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error"); + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask); + break; + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); + break; + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); + break; + default: + ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask); + } + } + return ret; +} + +esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) { + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + return esp_zb_basic_cluster_create(NULL); + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + return esp_zb_identify_cluster_create(NULL); + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return esp_zb_binary_input_cluster_create(NULL); + default: + return esp_zb_zcl_attr_list_create(cluster_id); + } +} + +esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, + void *value_p) { + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); + default: + return ESP_FAIL; + } +} + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h new file mode 100644 index 0000000000..0650c1689f --- /dev/null +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#ifdef __cplusplus +extern "C" { +#endif + +#include "esp_zigbee_core.h" + +esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, + esp_zb_attribute_list_t *attr_list, uint8_t role_mask); +esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id); +esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, + void *value_p); +esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, + uint16_t attr_id, void *value_p); + +#ifdef __cplusplus +} +namespace esphome::zigbee {} // namespace esphome::zigbee +#endif + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 3288d92483..f6e3e88c63 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime import random from esphome import automation @@ -7,6 +7,7 @@ from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, UNIT_AMPERE, @@ -48,19 +49,26 @@ from esphome.cpp_generator import ( ) from esphome.types import ConfigType -from .const_zephyr import ( - CONF_IEEE802154_VENDOR_OUI, +from .const import ( CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_WIPE_ON_BOOT, + KEY_ZIGBEE, + POWER_SOURCE, + AnalogAttrs, + AnalogAttrsOutput, + BinaryAttrs, + ZigbeeComponent, + zigbee_ns, +) +from .const_zephyr import ( + CONF_IEEE802154_VENDOR_OUI, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, CONF_ZIGBEE_SENSOR, CONF_ZIGBEE_SWITCH, KEY_EP_NUMBER, - KEY_ZIGBEE, - POWER_SOURCE, ZB_ZCL_BASIC_ATTRS_EXT_T, ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, @@ -69,11 +77,6 @@ from .const_zephyr import ( ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, ZB_ZCL_CLUSTER_ID_IDENTIFY, ZB_ZCL_IDENTIFY_ATTRS_T, - AnalogAttrs, - AnalogAttrsOutput, - BinaryAttrs, - ZigbeeComponent, - zigbee_ns, ) ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component) @@ -209,9 +212,9 @@ async def _attr_to_code(config: ConfigType) -> None: zigbee_assign(basic_attrs.stack_version, 0), zigbee_assign(basic_attrs.hw_version, 0), zigbee_set_string(basic_attrs.mf_name, "esphome"), - zigbee_set_string(basic_attrs.model_id, CORE.name), + zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( - basic_attrs.date_code, datetime.now().strftime("%d/%m/%y %H:%M") + basic_attrs.date_code, datetime.datetime.now().strftime("%Y%m%d %H%M%S") ), zigbee_assign( basic_attrs.power_source, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 63fe4e677e..9b751dd8c0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -322,6 +322,7 @@ #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD +#define USE_ZIGBEE #endif #endif diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3637481c92..c590f73642 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -37,6 +37,14 @@ dependencies: version: "2.0.0" rules: - if: "target in [esp32, esp32p4]" + espressif/esp-zboss-lib: + version: 1.6.4 + rules: + - if: "target in [esp32h2, esp32c5, esp32c6]" + espressif/esp-zigbee-lib: + version: 1.6.8 + rules: + - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: version: "1.0.0" rules: diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 72ca3f6e9c..2996490295 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -20,3 +20,8 @@ CONFIG_BT_ENABLED=y # esp32_camera CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y CONFIG_ESP32_SPIRAM_SUPPORT=y + +# zigbee +CONFIG_ZB_ENABLED=y +CONFIG_ZB_ZED=y +CONFIG_ZB_RADIO_NATIVE=y diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 2af35ff148..c689d07f6b 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -1,4 +1,3 @@ ---- binary_sensor: - platform: template name: "Garage Door Open 1" @@ -22,12 +21,6 @@ sensor: lambda: return 12.0; internal: True -zigbee: - wipe_on_boot: true - on_join: - then: - - logger.log: "Joined network" - output: - platform: template id: output_factory @@ -35,9 +28,6 @@ output: write_action: - zigbee.factory_reset -time: - - platform: zigbee - switch: - platform: template name: "Template Switch" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml new file mode 100644 index 0000000000..4494b4081d --- /dev/null +++ b/tests/components/zigbee/common_esp32.yaml @@ -0,0 +1,14 @@ +binary_sensor: + - platform: template + name: "Garage Door Open 10" + report: "enable" + - platform: template + name: "Garage Door Open 11" + report: "coordinator" + - platform: template + name: "Garage Door Open 12" + report: "force" + +zigbee: + model: zigbee_test + router: true diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml new file mode 100644 index 0000000000..bc39b371f5 --- /dev/null +++ b/tests/components/zigbee/common_nrf52.yaml @@ -0,0 +1,12 @@ +packages: + - !include common.yaml + +zigbee: + model: zigbee_test + wipe_on_boot: true + on_join: + then: + - logger.log: "Joined network" + +time: + - platform: zigbee diff --git a/tests/components/zigbee/test.esp32-c6-idf.yaml b/tests/components/zigbee/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..8e4796a073 --- /dev/null +++ b/tests/components/zigbee/test.esp32-c6-idf.yaml @@ -0,0 +1 @@ +<<: !include common_esp32.yaml diff --git a/tests/components/zigbee/test.nrf52-adafruit.yaml b/tests/components/zigbee/test.nrf52-adafruit.yaml index dade44d145..bf3cb9cdd9 100644 --- a/tests/components/zigbee/test.nrf52-adafruit.yaml +++ b/tests/components/zigbee/test.nrf52-adafruit.yaml @@ -1 +1 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml index dade44d145..bf3cb9cdd9 100644 --- a/tests/components/zigbee/test.nrf52-mcumgr.yaml +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -1 +1 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml index 254f370ca7..83d949b4dd 100644 --- a/tests/components/zigbee/test.nrf52-xiao-ble.yaml +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -1,4 +1,4 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml zigbee: wipe_on_boot: once