From 3926612281789284df820f21f159f8cf1bb24969 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 15:46:17 -0400 Subject: [PATCH 001/266] [core] Fix use-after-free when deleting a running StaticTask (#19048) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../micro_wake_word/micro_wake_word.cpp | 4 +-- .../mixer/speaker/mixer_speaker.cpp | 4 +-- .../resampler/speaker/resampler_speaker.cpp | 4 +-- .../speaker/media_player/audio_pipeline.cpp | 11 +++++-- esphome/core/static_task.cpp | 30 ++++++++++++++----- esphome/core/static_task.h | 17 +++++++---- 6 files changed, 50 insertions(+), 20 deletions(-) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 3dadb78077d..cebfe8e7914 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -446,9 +446,9 @@ void MicroWakeWord::loop() { xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING); } - if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 6128dc37678..0b79010773a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -382,8 +382,8 @@ void MixerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); } - if (event_group_bits & MIXER_TASK_STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); this->all_stopped_since_ms_ = 0; diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index f1ebd180cc0..edda00ae061 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING); } - if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS); } diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 010f0c50b33..c286a9d7d66 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() { if (!this->is_playing_) { // The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks if (this->read_task_.is_created() || this->decode_task_.is_created()) { - this->read_task_.deallocate(); - this->decode_task_.deallocate(); + // Both are attempted every time; a task that is still running on the other core is freed by a + // subsequent call, and freeing an already freed task succeeds without doing anything + bool read_task_freed = this->read_task_.deallocate(); + bool decode_task_freed = this->decode_task_.deallocate(); + if (!read_task_freed || !decode_task_freed) { + // A task is still running on the other core, so keep the pipeline in its current state and try + // again on the next call + return AudioPipelineState::PLAYING; + } if (this->hard_stop_) { // Stop command was sent, so immediately end the playback this->speaker_->stop(); diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 4cfead44c29..43011083159 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return true; } -void StaticTask::destroy() { - if (this->handle_ != nullptr) { - TaskHandle_t handle = this->handle_; - this->handle_ = nullptr; - vTaskDelete(handle); +bool StaticTask::destroy() { + if (this->handle_ == nullptr) { + return true; } + + // Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks + // the other core to yield though, so the task may still be running on it for a moment. + vTaskSuspend(this->handle_); + if (eTaskGetState(this->handle_) != eSuspended) { + // The task is still running on the other core and using its stack. Deleting it now would only put it on + // the termination list and return, so the caller has to try again once it has been swapped out. + return false; + } + + // The task cannot run again, so the delete completes right away instead of being left to the idle task. + TaskHandle_t handle = this->handle_; + this->handle_ = nullptr; + vTaskDelete(handle); + return true; } -void StaticTask::deallocate() { - this->destroy(); +bool StaticTask::deallocate() { + if (!this->destroy()) { + return false; + } if (this->stack_buffer_ != nullptr) { RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL : RAMAllocator::ALLOC_INTERNAL); @@ -57,6 +72,7 @@ void StaticTask::deallocate() { this->stack_buffer_ = nullptr; this->stack_size_ = 0; } + return true; } } // namespace esphome diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 5fd5b38f9ef..e2996abedae 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -11,6 +11,7 @@ namespace esphome { /** Helper for FreeRTOS static task management. * Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. + * Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on. */ class StaticTask { public: @@ -23,7 +24,7 @@ class StaticTask { /// @brief Allocate stack and create task. /// @param fn Task function /// @param name Task name (for debug) - /// @param stack_size Stack size in StackType_t words + /// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF) /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM @@ -31,11 +32,17 @@ class StaticTask { bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, bool use_psram); - /// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call. - void destroy(); + /// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call. + /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is + /// suspended here so that it cannot be scheduled again, and it is given no chance to clean up. + /// @return true if the task was deleted; false if it is still running on another core, in which case the + /// caller should try again later. + bool destroy(); - /// @brief Delete the task (if running) and free the stack buffer. - void deallocate(); + /// @brief Delete the task (if created) and free the stack buffer. + /// @return true if the stack buffer was freed; false if the task is still running on another core, in + /// which case the caller should try again later. + bool deallocate(); protected: TaskHandle_t handle_{nullptr}; From 4ab9298ab3eedddbd45507785b4d2453ae867bba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:11:36 +0200 Subject: [PATCH 002/266] Bump esptool from 5.3.1 to 5.4.0 (#19023) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cd3f7446f35..dfddbed00bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.3 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.3.1 +esptool==5.4.0 click==8.3.3 aioesphomeapi==46.3.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi From 5bb112f407e8edac9576a7eea1eafb9d94cb1f47 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:04:34 -0400 Subject: [PATCH 003/266] [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) --- esphome/components/audio/audio_reader.cpp | 3 +++ esphome/components/audio/audio_transfer_buffer.cpp | 12 ++++++------ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 4 ++-- .../components/micro_wake_word/micro_wake_word.cpp | 2 +- esphome/components/microphone/microphone_source.h | 2 +- esphome/components/mixer/speaker/mixer_speaker.cpp | 12 ++++++------ .../resampler/speaker/resampler_speaker.cpp | 6 +++--- .../speaker/media_player/audio_pipeline.cpp | 12 +++++++----- 8 files changed, 29 insertions(+), 24 deletions(-) diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 4678ed548c7..e69f33ac2d5 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &ou if (current_audio_file_ != nullptr) { // A transfer buffer isn't ncessary for a local file this->file_ring_buffer_ = output_ring_buffer.lock(); + if (this->file_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; + } return ESP_OK; } diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index a611549e58d..01fd4bb68a6 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le void AudioTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } } void AudioSinkTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } #ifdef USE_SPEAKER @@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() { } bool AudioTransferBuffer::has_buffered_data() const { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); @@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_ size_t bytes_to_read = AudioTransferBuffer::free(); size_t bytes_read = 0; if (bytes_to_read > 0) { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); } @@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait); } else #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_written = this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait); } else if (this->sink_callback_ != nullptr) { @@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->speaker_->has_buffered_data() || (this->available() > 0)); } #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1c2eb129046..b78a151ee42 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -218,8 +218,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t } bool I2SAudioSpeakerBase::has_buffered_data() const { - if (this->audio_ring_buffer_.use_count() > 0) { - std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { return temp_ring_buffer->available() > 0; } return false; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index cebfe8e7914..cf239be6960 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -129,7 +129,7 @@ void MicroWakeWord::setup() { return; } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() > 1) { + if (temp_ring_buffer != nullptr) { // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task // to drain it - reset() is a consumer operation and must run on the inference task's thread. // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index 7be3b8cdb59..d7a33524322 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -48,7 +48,7 @@ class MicrophoneSource final { template void add_data_callback(F &&data_callback) { this->mic_->add_data_callback([this, data_callback](const std::vector &data) { if (this->enabled_ || this->passive_) { - if (this->processed_samples_.use_count() == 0) { + if (this->processed_samples_ == nullptr) { // Create vector if its unused this->processed_samples_ = std::make_shared>(); } diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 0b79010773a..ef21da65c5a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_ } size_t bytes_written = 0; std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer.use_count() > 0) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); if (bytes_written > 0) { @@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; - if (this->audio_source_.use_count() == 0) { + if (this->audio_source_ == nullptr) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); this->ring_buffer_ = temp_ring_buffer; } - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { return ESP_ERR_NO_MEM; } @@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } bool SourceSpeaker::has_buffered_data() const { - return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data()); + return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data()); } void SourceSpeaker::set_mute_state(bool mute_state) { @@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (speaker->is_running() && !speaker->get_pause_state()) { // Speaker is running and not paused, so it possibly can provide audio data std::shared_ptr audio_source = speaker->get_audio_source().lock(); - if (audio_source.use_count() == 0) { + if (audio_source == nullptr) { // No audio source allocated, so skip processing this speaker continue; } diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index edda00ae061..16d2d5dc9e2 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic bytes_written = this->output_speaker_->play(data, length, ticks_to_wait); } else { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); } else { @@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const { bool has_ring_buffer_data = false; if (this->requires_resampling_()) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { has_ring_buffer_data = (temp_ring_buffer->available() > 0); } } @@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { this_resampler->ring_buffer_ = temp_ring_buffer; diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index c286a9d7d66..509984cfa29 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -322,17 +322,17 @@ void AudioPipeline::read_task(void *params) { if (err == ESP_OK) { size_t file_ring_buffer_size = this_pipeline->buffer_size_; - std::shared_ptr temp_ring_buffer; + std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size); this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer; } - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { - reader->add_sink(this_pipeline->raw_file_ring_buffer_); + err = reader->add_sink(temp_ring_buffer); } } @@ -403,7 +403,9 @@ void AudioPipeline::decode_task(void *params) { make_unique(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_); esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_); - decoder->add_source(this_pipeline->raw_file_ring_buffer_); + if (err == ESP_OK) { + err = decoder->add_source(this_pipeline->raw_file_ring_buffer_); + } if (err != ESP_OK) { // Send specific error message From 006f31af9308fd85212cec8b5a9816273608dbba Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:05:02 -0400 Subject: [PATCH 004/266] [i2s_audio] Fix spurious driver failure (#19045) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index b78a151ee42..1382a870465 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() { break; } + // Still starting up or winding down from a previous run + if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + break; + } + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); this->status_momentary_error("driver-failure", 1000); break; } - if (this->speaker_task_handle_ == nullptr) { - xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, - &this->speaker_task_handle_); + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->speaker_task_handle_); - if (this->speaker_task_handle_ == nullptr) { - ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt - } + if (this->speaker_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task-failure", 1000); + this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough From 8f511a365a471d1614e7578a03ceb3c0dbc4470f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 23:07:06 +0200 Subject: [PATCH 005/266] [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 4de706120e1..d17ebf235e5 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.24") + cg.add_library("esphome/noise-c", "0.1.26") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.6") + cg.add_library("esphome/libsodium", "1.10021.8") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 779a05e7de4..738773d1b56 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.24 ; noise (api, ota) + esphome/noise-c@0.1.26 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.24 ; noise (api, ota) + esphome/noise-c@0.1.26 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.24 ; used by noise (api, ota) + esphome/noise-c@0.1.26 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 4f7f5a4a4c8..00f22ca1389 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.24\n" + " esphome/noise-c @ 0.1.26\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.24\n" + " esphome/noise-c @ 0.1.26\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.24"] + assert libs == ["esphome/noise-c @ 0.1.26"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.26", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.24", - "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 0.1.26", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.24"] + assert cls.calls == ["esphome/noise-c @ 0.1.26"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.24"] is None + assert compats["esphome/noise-c @ 0.1.26"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 14c52dda8d5..b03bff19a27 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 6c5ab89d5f818ac501855479ea776984c5d3f16a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 23:08:16 +0200 Subject: [PATCH 006/266] [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) --- esphome/components/esphome/ota/ota_esphome.cpp | 5 ++++- esphome/espota2.py | 9 ++++++--- tests/unit_tests/test_espota2.py | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1005ed214b6..f853ed6a2db 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake -static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +// Milliseconds for data transfer. Covers the lwIP retransmit run seen in +// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits +// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; // Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/espota2.py b/esphome/espota2.py index ce403c398db..c683ffa323c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 # across the addresses on top of that. EXTRA_UPLOAD_ATTEMPTS = 2 UPLOAD_RETRY_DELAY = 5.0 +# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA +# (105 s) so a stalled session is gone before a retry, and long enough for lwIP +# to get a lost chunk ack through after the retransmit run seen in practice +DATA_PHASE_TIMEOUT = 160.0 _LOGGER = logging.getLogger(__name__) @@ -694,8 +698,7 @@ def perform_ota( _LOGGER.info("Handshake complete") - # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures - sock.settimeout(90.0) + sock.settimeout(DATA_PHASE_TIMEOUT) if extended_proto: send_check(sock, ota_type, "ota type") @@ -854,7 +857,7 @@ def run_ota_impl_( # clean up a half-open connection (its handshake watchdog runs at 20s); # moving on to the next address family stays immediate. Known limitation: # a silent mid-transfer drop with no reset can wedge the device until its - # 90s data timeout, which outlasts this budget; the retries target the + # 105s data timeout, which outlasts this budget; the retries target the # common failures where the device resets or closes the link promptly. total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS last_error = "" diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 8867e2c215b..2d65e8e0798 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -416,6 +416,9 @@ def test_perform_ota_no_auth( "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" in caplog.text ) + # The data phase timeout must outlast the device's 105 s data timeout + mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT) + assert espota2.DATA_PHASE_TIMEOUT > 105.0 @pytest.mark.usefixtures("mock_time") From b947094f45f7bc8b193db6a75b732c9bdbcce41b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:31:18 -0400 Subject: [PATCH 007/266] [sendspin] Add codec preference list to the media source (#19047) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/sendspin/__init__.py | 26 ++++-- .../sendspin/media_source/__init__.py | 31 +++++++ .../sendspin/test_media_source.py | 90 +++++++++++++++++++ .../sendspin/common-media_source.yaml | 1 + 4 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 tests/component_tests/sendspin/test_media_source.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 570fd3faddd..8ef11a7f909 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +CONF_CODECS = "codecs" # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +CODEC_FLAC = "flac" +CODEC_OPUS = "opus" +CODEC_PCM = "pcm" + +CODECS = { + CODEC_FLAC: CODEC_FORMAT_FLAC, + CODEC_OPUS: CODEC_FORMAT_OPUS, + CODEC_PCM: CODEC_FORMAT_PCM, +} + +# Opus only supports 48 kHz audio, so it is left out of the default list at other rates. +DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM] +OPUS_SAMPLE_RATE = 48000 + SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") @@ -286,16 +301,13 @@ async def to_code(config: ConfigType) -> None: if data.player_support: cg.add_define("USE_SENDSPIN_PLAYER", True) - # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate - # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server + # Configures the player role. Each configured codec is advertised for 16 bits per sample + # mono and stereo at the configured sample rate. The order is a preference order, both for + # the codecs themselves and for stereo over mono. player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - # OPUS only supports 48 kHz audio - codecs = [CODEC_FORMAT_FLAC] - if sample_rate == 48000: - codecs.append(CODEC_FORMAT_OPUS) - codecs.append(CODEC_FORMAT_PCM) + codecs = player_cfg[CONF_CODECS] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6af244d41f0..6a9f1f18ba2 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CODEC_OPUS, + CODECS, + CONF_CODECS, CONF_DECODE_MEMORY, CONF_FIXED_DELAY, CONF_INITIAL_STATIC_DELAY, CONF_SENDSPIN_ID, + DEFAULT_CODECS, MEMORY_LOCATIONS, + OPUS_SAMPLE_RATE, SendspinHub, register_player_config, request_controller_support, @@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_( ) +def _resolve_codecs(config: ConfigType) -> ConfigType: + """Validate the codec preference list, filling in the default when it is not set.""" + sample_rate = config[CONF_SAMPLE_RATE] + if (codecs := config.get(CONF_CODECS)) is None: + config[CONF_CODECS] = [ + codec + for codec in DEFAULT_CODECS + if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE + ] + return config + + if len(set(codecs)) != len(codecs): + raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS]) + if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE: + raise cv.Invalid( + f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", + path=[CONF_CODECS], + ) + return config + + def _register(config: ConfigType) -> ConfigType: request_controller_support() register_player_config( { + CONF_CODECS: config[CONF_CODECS], CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], @@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All( min=16000, max=96000 ), cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), + cv.Optional(CONF_CODECS): cv.All( + cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1) + ), } ), cv.only_on_esp32, + _resolve_codecs, _register, ) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py new file mode 100644 index 00000000000..6c2f79198dd --- /dev/null +++ b/tests/component_tests/sendspin/test_media_source.py @@ -0,0 +1,90 @@ +"""Validation tests for the sendspin media_source platform. + +These cover the codec preference list, whose rejection branches a compile test +cannot reach: a `test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_CODECS, _get_data +from esphome.components.sendspin.media_source import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _media_source_config(**overrides: Any) -> ConfigType: + """Build a minimal valid media source config, allowing field overrides.""" + config: ConfigType = { + "id": "sendspin_media_source", + "sendspin_id": "sendspin_hub", + } + config.update(overrides) + return config + + +def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None: + """Every codec is advertised when the sample rate suits all of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config()) + + assert config[CONF_CODECS] == ["flac", "opus", "pcm"] + + +def test_default_codecs_drop_opus_at_other_rates( + set_core_config: SetCoreConfigCallable, +) -> None: + """Opus only supports 48 kHz, so it leaves the default list at other rates.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100)) + + assert config[CONF_CODECS] == ["flac", "pcm"] + + +def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None: + """The list is a preference order, so it reaches the player role as written.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"])) + + assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"] + + +def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A player with no codecs at all could never be given a stream.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="length of value must be at least 1"): + CONFIG_SCHEMA(_media_source_config(codecs=[])) + + +def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A repeated codec has no meaning in a preference order.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="may only be listed once"): + CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"])) + + +def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Only codecs the player role can decode are accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Unknown value"): + CONFIG_SCHEMA(_media_source_config(codecs=["mp3"])) + + +def test_opus_at_wrong_sample_rate_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Asking for Opus at a rate it cannot handle fails rather than silently + dropping the stated preference.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 1977b79c04d..0c136fbd43d 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,3 +9,4 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal + codecs: [pcm, opus, flac] From 823d79c948eb4474423200d5a251210c31482b68 Mon Sep 17 00:00:00 2001 From: mipa87 <62723159+mipa87@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:10:55 +0200 Subject: [PATCH 008/266] [i2s_audio] Keep a start request that arrives while the speaker task stops (#19027) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/i2s_audio/speaker/i2s_audio_speaker.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 5e271e671e5..1c2eb129046 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -91,7 +91,14 @@ void I2SAudioSpeakerBase::loop() { this->speaker_task_handle_ = nullptr; this->stop_i2s_driver_(); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at + // the top of loop(): the audio source's task can raise a start at any point above, including + // during stop_i2s_driver_(), and nothing would ever re-issue it. + const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) { + ESP_LOGD(TAG, "Start requested while stopping; keeping the request"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } this->status_clear_error(); this->on_task_stopped(); From 628ebe23ec389d770e822f18de22753c167dff6f Mon Sep 17 00:00:00 2001 From: mipa87 <62723159+mipa87@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:13:03 +0200 Subject: [PATCH 009/266] [audio] Do not treat MP3_STREAM_INFO_CHANGED as a fatal decoder error (#19028) Co-authored-by: Claude --- esphome/components/audio/audio_decoder.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index fe9ad9c9add..051395606c8 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -313,9 +313,10 @@ FileDecoderState AudioDecoder::decode_mp3_() { this->output_transfer_buffer_->increase_buffer_length( this->audio_stream_info_.value().frames_to_bytes(samples_decoded)); } - } else if (result == micro_mp3::MP3_STREAM_INFO_READY) { - // First successful header parse: capture stream info and resize the output buffer to fit one full frame. - // microMP3 always outputs 16-bit PCM. + } else if (result == micro_mp3::MP3_STREAM_INFO_READY || result == micro_mp3::MP3_STREAM_INFO_CHANGED) { + // Header parsed: capture stream info and resize the output buffer to fit one full frame. + // microMP3 always outputs 16-bit PCM. MP3_STREAM_INFO_CHANGED is handled identically: despite its + // negative value it is documented as recoverable, so it must not reach the catch-all below. this->audio_stream_info_ = audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate()); this->free_buffer_required_ = From e7f45a0d315442dcf789997d6e28de72f082e28a Mon Sep 17 00:00:00 2001 From: Ryan Ronnander <61520+ryan-ronnander@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:09:02 -0400 Subject: [PATCH 010/266] [mqtt] Restore brightness flag in light discovery (#18950) --- esphome/components/mqtt/mqtt_light.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index aa47bdf996a..a8b52a3839f 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -67,6 +67,9 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery if (traits.supports_color_mode(ColorMode::RGB_COLD_WARM_WHITE)) color_modes.add(ESPHOME_F("rgbww")); + if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) + root[ESPHOME_F("brightness")] = true; + if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) || traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) { root[MQTT_MIN_MIREDS] = traits.get_min_mireds(); From d5cff6e9dfcdfce156483eecf205e64169a56dee Mon Sep 17 00:00:00 2001 From: AndreKR Date: Tue, 8 Sep 2026 03:13:51 +0200 Subject: [PATCH 011/266] [logger] Fix garbled stack traces (#17939) --- esphome/components/logger/logger_esp32.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 05fc959ceb2..c3d777299d9 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -5,6 +5,7 @@ #include #include +#include #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include @@ -76,7 +77,11 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { uart_config.parity = UART_PARITY_DISABLE; uart_config.stop_bits = UART_STOP_BITS_1; uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; +#if SOC_UART_SUPPORT_XTAL_CLK + uart_config.source_clk = UART_SCLK_XTAL; +#else uart_config.source_clk = UART_SCLK_DEFAULT; +#endif uart_param_config(uart_num, &uart_config); // The logger only writes to UART, never reads, so use the minimum RX buffer. // ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes). From 934086365217965f95c21bda0593b3f2ec960615 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 7 Sep 2026 18:27:49 -0700 Subject: [PATCH 012/266] [dallas_temp] filter 85 temp from sensor reset (#17877) Co-authored-by: Samuel Sieb --- esphome/components/dallas_temp/dallas_temp.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index ab4a8c458fe..c418362ced1 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -6,6 +6,7 @@ namespace esphome::dallas_temp { static const char *const TAG = "dallas.temp.sensor"; static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; +static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44; static const uint8_t DALLAS_COMMAND_READ_SCRATCH_PAD = 0xBE; static const uint8_t DALLAS_COMMAND_WRITE_SCRATCH_PAD = 0x4E; @@ -154,7 +155,14 @@ float DallasTemperatureSensor::get_temp_c_() { default: break; } - + // undocumented test for powerup measurement of 85 + // https://github.com/cpetrich/counterfeit_DS18B20#solution-to-the-85-c-problem + if ((this->address_ & 0xff) == DALLAS_MODEL_DS18B20) { + if ((temp == 85 * 16) && (this->scratch_pad_[6] == 0xc)) { + ESP_LOGD(TAG, "dropping reading caused by sensor reset"); + return NAN; + } + } return temp / 16.0f; } From 199acdf5222a923d5c7951af2e0ea632f45ba220 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 7 Sep 2026 18:57:50 -0700 Subject: [PATCH 013/266] [ble_client] Report Established from nodes that never read services (#17920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/ble_client/automation.h | 34 +++++++++++++++------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 94eeb83b3eb..93aae23b6a1 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -22,6 +22,23 @@ class Automation { static const char *const TAG; }; +// Base for nodes that never read the parent's services. +// The parent releases its services only once every node reports Established, so a node that never +// reports it keeps that memory allocated for the life of the connection. +class BLEClientServicelessNode : public BLEClientNode { + public: + // Final so that Established is always reported on SEARCH_CMPL, before the derived node sees the event. + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) final { + if (event == ESP_GATTC_SEARCH_CMPL_EVT) + this->node_state = espbt::ClientState::ESTABLISHED; + this->on_gattc_event(event, gattc_if, param); + } + + protected: + // Derived nodes handle GATT events here rather than by overriding the handler above. + virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {} +}; + // implement on_connect automation. class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: @@ -61,7 +78,7 @@ class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode } }; -class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +88,7 @@ class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientN } }; -class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +99,7 @@ class BLEClientPasskeyNotificationTrigger final : public Trigger, publ } }; -class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -315,19 +332,17 @@ template class BLEClientRemoveBondAction final : public Action class BLEClientConnectAction final : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { case ESP_GATTC_SEARCH_CMPL_EVT: - this->node_state = espbt::ClientState::ESTABLISHED; this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); }); break; // if the connection is closed, terminate the automation chain. @@ -364,14 +379,13 @@ template class BLEClientConnectAction final : public Action var_{}; }; -template class BLEClientDisconnectAction final : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { From 442e4a1ec2c70bfc8507aa1cf5f471402806d9b0 Mon Sep 17 00:00:00 2001 From: Davide D M Date: Tue, 8 Sep 2026 03:59:05 +0200 Subject: [PATCH 014/266] [debug] Check reboot source pref on ESP_RST_WDT and guard against empty source (#17537) --- esphome/components/debug/debug_esp32.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 969cd840cf6..8e1a67224eb 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -66,11 +66,15 @@ const char *DebugComponent::get_reset_reason_(std::spanmake_preference(REBOOT_MAX_LEN, fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str())); char reboot_source[REBOOT_MAX_LEN]{}; - if (pref.load(&reboot_source)) { + if (pref.load(&reboot_source) && reboot_source[0] != '\0') { reboot_source[REBOOT_MAX_LEN - 1] = '\0'; snprintf(buf, size, "Reboot request from %s", reboot_source); } else { From c9729244af79e5b36a2b712c2fa5b91efa6a504f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:17:22 +1200 Subject: [PATCH 015/266] [udp] Use cv.invalid for relocated packet_transport options (#19032) --- esphome/components/udp/__init__.py | 16 +++------ tests/unit_tests/components/udp/__init__.py | 0 tests/unit_tests/components/udp/test_init.py | 37 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/components/udp/__init__.py create mode 100644 tests/unit_tests/components/udp/test_init.py diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index a782d875b9d..d96a731e9c0 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,5 +1,4 @@ -from collections.abc import Callable -from typing import Any, NoReturn +from typing import Any from esphome import automation from esphome.automation import Trigger @@ -48,17 +47,10 @@ UDP_SCHEMA = cv.Schema( ) -def is_relocated(option: str) -> Callable[[Any], NoReturn]: - def validator(value: Any) -> NoReturn: - raise cv.Invalid( - f"The '{option}' option should now be configured in the 'packet_transport' component" - ) - - return validator - - RELOCATED = { - cv.Optional(x): is_relocated(x) + cv.Optional(x): cv.invalid( + f"The '{x}' option should now be configured in the 'packet_transport' component" + ) for x in ( CONF_PROVIDERS, CONF_ENCRYPTION, diff --git a/tests/unit_tests/components/udp/__init__.py b/tests/unit_tests/components/udp/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/udp/test_init.py b/tests/unit_tests/components/udp/test_init.py new file mode 100644 index 00000000000..5afc92e9c67 --- /dev/null +++ b/tests/unit_tests/components/udp/test_init.py @@ -0,0 +1,37 @@ +"""Tests for the udp component configuration schema.""" + +from __future__ import annotations + +import pytest + +from esphome.components import udp +from esphome.components.packet_transport import ( + CONF_BINARY_SENSORS, + CONF_ENCRYPTION, + CONF_PING_PONG_ENABLE, + CONF_PROVIDERS, + CONF_ROLLING_CODE_ENABLE, + CONF_SENSORS, +) +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + "option", + [ + CONF_PROVIDERS, + CONF_ENCRYPTION, + CONF_PING_PONG_ENABLE, + CONF_ROLLING_CODE_ENABLE, + CONF_SENSORS, + CONF_BINARY_SENSORS, + ], +) +def test_relocated_option_rejected(option: str) -> None: + """Options that moved to packet_transport raise a pointing error.""" + with pytest.raises(cv.Invalid) as exc_info: + udp.CONFIG_SCHEMA({option: True}) + assert ( + f"The '{option}' option should now be configured in the 'packet_transport' component" + in str(exc_info.value) + ) From ca864c22b4c0810e9e4779bfa6d95b3597cc8abe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:36:04 -0400 Subject: [PATCH 016/266] [tuya] Build without a network component (#18948) --- esphome/components/tuya/tuya.cpp | 17 +++++++++-- .../tuya/test-no-network.bk72xx-ard.yaml | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tests/components/tuya/test-no-network.bk72xx-ard.yaml diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 82fb96d7879..f9b4fe24532 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -1,10 +1,13 @@ #include "tuya.h" -#include "esphome/components/network/util.h" #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif + #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif @@ -22,6 +25,14 @@ static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static bool network_is_connected() { +#ifdef USE_NETWORK + return network::is_connected(); +#else + return false; +#endif +} + void Tuya::setup() { this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); if (this->status_pin_ != nullptr) { @@ -554,14 +565,14 @@ void Tuya::send_empty_command_(TuyaCommandType command) { } void Tuya::set_status_pin_() { - bool is_network_ready = network::is_connected() && remote_is_connected(); + bool is_network_ready = network_is_connected() && remote_is_connected(); this->status_pin_->digital_write(is_network_ready); } uint8_t Tuya::get_wifi_status_code_() { uint8_t status = 0x02; - if (network::is_connected()) { + if (network_is_connected()) { status = 0x03; // Protocol version 3 also supports specifying when connected to "the cloud" diff --git a/tests/components/tuya/test-no-network.bk72xx-ard.yaml b/tests/components/tuya/test-no-network.bk72xx-ard.yaml new file mode 100644 index 00000000000..64207e94e38 --- /dev/null +++ b/tests/components/tuya/test-no-network.bk72xx-ard.yaml @@ -0,0 +1,29 @@ +# Tuya without any network component (no wifi/ethernet/api), as used on +# serial-only or BLE-only Tuya MCU boards. Regression test for +# https://github.com/esphome/esphome/issues/18942 +substitutions: + status_pin: P6 + +packages: + uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml + +tuya: + status_pin: ${status_pin} + +binary_sensor: + - platform: tuya + id: tuya_presence + sensor_datapoint: 101 + +sensor: + - platform: tuya + id: tuya_light_intensity + sensor_datapoint: 103 + +number: + - platform: tuya + id: tuya_far_detection + number_datapoint: 109 + min_value: 0 + max_value: 600 + step: 1 From 866ddb6e5729f11e2a3a107fa7145dc596b9a17a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 06:42:30 +0200 Subject: [PATCH 017/266] [core] Skip PlatformIO's private-package authorization probe (#18823) --- esphome/platformio/library.py | 6 ++- esphome/platformio/prefetch.py | 2 + esphome/platformio/runner.py | 14 ++++++- tests/unit_tests/test_platformio_library.py | 19 ++++++++++ tests/unit_tests/test_platformio_prefetch.py | 14 +++++++ tests/unit_tests/test_platformio_runner.py | 40 ++++++++++++++++++++ 6 files changed, 93 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 3ff60f8aaab..fb6779b8078 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -616,11 +616,15 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin + from platformio.registry.client import RegistryClient class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: - self._registry_client = None self.pkg_type = "library" + self._registry_client = RegistryClient() + # The probe sleeps ~500 ms per lookup (see runner.patch_registry_private_packages); + # instance-level so the ESPHome process never patches PlatformIO's class + self._registry_client.allowed_private_packages = lambda: False @staticmethod def is_system_compatible(value: Any, custom_system: Any = None) -> bool: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 17a06cb9c10..e648192b737 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -951,8 +951,10 @@ def main(argv: list[str]) -> int: """Subprocess entry point: ``prefetch ``.""" from esphome.core import CORE from esphome.log import setup_log + from esphome.platformio.runner import patch_registry_private_packages signal.signal(signal.SIGTERM, _sigterm) + patch_registry_private_packages() raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL") try: level = int(raw_level) if raw_level is not None else logging.INFO diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index 9bb2205a909..b9fbdec38d0 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -2,7 +2,8 @@ Invoked via ``python -m esphome.platformio.runner`` instead of ``python -m platformio`` so that the patches (incremental rebuild -preservation, download retries) apply inside the subprocess. Running +preservation, download retries, skipping the private-package probe) apply +inside the subprocess. Running PlatformIO in a subprocess keeps its ``sys.path`` mutations and other global state from leaking into the ESPHome process. """ @@ -105,6 +106,16 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def patch_registry_private_packages() -> None: + """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. + + ESPHome never uses private packages, so the answer is always False. + """ + from platformio.registry.client import RegistryClient + + RegistryClient.allowed_private_packages = staticmethod(lambda: False) # type: ignore[method-assign] + + _IGNORE_LIB_WARNINGS = "(?:Hash|Update)" # Regex patterns matched against each line of PlatformIO output. Lines that # match are dropped by RedirectText before they reach the parent process. @@ -152,6 +163,7 @@ FILTER_PLATFORMIO_LINES = [ def main() -> int: patch_structhash() patch_file_downloader() + patch_registry_private_packages() # Wrap stdout/stderr with RedirectText before PlatformIO runs: # diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 3bae39b3c1a..512c883c374 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -7,6 +7,7 @@ exercised in their own test modules).""" import json import logging from pathlib import Path +from unittest.mock import Mock import pytest @@ -228,6 +229,24 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): _resolve_registry_version("owner", "pkg", set()) +def test_make_registry_client_skips_private_package_probe(monkeypatch): + """Our client answers the probe locally without patching PlatformIO's class.""" + from platformio.account.client import AccountClient + from platformio.registry.client import RegistryClient + + pio_probe = RegistryClient.__dict__["allowed_private_packages"] + monkeypatch.setattr( + AccountClient, + "get_account_info", + Mock(side_effect=AssertionError("account probe must not run")), + ) + + client = lib._make_registry_client().get_registry_client_instance() + + assert client.allowed_private_packages() is False + assert RegistryClient.__dict__["allowed_private_packages"] is pio_probe + + def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None: """Stub the registry lookup so tests never touch the network.""" monkeypatch.setattr( diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 77490fd8613..14c52dda8d5 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1225,6 +1225,20 @@ def test_main_runs_prefetch(tmp_path: Path) -> None: mock_prefetch.assert_called_once_with(tmp_path, "testenv") +def test_main_skips_private_package_probe_before_prefetch(tmp_path: Path) -> None: + """The registry probe patch is applied before any package manager runs.""" + order: list[str] = [] + with ( + patch.object(pf, "_prefetch", side_effect=lambda *_: order.append("prefetch")), + patch( + "esphome.platformio.runner.patch_registry_private_packages", + side_effect=lambda: order.append("patch"), + ), + ): + assert pf.main([str(tmp_path), "testenv"]) == 0 + assert order == ["patch", "prefetch"] + + def test_main_bad_argv_is_a_distinct_exit( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index f375aa457ac..007455f45a3 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -6,7 +6,9 @@ from collections.abc import Callable import io import sys from types import ModuleType +from unittest.mock import Mock +from platformio.registry.client import RegistryClient import pytest from esphome.platformio import runner @@ -30,6 +32,7 @@ def _prepare_main( monkeypatch.setattr(sys, "stderr", stream) monkeypatch.setattr(runner, "patch_structhash", lambda: None) monkeypatch.setattr(runner, "patch_file_downloader", lambda: None) + monkeypatch.setattr(runner, "patch_registry_private_packages", lambda: None) platformio = ModuleType("platformio") platformio_main = ModuleType("platformio.__main__") @@ -91,3 +94,40 @@ def test_main_still_filters_a_drained_partial_line( assert runner.main() == 0 assert buf.getvalue() == b"" + + +def test_main_applies_registry_private_packages_patch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The probe is patched before PlatformIO runs.""" + order: list[str] = [] + _prepare_main(monkeypatch, lambda: order.append("pio") or 0) + monkeypatch.setattr( + runner, "patch_registry_private_packages", lambda: order.append("patch") + ) + + assert runner.main() == 0 + assert order == ["patch", "pio"] + + +# Snapshot PlatformIO's own probe at import, before any test can patch it +_PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] + + +def test_patch_registry_private_packages_skips_account_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Answers False without touching the account client.""" + from platformio.account.client import AccountClient + + monkeypatch.setattr(RegistryClient, "allowed_private_packages", _PIO_PROBE) + monkeypatch.setattr( + AccountClient, + "get_account_info", + Mock(side_effect=AssertionError("account probe must not run")), + ) + + runner.patch_registry_private_packages() + + assert RegistryClient.allowed_private_packages() is False + assert RegistryClient().allowed_private_packages() is False From f8a4cfa945ef765e469daa03edbe263346a03154 Mon Sep 17 00:00:00 2001 From: Gytis Date: Tue, 8 Sep 2026 08:31:30 +0200 Subject: [PATCH 018/266] [lvgl] Add missing label dependency to qrcode, keyboard and tabview (#18387) --- esphome/components/lvgl/widgets/keyboard.py | 3 +- esphome/components/lvgl/widgets/qrcode.py | 3 +- esphome/components/lvgl/widgets/tabview.py | 3 +- .../lvgl/config/keyboard_no_label.yaml | 32 +++++++++++++++++ .../lvgl/config/qrcode_no_label.yaml | 34 ++++++++++++++++++ .../lvgl/config/tabview_no_label.yaml | 35 +++++++++++++++++++ .../lvgl/test_widget_label_dependency.py | 32 +++++++++++++++++ 7 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/config/keyboard_no_label.yaml create mode 100644 tests/component_tests/lvgl/config/qrcode_no_label.yaml create mode 100644 tests/component_tests/lvgl/config/tabview_no_label.yaml create mode 100644 tests/component_tests/lvgl/test_widget_label_dependency.py diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index bcd2d2ae597..65516513a6c 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -15,6 +15,7 @@ from ..defines import ( from ..types import LvCompound, LvType from . import Widget, WidgetType, get_widgets from .buttonmatrix import CONF_BUTTONMATRIX +from .label import CONF_LABEL from .textarea import CONF_TEXTAREA, lv_textarea_t CONF_KEYBOARD = "keyboard" @@ -49,7 +50,7 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX + return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL async def to_code(self, w: Widget, config: dict): add_lv_use("KEY_LISTENER") diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index df76ab6bb0d..59af9168aad 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -10,6 +10,7 @@ from ..types import lv_obj_t from . import Widget, WidgetType from .canvas import CONF_CANVAS from .img import CONF_IMAGE +from .label import CONF_LABEL CONF_QRCODE = "qrcode" CONF_DARK_COLOR = "dark_color" @@ -41,7 +42,7 @@ class QrCodeType(WidgetType): ) def get_uses(self): - return CONF_CANVAS, CONF_IMAGE + return CONF_CANVAS, CONF_IMAGE, CONF_LABEL async def to_code(self, w: Widget, config): await w.set_property( diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index ee252ecf0b2..77c88c48ff8 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -28,6 +28,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties from .button import button_spec from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec +from .label import CONF_LABEL from .obj import obj_spec CONF_TABVIEW = "tabview" @@ -74,7 +75,7 @@ class TabviewType(WidgetType): ) def get_uses(self): - return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON + return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON, CONF_LABEL async def to_code(self, w: Widget, config: dict): await w.set_property( diff --git a/tests/component_tests/lvgl/config/keyboard_no_label.yaml b/tests/component_tests/lvgl/config/keyboard_no_label.yaml new file mode 100644 index 00000000000..7a45a537d3d --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_no_label.yaml @@ -0,0 +1,32 @@ +esphome: + name: test-keyboard-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - keyboard: + id: keyboard_widget diff --git a/tests/component_tests/lvgl/config/qrcode_no_label.yaml b/tests/component_tests/lvgl/config/qrcode_no_label.yaml new file mode 100644 index 00000000000..8bb1aafdd6e --- /dev/null +++ b/tests/component_tests/lvgl/config/qrcode_no_label.yaml @@ -0,0 +1,34 @@ +esphome: + name: test-qrcode-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - qrcode: + id: qr_widget + size: 100 + text: "esphome.io" diff --git a/tests/component_tests/lvgl/config/tabview_no_label.yaml b/tests/component_tests/lvgl/config/tabview_no_label.yaml new file mode 100644 index 00000000000..a3c16ab3471 --- /dev/null +++ b/tests/component_tests/lvgl/config/tabview_no_label.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-tabview-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - tabview: + id: tabview_widget + tabs: + - name: "Tab 1" + id: tab_1 diff --git a/tests/component_tests/lvgl/test_widget_label_dependency.py b/tests/component_tests/lvgl/test_widget_label_dependency.py new file mode 100644 index 00000000000..9d3e24c8c5a --- /dev/null +++ b/tests/component_tests/lvgl/test_widget_label_dependency.py @@ -0,0 +1,32 @@ +"""Widgets whose LVGL C implementation creates or references labels +internally (tab titles, key legends, the QR canvas fallback) must declare +the label dependency in ``get_uses()``. Otherwise a config that contains +no ``label`` widget of its own compiles LVGL without ``LV_USE_LABEL`` and +fails at C compile time with undefined ``lv_label_*`` symbols. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.lvgl import defines as df + + +@pytest.mark.parametrize( + "yaml_file", + [ + "qrcode_no_label.yaml", + "keyboard_no_label.yaml", + "tabview_no_label.yaml", + ], +) +def test_label_less_config_enables_lv_use_label( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + yaml_file: str, +) -> None: + generate_main(component_config_path(yaml_file)) + assert "LV_USE_LABEL" in df.get_defines() From c3ce07755f32292af3da6466aa1fc4a2cfeca07d Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Mon, 7 Sep 2026 23:36:42 -0700 Subject: [PATCH 019/266] [rf_bridge] Fix bucket sniffing with Portisch firmware (#17683) Co-authored-by: Bryan Li Co-authored-by: Claude Fable 5 --- esphome/components/rf_bridge/rf_bridge.cpp | 109 +++++++++++++++++---- esphome/components/rf_bridge/rf_bridge.h | 13 +++ 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 549cce72dfd..a4a4da5d8c0 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -18,6 +18,16 @@ void RFBridgeComponent::ack_() { } bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { + if (this->bucket_frame_candidate_ && byte == RF_CODE_START) { + // A queued next frame proves the trailing 0x55 really was the bucket + // frame's terminator: Portisch builds pulse entries from alternating + // signal edges, so the two level bits inside one pulse byte are always + // opposite — 0xAA (two high-level nibbles) cannot occur in pulse data. + // Finalize before this byte starts the new frame, so back-to-back + // deliveries are split even when loop() never observed a quiet gap + // between them. + this->finish_bucket_frame_(); + } size_t at = this->rx_buffer_.size(); this->rx_buffer_.push_back(byte); const uint8_t *raw = &this->rx_buffer_[0]; @@ -84,26 +94,21 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { break; } case RF_CODE_RFIN_BUCKET: { - if (byte != RF_CODE_STOP) { - return true; + if (at == 2) { + // The count byte: Portisch sends at most 7 buckets + sync, so 0 or + // >8 cannot be a genuine capture — reject before it can occupy the + // buffer for a full frame timeout. + return byte != 0 && byte <= B1_MAX_BUCKET_COUNT; } - - uint8_t buckets = raw[2] << 1; - std::string str; - char next_byte[3]; // 2 hex chars + null - - for (uint32_t i = 0; i <= at; i++) { - buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]); - str += next_byte; - if ((i > 3) && buckets) { - buckets--; - } - if ((i < 3) || (buckets % 2) || (i == at - 1)) { - str += " "; - } - } - ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); - break; + // 0x55 is legal DATA inside a B1 frame: bucket durations are sent + // with only their HIGH byte masked to 7 bits, so a duration such as + // 0x0155 puts a raw 0x55 low byte inside the table — the first 0x55 + // must therefore not end the capture. The header declares the table + // length (raw[2] pairs), so a 0x55 there is always data; one at or + // past the first pulse index is a terminator CANDIDATE, confirmed + // once the UART goes quiet (finish_bucket_frame_ in loop()). + this->bucket_frame_candidate_ = byte == RF_CODE_STOP && at >= 3 + static_cast(raw[2]) * 2; + return true; } default: ESP_LOGW(TAG, "Unknown action: 0x%02X", action); @@ -119,6 +124,47 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { return false; } +void RFBridgeComponent::finish_bucket_frame_() { + if (this->rx_buffer_.size() < 4) { + // The candidate flag requires a header + non-empty bucket table, so + // this cannot happen while flag and buffer stay consistent; guard the + // raw[2] / size-1 reads against any future divergence anyway. + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; + return; + } + const uint8_t *raw = this->rx_buffer_.data(); + const size_t at = this->rx_buffer_.size() - 1; + + uint8_t buckets = raw[2] << 1; + std::string str; + char next_byte[3]; // 2 hex chars + null + + for (uint32_t i = 0; i <= at; i++) { + buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]); + str += next_byte; + if ((i > 3) && buckets) { + buckets--; + } + if ((i < 3) || (buckets % 2) || (i == at - 1)) { + str += " "; + } + } + ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); + + // Deliberately NOT ACKed: Portisch's B1 command handler leaves its + // last_sniffing_command at the previous mode (RF_CODE_RFIN), and its + // host-ACK handler re-arms sniffing from that stale value — so ACKing a + // bucket delivery silently reverts the radio to standard sniffing and + // ends bucket capture. Its delivery path is fire-and-forget and never + // waits for a host ACK. Stock Itead firmware never sends B1 frames, so + // suppressing this ACK cannot change stock-firmware behavior. + // https://github.com/esphome/esphome/issues/17682 + + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; +} + void RFBridgeComponent::write_byte_str_(const std::string &codes) { uint8_t code; int size = codes.length(); @@ -130,12 +176,31 @@ void RFBridgeComponent::write_byte_str_(const std::string &codes) { void RFBridgeComponent::loop() { const uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_bridge_byte_ > 50) { + size_t avail = this->available(); + if (avail == 0 && this->bucket_frame_candidate_ && now - this->last_bridge_byte_ > BUCKET_CANDIDATE_QUIET_MS) { + // The trailing 0x55 was followed by UART quiet, so it really was the + // frame terminator and not an interior data byte. + this->finish_bucket_frame_(); + this->last_bridge_byte_ = now; + } + const bool receiving_bucket = this->rx_buffer_.size() >= 2 && this->rx_buffer_[1] == RF_CODE_RFIN_BUCKET; + if (receiving_bucket) { + // Never declare an in-progress bucket frame dead while its continuation + // bytes are already queued: a stalled loop() otherwise discards a live + // frame that the UART buffer proves is still arriving. + if (avail == 0 && now - this->last_bridge_byte_ > BUCKET_FRAME_TIMEOUT_MS) { + ESP_LOGD(TAG, "Discarding incomplete RFBridge Bucket frame (%u bytes)", + static_cast(this->rx_buffer_.size())); + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; + this->last_bridge_byte_ = now; + } + } else if (now - this->last_bridge_byte_ > 50) { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; this->last_bridge_byte_ = now; } - size_t avail = this->available(); while (avail > 0) { uint8_t buf[64]; size_t to_read = std::min(avail, sizeof(buf)); @@ -146,12 +211,14 @@ void RFBridgeComponent::loop() { for (size_t i = 0; i < to_read; i++) { if (this->rx_buffer_.size() > MAX_RX_BUFFER_SIZE) { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; } if (this->parse_bridge_byte_(buf[i])) { ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]); this->last_bridge_byte_ = now; } else { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; } } } diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 5ad75650abb..cbb1880ec53 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -30,6 +30,17 @@ static const uint8_t RF_CODE_BEEP = 0xC0; static const uint8_t RF_CODE_STOP = 0x55; static const uint8_t RF_DEBOUNCE = 200; static const size_t MAX_RX_BUFFER_SIZE = 512; +// ~10 byte times at 19200 baud: long enough to prove the UART went quiet +// after a possible bucket-frame terminator, short enough to finish well +// before the next radio capture can be delivered. +static const uint32_t BUCKET_CANDIDATE_QUIET_MS = 5; +// Portisch drains a B1 frame's header, bucket table, and pulse data as +// separate UART writes, so an in-progress bucket frame tolerates a longer +// inter-region gap than the generic 50 ms inter-byte timeout. +static const uint32_t BUCKET_FRAME_TIMEOUT_MS = 250; +// Portisch's uart_put_RF_buckets sends at most 7 buckets plus the sync +// bucket, so a B1 count byte above 8 (or 0) is malformed for any protocol. +static const uint8_t B1_MAX_BUCKET_COUNT = 8; struct RFBridgeData { uint16_t sync; @@ -67,10 +78,12 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component { void ack_(); void decode_(); bool parse_bridge_byte_(uint8_t byte); + void finish_bucket_frame_(); void write_byte_str_(const std::string &codes); std::vector rx_buffer_; uint32_t last_bridge_byte_{0}; + bool bucket_frame_candidate_{false}; CallbackManager data_callback_; CallbackManager advanced_data_callback_; From 7660dd7fa7059a6154e65797c26e45d27aa8bf78 Mon Sep 17 00:00:00 2001 From: raykholo Date: Tue, 8 Sep 2026 02:57:33 -0400 Subject: [PATCH 020/266] [anova] Re-assert temperature unit on every poll cycle (#17141) --- esphome/components/anova/anova.cpp | 107 ++++++++++++++--------------- esphome/components/anova/anova.h | 13 +++- 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index 6e382872e2b..b0769bb622e 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -13,7 +13,7 @@ void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); } void Anova::setup() { this->codec_ = make_unique(); - this->current_request_ = 0; + this->poll_step_ = PollStep::IDLE; } void Anova::loop() { @@ -22,6 +22,15 @@ void Anova::loop() { this->disable_loop(); } +void Anova::write_request_(AnovaPacket *pkt) { + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, + pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status) { + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); + } +} + void Anova::control(const ClimateCall &call) { auto mode_val = call.get_mode(); if (mode_val.has_value()) { @@ -38,22 +47,11 @@ void Anova::control(const ClimateCall &call) { ESP_LOGW(TAG, "Unsupported mode: %d", mode); return; } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(pkt); } auto target_temp = call.get_target_temperature(); if (target_temp.has_value()) { - auto *pkt = this->codec_->get_set_target_temp_request(*target_temp); - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(this->codec_->get_set_target_temp_request(*target_temp)); } } @@ -62,6 +60,7 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ case ESP_GATTC_DISCONNECT_EVT: { this->current_temperature = NAN; this->target_temperature = NAN; + this->poll_step_ = PollStep::IDLE; this->publish_state(); break; } @@ -83,8 +82,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->node_state = espbt::ClientState::ESTABLISHED; - this->current_request_ = 0; - this->update(); + this->poll_step_ = PollStep::IDLE; + this->update(); // begin the first poll cycle immediately break; } case ESP_GATTC_NOTIFY_EVT: { @@ -101,33 +100,30 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF; } if (this->codec_->has_unit()) { - this->fahrenheit_ = (this->codec_->unit_ == 'f'); - ESP_LOGD(TAG, "Anova units is %s", this->fahrenheit_ ? "fahrenheit" : "celsius"); - this->current_request_++; + ESP_LOGD(TAG, "Anova units is %s", (this->codec_->unit_ == 'f') ? "fahrenheit" : "celsius"); } this->publish_state(); - if (this->current_request_ > 1) { - AnovaPacket *pkt = nullptr; - switch (this->current_request_++) { - case 2: - pkt = this->codec_->get_read_target_temp_request(); - break; - case 3: - pkt = this->codec_->get_read_current_temp_request(); - break; - default: - this->current_request_ = 1; - break; - } - if (pkt != nullptr) { - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - } + // Advance the poll cycle to its next request based on the reply we got. + switch (this->poll_step_) { + case PollStep::SET_UNIT: + this->poll_step_ = PollStep::STATUS; + this->write_request_(this->codec_->get_read_device_status_request()); + break; + case PollStep::STATUS: + this->poll_step_ = PollStep::TARGET; + this->write_request_(this->codec_->get_read_target_temp_request()); + break; + case PollStep::TARGET: + this->poll_step_ = PollStep::CURRENT; + this->write_request_(this->codec_->get_read_current_temp_request()); + break; + case PollStep::CURRENT: + this->poll_step_ = PollStep::IDLE; // full cycle complete + break; + default: + // A reply to an ad-hoc control() write, outside a managed cycle. + break; } break; } @@ -136,27 +132,26 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } } -void Anova::set_unit_of_measurement(const char *unit) { this->fahrenheit_ = !strncmp(unit, "f", 1); } +void Anova::set_unit_of_measurement(const char *unit) { this->want_fahrenheit_ = !strncmp(unit, "f", 1); } void Anova::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) return; - - if (this->current_request_ < 2) { - AnovaPacket *pkt; - if (this->current_request_ == 0) { - pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c'); - } else { - pkt = this->codec_->get_read_device_status_request(); - } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - this->current_request_++; + if (this->poll_step_ != PollStep::IDLE) { + // The previous cycle never finished within a full polling interval -- a + // reply was missed or a write failed. Restart the cycle rather than stall; + // the polling interval itself acts as the timeout. A late reply from the + // abandoned cycle is harmless: state decoding happens on every notify + // regardless of step, and each notify sends at most one follow-up request. + ESP_LOGW(TAG, "[%s] Poll cycle incomplete (step %u); restarting cycle", this->parent_->address_str(), + static_cast(this->poll_step_)); } + // Re-assert the configured unit at the start of every poll cycle, then fall + // through the status/temperature reads via the notification handler. Always + // command the configured unit (want_fahrenheit_) -- never the last value the + // device reported, or a drift to 'c' would lock itself in. + this->poll_step_ = PollStep::SET_UNIT; + this->write_request_(this->codec_->get_set_unit_request(this->want_fahrenheit_ ? 'f' : 'c')); } } // namespace esphome::anova diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index 49b1100c372..a0fa03df016 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -37,11 +37,20 @@ class Anova final : public climate::Climate, public esphome::ble_client::BLEClie void set_unit_of_measurement(const char *unit); protected: + // A poll cycle re-asserts the configured unit, then reads device state. + // Re-asserting every cycle prevents the cooker from silently reverting to + // its default (Celsius); previously the unit was only set once on + // connection, so a drift persisted (and corrupted the F/C interpretation of + // subsequent readings) until the BLE link was re-established. + enum class PollStep : uint8_t { SET_UNIT, STATUS, TARGET, CURRENT, IDLE }; + + void write_request_(AnovaPacket *pkt); + std::unique_ptr codec_; void control(const climate::ClimateCall &call) override; uint16_t char_handle_; - uint8_t current_request_; - bool fahrenheit_; + bool want_fahrenheit_{true}; // configured target unit; never overwritten by device replies + PollStep poll_step_{PollStep::IDLE}; }; } // namespace esphome::anova From f8b2e53609051bf6ac9a626305a7e6304c67393f Mon Sep 17 00:00:00 2001 From: John <34163498+CircuitSetup@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:02:49 -0400 Subject: [PATCH 021/266] [atm90e32] Verify offset calibration writes (#18701) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/atm90e32/atm90e32.cpp | 360 ++++++++++-------- esphome/components/atm90e32/atm90e32.h | 71 ++-- tests/components/atm90e32/__init__.py | 5 + .../offset_register_verification_test.cpp | 62 +++ 4 files changed, 322 insertions(+), 176 deletions(-) create mode 100644 tests/components/atm90e32/__init__.py create mode 100644 tests/components/atm90e32/offset_register_verification_test.cpp diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index d948b3741df..23701e78348 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -9,6 +9,10 @@ namespace esphome::atm90e32 { static const char *const TAG = "atm90e32"; +static const LogString *offset_calibration_name(bool power_offsets) { + return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset"); +} + static uint32_t pref_hash(const char *prefix, const char *name_space) { auto hash = fnv1_hash(prefix); return fnv1_hash_extend(hash, name_space); @@ -203,13 +207,12 @@ void ATM90E32Component::setup() { // Initialize flash storage for power offset calibrations uint32_t po_hash = pref_hash("_power_offset_calibration_", cs); - this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); + this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); bool migrated_power_offset = false; if (has_distinct_legacy_namespace) { uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs); - auto legacy_power_offset_pref = - global_preferences->make_preference(legacy_po_hash, true); - PowerOffsetCalibration power_offset_data[3]{}; + auto legacy_power_offset_pref = global_preferences->make_preference(legacy_po_hash, true); + OffsetCalibration power_offset_data[3]{}; int migration_status = migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data); migrated_power_offset = migration_status > 0; @@ -224,20 +227,20 @@ void ATM90E32Component::setup() { global_preferences->sync(); } - this->restore_offset_calibrations_(); - this->restore_power_offset_calibrations_(); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } else { ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.", cs); for (uint8_t phase = 0; phase < 3; ++phase) { this->write16_(this->voltage_offset_registers[phase], - static_cast(this->offset_phase_[phase].voltage_offset_)); + static_cast(this->offset_phase_[phase].first_offset)); this->write16_(this->current_offset_registers[phase], - static_cast(this->offset_phase_[phase].current_offset_)); + static_cast(this->offset_phase_[phase].second_offset)); this->write16_(this->power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].active_power_offset)); + static_cast(this->power_offset_phase_[phase].first_offset)); this->write16_(this->reactive_power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].reactive_power_offset)); + static_cast(this->power_offset_phase_[phase].second_offset)); } } @@ -317,8 +320,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_, - this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_); + this->config_offset_phase_[phase].first_offset, this->offset_phase_[phase].first_offset, + this->config_offset_phase_[phase].second_offset, this->offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -335,10 +338,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].active_power_offset, - this->config_power_offset_phase_[phase].reactive_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->config_power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].first_offset, + this->config_power_offset_phase_[phase].second_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -372,7 +373,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs); } @@ -385,8 +386,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); } @@ -756,36 +756,68 @@ void ATM90E32Component::save_gain_calibration_to_memory_() { } } -void ATM90E32Component::save_offset_calibration_to_memory_() { +void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); - bool success = this->offset_pref_.save(&this->offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_offset_calibration_ = true; - for (bool &phase : this->offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs); - } -} + const LogString *name = offset_calibration_name(power_offsets); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; -void ATM90E32Component::save_power_offset_calibration_to_memory_() { - const char *cs = this->get_calibration_id_(); - bool success = this->power_offset_pref_.save(&this->power_offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_power_offset_calibration_ = true; - for (bool &phase : this->power_offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs); + const bool writes_verified = this->verify_offset_writes_(type); + bool saved = false; + bool synced = false; + if (writes_verified) { + saved = preference->save(offsets); + synced = global_preferences->sync(); } + + if (writes_verified && saved && synced) { + this->using_saved_calibrations_ = true; + *has_stored = true; + *restored = true; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs, + LOG_STR_ARG(name), LOG_STR_ARG(name)); + return; + } + + if (writes_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name)); + } + + for (uint8_t phase = 0; phase < 3; phase++) { + this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type); + } + const bool rollback_verified = this->verify_offset_writes_(type); + + bool rollback_persisted = false; + if (writes_verified) { + OffsetCalibration rollback[3]{}; + prepare_offset_rollback(previous, previous_restored, rollback); + const bool rollback_saved = preference->save(&rollback); + const bool rollback_synced = global_preferences->sync(); + rollback_persisted = rollback_saved && rollback_synced; + if (!rollback_saved || !rollback_synced) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name)); + } + } + + *restored = previous_restored; + if (rollback_persisted) + *has_stored = previous_restored; + this->using_saved_calibrations_ = previous_using_saved; + if (!rollback_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs, + LOG_STR_ARG(name)); + return; + } + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name)); } void ATM90E32Component::run_offset_calibrations() { @@ -803,11 +835,16 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]}; + const bool previous_restored = this->restored_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = calibrate_offset(phase, true); int16_t current_offset = calibrate_offset(phase, false); - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); @@ -815,7 +852,8 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs); - this->save_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); } void ATM90E32Component::run_power_offset_calibrations() { @@ -834,18 +872,25 @@ void ATM90E32Component::run_power_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1], + this->power_offset_phase_[2]}; + const bool previous_restored = this->restored_power_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; ++phase) { int16_t active_offset = calibrate_power_offset(phase, false); int16_t reactive_offset = calibrate_power_offset(phase, true); - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - this->save_power_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } void ATM90E32Component::write_gains_to_registers_() { @@ -859,35 +904,26 @@ void ATM90E32Component::write_gains_to_registers_() { this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } -void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset) { - // Save to runtime - this->offset_phase_[phase].voltage_offset_ = voltage_offset; - this->phase_[phase].voltage_offset_ = voltage_offset; +void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + OffsetCalibration &offsets = power_offsets ? this->power_offset_phase_[phase] : this->offset_phase_[phase]; + offsets.first_offset = first_offset; + offsets.second_offset = second_offset; + if (power_offsets) { + this->phase_[phase].active_power_offset_ = first_offset; + this->phase_[phase].reactive_power_offset_ = second_offset; + } else { + this->phase_[phase].voltage_offset_ = first_offset; + this->phase_[phase].current_offset_ = second_offset; + } - // Save to flash-storable struct - this->offset_phase_[phase].current_offset_ = current_offset; - this->phase_[phase].current_offset_ = current_offset; - - // Write to registers + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(voltage_offset_registers[phase], static_cast(voltage_offset)); - this->write16_(current_offset_registers[phase], static_cast(current_offset)); - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); -} - -void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset) { - // Save to runtime - this->phase_[phase].active_power_offset_ = p_offset; - this->phase_[phase].reactive_power_offset_ = q_offset; - - // Save to flash-storable struct - this->power_offset_phase_[phase].active_power_offset = p_offset; - this->power_offset_phase_[phase].reactive_power_offset = q_offset; - - // Write to registers - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(this->power_offset_registers[phase], static_cast(p_offset)); - this->write16_(this->reactive_power_offset_registers[phase], static_cast(q_offset)); + this->write16_(first_registers[phase], static_cast(first_offset)); + this->write16_(second_registers[phase], static_cast(second_offset)); this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } @@ -947,89 +983,78 @@ void ATM90E32Component::restore_gain_calibrations_() { ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs); } -void ATM90E32Component::restore_offset_calibrations_() { +void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); + const LogString *name = power_offsets ? LOG_STR("power offset") : LOG_STR("offset"); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + OffsetCalibration(*config_offsets)[3] = + power_offsets ? &this->config_power_offset_phase_ : &this->config_offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; + const bool *has_first = power_offsets ? this->has_config_active_power_offset_ : this->has_config_voltage_offset_; + const bool *has_second = power_offsets ? this->has_config_reactive_power_offset_ : this->has_config_current_offset_; + for (uint8_t i = 0; i < 3; ++i) - this->config_offset_phase_[i] = this->offset_phase_[i]; - - bool have_data = this->offset_pref_.load(&this->offset_phase_); + (*config_offsets)[i] = (*offsets)[i]; + const bool have_data = preference->load(offsets); bool all_zero = true; if (have_data) { - for (auto &phase : this->offset_phase_) { - if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) { + for (const auto &phase : *offsets) { + if (phase.first_offset != 0 || phase.second_offset != 0) { all_zero = false; break; } } } - if (have_data && !all_zero) { - this->restored_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; phase++) { - auto &offset = this->offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_voltage_offset_[phase] && - offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_) - mismatch = true; - if (this->has_config_current_offset_[phase] && - offset.current_offset_ != this->config_offset_phase_[phase].current_offset_) - mismatch = true; - if (mismatch) - this->offset_calibration_mismatch_[phase] = true; + *has_stored = have_data && !all_zero; + *restored = false; + for (uint8_t phase = 0; phase < 3; phase++) { + mismatches[phase] = false; + if (*has_stored) { + mismatches[phase] = + (has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) || + (has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset); } - } else { + } + + if (!*has_stored) { for (uint8_t phase = 0; phase < 3; phase++) - this->offset_phase_[phase] = this->config_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs); + (*offsets)[phase] = (*config_offsets)[phase]; + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored %s calibrations found. Using default values.", cs, LOG_STR_ARG(name)); } for (uint8_t phase = 0; phase < 3; phase++) { - write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_, - this->offset_phase_[phase].current_offset_); + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); } -} - -void ATM90E32Component::restore_power_offset_calibrations_() { - const char *cs = this->get_calibration_id_(); - for (uint8_t i = 0; i < 3; ++i) - this->config_power_offset_phase_[i] = this->power_offset_phase_[i]; - - bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_); - - bool all_zero = true; - if (have_data) { - for (auto &phase : this->power_offset_phase_) { - if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) { - all_zero = false; - break; - } - } + const bool initial_values_verified = this->verify_offset_writes_(type); + if (initial_values_verified) { + const auto state = resolve_offset_restore_state(*has_stored, true, false); + *restored = state.restored; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name)); + return; } - if (have_data && !all_zero) { - this->restored_power_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; ++phase) { - auto &offset = this->power_offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_active_power_offset_[phase] && - offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset) - mismatch = true; - if (this->has_config_reactive_power_offset_[phase] && - offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset) - mismatch = true; - if (mismatch) - this->power_offset_calibration_mismatch_[phase] = true; - } + this->using_saved_calibrations_ = false; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + for (uint8_t phase = 0; phase < 3; phase++) { + (*offsets)[phase] = (*config_offsets)[phase]; + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); + } + const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type)); + *restored = state.restored; + if (state.values_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs, + LOG_STR_ARG(name)); } else { - for (uint8_t phase = 0; phase < 3; ++phase) - this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs); - } - - for (uint8_t phase = 0; phase < 3; ++phase) { - write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs, + LOG_STR_ARG(name)); } } @@ -1084,14 +1109,14 @@ void ATM90E32Component::clear_gain_calibrations() { void ATM90E32Component::clear_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_offset_calibration_) { + if (!this->has_stored_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs); return; @@ -1104,10 +1129,11 @@ void ATM90E32Component::clear_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = - this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0; + this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].first_offset : 0; int16_t current_offset = - this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0; - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); } @@ -1117,6 +1143,7 @@ void ATM90E32Component::clear_offset_calibrations() { this->offset_pref_.save(&zero_offsets); // Clear stored values in flash global_preferences->sync(); + this->has_stored_offset_calibration_ = false; this->restored_offset_calibration_ = false; for (bool &phase : this->offset_calibration_mismatch_) phase = false; @@ -1126,15 +1153,14 @@ void ATM90E32Component::clear_offset_calibrations() { void ATM90E32Component::clear_power_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_power_offset_calibration_) { + if (!this->has_stored_power_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); return; @@ -1147,20 +1173,21 @@ void ATM90E32Component::clear_power_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t active_offset = - this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0; - int16_t reactive_offset = this->has_config_reactive_power_offset_[phase] - ? this->config_power_offset_phase_[phase].reactive_power_offset - : 0; - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].first_offset : 0; + int16_t reactive_offset = + this->has_config_reactive_power_offset_[phase] ? this->config_power_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; + OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; this->power_offset_pref_.save(&zero_power_offsets); global_preferences->sync(); + this->has_stored_power_offset_calibration_ = false; this->restored_power_offset_calibration_ = false; for (bool &phase : this->power_offset_calibration_mismatch_) phase = false; @@ -1215,6 +1242,31 @@ bool ATM90E32Component::verify_gain_writes_() { return success; // Return true if all writes were successful, false otherwise } +bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + const char *cs = this->get_calibration_id_(); + const LogString *name = offset_calibration_name(power_offsets); + const LogString *first_name = power_offsets ? LOG_STR("active") : LOG_STR("voltage"); + const LogString *second_name = power_offsets ? LOG_STR("reactive") : LOG_STR("current"); + const OffsetCalibration *offsets = power_offsets ? this->power_offset_phase_ : this->offset_phase_; + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; + bool success = true; + for (uint8_t phase = 0; phase < 3; phase++) { + const uint16_t first = this->read16_(first_registers[phase]); + const uint16_t second = this->read16_(second_registers[phase]); + if (!offset_register_value_matches(first, offsets[phase].first_offset) || + !offset_register_value_matches(second, offsets[phase].second_offset)) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s readback failed for Phase %s: %s %d/%d, %s %d/%d.", cs, LOG_STR_ARG(name), + phase_labels[phase], LOG_STR_ARG(first_name), static_cast(first), offsets[phase].first_offset, + LOG_STR_ARG(second_name), static_cast(second), offsets[phase].second_offset); + success = false; + } + } + return success; +} + #ifdef USE_TEXT_SENSOR void ATM90E32Component::check_phase_status() { uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0); diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c636e5065a5..fe7d903962f 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,6 +13,40 @@ namespace esphome::atm90e32 { +inline bool offset_register_value_matches(uint16_t actual, int16_t expected) { + return actual == static_cast(expected); +} + +struct OffsetCalibration { + int16_t first_offset{0}; + int16_t second_offset{0}; +}; + +static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible"); + +enum class OffsetCalibrationType : uint8_t { + OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT, + OFFSET_CALIBRATION_TYPE_POWER, +}; + +struct OffsetRestoreState { + bool restored; + bool values_verified; +}; + +inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified, + bool fallback_values_verified) { + if (initial_values_verified) + return {has_stored_values, true}; + return {false, fallback_values_verified}; +} + +inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values, + OffsetCalibration (&rollback)[3]) { + for (uint8_t phase = 0; phase < 3; phase++) + rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{}; +} + class ATM90E32Component final : public PollingComponent, public spi::SPIDevice { @@ -71,19 +105,19 @@ class ATM90E32Component final : public PollingComponent, this->has_config_current_gain_[phase] = true; } void set_voltage_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].voltage_offset_ = offset; + this->offset_phase_[phase].first_offset = offset; this->has_config_voltage_offset_[phase] = true; } void set_current_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].current_offset_ = offset; + this->offset_phase_[phase].second_offset = offset; this->has_config_current_offset_[phase] = true; } void set_active_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].active_power_offset = offset; + this->power_offset_phase_[phase].first_offset = offset; this->has_config_active_power_offset_[phase] = true; } void set_reactive_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].reactive_power_offset = offset; + this->power_offset_phase_[phase].second_offset = offset; this->has_config_reactive_power_offset_[phase] = true; } void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; } @@ -171,16 +205,16 @@ class ATM90E32Component final : public PollingComponent, float get_chip_temperature_(); bool get_publish_interval_flag_() { return publish_interval_flag_; }; void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; }; - void restore_offset_calibrations_(); - void restore_power_offset_calibrations_(); + void restore_offset_calibrations_(OffsetCalibrationType type); void restore_gain_calibrations_(); - void save_offset_calibration_to_memory_(); void save_gain_calibration_to_memory_(); - void save_power_offset_calibration_to_memory_(); - void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset); - void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset); + void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type); + void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type); void write_gains_to_registers_(); bool verify_gain_writes_(); + bool verify_offset_writes_(OffsetCalibrationType type); bool validate_spi_read_(uint16_t expected, const char *context = nullptr); void log_calibration_status_(); const char *get_calibration_id_(); @@ -219,19 +253,10 @@ class ATM90E32Component final : public PollingComponent, uint32_t cumulative_reverse_active_energy_{0}; } phase_[3]; - struct OffsetCalibration { - int16_t voltage_offset_{0}; - int16_t current_offset_{0}; - } offset_phase_[3]; - + OffsetCalibration offset_phase_[3]; OffsetCalibration config_offset_phase_[3]; - - struct PowerOffsetCalibration { - int16_t active_power_offset{0}; - int16_t reactive_power_offset{0}; - } power_offset_phase_[3]; - - PowerOffsetCalibration config_power_offset_phase_[3]; + OffsetCalibration power_offset_phase_[3]; + OffsetCalibration config_power_offset_phase_[3]; struct GainCalibration { uint16_t voltage_gain{1}; @@ -265,6 +290,8 @@ class ATM90E32Component final : public PollingComponent, bool enable_offset_calibration_{false}; bool enable_gain_calibration_{false}; const char *instance_id_{nullptr}; + bool has_stored_offset_calibration_{false}; + bool has_stored_power_offset_calibration_{false}; bool restored_offset_calibration_{false}; bool restored_power_offset_calibration_{false}; bool restored_gain_calibration_{false}; diff --git a/tests/components/atm90e32/__init__.py b/tests/components/atm90e32/__init__.py new file mode 100644 index 00000000000..37d6797e2dd --- /dev/null +++ b/tests/components/atm90e32/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["sensor", "spi"] diff --git a/tests/components/atm90e32/offset_register_verification_test.cpp b/tests/components/atm90e32/offset_register_verification_test.cpp new file mode 100644 index 00000000000..3bb3eb76ea9 --- /dev/null +++ b/tests/components/atm90e32/offset_register_verification_test.cpp @@ -0,0 +1,62 @@ +#include + +#include "esphome/components/atm90e32/atm90e32.h" + +namespace esphome::atm90e32::testing { + +TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) { + EXPECT_TRUE(offset_register_value_matches(0x007B, 123)); + EXPECT_TRUE(offset_register_value_matches(0xFF85, -123)); +} + +TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) { + EXPECT_FALSE(offset_register_value_matches(0x007C, 123)); + EXPECT_FALSE(offset_register_value_matches(0xFF84, -123)); +} + +TEST(ATM90E32OffsetRestoreState, ReportsVerifiedStoredValuesAsRestored) { + const auto state = resolve_offset_restore_state(true, true, false); + + EXPECT_TRUE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsVerifiedConfigFallbackAsNotRestored) { + const auto state = resolve_offset_restore_state(true, false, true); + + EXPECT_FALSE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsFailedConfigFallbackAsUnverified) { + const auto state = resolve_offset_restore_state(true, false, false); + + EXPECT_FALSE(state.restored); + EXPECT_FALSE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsConfigWithoutStoredValuesAsNotRestored) { + const auto state = resolve_offset_restore_state(false, true, false); + + EXPECT_FALSE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetPersistence, RollsBackStoredValuesOrZeroSentinel) { + const OffsetCalibration previous[3]{{1, -1}, {2, -2}, {3, -3}}; + OffsetCalibration rollback[3]{}; + + prepare_offset_rollback(previous, true, rollback); + for (uint8_t phase = 0; phase < 3; phase++) { + EXPECT_EQ(rollback[phase].first_offset, previous[phase].first_offset); + EXPECT_EQ(rollback[phase].second_offset, previous[phase].second_offset); + } + + prepare_offset_rollback(previous, false, rollback); + for (const auto &phase : rollback) { + EXPECT_EQ(phase.first_offset, 0); + EXPECT_EQ(phase.second_offset, 0); + } +} + +} // namespace esphome::atm90e32::testing From f89b9e704c7dfabce1e8ce670dd2c6e7aa9ea086 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:21:48 +0200 Subject: [PATCH 022/266] Bump bundled esphome-device-builder to 1.14.5 (#19040) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index da76ab7b6a7..ac84ee4689f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5 RUN \ platformio settings set enable_telemetry No \ From 9b6facb20d5461dfaf47fd3993a7b4601f082c34 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 15:46:17 -0400 Subject: [PATCH 023/266] [core] Fix use-after-free when deleting a running StaticTask (#19048) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../micro_wake_word/micro_wake_word.cpp | 4 +-- .../mixer/speaker/mixer_speaker.cpp | 4 +-- .../resampler/speaker/resampler_speaker.cpp | 4 +-- .../speaker/media_player/audio_pipeline.cpp | 11 +++++-- esphome/core/static_task.cpp | 30 ++++++++++++++----- esphome/core/static_task.h | 17 +++++++---- 6 files changed, 50 insertions(+), 20 deletions(-) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 3dadb78077d..cebfe8e7914 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -446,9 +446,9 @@ void MicroWakeWord::loop() { xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING); } - if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 6128dc37678..0b79010773a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -382,8 +382,8 @@ void MixerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); } - if (event_group_bits & MIXER_TASK_STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); this->all_stopped_since_ms_ = 0; diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index f1ebd180cc0..edda00ae061 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING); } - if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS); } diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 010f0c50b33..c286a9d7d66 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() { if (!this->is_playing_) { // The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks if (this->read_task_.is_created() || this->decode_task_.is_created()) { - this->read_task_.deallocate(); - this->decode_task_.deallocate(); + // Both are attempted every time; a task that is still running on the other core is freed by a + // subsequent call, and freeing an already freed task succeeds without doing anything + bool read_task_freed = this->read_task_.deallocate(); + bool decode_task_freed = this->decode_task_.deallocate(); + if (!read_task_freed || !decode_task_freed) { + // A task is still running on the other core, so keep the pipeline in its current state and try + // again on the next call + return AudioPipelineState::PLAYING; + } if (this->hard_stop_) { // Stop command was sent, so immediately end the playback this->speaker_->stop(); diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 4cfead44c29..43011083159 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return true; } -void StaticTask::destroy() { - if (this->handle_ != nullptr) { - TaskHandle_t handle = this->handle_; - this->handle_ = nullptr; - vTaskDelete(handle); +bool StaticTask::destroy() { + if (this->handle_ == nullptr) { + return true; } + + // Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks + // the other core to yield though, so the task may still be running on it for a moment. + vTaskSuspend(this->handle_); + if (eTaskGetState(this->handle_) != eSuspended) { + // The task is still running on the other core and using its stack. Deleting it now would only put it on + // the termination list and return, so the caller has to try again once it has been swapped out. + return false; + } + + // The task cannot run again, so the delete completes right away instead of being left to the idle task. + TaskHandle_t handle = this->handle_; + this->handle_ = nullptr; + vTaskDelete(handle); + return true; } -void StaticTask::deallocate() { - this->destroy(); +bool StaticTask::deallocate() { + if (!this->destroy()) { + return false; + } if (this->stack_buffer_ != nullptr) { RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL : RAMAllocator::ALLOC_INTERNAL); @@ -57,6 +72,7 @@ void StaticTask::deallocate() { this->stack_buffer_ = nullptr; this->stack_size_ = 0; } + return true; } } // namespace esphome diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 5fd5b38f9ef..e2996abedae 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -11,6 +11,7 @@ namespace esphome { /** Helper for FreeRTOS static task management. * Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. + * Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on. */ class StaticTask { public: @@ -23,7 +24,7 @@ class StaticTask { /// @brief Allocate stack and create task. /// @param fn Task function /// @param name Task name (for debug) - /// @param stack_size Stack size in StackType_t words + /// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF) /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM @@ -31,11 +32,17 @@ class StaticTask { bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, bool use_psram); - /// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call. - void destroy(); + /// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call. + /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is + /// suspended here so that it cannot be scheduled again, and it is given no chance to clean up. + /// @return true if the task was deleted; false if it is still running on another core, in which case the + /// caller should try again later. + bool destroy(); - /// @brief Delete the task (if running) and free the stack buffer. - void deallocate(); + /// @brief Delete the task (if created) and free the stack buffer. + /// @return true if the stack buffer was freed; false if the task is still running on another core, in + /// which case the caller should try again later. + bool deallocate(); protected: TaskHandle_t handle_{nullptr}; From 0a1e2acbcba4521391742824c617c8cf206beb63 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:04:34 -0400 Subject: [PATCH 024/266] [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) --- esphome/components/audio/audio_reader.cpp | 3 +++ esphome/components/audio/audio_transfer_buffer.cpp | 12 ++++++------ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 4 ++-- .../components/micro_wake_word/micro_wake_word.cpp | 2 +- esphome/components/microphone/microphone_source.h | 2 +- esphome/components/mixer/speaker/mixer_speaker.cpp | 12 ++++++------ .../resampler/speaker/resampler_speaker.cpp | 6 +++--- .../speaker/media_player/audio_pipeline.cpp | 12 +++++++----- 8 files changed, 29 insertions(+), 24 deletions(-) diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 4678ed548c7..e69f33ac2d5 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &ou if (current_audio_file_ != nullptr) { // A transfer buffer isn't ncessary for a local file this->file_ring_buffer_ = output_ring_buffer.lock(); + if (this->file_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; + } return ESP_OK; } diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index a611549e58d..01fd4bb68a6 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le void AudioTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } } void AudioSinkTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } #ifdef USE_SPEAKER @@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() { } bool AudioTransferBuffer::has_buffered_data() const { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); @@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_ size_t bytes_to_read = AudioTransferBuffer::free(); size_t bytes_read = 0; if (bytes_to_read > 0) { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); } @@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait); } else #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_written = this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait); } else if (this->sink_callback_ != nullptr) { @@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->speaker_->has_buffered_data() || (this->available() > 0)); } #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1c2eb129046..b78a151ee42 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -218,8 +218,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t } bool I2SAudioSpeakerBase::has_buffered_data() const { - if (this->audio_ring_buffer_.use_count() > 0) { - std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { return temp_ring_buffer->available() > 0; } return false; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index cebfe8e7914..cf239be6960 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -129,7 +129,7 @@ void MicroWakeWord::setup() { return; } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() > 1) { + if (temp_ring_buffer != nullptr) { // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task // to drain it - reset() is a consumer operation and must run on the inference task's thread. // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index 7be3b8cdb59..d7a33524322 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -48,7 +48,7 @@ class MicrophoneSource final { template void add_data_callback(F &&data_callback) { this->mic_->add_data_callback([this, data_callback](const std::vector &data) { if (this->enabled_ || this->passive_) { - if (this->processed_samples_.use_count() == 0) { + if (this->processed_samples_ == nullptr) { // Create vector if its unused this->processed_samples_ = std::make_shared>(); } diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 0b79010773a..ef21da65c5a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_ } size_t bytes_written = 0; std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer.use_count() > 0) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); if (bytes_written > 0) { @@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; - if (this->audio_source_.use_count() == 0) { + if (this->audio_source_ == nullptr) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); this->ring_buffer_ = temp_ring_buffer; } - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { return ESP_ERR_NO_MEM; } @@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } bool SourceSpeaker::has_buffered_data() const { - return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data()); + return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data()); } void SourceSpeaker::set_mute_state(bool mute_state) { @@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (speaker->is_running() && !speaker->get_pause_state()) { // Speaker is running and not paused, so it possibly can provide audio data std::shared_ptr audio_source = speaker->get_audio_source().lock(); - if (audio_source.use_count() == 0) { + if (audio_source == nullptr) { // No audio source allocated, so skip processing this speaker continue; } diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index edda00ae061..16d2d5dc9e2 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic bytes_written = this->output_speaker_->play(data, length, ticks_to_wait); } else { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); } else { @@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const { bool has_ring_buffer_data = false; if (this->requires_resampling_()) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { has_ring_buffer_data = (temp_ring_buffer->available() > 0); } } @@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { this_resampler->ring_buffer_ = temp_ring_buffer; diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index c286a9d7d66..509984cfa29 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -322,17 +322,17 @@ void AudioPipeline::read_task(void *params) { if (err == ESP_OK) { size_t file_ring_buffer_size = this_pipeline->buffer_size_; - std::shared_ptr temp_ring_buffer; + std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size); this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer; } - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { - reader->add_sink(this_pipeline->raw_file_ring_buffer_); + err = reader->add_sink(temp_ring_buffer); } } @@ -403,7 +403,9 @@ void AudioPipeline::decode_task(void *params) { make_unique(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_); esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_); - decoder->add_source(this_pipeline->raw_file_ring_buffer_); + if (err == ESP_OK) { + err = decoder->add_source(this_pipeline->raw_file_ring_buffer_); + } if (err != ESP_OK) { // Send specific error message From ac79173f4ae83ff10c6a571140091be6ec7878b1 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:05:02 -0400 Subject: [PATCH 025/266] [i2s_audio] Fix spurious driver failure (#19045) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index b78a151ee42..1382a870465 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() { break; } + // Still starting up or winding down from a previous run + if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + break; + } + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); this->status_momentary_error("driver-failure", 1000); break; } - if (this->speaker_task_handle_ == nullptr) { - xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, - &this->speaker_task_handle_); + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->speaker_task_handle_); - if (this->speaker_task_handle_ == nullptr) { - ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt - } + if (this->speaker_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task-failure", 1000); + this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough From 9c16aba6f78af2657cd9e5876d0e771c72692fcd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 23:07:06 +0200 Subject: [PATCH 026/266] [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 4de706120e1..d17ebf235e5 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.24") + cg.add_library("esphome/noise-c", "0.1.26") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.6") + cg.add_library("esphome/libsodium", "1.10021.8") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 779a05e7de4..738773d1b56 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.24 ; noise (api, ota) + esphome/noise-c@0.1.26 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.24 ; noise (api, ota) + esphome/noise-c@0.1.26 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.24 ; used by noise (api, ota) + esphome/noise-c@0.1.26 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 4f7f5a4a4c8..00f22ca1389 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.24\n" + " esphome/noise-c @ 0.1.26\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.24\n" + " esphome/noise-c @ 0.1.26\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.24"] + assert libs == ["esphome/noise-c @ 0.1.26"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.26", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.24", - "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 0.1.26", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.24"] + assert cls.calls == ["esphome/noise-c @ 0.1.26"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.24"] is None + assert compats["esphome/noise-c @ 0.1.26"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 14c52dda8d5..b03bff19a27 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 42fffd16fef3d08f58b5ace59b0545e551c07e64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 23:08:16 +0200 Subject: [PATCH 027/266] [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) --- esphome/components/esphome/ota/ota_esphome.cpp | 5 ++++- esphome/espota2.py | 9 ++++++--- tests/unit_tests/test_espota2.py | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1005ed214b6..f853ed6a2db 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake -static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +// Milliseconds for data transfer. Covers the lwIP retransmit run seen in +// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits +// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; // Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/espota2.py b/esphome/espota2.py index ce403c398db..c683ffa323c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 # across the addresses on top of that. EXTRA_UPLOAD_ATTEMPTS = 2 UPLOAD_RETRY_DELAY = 5.0 +# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA +# (105 s) so a stalled session is gone before a retry, and long enough for lwIP +# to get a lost chunk ack through after the retransmit run seen in practice +DATA_PHASE_TIMEOUT = 160.0 _LOGGER = logging.getLogger(__name__) @@ -694,8 +698,7 @@ def perform_ota( _LOGGER.info("Handshake complete") - # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures - sock.settimeout(90.0) + sock.settimeout(DATA_PHASE_TIMEOUT) if extended_proto: send_check(sock, ota_type, "ota type") @@ -854,7 +857,7 @@ def run_ota_impl_( # clean up a half-open connection (its handshake watchdog runs at 20s); # moving on to the next address family stays immediate. Known limitation: # a silent mid-transfer drop with no reset can wedge the device until its - # 90s data timeout, which outlasts this budget; the retries target the + # 105s data timeout, which outlasts this budget; the retries target the # common failures where the device resets or closes the link promptly. total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS last_error = "" diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 8867e2c215b..2d65e8e0798 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -416,6 +416,9 @@ def test_perform_ota_no_auth( "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" in caplog.text ) + # The data phase timeout must outlast the device's 105 s data timeout + mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT) + assert espota2.DATA_PHASE_TIMEOUT > 105.0 @pytest.mark.usefixtures("mock_time") From d2bc056f0ab3ea93e62c7ca468f7a3da17a3f421 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:31:18 -0400 Subject: [PATCH 028/266] [sendspin] Add codec preference list to the media source (#19047) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/sendspin/__init__.py | 26 ++++-- .../sendspin/media_source/__init__.py | 31 +++++++ .../sendspin/test_media_source.py | 90 +++++++++++++++++++ .../sendspin/common-media_source.yaml | 1 + 4 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 tests/component_tests/sendspin/test_media_source.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 570fd3faddd..8ef11a7f909 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +CONF_CODECS = "codecs" # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +CODEC_FLAC = "flac" +CODEC_OPUS = "opus" +CODEC_PCM = "pcm" + +CODECS = { + CODEC_FLAC: CODEC_FORMAT_FLAC, + CODEC_OPUS: CODEC_FORMAT_OPUS, + CODEC_PCM: CODEC_FORMAT_PCM, +} + +# Opus only supports 48 kHz audio, so it is left out of the default list at other rates. +DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM] +OPUS_SAMPLE_RATE = 48000 + SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") @@ -286,16 +301,13 @@ async def to_code(config: ConfigType) -> None: if data.player_support: cg.add_define("USE_SENDSPIN_PLAYER", True) - # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate - # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server + # Configures the player role. Each configured codec is advertised for 16 bits per sample + # mono and stereo at the configured sample rate. The order is a preference order, both for + # the codecs themselves and for stereo over mono. player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - # OPUS only supports 48 kHz audio - codecs = [CODEC_FORMAT_FLAC] - if sample_rate == 48000: - codecs.append(CODEC_FORMAT_OPUS) - codecs.append(CODEC_FORMAT_PCM) + codecs = player_cfg[CONF_CODECS] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6af244d41f0..6a9f1f18ba2 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CODEC_OPUS, + CODECS, + CONF_CODECS, CONF_DECODE_MEMORY, CONF_FIXED_DELAY, CONF_INITIAL_STATIC_DELAY, CONF_SENDSPIN_ID, + DEFAULT_CODECS, MEMORY_LOCATIONS, + OPUS_SAMPLE_RATE, SendspinHub, register_player_config, request_controller_support, @@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_( ) +def _resolve_codecs(config: ConfigType) -> ConfigType: + """Validate the codec preference list, filling in the default when it is not set.""" + sample_rate = config[CONF_SAMPLE_RATE] + if (codecs := config.get(CONF_CODECS)) is None: + config[CONF_CODECS] = [ + codec + for codec in DEFAULT_CODECS + if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE + ] + return config + + if len(set(codecs)) != len(codecs): + raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS]) + if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE: + raise cv.Invalid( + f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", + path=[CONF_CODECS], + ) + return config + + def _register(config: ConfigType) -> ConfigType: request_controller_support() register_player_config( { + CONF_CODECS: config[CONF_CODECS], CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], @@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All( min=16000, max=96000 ), cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), + cv.Optional(CONF_CODECS): cv.All( + cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1) + ), } ), cv.only_on_esp32, + _resolve_codecs, _register, ) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py new file mode 100644 index 00000000000..6c2f79198dd --- /dev/null +++ b/tests/component_tests/sendspin/test_media_source.py @@ -0,0 +1,90 @@ +"""Validation tests for the sendspin media_source platform. + +These cover the codec preference list, whose rejection branches a compile test +cannot reach: a `test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_CODECS, _get_data +from esphome.components.sendspin.media_source import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _media_source_config(**overrides: Any) -> ConfigType: + """Build a minimal valid media source config, allowing field overrides.""" + config: ConfigType = { + "id": "sendspin_media_source", + "sendspin_id": "sendspin_hub", + } + config.update(overrides) + return config + + +def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None: + """Every codec is advertised when the sample rate suits all of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config()) + + assert config[CONF_CODECS] == ["flac", "opus", "pcm"] + + +def test_default_codecs_drop_opus_at_other_rates( + set_core_config: SetCoreConfigCallable, +) -> None: + """Opus only supports 48 kHz, so it leaves the default list at other rates.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100)) + + assert config[CONF_CODECS] == ["flac", "pcm"] + + +def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None: + """The list is a preference order, so it reaches the player role as written.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"])) + + assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"] + + +def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A player with no codecs at all could never be given a stream.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="length of value must be at least 1"): + CONFIG_SCHEMA(_media_source_config(codecs=[])) + + +def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A repeated codec has no meaning in a preference order.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="may only be listed once"): + CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"])) + + +def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Only codecs the player role can decode are accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Unknown value"): + CONFIG_SCHEMA(_media_source_config(codecs=["mp3"])) + + +def test_opus_at_wrong_sample_rate_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Asking for Opus at a rate it cannot handle fails rather than silently + dropping the stated preference.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 1977b79c04d..0c136fbd43d 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,3 +9,4 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal + codecs: [pcm, opus, flac] From 008677298ada0a95adeef14c5ac88d1895d5fb2f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:54:44 +1200 Subject: [PATCH 029/266] Bump version to 2026.9.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 060de51d3af..97ce92240c7 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b2 +PROJECT_NUMBER = 2026.9.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 287804ace3b..b013098f336 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b2" +__version__ = "2026.9.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From fd598057efdfa689a10b53329753a936a15016a1 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 9 Sep 2026 14:22:23 +0200 Subject: [PATCH 030/266] [sendspin] Fix codec enum codegen when codecs is not set (#19055) --- esphome/components/sendspin/__init__.py | 2 +- tests/components/sendspin/common-media_source.yaml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 8ef11a7f909..c1970ab1325 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -307,7 +307,7 @@ async def to_code(config: ConfigType) -> None: player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - codecs = player_cfg[CONF_CODECS] + codecs = [CODECS[codec] for codec in player_cfg[CONF_CODECS]] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 0c136fbd43d..1977b79c04d 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,4 +9,3 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal - codecs: [pcm, opus, flac] From 58ca3456845ca130ac106796225e8ea4cb9c5107 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:42:09 -0400 Subject: [PATCH 031/266] [ci] Refresh integration test durations (#19049) --- .../integration_test_durations.json | 293 +++++++++--------- 1 file changed, 152 insertions(+), 141 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 5a5aac3b229..b4a7f4e1aeb 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,143 +1,154 @@ { - "tests/integration/test_action_concurrent_reentry.py": 57.91, - "tests/integration/test_addressable_light_transition.py": 21.25, - "tests/integration/test_alarm_control_panel_state_transitions.py": 70.71, - "tests/integration/test_api_action_metadata.py": 66.6, - "tests/integration/test_api_action_responses.py": 36.1, - "tests/integration/test_api_action_timeout.py": 68.86, - "tests/integration/test_api_conditional_memory.py": 15.48, - "tests/integration/test_api_custom_services.py": 18.77, - "tests/integration/test_api_get_time_response_timezone.py": 21.08, - "tests/integration/test_api_homeassistant.py": 65.59, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 18.44, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 15.05, - "tests/integration/test_api_list_entities_backpressure.py": 13.88, - "tests/integration/test_api_message_size_batching.py": 29.98, - "tests/integration/test_api_reboot_timeout.py": 16.05, - "tests/integration/test_api_string_lambda.py": 15.31, - "tests/integration/test_api_vv_logging.py": 19.28, - "tests/integration/test_api_zero_psk_provisioning.py": 31.5, - "tests/integration/test_areas_and_devices.py": 24.95, - "tests/integration/test_automation_wait_actions.py": 20.92, - "tests/integration/test_automations.py": 35.19, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 17.99, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 20.39, - "tests/integration/test_binary_sensor_invalidate_state.py": 18.41, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 24.69, - "tests/integration/test_build_info.py": 18.7, - "tests/integration/test_camera_mock.py": 16.23, - "tests/integration/test_climate_control_action.py": 21.14, - "tests/integration/test_climate_custom_modes.py": 20.74, - "tests/integration/test_continuation_actions.py": 16.81, - "tests/integration/test_cover_control_action.py": 20.34, - "tests/integration/test_crc8_helper.py": 9.36, - "tests/integration/test_device_id_in_state.py": 44.67, - "tests/integration/test_duplicate_entities.py": 23.58, - "tests/integration/test_entity_icon.py": 34.35, - "tests/integration/test_fan_turn_on_action.py": 24.23, - "tests/integration/test_fnv1_hash_object_id.py": 16.21, - "tests/integration/test_fnv1a_hash.py": 13.38, - "tests/integration/test_gpio_expander_cache.py": 13.06, - "tests/integration/test_host_logger_thread_safety.py": 23.66, - "tests/integration/test_host_mode_basic.py": 8.01, - "tests/integration/test_host_mode_batch_delay.py": 21.0, - "tests/integration/test_host_mode_climate_basic_state.py": 22.14, - "tests/integration/test_host_mode_climate_control.py": 19.39, - "tests/integration/test_host_mode_empty_string_options.py": 21.76, - "tests/integration/test_host_mode_entity_fields.py": 29.61, - "tests/integration/test_host_mode_fan_preset.py": 20.01, - "tests/integration/test_host_mode_many_entities.py": 39.08, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.92, - "tests/integration/test_host_mode_noise_encryption.py": 42.42, - "tests/integration/test_host_mode_reconnect.py": 3.41, - "tests/integration/test_host_mode_sensor.py": 22.96, - "tests/integration/test_host_ota.py": 29.5, - "tests/integration/test_host_preferences.py": 16.06, - "tests/integration/test_host_preferences_suspend_resume.py": 18.71, - "tests/integration/test_improv_serial_uart.py": 20.22, - "tests/integration/test_large_message_batching.py": 26.56, - "tests/integration/test_legacy_area.py": 22.72, - "tests/integration/test_legacy_climate_compat.py": 14.13, - "tests/integration/test_legacy_fan_compat.py": 14.33, - "tests/integration/test_light_automations.py": 18.81, - "tests/integration/test_light_binary_effect_off_phase.py": 8.38, - "tests/integration/test_light_calls.py": 21.88, - "tests/integration/test_light_constant_brightness.py": 59.45, - "tests/integration/test_light_control_action.py": 31.91, - "tests/integration/test_light_dim_relative_action.py": 14.43, - "tests/integration/test_light_effect_zero_brightness.py": 25.05, - "tests/integration/test_light_initial_state.py": 18.97, - "tests/integration/test_light_toggle_action.py": 17.44, - "tests/integration/test_lock_automations.py": 18.9, - "tests/integration/test_logger_buffered_recursion_guard.py": 18.2, - "tests/integration/test_loop_disable_enable.py": 63.35, - "tests/integration/test_loop_interval_decoupling.py": 17.7, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.56, - "tests/integration/test_micros_to_millis.py": 15.89, - "tests/integration/test_multi_click_trigger.py": 17.23, - "tests/integration/test_multi_device_preferences.py": 19.4, - "tests/integration/test_noise_encryption_key_protection.py": 72.59, - "tests/integration/test_object_id_api_verification.py": 19.22, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.77, - "tests/integration/test_object_id_no_friendly_name.py": 45.8, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 86.73, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.4, - "tests/integration/test_online_image_bmp.py": 37.24, - "tests/integration/test_oversized_payloads.py": 55.75, - "tests/integration/test_preference_key_stability.py": 25.49, - "tests/integration/test_runtime_stats.py": 29.81, - "tests/integration/test_safe_mode_loop_runs.py": 6.26, - "tests/integration/test_scheduler_blocking_warning.py": 37.98, - "tests/integration/test_scheduler_bulk_cleanup.py": 18.67, - "tests/integration/test_scheduler_defer_cancel.py": 18.46, - "tests/integration/test_scheduler_defer_cancel_regular.py": 16.34, - "tests/integration/test_scheduler_defer_fifo_simple.py": 18.26, - "tests/integration/test_scheduler_defer_stress.py": 17.74, - "tests/integration/test_scheduler_heap_stress.py": 3.89, - "tests/integration/test_scheduler_internal_id_no_collision.py": 20.01, - "tests/integration/test_scheduler_interval_reschedule.py": 16.29, - "tests/integration/test_scheduler_interval_zero_coerced.py": 16.09, - "tests/integration/test_scheduler_null_name.py": 14.69, - "tests/integration/test_scheduler_numeric_id_test.py": 17.08, - "tests/integration/test_scheduler_pool.py": 19.88, - "tests/integration/test_scheduler_rapid_cancellation.py": 4.42, - "tests/integration/test_scheduler_recursive_timeout.py": 4.3, - "tests/integration/test_scheduler_removed_item_race.py": 15.49, - "tests/integration/test_scheduler_self_keyed.py": 25.77, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 14.84, - "tests/integration/test_scheduler_string_test.py": 15.42, - "tests/integration/test_script_array_params.py": 12.73, - "tests/integration/test_script_delay_params.py": 12.69, - "tests/integration/test_script_queued.py": 20.38, - "tests/integration/test_script_queued_idle_loop.py": 25.06, - "tests/integration/test_script_wait_on_boot.py": 15.67, - "tests/integration/test_select_stringref_trigger.py": 19.48, - "tests/integration/test_sensor_filters_delta.py": 27.62, - "tests/integration/test_sensor_filters_ring_buffer.py": 20.27, - "tests/integration/test_sensor_filters_sliding_window.py": 56.28, - "tests/integration/test_sensor_filters_value_list.py": 20.6, - "tests/integration/test_sensor_timeout_filter.py": 22.21, - "tests/integration/test_socket_wake_gate_tcp.py": 16.37, - "tests/integration/test_status_flags.py": 29.68, - "tests/integration/test_strftime_to.py": 17.42, - "tests/integration/test_syslog.py": 18.39, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 25.61, - "tests/integration/test_template_text_save.py": 19.16, - "tests/integration/test_text_command.py": 16.43, - "tests/integration/test_text_sensor_raw_state.py": 17.19, - "tests/integration/test_uart_mock_ld2410.py": 37.0, - "tests/integration/test_uart_mock_ld2412.py": 40.82, - "tests/integration/test_uart_mock_ld2420.py": 32.7, - "tests/integration/test_uart_mock_ld2450.py": 32.84, - "tests/integration/test_uart_mock_modbus.py": 548.87, - "tests/integration/test_udp.py": 16.67, - "tests/integration/test_use_address_runtime.py": 27.26, - "tests/integration/test_valve_control_action.py": 24.58, - "tests/integration/test_varint_five_byte_device_id.py": 22.5, - "tests/integration/test_wait_until_mid_loop_timing.py": 22.05, - "tests/integration/test_wait_until_on_boot.py": 10.37, - "tests/integration/test_wait_until_ordering.py": 18.23, - "tests/integration/test_wait_until_reentrant_restart.py": 19.35, - "tests/integration/test_wake_loop_forces_phase_b.py": 17.83, - "tests/integration/test_water_heater_template.py": 25.7 + "tests/integration/test_action_concurrent_reentry.py": 30.48, + "tests/integration/test_addressable_light_transition.py": 42.1, + "tests/integration/test_alarm_control_panel_state_transitions.py": 35.76, + "tests/integration/test_api_action_metadata.py": 22.35, + "tests/integration/test_api_action_responses.py": 30.31, + "tests/integration/test_api_action_timeout.py": 34.73, + "tests/integration/test_api_conditional_memory.py": 18.35, + "tests/integration/test_api_custom_services.py": 15.99, + "tests/integration/test_api_get_time_response_timezone.py": 24.21, + "tests/integration/test_api_homeassistant.py": 33.77, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 20.8, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 23.55, + "tests/integration/test_api_list_entities_backpressure.py": 23.04, + "tests/integration/test_api_message_size_batching.py": 27.31, + "tests/integration/test_api_reboot_timeout.py": 29.32, + "tests/integration/test_api_string_lambda.py": 14.9, + "tests/integration/test_api_vv_logging.py": 26.25, + "tests/integration/test_api_zero_psk_provisioning.py": 38.19, + "tests/integration/test_areas_and_devices.py": 27.52, + "tests/integration/test_automation_wait_actions.py": 24.25, + "tests/integration/test_automations.py": 36.02, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 18.46, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 17.47, + "tests/integration/test_binary_sensor_invalidate_state.py": 14.79, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 21.52, + "tests/integration/test_build_info.py": 21.42, + "tests/integration/test_camera_mock.py": 17.02, + "tests/integration/test_climate_control_action.py": 26.56, + "tests/integration/test_climate_custom_modes.py": 18.82, + "tests/integration/test_continuation_actions.py": 20.39, + "tests/integration/test_cover_control_action.py": 19.91, + "tests/integration/test_crc8_helper.py": 16.73, + "tests/integration/test_device_id_in_state.py": 58.41, + "tests/integration/test_duplicate_entities.py": 30.76, + "tests/integration/test_entity_icon.py": 25.34, + "tests/integration/test_fan_turn_on_action.py": 23.64, + "tests/integration/test_fnv1_hash_object_id.py": 25.44, + "tests/integration/test_fnv1a_hash.py": 20.85, + "tests/integration/test_gpio_expander_cache.py": 14.42, + "tests/integration/test_host_logger_thread_safety.py": 21.31, + "tests/integration/test_host_mode_basic.py": 2.65, + "tests/integration/test_host_mode_batch_delay.py": 22.21, + "tests/integration/test_host_mode_climate_basic_state.py": 27.12, + "tests/integration/test_host_mode_climate_control.py": 21.57, + "tests/integration/test_host_mode_empty_string_options.py": 27.17, + "tests/integration/test_host_mode_entity_fields.py": 30.1, + "tests/integration/test_host_mode_fan_preset.py": 17.55, + "tests/integration/test_host_mode_many_entities.py": 38.98, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.82, + "tests/integration/test_host_mode_noise_encryption.py": 39.84, + "tests/integration/test_host_mode_reconnect.py": 13.1, + "tests/integration/test_host_mode_sensor.py": 22.17, + "tests/integration/test_host_ota.py": 92.05, + "tests/integration/test_host_preferences.py": 20.29, + "tests/integration/test_host_preferences_suspend_resume.py": 15.02, + "tests/integration/test_improv_serial_uart.py": 30.15, + "tests/integration/test_large_message_batching.py": 25.84, + "tests/integration/test_legacy_area.py": 21.24, + "tests/integration/test_legacy_climate_compat.py": 17.34, + "tests/integration/test_legacy_fan_compat.py": 22.6, + "tests/integration/test_light_automations.py": 29.13, + "tests/integration/test_light_binary_effect_off_phase.py": 33.99, + "tests/integration/test_light_calls.py": 26.81, + "tests/integration/test_light_constant_brightness.py": 25.0, + "tests/integration/test_light_control_action.py": 25.57, + "tests/integration/test_light_dim_relative_action.py": 21.4, + "tests/integration/test_light_effect_zero_brightness.py": 19.65, + "tests/integration/test_light_initial_state.py": 17.58, + "tests/integration/test_light_toggle_action.py": 28.28, + "tests/integration/test_lock_automations.py": 23.3, + "tests/integration/test_logger_buffered_recursion_guard.py": 22.96, + "tests/integration/test_loop_disable_enable.py": 16.18, + "tests/integration/test_loop_interval_decoupling.py": 25.19, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 20.59, + "tests/integration/test_lvgl_headless_render.py": 87.78, + "tests/integration/test_micros_to_millis.py": 18.73, + "tests/integration/test_multi_click_trigger.py": 24.2, + "tests/integration/test_multi_device_preferences.py": 20.52, + "tests/integration/test_noise_encryption_key_protection.py": 19.1, + "tests/integration/test_object_id_api_verification.py": 26.24, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 14.88, + "tests/integration/test_object_id_no_friendly_name.py": 61.27, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 82.32, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 46.03, + "tests/integration/test_online_image_bmp.py": 34.21, + "tests/integration/test_oversized_payloads.py": 62.75, + "tests/integration/test_preference_key_stability.py": 26.8, + "tests/integration/test_runtime_stats.py": 28.26, + "tests/integration/test_safe_mode_loop_runs.py": 18.14, + "tests/integration/test_scheduler_blocking_warning.py": 28.7, + "tests/integration/test_scheduler_bulk_cleanup.py": 20.73, + "tests/integration/test_scheduler_defer_cancel.py": 22.99, + "tests/integration/test_scheduler_defer_cancel_regular.py": 21.97, + "tests/integration/test_scheduler_defer_fifo_simple.py": 24.15, + "tests/integration/test_scheduler_defer_stress.py": 23.11, + "tests/integration/test_scheduler_heap_stress.py": 20.2, + "tests/integration/test_scheduler_internal_id_no_collision.py": 23.75, + "tests/integration/test_scheduler_interval_reschedule.py": 15.32, + "tests/integration/test_scheduler_interval_zero_coerced.py": 20.1, + "tests/integration/test_scheduler_null_name.py": 17.43, + "tests/integration/test_scheduler_numeric_id_test.py": 25.51, + "tests/integration/test_scheduler_pool.py": 24.22, + "tests/integration/test_scheduler_rapid_cancellation.py": 24.01, + "tests/integration/test_scheduler_recursive_timeout.py": 22.94, + "tests/integration/test_scheduler_removed_item_race.py": 23.07, + "tests/integration/test_scheduler_self_keyed.py": 18.43, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 21.99, + "tests/integration/test_scheduler_string_test.py": 17.27, + "tests/integration/test_script_array_params.py": 4.59, + "tests/integration/test_script_delay_params.py": 22.46, + "tests/integration/test_script_queued.py": 25.24, + "tests/integration/test_script_queued_idle_loop.py": 5.04, + "tests/integration/test_script_wait_on_boot.py": 21.77, + "tests/integration/test_sdl_headless_screenshot.py": 19.23, + "tests/integration/test_select_stringref_trigger.py": 19.31, + "tests/integration/test_sensor_filters_delta.py": 25.92, + "tests/integration/test_sensor_filters_ring_buffer.py": 22.39, + "tests/integration/test_sensor_filters_sliding_window.py": 57.93, + "tests/integration/test_sensor_filters_value_list.py": 20.32, + "tests/integration/test_sensor_timeout_filter.py": 25.35, + "tests/integration/test_snapshot_display.py": 19.7, + "tests/integration/test_socket_wake_gate_tcp.py": 14.5, + "tests/integration/test_status_flags.py": 33.83, + "tests/integration/test_strftime_to.py": 17.64, + "tests/integration/test_syslog.py": 24.49, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.81, + "tests/integration/test_template_climate_basic.py": 15.28, + "tests/integration/test_template_climate_custom_modes.py": 25.07, + "tests/integration/test_template_climate_nonoptimistic.py": 24.25, + "tests/integration/test_template_climate_on_control_ordering.py": 24.09, + "tests/integration/test_template_climate_publish_all_fields.py": 17.78, + "tests/integration/test_template_climate_sensor_push.py": 17.42, + "tests/integration/test_template_climate_set_actions.py": 23.63, + "tests/integration/test_template_climate_two_point_temperature.py": 25.19, + "tests/integration/test_template_text_save.py": 17.88, + "tests/integration/test_text_command.py": 22.71, + "tests/integration/test_text_sensor_raw_state.py": 25.17, + "tests/integration/test_uart_mock_ld2410.py": 58.15, + "tests/integration/test_uart_mock_ld2412.py": 61.14, + "tests/integration/test_uart_mock_ld2420.py": 33.87, + "tests/integration/test_uart_mock_ld2450.py": 26.06, + "tests/integration/test_uart_mock_modbus.py": 391.79, + "tests/integration/test_udp.py": 7.38, + "tests/integration/test_use_address_runtime.py": 24.09, + "tests/integration/test_valve_control_action.py": 23.22, + "tests/integration/test_varint_five_byte_device_id.py": 17.93, + "tests/integration/test_wait_until_mid_loop_timing.py": 22.26, + "tests/integration/test_wait_until_on_boot.py": 17.46, + "tests/integration/test_wait_until_ordering.py": 11.89, + "tests/integration/test_wait_until_reentrant_restart.py": 22.88, + "tests/integration/test_wake_loop_forces_phase_b.py": 16.6, + "tests/integration/test_water_heater_template.py": 19.66 } From c66fa812086f20aead9e56bf42b15f541b2e5bc8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:09:49 +1200 Subject: [PATCH 032/266] [core] Consolidate setup scripts into a cross-platform setup.py (#18856) --- script/git-hooks/post-checkout | 46 ++- script/setup | 74 +---- script/setup.bat | 29 +- script/setup.py | 222 +++++++++++++ tests/script/test_setup.py | 562 +++++++++++++++++++++++++++++++++ 5 files changed, 827 insertions(+), 106 deletions(-) create mode 100755 script/setup.py create mode 100644 tests/script/test_setup.py diff --git a/script/git-hooks/post-checkout b/script/git-hooks/post-checkout index 853c2b03521..73c1cb0f130 100755 --- a/script/git-hooks/post-checkout +++ b/script/git-hooks/post-checkout @@ -1,27 +1,49 @@ #!/bin/sh # Prepare the dev environment for a new checkout or worktree. # -# Installed into the git hooks directory by script/setup. Deliberately tiny and -# self-contained: it stays valid on branches where script/setup does not exist, -# and simply does nothing there. +# Installed into the git hooks directory by script/setup.py. Deliberately tiny +# and self-contained: it stays valid on branches where the setup script does not +# exist, and simply does nothing there. # $3 is 1 for a branch checkout, 0 for a file checkout. [ "$3" = "1" ] || exit 0 top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 -# This also runs on ordinary branch switches, where there is nothing to do. +# This also runs on ordinary branch switches, where there is nothing to do. Both +# layouts are checked because git for Windows runs hooks under its own bundled +# shell, where the environment lives in venv/Scripts rather than venv/bin. [ -x "$top/venv/bin/python" ] && exit 0 -[ -x "$top/script/setup" ] || exit 0 +[ -f "$top/venv/Scripts/python.exe" ] && exit 0 + +# Branches from before the setup script moved to Python carry only the shell +# entry point, so whichever one the checked out branch has is used. +py= +if [ -f "$top/script/setup.py" ]; then + # The interpreter goes by different names across platforms, and on Windows + # "python3" is often a stub that opens the app store instead of running + # anything, so each candidate is tried before it is used. Doing nothing is the + # right outcome when none of them work. + for candidate in "python3" "python" "py -3"; do + # Unquoted on purpose: the launcher candidate is a command plus a flag. + if $candidate -c "" >/dev/null 2>&1; then + py=$candidate + break + fi + done + [ -n "$py" ] || exit 0 +elif ! [ -x "$top/script/setup" ]; then + exit 0 +fi # Every worktree shares the hooks directory of the checkout it was created -# from, and the script/setup run below is the one from whichever branch was just +# from, and the setup script run below is the one from whichever branch was just # checked out. Older branches install their own pre-commit hook without checking # for a worktree: that moves the shared hook aside as pre-commit.legacy and # replaces it with one tied to this worktree's virtual environment, so commits # break in every checkout. To rule that out, the hooks directory is copied -# before script/setup runs and put back exactly as it was afterwards, including -# removing any file script/setup added. +# before the setup script runs and put back exactly as it was afterwards, +# including removing any file the setup script added. hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0 snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0 cp -p "$hooks"/* "$snap"/ 2>/dev/null @@ -29,7 +51,13 @@ cp -p "$hooks"/* "$snap"/ 2>/dev/null # Clear VIRTUAL_ENV so a checkout made from a shell with an environment already # activated still gets its own, rather than having the active one repointed at # this working tree. -env -u VIRTUAL_ENV "$top/script/setup" +unset VIRTUAL_ENV +if [ -n "$py" ]; then + # Unquoted on purpose, as above. + $py "$top/script/setup.py" +else + "$top/script/setup" +fi status=$? for f in "$hooks"/*; do diff --git a/script/setup b/script/setup index b96af6e8f34..91bcb881541 100755 --- a/script/setup +++ b/script/setup @@ -1,71 +1,7 @@ #!/usr/bin/env bash -# Set up ESPHome dev environment +# Set up ESPHome dev environment. +# +# The work is done by setup.py, which script/setup.bat also runs, so the Unix +# and Windows entry points share one implementation. -set -e - -cd "$(dirname "$0")/.." -if [ -n "$VIRTUAL_ENV" ]; then - # A virtual environment is already active (e.g. the devcontainer's pre-provisioned - # esphome-venv). Install into it rather than creating a ./venv in the workspace. - venv_state=active -elif [ -x venv/bin/python ]; then - # Reuse the environment from an earlier run, so this script can be run again - # at any time to pick up dependency changes. - venv_state=reused - source venv/bin/activate -else - venv_state=created - # --clear replaces a partial environment left behind by an interrupted run. - if [ -x "$(command -v uv)" ]; then - uv venv --clear --seed venv - else - python3 -m venv --clear venv - fi - source venv/bin/activate -fi - -if ! [ -x "$(command -v uv)" ]; then - python3 -m pip install uv -fi - -uv pip install setuptools wheel -uv pip install -e ".[dev,test]" --config-settings editable_mode=compat - -# A worktree shares one git hooks directory with the main checkout it was -# created from, so hooks are installed from the main checkout only. Installing -# from a worktree would point the shared hook at that worktree's virtual -# environment, breaking it for everyone once the worktree is removed. -git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)" -common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" -if [ -n "$common_dir" ] && [ "$git_dir" = "$common_dir" ]; then - # --overwrite replaces any hook already in place. Without it, prek finds a - # previously installed pre-commit hook, moves it aside to - # .git/hooks/pre-commit.legacy and keeps calling it, so every commit would - # run both tools. - prek install --overwrite - - # Prepares the virtual environment for new checkouts and worktrees. Installed - # once here, it covers every worktree created from this checkout. - if [ -d "$common_dir/hooks" ]; then - cp script/git-hooks/post-checkout "$common_dir/hooks/post-checkout" - chmod +x "$common_dir/hooks/post-checkout" - fi -fi - -mkdir -p .temp - -echo -echo -case "$venv_state" in - created) - echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it." - ;; - reused) - echo "Dependencies updated in the existing ./venv. Run 'source venv/bin/activate' to use it." - ;; - active) - echo "Dependencies installed into the active virtual environment:" - echo " $VIRTUAL_ENV" - echo "It is already active in this shell, so no 'source venv/bin/activate' is needed." - ;; -esac +exec python3 "$(dirname "$0")/setup.py" "$@" diff --git a/script/setup.bat b/script/setup.bat index 809d05ae933..405121b1390 100644 --- a/script/setup.bat +++ b/script/setup.bat @@ -1,28 +1 @@ -@echo off - -if defined VIRTUAL_ENV goto :install - -echo Starting the Virtual Environment -python -m venv venv -call venv/Scripts/activate -echo Running the Virtual Environment - -:install - -echo Installing required packages... - -python.exe -m pip install --upgrade pip - -pip3 install -r requirements.txt -r requirements_test.txt -r requirements_dev.txt -pip3 install setuptools wheel -pip3 install -e ".[dev,test]" --config-settings editable_mode=compat - -rem --overwrite replaces any hook already in place. Without it, prek finds a -rem previously installed pre-commit hook, moves it aside to -rem .git/hooks/pre-commit.legacy and keeps calling it, so every commit would -rem run both tools. -prek install --overwrite - -echo . -echo . -echo Virtual environment created. Run 'venv/Scripts/activate' to use it. +@python "%~dp0setup.py" %* diff --git a/script/setup.py b/script/setup.py new file mode 100755 index 00000000000..62129b8c050 --- /dev/null +++ b/script/setup.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Set up the ESPHome development environment. + +Shared implementation behind script/setup and script/setup.bat, so the Unix and +Windows entry points cannot drift apart. Uses only the standard library: it runs +before any dependency has been installed. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import sysconfig + +MIN_PYTHON = (3, 12) + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_VENV = ROOT / "venv" +POST_CHECKOUT_HOOK = ROOT / "script" / "git-hooks" / "post-checkout" + +# State of the environment the dependencies end up in, used for the closing +# message. +VENV_ACTIVE = "active" +VENV_REUSED = "reused" +VENV_CREATED = "created" + + +def bin_dir(venv: Path) -> Path: + """Return the directory holding a virtual environment's executables. + + The "venv" scheme resolves to bin on Unix and Scripts on Windows, so the + layout does not have to be hardcoded here. + """ + base = str(venv) + return Path( + sysconfig.get_path("scripts", "venv", vars={"base": base, "platbase": base}) + ) + + +def venv_python(venv: Path) -> Path: + """Return the path to a virtual environment's interpreter.""" + name = "python.exe" if os.name == "nt" else "python" + return bin_dir(venv) / name + + +def run(command: list[str], env: dict[str, str] | None = None) -> None: + """Run a command, aborting the whole script if it fails.""" + print(f"+ {' '.join(command)}", flush=True) + result = subprocess.run(command, cwd=ROOT, env=env, check=False) + if result.returncode != 0: + # Some tools fail without printing anything, so name the step that broke. + print( + f"Failed with exit code {result.returncode}: {command[0]}", file=sys.stderr + ) + raise SystemExit(result.returncode) + + +def git_output(*args: str) -> str: + """Return the trimmed output of a git command, or "" if it cannot be run.""" + try: + result = subprocess.run( + ["git", *args], cwd=ROOT, capture_output=True, text=True, check=False + ) + except OSError: + # Git is not required to install the dependencies, only to install hooks. + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def create_venv(venv: Path) -> None: + """Create a virtual environment, replacing anything already at the path.""" + # --clear replaces a partial environment left behind by an interrupted run. + if (uv := shutil.which("uv")) is not None: + run([uv, "venv", "--clear", "--seed", str(venv)]) + else: + run([sys.executable, "-m", "venv", "--clear", str(venv)]) + + +def venv_environment(venv: Path) -> dict[str, str]: + """Return the environment child processes need to target a virtual env. + + Equivalent to sourcing the environment's activate script: tools such as uv + and prek pick the environment up from VIRTUAL_ENV and PATH. + """ + env = dict(os.environ) + env["VIRTUAL_ENV"] = str(venv) + env.pop("PYTHONHOME", None) + path = str(bin_dir(venv)) + # An empty entry would be appended if PATH is unset, and on Unix that means + # the working directory is searched for executables. + if existing := env.get("PATH"): + path = os.pathsep.join([path, existing]) + env["PATH"] = path + return env + + +def find_uv(venv: Path, env: dict[str, str]) -> str: + """Return the path to uv, installing it into the environment if needed.""" + if (uv := shutil.which("uv", path=env["PATH"])) is not None: + return uv + run([str(venv_python(venv)), "-m", "pip", "install", "uv"], env=env) + if (uv := shutil.which("uv", path=env["PATH"])) is not None: + return uv + raise SystemExit("uv could not be installed, aborting.") + + +def install_dependencies(venv: Path, env: dict[str, str]) -> None: + """Install ESPHome and its development dependencies into the environment.""" + uv = find_uv(venv, env) + run([uv, "pip", "install", "setuptools", "wheel"], env=env) + # The dev and test extras pull in requirements_dev.txt and + # requirements_test.txt, and the package itself pulls in requirements.txt, + # so this single install covers every requirements file. + run( + [ + uv, + "pip", + "install", + "-e", + ".[dev,test]", + "--config-settings", + "editable_mode=compat", + ], + env=env, + ) + + +def install_git_hooks(env: dict[str, str]) -> None: + """Install the git hooks, but only when run from the main checkout. + + A worktree shares one git hooks directory with the main checkout it was + created from. Installing from a worktree would point the shared hook at that + worktree's virtual environment, breaking it for everyone once the worktree is + removed. + """ + git_dir = git_output("rev-parse", "--absolute-git-dir") + common_dir = git_output("rev-parse", "--path-format=absolute", "--git-common-dir") + if not git_dir or not common_dir or Path(git_dir) != Path(common_dir): + return + + prek = shutil.which("prek", path=env["PATH"]) + if prek is None: + raise SystemExit("prek was not installed, aborting.") + # --overwrite replaces any hook already in place. Without it, prek finds a + # previously installed pre-commit hook, moves it aside to + # .git/hooks/pre-commit.legacy and keeps calling it, so every commit would + # run both tools. + run([prek, "install", "--overwrite"], env=env) + + # Prepares the virtual environment for new checkouts and worktrees. Installed + # once here, it covers every worktree created from this checkout. + hooks_dir = Path(common_dir) / "hooks" + if hooks_dir.is_dir(): + installed = hooks_dir / "post-checkout" + shutil.copyfile(POST_CHECKOUT_HOOK, installed) + installed.chmod(0o755) + + +def activate_hint() -> str: + """Return the command that activates the environment this script creates.""" + activate = bin_dir(DEFAULT_VENV).relative_to(ROOT) / "activate" + if os.name == "nt": + return str(activate) + return f"source {activate.as_posix()}" + + +def report(state: str, venv: Path) -> None: + """Print the closing message for the environment that was set up.""" + location = f"./{DEFAULT_VENV.name}" + print() + print() + if state == VENV_ACTIVE: + print("Dependencies installed into the active virtual environment:") + print(f" {venv}") + print( + f"It is already active in this shell, so no '{activate_hint()}' is needed." + ) + elif state == VENV_REUSED: + print( + f"Dependencies updated in the existing {location}. " + f"Run '{activate_hint()}' to use it." + ) + else: + print( + f"Virtual environment created at {location}. " + f"Run '{activate_hint()}' to use it." + ) + + +def main() -> None: + """Set up the development environment.""" + if sys.version_info < MIN_PYTHON: + raise SystemExit( + f"ESPHome needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer, " + f"but this is Python {sys.version.split()[0]}." + ) + + # A virtual environment that is already active (for example the + # devcontainer's pre-provisioned esphome-venv) is installed into rather than + # creating a ./venv in the workspace. + if active := os.environ.get("VIRTUAL_ENV"): + state, venv = VENV_ACTIVE, Path(active) + elif venv_python(DEFAULT_VENV).is_file(): + # Reuse the environment from an earlier run, so this script can be run + # again at any time to pick up dependency changes. + state, venv = VENV_REUSED, DEFAULT_VENV + else: + state, venv = VENV_CREATED, DEFAULT_VENV + create_venv(venv) + + env = venv_environment(venv) + install_dependencies(venv, env) + install_git_hooks(env) + (ROOT / ".temp").mkdir(exist_ok=True) + report(state, venv) + + +if __name__ == "__main__": + main() diff --git a/tests/script/test_setup.py b/tests/script/test_setup.py new file mode 100644 index 00000000000..3e816c4b05d --- /dev/null +++ b/tests/script/test_setup.py @@ -0,0 +1,562 @@ +"""Tests for script/setup.py.""" + +import importlib.util +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import runpy +import sys +from types import ModuleType +from unittest.mock import Mock, call, patch + +import pytest + +_SCRIPT = Path(__file__).parents[2] / "script" / "setup.py" + + +def _load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("script_setup", _SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def script_setup() -> ModuleType: + """Fresh import of script/setup.py, isolated from other tests.""" + return _load_module() + + +# --- bin_dir / venv_python / activate_hint ----------------------------------- + + +def test_bin_dir_matches_host_layout(script_setup: ModuleType, tmp_path: Path) -> None: + """The venv scheme resolves to Scripts on Windows and bin everywhere else.""" + expected = "Scripts" if os.name == "nt" else "bin" + assert script_setup.bin_dir(tmp_path) == tmp_path / expected + + +# Both flavours are exercised on every host. Pure paths are used because a real +# Path refuses to change flavour: PosixPath cannot be built on Windows, and +# WindowsPath cannot be built on Unix. + + +def test_venv_python_posix(script_setup: ModuleType, tmp_path: Path) -> None: + with ( + patch.object( + script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin") + ), + patch.object(script_setup.os, "name", "posix"), + ): + result = script_setup.venv_python(tmp_path) + assert result == PurePosixPath("/x/venv/bin/python") + + +def test_venv_python_nt(script_setup: ModuleType, tmp_path: Path) -> None: + with ( + patch.object( + script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts") + ), + patch.object(script_setup.os, "name", "nt"), + ): + result = script_setup.venv_python(tmp_path) + assert result == PureWindowsPath(r"C:\x\venv\Scripts\python.exe") + + +def test_activate_hint_posix(script_setup: ModuleType) -> None: + with ( + patch.object(script_setup, "ROOT", PurePosixPath("/x")), + patch.object( + script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin") + ), + patch.object(script_setup.os, "name", "posix"), + ): + hint = script_setup.activate_hint() + assert hint == "source venv/bin/activate" + + +def test_activate_hint_nt(script_setup: ModuleType) -> None: + with ( + patch.object(script_setup, "ROOT", PureWindowsPath(r"C:\x")), + patch.object( + script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts") + ), + patch.object(script_setup.os, "name", "nt"), + ): + hint = script_setup.activate_hint() + # The nt branch returns str(activate) as-is, skipping the "source " prefix. + assert hint == r"venv\Scripts\activate" + + +# --- run ----------------------------------------------------------------- + + +def test_run_success(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run: + script_setup.run(["echo", "hi"]) + mock_run.assert_called_once_with( + ["echo", "hi"], cwd=script_setup.ROOT, env=None, check=False + ) + + +def test_run_failure_raises_system_exit_with_code( + script_setup: ModuleType, capsys: pytest.CaptureFixture[str] +) -> None: + with ( + patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=7)), + pytest.raises(SystemExit) as excinfo, + ): + script_setup.run(["false"]) + assert excinfo.value.code == 7 + assert "Failed with exit code 7: false" in capsys.readouterr().err + + +# --- git_output ------------------------------------------------------------ + + +def test_git_output_success_strips_stdout(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, + "run", + return_value=Mock(returncode=0, stdout=" /repo/.git \n"), + ) as mock_run: + result = script_setup.git_output("rev-parse", "--absolute-git-dir") + assert result == "/repo/.git" + mock_run.assert_called_once_with( + ["git", "rev-parse", "--absolute-git-dir"], + cwd=script_setup.ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_git_output_nonzero_returncode_is_empty(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, + "run", + return_value=Mock(returncode=1, stdout="whatever"), + ): + assert script_setup.git_output("status") == "" + + +def test_git_output_oserror_is_empty(script_setup: ModuleType) -> None: + with patch.object(script_setup.subprocess, "run", side_effect=OSError("no git")): + assert script_setup.git_output("status") == "" + + +# --- create_venv ----------------------------------------------------------- + + +def test_create_venv_uses_uv_when_present( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.create_venv(venv) + mock_run.assert_called_once_with( + ["/usr/bin/uv", "venv", "--clear", "--seed", str(venv)], + cwd=script_setup.ROOT, + env=None, + check=False, + ) + + +def test_create_venv_falls_back_to_venv_module( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + with ( + patch.object(script_setup.shutil, "which", return_value=None), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.create_venv(venv) + mock_run.assert_called_once_with( + [sys.executable, "-m", "venv", "--clear", str(venv)], + cwd=script_setup.ROOT, + env=None, + check=False, + ) + + +# --- venv_environment -------------------------------------------------------- + + +def test_venv_environment_sets_virtual_env_and_prepends_path( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + venv = tmp_path / "venv" + monkeypatch.setenv("PYTHONHOME", "/somewhere") + monkeypatch.setenv("PATH", "/usr/bin:/bin") + env = script_setup.venv_environment(venv) + assert env["VIRTUAL_ENV"] == str(venv) + assert "PYTHONHOME" not in env + expected_prefix = str(script_setup.bin_dir(venv)) + os.pathsep + assert env["PATH"] == expected_prefix + "/usr/bin:/bin" + + +def test_venv_environment_path_fallback_when_unset( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + venv = tmp_path / "venv" + monkeypatch.delenv("PATH", raising=False) + env = script_setup.venv_environment(venv) + # No trailing separator: an empty PATH entry means "search the cwd". + assert env["PATH"] == str(script_setup.bin_dir(venv)) + + +# --- find_uv ----------------------------------------------------------------- + + +def test_find_uv_found_immediately(script_setup: ModuleType, tmp_path: Path) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + result = script_setup.find_uv(venv, env) + assert result == "/usr/bin/uv" + mock_run.assert_not_called() + + +def test_find_uv_installed_then_found(script_setup: ModuleType, tmp_path: Path) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", side_effect=[None, "/usr/bin/uv"]), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + result = script_setup.find_uv(venv, env) + assert result == "/usr/bin/uv" + mock_run.assert_called_once_with( + [str(script_setup.venv_python(venv)), "-m", "pip", "install", "uv"], + cwd=script_setup.ROOT, + env=env, + check=False, + ) + + +def test_find_uv_still_missing_raises_system_exit( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", side_effect=[None, None]), + patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=0)), + pytest.raises(SystemExit, match="uv could not be installed"), + ): + script_setup.find_uv(venv, env) + + +# --- install_dependencies ----------------------------------------------------- + + +def test_install_dependencies_installs_setuptools_then_project( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_dependencies(venv, env) + assert mock_run.call_args_list == [ + call( + ["/usr/bin/uv", "pip", "install", "setuptools", "wheel"], + cwd=script_setup.ROOT, + env=env, + check=False, + ), + call( + [ + "/usr/bin/uv", + "pip", + "install", + "-e", + ".[dev,test]", + "--config-settings", + "editable_mode=compat", + ], + cwd=script_setup.ROOT, + env=env, + check=False, + ), + ] + + +# --- install_git_hooks --------------------------------------------------------- + + +def _fake_git_output(git_dir: str, common_dir: str): + def _run(*args: str) -> str: + if "--absolute-git-dir" in args: + return git_dir + return common_dir + + return _run + + +def test_install_git_hooks_returns_early_when_git_dir_empty( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, "git_output", side_effect=_fake_git_output("", "/repo/.git") + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_returns_early_when_common_dir_empty( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, "git_output", side_effect=_fake_git_output("/repo/.git", "") + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_returns_early_for_worktree( + script_setup: ModuleType, +) -> None: + """A worktree's git-dir differs from the shared common-dir.""" + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output("/repo/.git/worktrees/wt", "/repo/.git"), + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_missing_prek_raises_system_exit( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output("/repo/.git", "/repo/.git"), + ), + patch.object(script_setup.shutil, "which", return_value=None), + patch.object(script_setup.subprocess, "run") as mock_run, + pytest.raises(SystemExit, match="prek was not installed"), + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_happy_path_installs_hook( + script_setup: ModuleType, tmp_path: Path +) -> None: + env = {"PATH": "/usr/bin"} + common_dir = tmp_path / "repo" / ".git" + hooks_dir = common_dir / "hooks" + hooks_dir.mkdir(parents=True) + source_hook = tmp_path / "post-checkout" + source_hook.write_text("#!/bin/sh\necho post-checkout\n") + + with ( + patch.object(script_setup, "POST_CHECKOUT_HOOK", source_hook), + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output(str(common_dir), str(common_dir)), + ), + patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_git_hooks(env) + + mock_run.assert_called_once_with( + ["/usr/bin/prek", "install", "--overwrite"], + cwd=script_setup.ROOT, + env=env, + check=False, + ) + installed = hooks_dir / "post-checkout" + assert installed.read_text() == source_hook.read_text() + if os.name != "nt": + # Windows has no POSIX permission bits for chmod to set. + assert (installed.stat().st_mode & 0o777) == 0o755 + + +def test_install_git_hooks_skips_copy_when_hooks_dir_missing( + script_setup: ModuleType, tmp_path: Path +) -> None: + """The prek install still runs when the hooks directory does not exist.""" + env = {"PATH": "/usr/bin"} + common_dir = tmp_path / "repo" / ".git" + common_dir.mkdir(parents=True) # no "hooks" subdirectory created + + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output(str(common_dir), str(common_dir)), + ), + patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_git_hooks(env) + + mock_run.assert_called_once() + assert not (common_dir / "hooks").exists() + + +# --- report ------------------------------------------------------------------ + + +def test_report_active_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + venv = Path("/opt/esphome-venv") + script_setup.report(script_setup.VENV_ACTIVE, venv) + out = capsys.readouterr().out + assert "Dependencies installed into the active virtual environment:" in out + assert str(venv) in out + assert "is already active in this shell" in out + + +def test_report_reused_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + script_setup.report(script_setup.VENV_REUSED, script_setup.DEFAULT_VENV) + out = capsys.readouterr().out + assert "Dependencies updated in the existing ./venv" in out + + +def test_report_created_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + script_setup.report(script_setup.VENV_CREATED, script_setup.DEFAULT_VENV) + out = capsys.readouterr().out + assert "Virtual environment created at ./venv" in out + + +# --- main -------------------------------------------------------------------- + + +def test_main_raises_system_exit_when_python_too_old( + script_setup: ModuleType, +) -> None: + with ( + patch.object(script_setup.sys, "version_info", (3, 11, 5)), + pytest.raises(SystemExit, match="ESPHome needs Python 3.12"), + ): + script_setup.main() + + +def test_main_uses_active_virtual_env( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + active_venv = tmp_path / "active-venv" + monkeypatch.setenv("VIRTUAL_ENV", str(active_venv)) + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_not_called() + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_ACTIVE, active_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_main_reuses_existing_venv( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + default_venv = tmp_path / "venv" + python_path = script_setup.venv_python(default_venv) + python_path.parent.mkdir(parents=True) + python_path.touch() + + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "DEFAULT_VENV", default_venv), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_not_called() + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_REUSED, default_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_main_creates_new_venv( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + default_venv = tmp_path / "venv" # does not exist yet + + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "DEFAULT_VENV", default_venv), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_called_once_with(default_venv) + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_CREATED, default_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_run_as_script_calls_main(tmp_path: Path) -> None: + """The __main__ guard runs the whole flow, with every side effect stubbed.""" + completed = Mock(returncode=0, stdout="") + with ( + patch("subprocess.run", return_value=completed) as mock_run, + patch("shutil.which", return_value="/usr/bin/uv"), + patch("pathlib.Path.mkdir") as mock_mkdir, + patch.dict(os.environ, {"VIRTUAL_ENV": str(tmp_path / "env")}), + ): + runpy.run_path(str(_SCRIPT), run_name="__main__") + + # The dependency install ran, and git reported no hooks directory to touch. + assert mock_run.called + mock_mkdir.assert_called_once_with(exist_ok=True) From 4868b498cf80cf6fb6c59544a84797f74736bbfe Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:09:58 +1200 Subject: [PATCH 033/266] [ci] Ask stale PR authors to merge dev instead of rebasing (#19064) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index aa31094f81d..38d2418ac62 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -33,7 +33,7 @@ jobs: and will be closed if no further activity occurs within 7 days. If you are the author of this PR, please leave a comment if you want - to keep it open. Also, please rebase your PR onto the latest dev + to keep it open. Also, please merge the latest dev branch into your branch to ensure that it's up to date with the latest changes. Thank you for your contribution! From 5ed59af9204af5e4a4638d10379375b598dd8ace Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:11:13 +1200 Subject: [PATCH 034/266] [template] Surface value metadata on template entity forms (#17545) --- .../template/binary_sensor/__init__.py | 14 +++- .../components/template/button/__init__.py | 6 +- esphome/components/template/cover/__init__.py | 7 +- esphome/components/template/event/__init__.py | 6 +- .../components/template/number/__init__.py | 9 ++- .../components/template/sensor/__init__.py | 22 +++++- .../components/template/switch/__init__.py | 7 +- .../template/text_sensor/__init__.py | 8 +- esphome/components/template/valve/__init__.py | 7 +- esphome/config_validation.py | 32 ++++++++ .../template/test_template_visibility.py | 76 +++++++++++++++++++ tests/unit_tests/test_config_validation.py | 29 +++++++ 12 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 tests/component_tests/template/test_template_visibility.py diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 8f57df91c51..07028f7dffc 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -2,7 +2,13 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import CONF_CONDITION, CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import ( + CONF_CONDITION, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_LAMBDA, + CONF_STATE, +) from esphome.cpp_generator import LambdaExpression from .. import template_ns @@ -12,7 +18,11 @@ TemplateBinarySensor = template_ns.class_( ) CONFIG_SCHEMA = ( - binary_sensor.binary_sensor_schema(TemplateBinarySensor) + cv.with_visibility( + binary_sensor.binary_sensor_schema(TemplateBinarySensor), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Exclusive(CONF_LAMBDA, CONF_CONDITION): cv.returning_lambda, diff --git a/esphome/components/template/button/__init__.py b/esphome/components/template/button/__init__.py index e0101dfc8f3..9c6fa13c19b 100644 --- a/esphome/components/template/button/__init__.py +++ b/esphome/components/template/button/__init__.py @@ -1,10 +1,14 @@ from esphome.components import button +import esphome.config_validation as cv +from esphome.const import CONF_DEVICE_CLASS from .. import template_ns TemplateButton = template_ns.class_("TemplateButton", button.Button) -CONFIG_SCHEMA = button.button_schema(TemplateButton) +CONFIG_SCHEMA = cv.with_visibility( + button.button_schema(TemplateButton), cv.Visibility.UI, CONF_DEVICE_CLASS +) async def to_code(config): diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 7cb50df84c5..0e6f96e9f5b 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_ASSUMED_STATE, CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, @@ -38,7 +39,11 @@ CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" CONFIG_SCHEMA = ( - cover.cover_schema(TemplateCover) + cv.with_visibility( + cover.cover_schema(TemplateCover), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, diff --git a/esphome/components/template/event/__init__.py b/esphome/components/template/event/__init__.py index cf9c7f4c3df..bdcbd456d5d 100644 --- a/esphome/components/template/event/__init__.py +++ b/esphome/components/template/event/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import event import esphome.config_validation as cv -from esphome.const import CONF_EVENT_TYPES +from esphome.const import CONF_DEVICE_CLASS, CONF_EVENT_TYPES from .. import template_ns @@ -9,7 +9,9 @@ CODEOWNERS = ["@nohat"] TemplateEvent = template_ns.class_("TemplateEvent", event.Event, cg.Component) -CONFIG_SCHEMA = event.event_schema(TemplateEvent).extend( +CONFIG_SCHEMA = cv.with_visibility( + event.event_schema(TemplateEvent), cv.Visibility.UI, CONF_DEVICE_CLASS +).extend( { cv.Required(CONF_EVENT_TYPES): cv.ensure_list(cv.string_strict), } diff --git a/esphome/components/template/number/__init__.py b/esphome/components/template/number/__init__.py index 2f4c9cbffe6..3b6485fec3d 100644 --- a/esphome/components/template/number/__init__.py +++ b/esphome/components/template/number/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, CONF_ID, CONF_INITIAL_VALUE, CONF_LAMBDA, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RESTORE_VALUE, CONF_SET_ACTION, CONF_STEP, + CONF_UNIT_OF_MEASUREMENT, ) from .. import template_ns @@ -46,7 +48,12 @@ def validate(config): CONFIG_SCHEMA = cv.All( - number.number_schema(TemplateNumber) + cv.with_visibility( + number.number_schema(TemplateNumber), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + ) .extend( { cv.Required(CONF_MAX_VALUE): cv.float_, diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index 0c875bba0fb..55537a56369 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -2,7 +2,16 @@ from esphome import automation import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_ID, + CONF_LAMBDA, + CONF_STATE, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) from .. import template_ns @@ -11,9 +20,14 @@ TemplateSensor = template_ns.class_( ) CONFIG_SCHEMA = ( - sensor.sensor_schema( - TemplateSensor, - accuracy_decimals=1, + cv.with_visibility( + sensor.sensor_schema(TemplateSensor, accuracy_decimals=1), + cv.Visibility.UI, + CONF_UNIT_OF_MEASUREMENT, + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_FORCE_UPDATE, ) .extend( { diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index ca986365ede..37303abb0d7 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -4,6 +4,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import ( CONF_ASSUMED_STATE, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC, @@ -31,7 +32,11 @@ def validate(config): CONFIG_SCHEMA = cv.All( - switch.switch_schema(TemplateSwitch) + cv.with_visibility( + switch.switch_schema(TemplateSwitch), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, diff --git a/esphome/components/template/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index ddbdd6dadb7..77f5c2ff7cf 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import text_sensor from esphome.components.text_sensor import TextSensorPublishAction import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE from .. import template_ns @@ -12,7 +12,11 @@ TemplateTextSensor = template_ns.class_( ) CONFIG_SCHEMA = ( - text_sensor.text_sensor_schema() + cv.with_visibility( + text_sensor.text_sensor_schema(), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.GenerateID(): cv.declare_id(TemplateTextSensor), diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index a2d0c198805..11b35dad23a 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_ASSUMED_STATE, CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, @@ -36,7 +37,11 @@ CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" CONFIG_SCHEMA = ( - valve.valve_schema(TemplateValve) + cv.with_visibility( + valve.valve_schema(TemplateValve), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 685a9d04b3f..a38fb2ed82c 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import contextmanager, suppress +import copy from datetime import datetime from ipaddress import ( AddressValueError, @@ -419,6 +420,37 @@ class Required(vol.Required): self.visibility: Visibility | None = visibility +def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schema: + """Return a copy of ``schema`` with the given ``keys`` re-marked at ``visibility``. + + Lets a platform override the editor :class:`Visibility` of fields it + inherits from a shared schema builder — without that builder needing a + visibility parameter of its own. The canonical use is a ``template`` + platform promoting the value metadata its user is expected to define + (``device_class``, ``unit_of_measurement``, …) onto the main form: + + CONFIG_SCHEMA = cv.with_visibility( + sensor.sensor_schema(TemplateSensor), + cv.Visibility.UI, + CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT, + ) + + The original marker's key, default and validator are preserved; only the + visibility changes, and the input ``schema`` is left untouched. Raises if + a requested key is not present so typos fail at schema-build time. + """ + wanted = {str(k) for k in keys} + overrides = {} + for marker, validator in schema.schema.items(): + if str(marker) in wanted: + marker = copy.copy(marker) + marker.visibility = visibility + overrides[marker] = validator + if missing := wanted - {str(m) for m in overrides}: + raise ValueError(f"with_visibility: keys not in schema: {sorted(missing)}") + return schema.extend(overrides) + + class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" diff --git a/tests/component_tests/template/test_template_visibility.py b/tests/component_tests/template/test_template_visibility.py new file mode 100644 index 00000000000..a50a27e1f7b --- /dev/null +++ b/tests/component_tests/template/test_template_visibility.py @@ -0,0 +1,76 @@ +"""The template platforms surface value-describing metadata on the main form. + +Hardware platforms get sensible defaults for unit/device_class/etc., so those +fields fall through to the editor's advanced disclosure. A ``template`` entity +has no such defaults -- the user is expected to define them -- so the template +platforms pass ``visibility=cv.Visibility.UI`` to promote them onto the form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +@pytest.mark.parametrize( + ("platform", "fields"), + [ + ( + "sensor", + [ + "unit_of_measurement", + "accuracy_decimals", + "device_class", + "state_class", + "force_update", + ], + ), + ("binary_sensor", ["device_class"]), + ("switch", ["device_class"]), + ("cover", ["device_class"]), + ("button", ["device_class"]), + ("valve", ["device_class"]), + ("event", ["device_class"]), + ("text_sensor", ["device_class"]), + ("number", ["device_class", "unit_of_measurement"]), + ], +) +def test_template_metadata_is_ui(platform: str, fields: list[str]) -> None: + mod = importlib.import_module(f"esphome.components.template.{platform}") + markers = _markers(mod.CONFIG_SCHEMA) + for field in fields: + assert markers[field].visibility is cv.Visibility.UI, f"{platform}.{field}" + + +def test_template_sensor_promotion_preserves_defaults() -> None: + """Promoting to UI must not drop the fields' defaults.""" + from esphome.components.template.sensor import CONFIG_SCHEMA + + markers = _markers(CONFIG_SCHEMA) + assert markers["accuracy_decimals"].default() == 1 + assert markers["force_update"].default() is False + + +def test_hardware_platform_metadata_not_promoted() -> None: + """Without ``visibility=`` the builders leave metadata unset. + + Unset markers fall through to the consumer's ``Optional`` default of + advanced, so hardware platforms are unaffected by the template promotion. + """ + from esphome.components import binary_sensor, sensor + + hw_sensor = _markers(sensor.sensor_schema(device_class="temperature")) + assert hw_sensor["device_class"].visibility is None + hw_bs = _markers(binary_sensor.binary_sensor_schema(device_class="motion")) + assert hw_bs["device_class"].visibility is None diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 457b9d017b8..4092b4c0d5c 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1394,6 +1394,35 @@ def test_entity_metadata_visibility_hints() -> None: assert web["web_server"].visibility is advanced +def test_with_visibility_remarks_keys() -> None: + """``with_visibility`` re-marks the named keys, preserving each field's + default and validator, without touching the other keys or the input schema. + """ + base = cv.Schema( + { + cv.Optional("a", default=7): cv.int_, + cv.Optional("b", visibility=cv.Visibility.ADVANCED): cv.string, + } + ) + promoted = cv.with_visibility(base, cv.Visibility.UI, "a") + + pm = {str(k): k for k in promoted.schema} + assert pm["a"].visibility is cv.Visibility.UI # re-marked + assert pm["a"].default() == 7 # default preserved + assert pm["b"].visibility is cv.Visibility.ADVANCED # sibling untouched + assert promoted({}) == {"a": 7} # validator/default still applied + + # The input schema is left untouched (no shared-marker mutation). + assert {str(k): k for k in base.schema}["a"].visibility is None + + +def test_with_visibility_unknown_key_raises() -> None: + """A key not present in the schema is a typo — fail at build time.""" + base = cv.Schema({cv.Optional("a"): cv.int_}) + with pytest.raises(ValueError, match="not in schema"): + cv.with_visibility(base, cv.Visibility.UI, "nope") + + def _wrap_str(value: str) -> ESPHomeDataBase: """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" return make_data_base(value) From ddbd89dd2a5bb35b24217dae93a4a45ce5da476f Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Thu, 10 Sep 2026 06:06:44 +0200 Subject: [PATCH 035/266] [network] Improve `network::is_connected()` to better handle multiple interfaces (#18999) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/network/util.h | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 65a578c22ff..57c5a66833b 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -26,30 +26,34 @@ namespace esphome::network { /// Return whether the node is connected to the network (through wifi, eth, ...) ESPHOME_ALWAYS_INLINE inline bool is_connected() { + // With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which + // clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked. + // NOLINTBEGIN(readability-simplify-boolean-expr) #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) return true; #endif #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_connected(); + if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected()) + return true; #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_connected(); + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) + return true; #endif #ifdef USE_OPENTHREAD - if (openthread::global_openthread_component != nullptr) - return openthread::global_openthread_component->is_connected(); + if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) + return true; #endif #ifdef USE_HOST return true; // Assume it's connected #endif return false; + // NOLINTEND(readability-simplify-boolean-expr) } /// Return whether the network is disabled: every configured interface with a From 05f7d5e4f1b1d5ff14f0d4c30ce984ed319ca112 Mon Sep 17 00:00:00 2001 From: Anton Sergunov Date: Thu, 10 Sep 2026 10:14:43 +0600 Subject: [PATCH 036/266] [mlx90614] pec validation (#6689) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/mlx90614/mlx90614.cpp | 185 +++++++++++++++++------ esphome/components/mlx90614/mlx90614.h | 7 +- 2 files changed, 145 insertions(+), 47 deletions(-) diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 2d3b6631bc6..508b3743d18 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -26,44 +26,129 @@ static const uint8_t MLX90614_ID4 = 0x3F; static const char *const TAG = "mlx90614"; +// The EEPROM cell has a limited number of write cycles, so stop retrying after a few failures +static constexpr uint8_t EMISSIVITY_WRITE_ATTEMPTS = 3; + +// SMBus packet error code: CRC-8 with polynomial 0x07, MSB first +static uint8_t crc8_pec(const uint8_t *data, uint8_t len) { return crc8(data, len, 0x00, 0x07, true); } + void MLX90614Component::setup() { - if (!this->write_emissivity_()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + if (std::isnan(this->emissivity_)) { return; } + this->emissivity_write_attempts_ = EMISSIVITY_WRITE_ATTEMPTS; + this->try_write_emissivity_(); + if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } +} + +void MLX90614Component::try_write_emissivity_() { + if (this->emissivity_write_attempts_ == 0) { + return; + } + if (this->write_emissivity_()) { + this->emissivity_write_attempts_ = 0; + return; + } + if (--this->emissivity_write_attempts_ == 0) { + ESP_LOGE(TAG, "Giving up on writing emissivity after %u attempts", EMISSIVITY_WRITE_ATTEMPTS); + this->emissivity_write_failed_ = true; + } } bool MLX90614Component::write_emissivity_() { - if (std::isnan(this->emissivity_)) + // Skip the write when the EEPROM already holds the desired value to save write cycles + uint16_t current_emissivity; + if (this->read_register_(MLX90614_EMISSIVITY, current_emissivity) != i2c::ERROR_OK) { + return false; + } + + const auto desired_emissivity = static_cast(this->emissivity_ * 0xFFFF); + if (current_emissivity == desired_emissivity) { return true; - uint16_t value = (uint16_t) (this->emissivity_ * 65535); - if (!this->write_bytes_(MLX90614_EMISSIVITY, 0)) { - return false; } - delay(10); - if (!this->write_bytes_(MLX90614_EMISSIVITY, value)) { - return false; - } - delay(10); - return true; + + return this->write_register_(MLX90614_EMISSIVITY, desired_emissivity); } -bool MLX90614Component::write_bytes_(uint8_t reg, uint16_t data) { +bool MLX90614Component::write_register_(uint8_t reg, uint16_t data) { + // The PEC covers the whole write transaction: SLA+W, command, data low, data high uint8_t buf[5]; buf[0] = this->address_ << 1; buf[1] = reg; - buf[2] = data & 0xFF; - buf[3] = data >> 8; - buf[4] = crc8(buf, 4, 0x00, 0x07, true); - return this->write_bytes(reg, buf + 2, 3); + + // See datasheet 8.3.3.1 EEPROM write sequence + // 1. Write 0x0000 into the cell of interest (erases the cell) + buf[2] = buf[3] = 0; + buf[4] = crc8_pec(buf, 4); + auto ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't erase register 0x%02X, error %d", reg, ec); + return false; + } + + // 2. Wait at least 5ms + delay(10); + + // 3. Write the new value + if (data != 0) { + buf[2] = data & 0xFF; + buf[3] = data >> 8; + buf[4] = crc8_pec(buf, 4); + ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't write register 0x%02X, error %d", reg, ec); + return false; + } + // 4. Wait at least 5ms + delay(10); + } + + // 5. Read back to confirm the value was stored + uint16_t read_back; + ec = this->read_register_(reg, read_back); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't check register 0x%02X value, error %d", reg, ec); + return false; + } + + if (read_back != data) { + ESP_LOGW(TAG, "Read back mismatch on register 0x%02X. Expected 0x%04X, got 0x%04X", reg, data, read_back); + return false; + } + + return true; +} + +i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) { + // The PEC covers the whole read transaction: SLA+W, command, SLA+R, data low, data high + uint8_t buf[6]; + buf[0] = this->address_ << 1; + buf[1] = reg; + buf[2] = (this->address_ << 1) | 0x01; + + const auto ec = this->read_register(reg, buf + 3, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "i2c read error %d", ec); + return ec; + } + + const auto expected_pec = crc8_pec(buf, 5); + if (buf[5] != expected_pec) { + ESP_LOGW(TAG, "i2c CRC error. Expected 0x%02X, got 0x%02X", expected_pec, buf[5]); + return i2c::ERROR_CRC; + } + + data = encode_uint16(buf[4], buf[3]); + return i2c::ERROR_OK; } void MLX90614Component::dump_config() { ESP_LOGCONFIG(TAG, "MLX90614:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->emissivity_write_attempts_ != 0) { + ESP_LOGW(TAG, " Emissivity not written yet, will retry"); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Ambient", this->ambient_sensor_); @@ -71,33 +156,41 @@ void MLX90614Component::dump_config() { } void MLX90614Component::update() { - uint8_t emissivity[3]; - if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; + // Temperature reads run regardless of the emissivity state so a failure still shows up as NAN + this->try_write_emissivity_(); + + // Publishes NAN on a bus or CRC failure so a stuck reading is visible instead of silently stale + auto publish_sensor = [this](sensor::Sensor *sensor, uint8_t reg) { + if (sensor == nullptr) { + return i2c::ERROR_OK; + } + + uint16_t raw; + const auto ec = this->read_register_(reg, raw); + if (ec != i2c::ERROR_OK) { + sensor->publish_state(NAN); + return ec; + } + + // Bit 15 set means the device flagged the reading as invalid + const float temperature = (raw & 0x8000) ? NAN : raw * 0.02f - 273.15f; + ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", sensor->get_name().c_str(), temperature); + sensor->publish_state(temperature); + return ec; + }; + + const auto object_ec = publish_sensor(this->object_sensor_, MLX90614_TEMPERATURE_OBJECT_1); + const auto ambient_ec = publish_sensor(this->ambient_sensor_, MLX90614_TEMPERATURE_AMBIENT); + + if (object_ec != i2c::ERROR_OK || ambient_ec != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read some sensors")); + } else if (this->emissivity_write_failed_) { + this->status_set_warning(LOG_STR("Failed to write emissivity")); + } else if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } else { + this->status_clear_warning(); } - uint8_t raw_object[3]; - if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - uint8_t raw_ambient[3]; - if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - float ambient = raw_ambient[1] & 0x80 ? NAN : encode_uint16(raw_ambient[1], raw_ambient[0]) * 0.02f - 273.15f; - float object = raw_object[1] & 0x80 ? NAN : encode_uint16(raw_object[1], raw_object[0]) * 0.02f - 273.15f; - - ESP_LOGD(TAG, "Got Temperature=%.1f°C Ambient=%.1f°C", object, ambient); - - if (this->ambient_sensor_ != nullptr && !std::isnan(ambient)) - this->ambient_sensor_->publish_state(ambient); - if (this->object_sensor_ != nullptr && !std::isnan(object)) - this->object_sensor_->publish_state(object); - this->status_clear_warning(); } } // namespace esphome::mlx90614 diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 882ee45186a..758792acede 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -18,13 +18,18 @@ class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { void set_emissivity(float emissivity) { emissivity_ = emissivity; } protected: + void try_write_emissivity_(); bool write_emissivity_(); - bool write_bytes_(uint8_t reg, uint16_t data); + bool write_register_(uint8_t reg, uint16_t data); + i2c::ErrorCode read_register_(uint8_t reg, uint16_t &data); sensor::Sensor *ambient_sensor_{nullptr}; sensor::Sensor *object_sensor_{nullptr}; float emissivity_{NAN}; + // Remaining attempts to program the emissivity EEPROM cell, bounded to limit cell wear + uint8_t emissivity_write_attempts_{0}; + bool emissivity_write_failed_{false}; }; } // namespace esphome::mlx90614 From 54706e869c13abc0f688a91c0f326c779799b858 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:15 +0200 Subject: [PATCH 037/266] [deep_sleep] disable loop (#18962) --- esphome/components/deep_sleep/deep_sleep_bk72xx.cpp | 2 +- esphome/components/deep_sleep/deep_sleep_component.cpp | 3 ++- esphome/components/deep_sleep/deep_sleep_component.h | 5 +++++ esphome/components/deep_sleep/deep_sleep_esp32.cpp | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 2c97dc32114..a955095875b 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -44,7 +44,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep..."); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } } diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 9a3e537e051..d33102bf4fa 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -17,6 +17,7 @@ void DeepSleepComponent::setup() { void DeepSleepComponent::schedule_sleep_() { this->next_enter_deep_sleep_ = false; + this->disable_loop(); const optional run_duration = get_run_duration_(); if (run_duration.has_value()) { ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration); @@ -45,7 +46,7 @@ void DeepSleepComponent::loop() { void DeepSleepComponent::begin_sleep(bool manual) { if (this->prevent_ && !manual) { - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 208f88d7074..0bbca4c5c4d 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -190,6 +190,11 @@ class DeepSleepComponent final : public Component { void schedule_sleep_(); bool should_teardown_(); + void defer_sleep_() { + this->next_enter_deep_sleep_ = true; + this->enable_loop(); + } + #ifdef USE_BK72XX bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 3fa1a1f1edf..20297028b2c 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -100,7 +100,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGW(TAG, "Waiting for wakeup pin state change"); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } return true; From a88ec7d90b6b19813f2e06bb012c7192b04b3c73 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:07:09 +0200 Subject: [PATCH 038/266] [logger] Flush uart before sleep in idf 6 (#18975) --- esphome/components/logger/logger_esp32.cpp | 13 +++++++++++-- sdkconfig.defaults | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index c3d777299d9..8579708559d 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -3,6 +3,7 @@ #include "esphome/components/esp32/crash_handler.h" #include +#include #include #include @@ -16,8 +17,10 @@ #include #endif #endif - -#include "esp_idf_version.h" +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) +#include "esp_sleep.h" +#endif #include "freertos/FreeRTOS.h" #include @@ -87,6 +90,12 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { // ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes). const int min_rx_buffer_size = UART_HW_FIFO_LEN(uart_num) + 1; uart_driver_install(uart_num, min_rx_buffer_size, tx_buffer_size, 0, nullptr, 0); +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) + // Always flush before going to light sleep. Could be disabled for devices + // without TOP_PD or if source_clk = UART_SCLK_RTC + esp_sleep_set_console_uart_handling_mode(ESP_SLEEP_ALWAYS_FLUSH_UART); +#endif } void Logger::pre_setup() { diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 2bd702f48e5..f4fe331df45 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -17,6 +17,8 @@ CONFIG_ESP_TASK_WDT_INIT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n +CONFIG_FREERTOS_USE_TICKLESS_IDLE=y +CONFIG_PM_ENABLE=y # esp32_ble CONFIG_BT_ENABLED=y From 66f829c760358a291a9a97d90f9b981d8ac6a6ec Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:08:07 +0200 Subject: [PATCH 039/266] [zigbee] wake loop on defer/set_timeout (#19050) --- esphome/components/zigbee/time/zigbee_time_zephyr.cpp | 2 ++ esphome/components/zigbee/zigbee_esp32.cpp | 5 ++++- esphome/components/zigbee/zigbee_zephyr.cpp | 6 ++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 92d238629a1..3f14d0a62d2 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,6 +1,7 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" +#include "esphome/core/application.h" namespace esphome::zigbee { @@ -47,6 +48,7 @@ void ZigbeeTime::set_epoch_time(uint32_t epoch) { this->synchronize_epoch_(epoch); this->has_time_ = true; }); + App.wake_loop_threadsafe(); } void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index cd094306f43..4f9c70da75c 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -49,7 +49,8 @@ void ZigbeeComponent::factory_reset() { void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + global_zigbee->set_timeout("zb_init", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + App.wake_loop_threadsafe(); return; } if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { @@ -88,6 +89,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { global_zigbee->set_timeout("zb_init", 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); + App.wake_loop_threadsafe(); } } break; case EZB_BDB_SIGNAL_STEERING: { @@ -113,6 +115,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } + App.wake_loop_threadsafe(); } } break; case EZB_ZDO_SIGNAL_LEAVE: { diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index b8bb0a20369..286c83b8f56 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -1,10 +1,10 @@ #include "zigbee_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) #include "esphome/core/log.h" +#include "esphome/core/application.h" #include #include #include "esphome/core/hal.h" -#include "esphome/core/wake.h" extern "C" { #include @@ -120,7 +120,7 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; - esphome::wake_loop_threadsafe(); + App.wake_loop_threadsafe(); // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { @@ -138,6 +138,7 @@ void ZigbeeComponent::on_join_(bool factory_new) { ESP_LOGD(TAG, "Joined the network"); this->join_cb_.call(factory_new); }); + App.wake_loop_threadsafe(); } void ZigbeeComponent::on_start_() { @@ -145,6 +146,7 @@ void ZigbeeComponent::on_start_() { ESP_LOGD(TAG, "Started zigbee stack"); this->start_cb_.call(); }); + App.wake_loop_threadsafe(); } #ifdef USE_ZIGBEE_WIPE_ON_BOOT From 99241483026d54d47f8f06bdd2415e02c0cd3ebb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:59:23 +0000 Subject: [PATCH 040/266] Bump aioesphomeapi from 46.3.0 to 46.4.0 (#19071) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dfddbed00bf..72c42dad32f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.4.0 click==8.3.3 -aioesphomeapi==46.3.0 +aioesphomeapi==46.4.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 From 280fac11e6a8b571f6859dc4f9203e470cbbf1d4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 10 Sep 2026 10:03:33 -0500 Subject: [PATCH 041/266] [serial_proxy] Add tap interface and port mode (#18955) Co-authored-by: puddly <32534428+puddly@users.noreply.github.com> --- esphome/components/api/api.proto | 37 +++- esphome/components/api/api_connection.cpp | 16 +- esphome/components/api/api_connection.h | 1 + esphome/components/api/api_pb2.cpp | 13 ++ esphome/components/api/api_pb2.h | 21 ++ esphome/components/api/api_pb2_dump.cpp | 18 ++ esphome/components/api/api_pb2_service.cpp | 11 ++ esphome/components/api/api_pb2_service.h | 3 + esphome/components/serial_proxy/__init__.py | 1 + .../components/serial_proxy/serial_proxy.cpp | 182 +++++++++++++++--- .../components/serial_proxy/serial_proxy.h | 103 +++++++++- esphome/core/defines.h | 1 + .../components/serial_proxy/serial_proxy.h | 3 + .../serial_proxy/test-tap.esp32-idf.yaml | 14 ++ 14 files changed, 394 insertions(+), 30 deletions(-) create mode 100644 tests/components/serial_proxy/test-tap.esp32-idf.yaml diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 3a0e0abea96..21972decad1 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -77,6 +77,7 @@ service APIConnection { rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} + rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} } @@ -2726,7 +2727,8 @@ enum SerialProxyParity { SERIAL_PROXY_PARITY_ODD = 2; } -// Configure UART parameters for a serial proxy instance +// Configure UART parameters for a serial proxy instance. Only the subscribed client may +// configure the port; others are refused with PORT_IN_USE (since API 1.17). message SerialProxyConfigureRequest { option (id) = 138; option (source) = SOURCE_CLIENT; @@ -2752,7 +2754,8 @@ message SerialProxyDataReceived { bytes data = 2; // Raw data received from the serial device } -// Write data to a serial device +// Write data to a serial device. Only the subscribed client may write; writes from +// others are ignored (since API 1.17). message SerialProxyWriteRequest { option (id) = 140; option (source) = SOURCE_CLIENT; @@ -2763,7 +2766,8 @@ message SerialProxyWriteRequest { bytes data = 2; // Raw data to write to the serial device } -// Set modem control pin states (RTS and DTR) +// Set modem control pin states (RTS and DTR). Only the subscribed client may set them; +// others are refused with PORT_IN_USE (since API 1.17). message SerialProxySetModemPinsRequest { option (id) = 141; option (source) = SOURCE_CLIENT; @@ -2802,6 +2806,7 @@ enum SerialProxyRequestType { // error the device answers with INVALID_ARGUMENT. SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17) } enum SerialProxyStatus { @@ -2814,7 +2819,8 @@ enum SerialProxyStatus { SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value } -// Generic request message for simple serial proxy operations +// Generic request message for simple serial proxy operations. FLUSH requires an active +// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17). message SerialProxyRequest { option (id) = 144; option (source) = SOURCE_CLIENT; @@ -2838,6 +2844,29 @@ message SerialProxyRequestResponse { string error_message = 4; // Additional detail on failure (optional) } +// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL +// activates the port's protocol-aware tap (if one is configured), letting it observe +// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap +// speaks is a property of the device configuration, discoverable from the tap +// component's own API surface. A client that is about to flash firmware selects RAW +// first, which definitively disables that injection. +enum SerialProxyMode { + SERIAL_PROXY_MODE_RAW = 0; + SERIAL_PROXY_MODE_PROTOCOL = 1; +} + +// Only the subscribed client may change the mode; any other caller -- including one that +// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED +// when the port has no protocol-aware tap configured. +message SerialProxySetModeRequest { + option (id) = 152; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyMode mode = 2; +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index da4b7d7702f..d910f6fc67a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1661,6 +1661,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { break; case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE: case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: // Response-only discriminators; never valid in a request ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast(msg.type)); status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; @@ -1673,6 +1674,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { send_serial_proxy_ack(this, msg.instance, msg.type, status); } +void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); + return; + } + serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + serial_proxy_result_to_status(result)); +} + void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { if (!this->send_message(msg)) { ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); @@ -1799,7 +1813,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 16; + resp.api_version_minor = 17; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index a4c49dccf40..c19a33ca9bb 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -244,6 +244,7 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); void on_serial_proxy_request(const SerialProxyRequest &msg); + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg); void send_serial_proxy_data(const SerialProxyDataReceived &msg); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2de1f0a15ce..7f162d9c159 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4253,6 +4253,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->error_message.size()); return size; } +bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->instance = value; + break; + case 2: + this->mode = static_cast(value); + break; + default: + return false; + } + return true; +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5c3429a63aa..799aaa27b55 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -356,6 +356,7 @@ enum SerialProxyRequestType : uint32_t { SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2, SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3, SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4, + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5, }; enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_OK = 0, @@ -366,6 +367,10 @@ enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_PORT_IN_USE = 5, SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6, }; +enum SerialProxyMode : uint32_t { + SERIAL_PROXY_MODE_RAW = 0, + SERIAL_PROXY_MODE_PROTOCOL = 1, +}; #endif } // namespace enums @@ -3403,6 +3408,22 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; +class SerialProxySetModeRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 152; + static constexpr uint8_t ESTIMATED_SIZE = 6; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); } +#endif + uint32_t instance{0}; + enums::SerialProxyMode mode{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; +}; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index dced81ee307..bb244973a11 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -854,6 +854,8 @@ template<> const char *proto_enum_to_string(enums return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE"); case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS"); + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: + return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -878,6 +880,16 @@ template<> const char *proto_enum_to_string(enums::Ser return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyMode value) { + switch (value) { + case enums::SERIAL_PROXY_MODE_RAW: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW"); + case enums::SERIAL_PROXY_MODE_PROTOCOL: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif const char *HelloRequest::dump_to(DumpBuffer &out) const { @@ -2805,6 +2817,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error_message"), this->error_message); return out.c_str(); } +const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 65c7b8858cc..172062be636 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -712,6 +712,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_device_capabilities_request(); break; } +#ifdef USE_SERIAL_PROXY + case SerialProxySetModeRequest::MESSAGE_TYPE: { + SerialProxySetModeRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg); +#endif + this->on_serial_proxy_set_mode_request(msg); + break; + } +#endif default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6abdf7093e1..a4dfd6a3663 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -235,6 +235,9 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; +#endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 4186fcf8b13..b6e780fabdc 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -30,6 +30,7 @@ MULTI_CONF = True serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy") SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice) +SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index c1c15106438..129745c1c91 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -29,26 +29,57 @@ void SerialProxy::setup() { #ifdef USE_API // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; +#endif +#ifdef USE_SERIAL_PROXY_TAP + // A tap sets itself up before this runs (its setup priority is higher), so it may + // already be waiting on the port -- a boot-time handshake with the device, say. Leaving + // the loop enabled is what lets that finish; without it the tap would stall until a + // client happened to subscribe. + if (this->tap_ != nullptr && this->tap_->tap_needs_port()) { + return; + } #endif // No subscriber at startup; disable loop until a client subscribes this->disable_loop(); } -void SerialProxy::loop() { -#ifdef USE_API - // Safety check — loop should only run when subscribed, but guard against races - if (this->api_connection_ == nullptr) [[unlikely]] { - this->disable_loop(); +#ifdef USE_SERIAL_PROXY_TAP +void SerialProxy::reset_mode_() { + // The mode belongs to a session, not to the port. Carrying a departed client's choice + // over to the next one would inject protocol bytes into a stream that never asked for + // them -- a firmware upload, or any client built before this request existed and so + // unable to turn it off. Guessing RAW is the safe direction: a client that wanted + // protocol handling and did not ask for it merely sends its own acknowledgements. + if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) { return; } + ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_); + this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW; +} +#endif +void SerialProxy::loop() { +#ifdef USE_API // Detect subscriber disconnect - if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() || - !api_is_connected()) { + if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() || + !this->api_connection_->is_connection_setup() || !api_is_connected())) { ESP_LOGW(TAG, "Subscriber disconnected"); this->api_connection_ = nullptr; + this->reset_mode_(); + } + + // With no subscriber there is normally nothing to do, but a tap may still need the port + // read -- it does its protocol work precisely while nobody else is listening. + if (this->api_connection_ == nullptr) [[unlikely]] { +#ifdef USE_SERIAL_PROXY_TAP + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + return; + } +#else this->disable_loop(); return; +#endif } // Read available data from UART and forward to subscribed client @@ -69,11 +100,54 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { if (!this->read_array(buffer, to_read)) return; +#ifdef USE_SERIAL_PROXY_TAP + // Before forwarding, so a tap that answers the device (an acknowledgement, say) is not + // waiting on the network round trip to a subscriber that may not even exist. + if (this->tap_observing_()) { + this->tap_->on_device_rx(buffer, to_read); + } +#endif + + if (this->api_connection_ == nullptr) { + return; + } this->outgoing_msg_.set_data(buffer, to_read); this->api_connection_->send_serial_proxy_data(this->outgoing_msg_); } #endif +#ifdef USE_SERIAL_PROXY_TAP + +bool SerialProxy::tap_observing_() const { + if (this->tap_ == nullptr) { + return false; + } + // With no subscriber, a tap doing its own protocol work (the boot-time handshake with + // the device, say) is served regardless of mode -- nobody has chosen one yet. Once a + // subscriber holds the port, the mode alone decides, so RAW stays inert. + if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) { + return true; + } + // Otherwise the mode decides. RAW must be inert: a client that flips to RAW before + // flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes + // into it, and "the tap turned out not to recognise the stream" is not good enough. + return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL; +} + +void SerialProxy::tap_pump() { +#ifdef USE_API + // Nothing would consume the bytes; leave them in the FIFO + if (!this->tap_observing_() && this->api_connection_ == nullptr) { + return; + } + const size_t available = this->available(); + if (available > 0) { + this->read_and_send_(available); + } +#endif +} +#endif + void SerialProxy::dump_config() { ESP_LOGCONFIG(TAG, "Serial Proxy [%" PRIu32 "]:\n" @@ -92,8 +166,9 @@ void SerialProxy::dump_config() { SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -159,24 +234,80 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } +SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection, + api::enums::SerialProxyMode mode) { +#ifdef USE_API + // Only the live subscriber may change the mode, so the mode cannot outlive a session + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; + } +#endif + // Values come from a remote client + if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) { + ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast(mode)); + return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT; + } + // PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows +#ifdef USE_SERIAL_PROXY_TAP + const bool has_tap = this->tap_ != nullptr; +#else + const bool has_tap = false; +#endif + if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) { + ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; + } + ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, + mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); +#ifdef USE_SERIAL_PROXY_TAP + const bool leaving_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; + this->mode_ = mode; + + // Only for an explicit client request, not for reset_mode_() at the end of a session: + // an ordinary disconnect says nothing about the device, whereas a client deliberately + // asking for raw bytes usually precedes changing what the device is. + if (leaving_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_disabled(); + } +#endif + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; +} + void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { #ifdef USE_API - // Bytes from a client other than the live subscriber would interleave with the - // subscriber's traffic on the wire - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_); + // Bytes from anyone but the live subscriber would interleave with the subscriber's + // traffic -- or with an active tap's -- on the wire + if (!this->is_subscriber_(api_connection)) { + if (this->api_connection_ != nullptr) { + ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_); + } else { + // A legacy client streaming writes without subscribing would flood WARN, one per + // request; writes are the only high-rate, unacknowledged operation, so keep this + // visible without drowning the log + ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_); + } return; } #endif if (data == nullptr || len == 0) return; this->write_array(data, len); + +#ifdef USE_SERIAL_PROXY_TAP + // After the write, so the tap observes the same ordering the device does + if (this->tap_observing_()) { + this->tap_->on_client_tx(data, len); + } +#endif } SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -210,8 +341,8 @@ uint32_t SerialProxy::get_modem_pins() const { SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { #ifdef USE_API // Flushing stalls the port, so it gets the same ownership check as writes - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -230,11 +361,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { } #ifdef USE_API -bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const { - return this->api_connection_ != nullptr && this->api_connection_ != api_connection && - this->api_connection_->is_connection_setup(); -} - SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) { switch (type) { @@ -252,6 +378,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + // End the dead client's session before starting the new one, so its mode + // cannot leak into a session that never asked for it + this->api_connection_ = nullptr; + this->reset_mode_(); } this->api_connection_ = api_connection; this->enable_loop(); @@ -264,7 +394,15 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } this->api_connection_ = nullptr; + this->reset_mode_(); +#ifdef USE_SERIAL_PROXY_TAP + // Keep the loop alive for a tap that still needs the port (mirrors loop()) + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + } +#else this->disable_loop(); +#endif ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_OK; default: diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index a0e47ee6864..e3f4264cfa5 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -26,6 +26,7 @@ class APIConnection; namespace enums { enum SerialProxyPortType : uint32_t; enum SerialProxyRequestType : uint32_t; +enum SerialProxyMode : uint32_t; } // namespace enums } // namespace esphome::api @@ -52,6 +53,36 @@ enum class SerialProxyResult : uint8_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; +#ifdef USE_SERIAL_PROXY_TAP +/// Observes a port's traffic without owning it, and may inject bytes of its own. +/// +/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without +/// the pipe knowing anything about the protocol: the tap is compiled in only when some +/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it. +/// +/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes +/// flowing in either direction, so a misbehaving tap cannot corrupt the stream. +class SerialProxyTap { + public: + /// Bytes read from the device, before they are forwarded to any subscriber. + virtual void on_device_rx(const uint8_t *data, size_t len) = 0; + + /// Bytes a subscriber sent towards the device, after they have been written. + virtual void on_client_tx(const uint8_t *data, size_t len) = 0; + + /// True when the port must keep reading even with no subscriber attached, so a tap can + /// do its own protocol work while nobody is listening. Honoured only while no + /// subscriber holds the port; with one attached, the port mode alone decides. + virtual bool tap_needs_port() const = 0; + + /// A client explicitly turned protocol handling off for this port. Distinct from the + /// automatic reset when a session ends: this one means a client intends to do something + /// else with the device -- reflash it, most likely -- so anything the tap believes about + /// it should be treated as suspect. + virtual void on_protocol_disabled() = 0; +}; +#endif + class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; @@ -77,6 +108,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get the port type api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } + /// Handle a mode change requested by an API client + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); + /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second @@ -121,13 +155,67 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Set the DTR GPIO pin (from YAML configuration) void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } +#ifdef USE_SERIAL_PROXY_TAP + /// Attach a traffic observer. At most one, set once at setup time. + void set_tap(SerialProxyTap *tap) { this->tap_ = tap; } + + /// Write bytes originating from the tap rather than from a client. Bypasses the + /// subscriber ownership check, but only while the tap is being served bytes -- so a + /// port in RAW mode with a subscriber attached stays inert. Returns false when the + /// bytes were dropped for that reason. + bool write_from_tap(const uint8_t *data, size_t len) { + if (!this->tap_observing_()) { + return false; + } + this->write_array(data, len); + return true; + } + + /// Whether the tap is currently being served bytes. Can flip false with no callback + /// (a subscriber attaching in RAW mode, say), so a tap should check before starting + /// protocol work and when a reply seems overdue. + bool tap_is_observed() const { return this->tap_observing_(); } + + /// Resume reading after a tap's needs change. loop() disables itself when there is + /// neither a subscriber nor a tap that wants the port, so a tap starting fresh work + /// must ask for it back. Must be called from the main loop. + void tap_request_port() { this->enable_loop(); } + + /// Whether the underlying device is present. On a USB UART this tracks enumeration, so + /// a tap can notice the device being unplugged and plugged back in. + bool is_device_connected() const { return this->parent_->is_connected(); } + + /// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the + /// main loop is running -- during setup, for instance, while a component is still + /// blocking on can_proceed(). Must not be called from on_device_rx() or + /// on_client_tx(): each nested cycle costs a 256-byte stack frame. + void tap_pump(); +#endif + protected: #ifdef USE_API - /// Read from UART and send to API client (slow path with 256-byte stack buffer) + /// Read from UART, hand the bytes to any tap, and forward them to a subscriber + /// (slow path with a 256-byte stack buffer) void read_and_send_(size_t available); - /// True when a live subscriber other than the given connection holds the port - bool port_claimed_by_other_(api::APIConnection *api_connection) const; + /// True when the given connection is the live subscriber. Every port operation + /// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed + /// client can never share the wire with the subscriber or an active tap. + bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } +#endif + +#ifdef USE_SERIAL_PROXY_TAP + /// Return the port to RAW when a subscriber goes away, so the mode never outlives it + void reset_mode_(); +#else + /// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is + /// nothing to reset + void reset_mode_() {} +#endif + +#ifdef USE_SERIAL_PROXY_TAP + /// True when the tap should be shown the traffic passing through this port + bool tap_observing_() const; #endif /// Instance index for identifying this proxy in API messages @@ -147,6 +235,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Port type api::enums::SerialProxyPortType port_type_{}; +#ifdef USE_SERIAL_PROXY_TAP + /// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW + api::enums::SerialProxyMode mode_{}; +#endif + /// Optional GPIO pins for modem control GPIOPin *rts_pin_{nullptr}; GPIOPin *dtr_pin_{nullptr}; @@ -154,6 +247,10 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Current modem pin states bool rts_state_{false}; bool dtr_state_{false}; + +#ifdef USE_SERIAL_PROXY_TAP + SerialProxyTap *tap_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/core/defines.h b/esphome/core/defines.h index eaece6d5ffa..c3b16d833a4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -181,6 +181,7 @@ #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY +#define USE_SERIAL_PROXY_TAP #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index 6fc20f33508..7da6fff017b 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -40,6 +40,9 @@ class SerialProxy { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {} + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; + } SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } diff --git a/tests/components/serial_proxy/test-tap.esp32-idf.yaml b/tests/components/serial_proxy/test-tap.esp32-idf.yaml new file mode 100644 index 00000000000..5522e53c476 --- /dev/null +++ b/tests/components/serial_proxy/test-tap.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +# Compile the tap code paths; no tap is attached, so this exercises the +# null-tap branches that a normal build never defines. +esphome: + platformio_options: + build_flags: + - "-DUSE_SERIAL_PROXY_TAP" + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + serial_proxy: !include common.yaml From a807a8f9451b172abf4cb05ca2f35c609224e414 Mon Sep 17 00:00:00 2001 From: matt123p Date: Thu, 10 Sep 2026 16:11:43 +0100 Subject: [PATCH 042/266] [es7210] Fix 4 channel microphone support (#19034) --- esphome/components/es7210/es7210.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 892b67b270a..5afc22aec4f 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -153,13 +153,14 @@ bool ES7210::configure_mic_gain_() { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv)); // Configure mic 3 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + // MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains. + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv)); // Configure mic 4 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv)); From 7564f5ff1ace9bbe107ec79107bd6606796f3156 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 10 Sep 2026 12:06:58 -0400 Subject: [PATCH 043/266] [sendspin] Add manufacturer, model, and firmware version options (#18792) Co-authored-by: J. Nick Koston --- esphome/components/sendspin/__init__.py | 32 +++++++ esphome/components/sendspin/sendspin_hub.cpp | 16 +++- esphome/components/sendspin/sendspin_hub.h | 19 +++++ .../sendspin/config/device_info_default.yaml | 12 +++ .../sendspin/config/device_info_explicit.yaml | 18 ++++ .../sendspin/config/device_info_project.yaml | 15 ++++ .../sendspin/test_device_info.py | 83 +++++++++++++++++++ tests/components/sendspin/common-hub.yaml | 3 + 8 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/sendspin/config/device_info_default.yaml create mode 100644 tests/component_tests/sendspin/config/device_info_explicit.yaml create mode 100644 tests/component_tests/sendspin/config/device_info_project.yaml create mode 100644 tests/component_tests/sendspin/test_device_info.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index c1970ab1325..c21047c70a7 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -6,12 +6,17 @@ from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_ESPHOME, CONF_FORMAT, CONF_HEIGHT, CONF_ID, + CONF_MODEL, + CONF_NAME, + CONF_PROJECT, CONF_SAMPLE_RATE, CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_VERSION, CONF_WIDTH, ) from esphome.core import CORE, ID @@ -27,6 +32,14 @@ DOMAIN = "sendspin" CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" +CONF_FIRMWARE_VERSION = "firmware_version" +CONF_MANUFACTURER = "manufacturer" + +# An empty device information string would be sent to the server as an empty value rather than +# falling back, so reject it instead of silently substituting the fallback. The 127 byte cap keeps +# the length prefix of a protobuf string field to a single byte, matching `esphome: project:`. +DEVICE_INFO_STRING = cv.All(cv.string_strict, cv.Length(min=1), cv.ByteLength(max=127)) + CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" @@ -198,6 +211,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(SendspinHub), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, + cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, + cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, + cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, } ), cv.only_on_esp32, @@ -248,6 +264,22 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() + # Device information for the server's client/hello message. Falls back to the project + # information, which is written as `manufacturer.model`. Anything still unset keeps the + # default the hub itself applies: the ESPHome name and version. + project = CORE.config[CONF_ESPHOME].get(CONF_PROJECT, {}) + project_manufacturer, _, project_model = project.get(CONF_NAME, "").partition(".") + for value, setter in ( + (config.get(CONF_MANUFACTURER) or project_manufacturer, var.set_manufacturer), + (config.get(CONF_MODEL) or project_model, var.set_model), + ( + config.get(CONF_FIRMWARE_VERSION) or project.get(CONF_VERSION), + var.set_firmware_version, + ), + ): + if value: + cg.add(setter(value)) + # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 028491284a7..2cb2b909951 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -76,8 +76,12 @@ void SendspinHub::dump_config() { ESP_LOGCONFIG(TAG, "Sendspin Hub:\n" " Client ID: %s\n" + " Manufacturer: %s\n" + " Model: %s\n" + " Firmware version: %s\n" " Task stack in PSRAM: %s", - get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), + this->firmware_version_, YESNO(this->task_stack_in_psram_)); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -127,15 +131,19 @@ const char *SendspinHub::get_client_id_into_buffer(std::spanmodel_ != nullptr ? this->model_ : App.get_name().c_str(); +} + sendspin::SendspinClientConfig SendspinHub::build_client_config_() { sendspin::SendspinClientConfig config; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf); config.name = App.get_friendly_name(); - config.product_name = App.get_name(); - config.manufacturer = "ESPHome"; - config.software_version = ESPHOME_VERSION; + config.product_name = this->get_product_name_(); + config.manufacturer = this->manufacturer_; + config.software_version = this->firmware_version_; config.httpd_psram_stack = this->task_stack_in_psram_; return config; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 7c50c3eb809..c66c7db3ccb 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/version.h" #include #include @@ -125,6 +126,15 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + /// @brief Sets the device information reported to the server in the `client/hello` message. + /// + /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the + /// lifetime of the hub. Only called for values the configuration overrides; anything left alone + /// keeps the default described on the member below. + void set_manufacturer(const char *manufacturer) { this->manufacturer_ = manufacturer; } + void set_model(const char *model) { this->model_ = model; } + void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -187,6 +197,9 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// @brief Returns the product name reported to the server: the configured model, or the device name. + const char *get_product_name_() const; + /// @brief Writes the active network interface's MAC into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_client_id_into_buffer(std::span buf); @@ -268,6 +281,12 @@ class SendspinHub final : public Component, CallbackManager group_update_callbacks_{}; bool task_stack_in_psram_{false}; + + // Device information sent in the `client/hello` message. Defaults apply when neither the + // sendspin configuration nor the project information supplies a value. + const char *manufacturer_{"ESPHome"}; + const char *model_{nullptr}; // nullptr reports the device name instead + const char *firmware_version_{ESPHOME_VERSION}; }; /// @brief Base class for all sendspin subcomponents. diff --git a/tests/component_tests/sendspin/config/device_info_default.yaml b/tests/component_tests/sendspin/config/device_info_default.yaml new file mode 100644 index 00000000000..669b2e99bc6 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_default.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: diff --git a/tests/component_tests/sendspin/config/device_info_explicit.yaml b/tests/component_tests/sendspin/config/device_info_explicit.yaml new file mode 100644 index 00000000000..c3fec3ead45 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_explicit.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + project: + name: project_manufacturer.project_model + version: 9.9.9 + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: + manufacturer: Explicit Manufacturer + model: Explicit Model + firmware_version: 1.2.3 diff --git a/tests/component_tests/sendspin/config/device_info_project.yaml b/tests/component_tests/sendspin/config/device_info_project.yaml new file mode 100644 index 00000000000..395b2889fc3 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_project.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + project: + name: project_manufacturer.project_model + version: 9.9.9 + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: diff --git a/tests/component_tests/sendspin/test_device_info.py b/tests/component_tests/sendspin/test_device_info.py new file mode 100644 index 00000000000..833dd398b41 --- /dev/null +++ b/tests/component_tests/sendspin/test_device_info.py @@ -0,0 +1,83 @@ +"""Tests for the device information the sendspin hub reports to the server.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import ( + CONF_FIRMWARE_VERSION, + CONF_MANUFACTURER, + CONFIG_SCHEMA, +) +from esphome.const import CONF_MODEL, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_explicit_device_info_wins_over_project( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Configured values take precedence over the project information.""" + main_cpp = generate_main(component_config_path("device_info_explicit.yaml")) + + assert 'set_manufacturer("Explicit Manufacturer")' in main_cpp + assert 'set_model("Explicit Model")' in main_cpp + assert 'set_firmware_version("1.2.3")' in main_cpp + + +def test_project_supplies_device_info( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without configured values, the project name splits into manufacturer and model.""" + main_cpp = generate_main(component_config_path("device_info_project.yaml")) + + assert 'set_manufacturer("project_manufacturer")' in main_cpp + assert 'set_model("project_model")' in main_cpp + assert 'set_firmware_version("9.9.9")' in main_cpp + + +def test_no_device_info_leaves_hub_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With neither source, nothing is emitted and the hub keeps its own defaults.""" + main_cpp = generate_main(component_config_path("device_info_default.yaml")) + + assert "set_manufacturer(" not in main_cpp + assert "set_model(" not in main_cpp + assert "set_firmware_version(" not in main_cpp + + +@pytest.mark.parametrize( + "conf_key", [CONF_MANUFACTURER, CONF_MODEL, CONF_FIRMWARE_VERSION] +) +def test_empty_device_info_rejected( + set_core_config: SetCoreConfigCallable, conf_key: str +) -> None: + """An empty string would be sent to the server as an empty value, so it is not accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: ""}) + + +@pytest.mark.parametrize( + "conf_key", [CONF_MANUFACTURER, CONF_MODEL, CONF_FIRMWARE_VERSION] +) +def test_device_info_capped_at_127_bytes( + set_core_config: SetCoreConfigCallable, conf_key: str +) -> None: + """The cap is in bytes so the protobuf length prefix stays a single byte.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA({conf_key: "a" * 127}) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: "a" * 128}) + # 64 two-byte characters is 128 bytes. + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: "é" * 64}) diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index 7a6a9ffd4f3..bd6747ee07b 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -4,3 +4,6 @@ psram: sendspin: id: sendspin_hub_id task_stack_in_psram: true + manufacturer: Test Manufacturer + model: Test Model + firmware_version: 1.2.3 From 3e3822e5541f3562fae64b29837b79b1027af674 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:22:30 +0000 Subject: [PATCH 044/266] Bump bundled esphome-device-builder to 1.14.6 (#19072) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ac84ee4689f..cfa47fbdad2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 RUN \ platformio settings set enable_telemetry No \ From f66ef23256f467a572517f6ee87956f5f527fa60 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 03:20:30 -0500 Subject: [PATCH 045/266] [core] Support set_internal() during setup, log error after setup (#19069) --- esphome/core/entity_base.cpp | 9 ++++ esphome/core/entity_base.h | 27 ++++++++---- .../fixtures/set_internal_at_boot.yaml | 34 +++++++++++++++ .../integration/test_set_internal_at_boot.py | 41 +++++++++++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/set_internal_at_boot.yaml create mode 100644 tests/integration/test_set_internal_at_boot.py diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 21a5fc3706c..dc27c1e56a2 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -56,6 +56,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3; } +void EntityBase::set_internal(bool internal) { + // Remove the after-setup path in 2027.3.0 and ignore the call instead. + if (App.is_setup_complete()) { + ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0", + this->get_name().c_str()); + } + this->flags_.internal = internal; +} + // Weak default lookup functions — overridden by generated code in main.cpp __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index f38e30bf52d..8796e9f067a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -88,13 +88,26 @@ class EntityBase { // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } - // Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients - // are NOT notified of the change, the flag may have already been read during setup, and there - // is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead. - ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT " - "notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.", - "2026.3.0") - void set_internal(bool internal) { this->flags_.internal = internal; } + // Set whether this Entity should be hidden outside ESPHome. Prefer the 'internal:' YAML key + // whenever possible: it is guaranteed and has none of the limitations below. Use this only when + // the decision can only be made at boot. Must be called before MQTT and the API read the flag: + // from on_boot at the default priority, or a setup() that runs above setup_priority::AFTER_WIFI. + // If the answer comes from a device handshake, hold setup with can_proceed() until it arrives. + // Calls after setup finishes are undefined behavior: the flag is still written and an error is + // logged, and from 2027.3.0 the call will be ignored. + // + // Known limitations. Not bugs, so no issue reports please; a PR that removes one with no RAM + // or performance cost would be considered. + // - No consumer is notified of a change, so the flag can only be decided once per boot. + // - The guard is coarse: a call from a priority below AFTER_WIFI (an on_boot with a low priority, + // or a setup() at LATE) still passes, but the API camera listener is already registered, MQTT + // (AFTER_CONNECTION) has cached the flag, and an API client that connected while setup was + // stalled on a slow component has already listed the entities, so they keep the old value. + // - Un-hiding an entity declared 'internal: true' in YAML skips the duplicate name check that + // codegen runs for exposed entities, so a name collision can surface at runtime. Entities with + // only an 'id:' are forced internal and use the id as their name. + // - Zigbee codegen skips YAML internal entities entirely, so un-hiding cannot add them to Zigbee. + void set_internal(bool internal); // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should diff --git a/tests/integration/fixtures/set_internal_at_boot.yaml b/tests/integration/fixtures/set_internal_at_boot.yaml new file mode 100644 index 00000000000..b3007e9dbda --- /dev/null +++ b/tests/integration/fixtures/set_internal_at_boot.yaml @@ -0,0 +1,34 @@ +esphome: + name: set-internal-at-boot + on_boot: + then: + - lambda: |- + id(hidden_at_boot).set_internal(true); + id(shown_at_boot).set_internal(false); + +host: + +api: + actions: + - action: set_internal_late + then: + - lambda: id(untouched).set_internal(true); + +logger: + +sensor: + - platform: template + name: "Hidden At Boot" + id: hidden_at_boot + lambda: return 1.0; + + - platform: template + name: "Shown At Boot" + id: shown_at_boot + internal: true + lambda: return 2.0; + + - platform: template + name: "Untouched" + id: untouched + lambda: return 3.0; diff --git a/tests/integration/test_set_internal_at_boot.py b/tests/integration/test_set_internal_at_boot.py new file mode 100644 index 00000000000..68b0bd10802 --- /dev/null +++ b/tests/integration/test_set_internal_at_boot.py @@ -0,0 +1,41 @@ +"""Integration test for set_internal() called during and after setup.""" + +from __future__ import annotations + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_set_internal_at_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """set_internal() in on_boot changes API exposure, later calls log an error.""" + waiter = LineWaiter() + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + names = {entity.name for entity in entities} + + assert "Hidden At Boot" not in names + assert "Shown At Boot" in names + assert "Untouched" in names + + late = next(s for s in services if s.name == "set_internal_late") + await client.execute_service(late, {}) + await waiter.wait_for( + "'Untouched'", + "set_internal() after setup is undefined behavior", + timeout=5.0, + ) + + # Still written during the deprecation window, ignored from 2027.3.0 + entities, _ = await client.list_entities_services() + assert "Untouched" not in {entity.name for entity in entities} From 380938177c1cc0599f4df97f1368adb96f48f07a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 11 Sep 2026 03:25:52 -0500 Subject: [PATCH 046/266] [uart] Add apply_settings_live() for in-place ESP-IDF reconfiguration (#19087) Co-authored-by: Claude Fable 5.1 --- .../uart/uart_component_esp_idf.cpp | 129 +++++++++++++----- .../components/uart/uart_component_esp_idf.h | 36 +++++ 2 files changed, 134 insertions(+), 31 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index bbeb86bcdb7..e5d5fbc9839 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -160,6 +160,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { this->mark_failed(); return; } + this->last_good_framing_ = this->framing_(); int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; @@ -189,18 +190,9 @@ void IDFUARTComponent::load_settings(bool dump_config) { setup_pin_if_needed(this->tx_pin_); } - uint32_t invert = 0; - if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { - invert |= UART_SIGNAL_TXD_INV; - } - if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { - invert |= UART_SIGNAL_RXD_INV; - } - if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { - invert |= UART_SIGNAL_RTS_INV; - } - - err = uart_set_line_inverse(this->uart_num_, invert); + // Must precede uart_set_pin() so an inverted TX line never shows the wrong idle + // level; apply_line_settings_() repeats it later for the reset registers. + err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); this->mark_failed(); @@ -214,25 +206,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). - auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + if (this->apply_line_settings_() != ESP_OK) { this->mark_failed(); return; } @@ -250,6 +224,99 @@ void IDFUARTComponent::load_settings(bool dump_config) { } } +uint32_t IDFUARTComponent::line_inversion_mask_() { + uint32_t invert = 0; + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { + invert |= UART_SIGNAL_TXD_INV; + } + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { + invert |= UART_SIGNAL_RXD_INV; + } + if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { + invert |= UART_SIGNAL_RTS_INV; + } + return invert; +} + +esp_err_t IDFUARTComponent::apply_line_settings_() { + // uart_param_config() resets these; call after every use of it. + esp_err_t err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); + return err; + } + + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). + auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; + err = uart_set_mode(this->uart_num_, mode); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + return err; + } + + return ESP_OK; +} + +void IDFUARTComponent::set_framing_(const Framing &framing) { + this->baud_rate_ = framing.baud_rate; + this->data_bits_ = framing.data_bits; + this->stop_bits_ = framing.stop_bits; + this->parity_ = framing.parity; + this->rx_full_threshold_ = framing.rx_full_threshold; +} + +esp_err_t IDFUARTComponent::apply_settings_live() { + if (this->is_failed()) { + return ESP_ERR_INVALID_STATE; + } + // No driver yet: nothing to reconfigure in place. + if (!uart_is_driver_installed(this->uart_num_)) { + this->load_settings(false); + return this->is_failed() ? ESP_FAIL : ESP_OK; + } + // Keeps the driver ring buffers; flushes both hardware FIFOs (in-flight bytes lost). + uart_config_t uart_config = this->get_config_(); + esp_err_t err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + // Failure leaves the registers reset; put back the last accepted framing so the + // getters still describe the hardware. + if (this->last_good_framing_.baud_rate == 0) { + ESP_LOGE(TAG, "uart_param_config (live) failed: %s; no previous framing to restore", esp_err_to_name(err)); + this->mark_failed(); + return err; + } + ESP_LOGW(TAG, "uart_param_config (live) failed: %s; restoring %" PRIu32 " baud", esp_err_to_name(err), + this->last_good_framing_.baud_rate); + this->set_framing_(this->last_good_framing_); + uart_config = this->get_config_(); + esp_err_t restore_err = uart_param_config(this->uart_num_, &uart_config); + if (restore_err != ESP_OK) { + ESP_LOGE(TAG, "UART left unconfigured after failed live reconfigure: %s", esp_err_to_name(restore_err)); + this->mark_failed(); + return err; + } + // Previous framing is live again; report the refusal (line-setting errors log). + this->apply_line_settings_(); + return err; + } + this->last_good_framing_ = this->framing_(); + // The new framing is live; a line-setting failure here only logs. + this->apply_line_settings_(); + return ESP_OK; +} + void IDFUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus %u:", this->uart_num_); LOG_PIN(" TX Pin: ", this->tx_pin_); diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index a761d80f04a..d9297bfa34a 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -52,13 +52,49 @@ class IDFUARTComponent final : public UARTComponent, public Component { void load_settings(bool dump_config) override; using UARTComponent::load_settings; // also bring in the no-arg overload for convenience + /** + * Apply the current framing (baud rate, parity, data/stop bits) to the installed + * driver in place, without the delete/reinstall of load_settings(). Tasks blocked in + * the driver survive and the ring buffers are kept, but both hardware FIFOs are + * flushed: a frame in flight reaches the peer truncated and bytes not yet out of the + * RX FIFO are dropped. No lock is taken: quiesce writers first if that matters. + * rx_full_threshold is not rescaled (call set_rx_full_threshold_ms() first if it + * should follow the baud rate); a rollback restores the value from the last accepted + * configuration, undoing a standalone set_rx_full_threshold() made since. Without an + * installed driver this is a full load_settings(false) instead. + * + * @return ESP_OK once the new framing is live (a line-setting error after that only + * logs). On rejection (unreachable baud rate) the previous framing is restored and + * the driver's error returned; if the restore fails too the component is marked + * failed. ESP_ERR_INVALID_STATE if already failed; ESP_FAIL if the fallback + * load_settings() fails. + */ + esp_err_t apply_settings_live(); + void on_shutdown() override; protected: void check_logger_conflict() override; + uint32_t line_inversion_mask_(); + // Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode. + esp_err_t apply_line_settings_(); uart_port_t uart_num_{UART_NUM_MAX}; uart_config_t get_config_(); + struct Framing { + uint32_t baud_rate; + uint8_t data_bits; + uint8_t stop_bits; + UARTParityOptions parity; + size_t rx_full_threshold; // sized for the baud rate, so rolled back with it + }; + Framing framing_() const { + return {this->baud_rate_, this->data_bits_, this->stop_bits_, this->parity_, this->rx_full_threshold_}; + } + void set_framing_(const Framing &framing); + // Last framing the driver accepted; baud_rate 0 means none yet. + Framing last_good_framing_{}; + bool has_peek_{false}; uint8_t peek_byte_; uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). From 6a21ab4ea705cb4c885cb2590683949874d9b931 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:30:51 +1200 Subject: [PATCH 047/266] [esp32] Trim mbedTLS to client-only defaults and stub vasprintf on the C6 (#19088) --- esphome/components/esp32/__init__.py | 118 ++++++++++++++++++ esphome/components/esp32/vasprintf_stubs.cpp | 53 ++++++++ esphome/components/openthread/__init__.py | 10 ++ esphome/components/wifi/__init__.py | 7 ++ esphome/core/defines.h | 1 + .../esp32/config/mbedtls_tls_default.yaml | 14 +++ .../esp32/config/mbedtls_tls_openthread.yaml | 19 +++ .../esp32/config/mbedtls_tls_opt_out.yaml | 17 +++ .../config/mbedtls_tls_user_sdkconfig.yaml | 17 +++ .../esp32/config/mbedtls_tls_wifi_eap.yaml | 17 +++ .../esp32/config/vasprintf_stub_c6.yaml | 7 ++ .../config/vasprintf_stub_c6_full_printf.yaml | 9 ++ tests/component_tests/esp32/test_esp32.py | 99 +++++++++++++++ tests/components/esp32/test.esp32-idf.yaml | 2 + .../http_request/test.esp32-c6-idf.yaml | 4 + 15 files changed, 394 insertions(+) create mode 100644 esphome/components/esp32/vasprintf_stubs.cpp create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_default.yaml create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml create mode 100644 tests/component_tests/esp32/config/vasprintf_stub_c6.yaml create mode 100644 tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml create mode 100644 tests/components/http_request/test.esp32-c6-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3f5a34bc735..d027c9a1c61 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -189,6 +189,13 @@ PSRAM_XIP_VARIANTS = { VARIANT_ESP32S31, } +# Variants whose ROM exports a full-format vsnprintf but no vasprintf +# (esp32c6.rom.newlib-normal.ld). There, the newlib printf engine is only +# linked because esp_http_client calls vasprintf; see vasprintf_stubs.cpp. +# The other variants either export both (classic ESP32, nano-format only) or +# neither, so the engine is already in the image and the wrap saves nothing. +ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6} + # NVS encryption (HMAC peripheral scheme) is only available on variants that # expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original # ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral @@ -1732,6 +1739,8 @@ CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" +CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server" +CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" @@ -1746,6 +1755,8 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" +KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required" +KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" @@ -1830,6 +1841,30 @@ def require_mbedtls_pkcs7() -> None: CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True +def require_mbedtls_tls_server() -> None: + """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + + Call this from components that accept TLS connections (OpenThread's DTLS + commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from + being selected. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + + +def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: + """Mark TLS features disabled by ``disable_mbedtls_tls_extras`` as required. + + ``options`` names the entries of ``MBEDTLS_TLS_EXTRA_OPTIONS`` to keep; + omit it to keep all of them. Call this from components that need AES-CCM, + deterministic ECDSA signing, static RSA/ECDH key exchange, TLS + renegotiation or session tickets, or that run a TLS client against + servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied + sdkconfig_options value is never overridden either. + """ + required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options) + + def require_mbedtls_sha512() -> None: """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. @@ -1987,6 +2022,8 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, @@ -2302,6 +2339,69 @@ async def _reconcile_certificate_bundle_sdkconfig() -> None: set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True) +# TLS features an HTTPS/MQTT client talking to a modern server never +# negotiates. Static RSA and static ECDH key exchange have no forward secrecy +# and are gone in TLS 1.3, renegotiation is deprecated, esp-tls never enables +# session tickets, AES-CCM ciphersuites are not offered by web servers, and +# deterministic ECDSA only matters when signing with a private key. Together +# they cost ~10 KB of flash whenever TLS is linked (http_request, mqtt). +# wpa_supplicant's EAP client is a second TLS client that talks to RADIUS +# servers ESPHome cannot vet, and a failed EAP handshake leaves the device +# off the network, so the wifi component re-enables all of these when eap is +# configured. +# The EC public key parsing extras stay enabled: they decide whether a peer +# certificate with a compressed point or explicit curve parameters parses, +# which no component can know ahead of time. +MBEDTLS_TLS_EXTRA_OPTIONS = ( + "CONFIG_MBEDTLS_KEY_EXCHANGE_RSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA", + "CONFIG_MBEDTLS_SSL_RENEGOTIATION", + "CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_CCM_C", + "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC", +) + +# Members of the mbedTLS "TLS Protocol Role" Kconfig choice. Setting one +# member is only valid when the user has not already chosen another. +MBEDTLS_TLS_ROLE_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_DISABLED", +) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_tls_sdkconfig( + disable_tls_server: bool, disable_tls_extras: bool +) -> None: + """Trim mbedTLS to what a TLS client needs unless a component asked otherwise. + + Runs at FINAL priority so every require_mbedtls_tls_server() and + require_mbedtls_tls_extras() call has happened. Only the server-side + handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS + connections, but OpenThread's DTLS commissioner does. A user-supplied + sdkconfig_options value always wins; for the TLS role choice, any member + the user set leaves the whole choice alone so the pair cannot conflict. + """ + data = CORE.data[KEY_ESP32] + sdkconfig = data[KEY_SDKCONFIG_OPTIONS] + if ( + disable_tls_server + and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False) + and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS) + ): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False) + if disable_tls_extras: + required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + for option in MBEDTLS_TLS_EXTRA_OPTIONS: + if option not in required: + set_idf_sdkconfig_default(option, False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. @@ -2566,6 +2666,17 @@ async def to_code(config): else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # esp_http_client calls vasprintf, which on the ESP32-C6 is the only + # reference to newlib's full printf engine (~20 KB: _svfprintf_r, + # _dtoa_r and their helpers); every other caller resolves to the + # ROM. See vasprintf_stubs.cpp. The --undefined flag is needed + # because libsrc.a is scanned before the IDF libraries that + # reference the symbol, so the stub would otherwise never be pulled + # from the archive. + if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS: + cg.add_define("USE_ESP32_VASPRINTF_STUB") + cg.add_build_flag("-Wl,--wrap=vasprintf") + cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf") else: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") @@ -2991,6 +3102,13 @@ async def to_code(config): # FINAL priority: runs after every require_certificate_bundle() call CORE.add_job(_reconcile_certificate_bundle_sdkconfig) + # FINAL priority: runs after every require_mbedtls_tls_*() call + CORE.add_job( + _reconcile_mbedtls_tls_sdkconfig, + advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER], + advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS], + ) + # FINAL: require_*() calls can come from to_code at or below this priority, so an # inline read would be iteration-order-dependent; reconcile once after every job ran. CORE.add_job( diff --git a/esphome/components/esp32/vasprintf_stubs.cpp b/esphome/components/esp32/vasprintf_stubs.cpp new file mode 100644 index 00000000000..308a58ebda7 --- /dev/null +++ b/esphome/components/esp32/vasprintf_stubs.cpp @@ -0,0 +1,53 @@ +/* + * Linker wrap stub for vasprintf() on variants whose ROM exports a + * full-format vsnprintf() but no vasprintf() (ESP32-C6, newlib only). + * + * On those chips every snprintf/vsnprintf call in the image resolves to + * the ROM, so the newlib printf engine (_svfprintf_r, _dtoa_r and their + * helpers, ~20 KB) is not linked at all until something references a + * printf-family function the ROM lacks. esp_http_client does exactly that + * through vasprintf() in its header and auth helpers, so adding + * http_request to a build costs the whole engine on top of the HTTP and + * TLS code itself. + * + * This stub reimplements vasprintf() on top of the ROM vsnprintf(), which + * keeps the engine out of the image. It is only compiled in when codegen + * defines USE_ESP32_VASPRINTF_STUB, which is gated on the variant's ROM + * linker script and on the same newlib condition as printf_stubs.cpp. + */ + +#include "esphome/core/defines.h" + +#if defined(USE_ESP_IDF) && defined(USE_ESP32_VASPRINTF_STUB) + +#include +#include +#include + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vasprintf(char **strp, const char *fmt, va_list ap) { + va_list ap_copy; + va_copy(ap_copy, ap); + int len = vsnprintf(nullptr, 0, fmt, ap_copy); + va_end(ap_copy); + if (len < 0) { + return len; + } + // vasprintf's contract is a malloc'd buffer the caller releases with free() + char *buf = static_cast(malloc(static_cast(len) + 1)); // NOLINT(cppcoreguidelines-no-malloc) + if (buf == nullptr) { + return -1; + } + vsnprintf(buf, static_cast(len) + 1, fmt, ap); + *strp = buf; + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP_IDF && USE_ESP32_VASPRINTF_STUB diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index ab69f5d9ae5..a71151f3ffb 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,6 +13,8 @@ from esphome.components.esp32 import ( get_esp32_variant, include_builtin_idf_component, only_on_variant, + require_mbedtls_tls_extras, + require_mbedtls_tls_server, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage @@ -109,6 +111,14 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) + # OpenThread's DTLS commissioner is a TLS server, and its crypto platform + # uses AES-CCM and deterministic ECDSA directly. Keep the esp32 component + # from trimming them out of mbedTLS. + require_mbedtls_tls_server() + require_mbedtls_tls_extras( + ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") + ) + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1691dcc2935..58803a8cdff 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,6 +12,7 @@ from esphome.components.esp32 import ( get_esp32_variant, only_on_variant, request_wifi, + require_mbedtls_tls_extras, ) from esphome.components.network import ( add_use_address, @@ -658,6 +659,12 @@ async def to_code(config): # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) + if has_eap: + # wpa_supplicant's EAP client negotiates with whatever the RADIUS + # server offers, and a failed handshake leaves the device off the + # network, so keep every mbedTLS client feature the esp32 platform + # would otherwise trim. + require_mbedtls_tls_extras() # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP if has_manual_ip: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c3b16d833a4..9144e65576f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER +#define USE_ESP32_VASPRINTF_STUB #define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER diff --git a/tests/component_tests/esp32/config/mbedtls_tls_default.yaml b/tests/component_tests/esp32/config/mbedtls_tls_default.yaml new file mode 100644 index 00000000000..b29e5de2bd9 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_default.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml b/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml new file mode 100644 index 00000000000..62ca893d2c5 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 diff --git a/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml b/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml new file mode 100644 index 00000000000..e675848391b --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + disable_mbedtls_tls_server: false + disable_mbedtls_tls_extras: false + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml b/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml new file mode 100644 index 00000000000..44ff047a489 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT: y + CONFIG_MBEDTLS_CCM_C: y + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml new file mode 100644 index 00000000000..6c78e062650 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + eap: + identity: "user@example.org" + username: "user" + password: "secret" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml b/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml new file mode 100644 index 00000000000..8fa28e7c0f4 --- /dev/null +++ b/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml b/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml new file mode 100644 index 00000000000..075c3913b53 --- /dev/null +++ b/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + advanced: + enable_full_printf: true diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 759020c732d..2dd2a50c83c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -11,9 +11,12 @@ import pytest from esphome.components.esp32 import ( KEY_FATFS_REQUIRED, + KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, + KEY_MBEDTLS_TLS_SERVER_REQUIRED, KEY_VFS_DIR_REQUIRED, KEY_VFS_SELECT_REQUIRED, KEY_VFS_TERMIOS_REQUIRED, + MBEDTLS_TLS_EXTRA_OPTIONS, VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, @@ -1339,3 +1342,99 @@ def test_esp32_s31_gpio_validation( with caplog.at_level("WARNING"): validate_supports(pin) assert "GPIO36 is a strapping PIN" in caplog.text + + +_TLS_SERVER_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", +) + + +@pytest.mark.parametrize( + ("config_file", "server", "extras"), + [ + pytest.param("mbedtls_tls_default.yaml", (True, False), False, id="default"), + pytest.param("mbedtls_tls_opt_out.yaml", (None, None), None, id="opt_out"), + pytest.param("mbedtls_tls_wifi_eap.yaml", (True, False), None, id="wifi_eap"), + ], +) +def test_mbedtls_tls_trim_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + server: tuple[bool | None, bool | None], + extras: bool | None, +) -> None: + """Client-only TLS and the unused-feature trims apply unless opted out or required.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == server + assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras} + + +_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"} + + +def test_mbedtls_tls_openthread_keeps_only_what_it_uses( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OpenThread config keeps the DTLS server, CCM and deterministic ECDSA; the rest is trimmed.""" + generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) + for name in MBEDTLS_TLS_EXTRA_OPTIONS: + assert sdkconfig.get(name) is (None if name in _OPENTHREAD_EXTRAS else False) + + +def test_mbedtls_tls_user_sdkconfig_wins( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A user-set TLS role member leaves the whole choice alone; other user values are kept.""" + generate_main(component_config_path("mbedtls_tls_user_sdkconfig.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_CLIENT_ONLY") is None + role = sdkconfig["CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT"] + assert isinstance(role, RawSdkconfigValue) and role.value == "y" + ccm = sdkconfig["CONFIG_MBEDTLS_CCM_C"] + assert isinstance(ccm, RawSdkconfigValue) and ccm.value == "y" + assert { + sdkconfig.get(name) + for name in MBEDTLS_TLS_EXTRA_OPTIONS + if name != "CONFIG_MBEDTLS_CCM_C" + } == {False} + + +def test_mbedtls_tls_openthread_requires_server_and_extras( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" + generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS + + +_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + pytest.param("vasprintf_stub_c6.yaml", True, id="c6"), + pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"), + pytest.param("exclusion_reincludes.yaml", False, id="esp32"), + ], +) +def test_vasprintf_stub_only_on_rom_vsnprintf_variants( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf.""" + generate_main(component_config_path(config_file)) + assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected + defines = {define.name for define in CORE.defines} + assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 523e614e240..7f31fe59c6f 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -17,6 +17,8 @@ esp32: disable_dev_null_vfs: true disable_mbedtls_peer_cert: true disable_mbedtls_pkcs7: true + disable_mbedtls_tls_server: true + disable_mbedtls_tls_extras: true disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true diff --git a/tests/components/http_request/test.esp32-c6-idf.yaml b/tests/components/http_request/test.esp32-c6-idf.yaml new file mode 100644 index 00000000000..ee2f5aa59b8 --- /dev/null +++ b/tests/components/http_request/test.esp32-c6-idf.yaml @@ -0,0 +1,4 @@ +substitutions: + verify_ssl: "true" + +<<: !include common.yaml From 37e9b2b7af3ae84358bf59c9270462d551ec7644 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 14:12:40 -0500 Subject: [PATCH 048/266] [remote_base] Make protocol methods non-virtual and size receiver lists from codegen (#19084) --- AGENTS.md | 3 + esphome/components/coolix/climate.py | 3 +- esphome/components/infrared/infrared.cpp | 5 - esphome/components/infrared/infrared.h | 3 +- esphome/components/ir_rf_proxy/infrared.py | 10 +- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 4 - esphome/components/ir_rf_proxy/ir_rf_proxy.h | 3 +- .../components/ir_rf_proxy/radio_frequency.py | 10 +- esphome/components/midea/climate.py | 3 +- esphome/components/midea_ir/climate.py | 6 +- esphome/components/remote_base/__init__.py | 109 ++++++++++++++++-- .../remote_base/abbwelcome_protocol.h | 6 +- .../components/remote_base/aeha_protocol.h | 6 +- .../components/remote_base/beo4_protocol.h | 6 +- .../remote_base/brennenstuhl_protocol.h | 6 +- .../components/remote_base/byronsx_protocol.h | 6 +- .../remote_base/canalsat_protocol.h | 6 +- .../components/remote_base/coolix_protocol.h | 6 +- .../components/remote_base/dish_protocol.h | 6 +- .../components/remote_base/dooya_protocol.h | 6 +- .../components/remote_base/drayton_protocol.h | 6 +- .../components/remote_base/dyson_protocol.h | 6 +- .../components/remote_base/gobox_protocol.h | 6 +- .../components/remote_base/haier_protocol.h | 6 +- esphome/components/remote_base/jvc_protocol.h | 6 +- .../components/remote_base/keeloq_protocol.h | 6 +- esphome/components/remote_base/lg_protocol.h | 6 +- .../remote_base/magiquest_protocol.h | 6 +- .../components/remote_base/midea_protocol.h | 6 +- .../components/remote_base/mirage_protocol.h | 6 +- esphome/components/remote_base/nec_protocol.h | 6 +- .../components/remote_base/nexa_protocol.h | 6 +- .../remote_base/panasonic_protocol.h | 6 +- .../components/remote_base/pioneer_protocol.h | 6 +- .../components/remote_base/pronto_protocol.h | 6 +- esphome/components/remote_base/rc5_protocol.h | 6 +- esphome/components/remote_base/rc6_protocol.h | 6 +- .../remote_base/rc_switch_protocol.cpp | 38 +++--- .../remote_base/rc_switch_protocol.h | 35 +++++- .../components/remote_base/remote_base.cpp | 39 +++++-- esphome/components/remote_base/remote_base.h | 74 ++++++++---- .../components/remote_base/roomba_protocol.h | 6 +- .../remote_base/samsung36_protocol.h | 6 +- .../components/remote_base/samsung_protocol.h | 6 +- .../components/remote_base/sony_protocol.h | 6 +- .../remote_base/symphony_protocol.h | 6 +- .../remote_base/toshiba_ac_protocol.h | 6 +- .../components/remote_base/toto_protocol.h | 6 +- .../components/remote_receiver/__init__.py | 4 +- esphome/components/toshiba/climate.py | 3 +- esphome/core/defines.h | 37 ++++++ esphome/cpp_helpers.py | 38 ++++-- .../remote_receiver/__init__.py | 0 .../remote_receiver/config/receiver_bare.yaml | 9 ++ .../config/receiver_with_dumpers.yaml | 24 ++++ .../config/receiver_with_proxies.yaml | 22 ++++ .../remote_receiver/test_slot_counts.py | 91 +++++++++++++++ .../remote_receiver/bare-common.yaml | 6 + .../remote_receiver/test-bare.esp32-idf.yaml | 5 + tests/unit_tests/test_cpp_helpers.py | 25 ++++ 60 files changed, 606 insertions(+), 201 deletions(-) create mode 100644 tests/component_tests/remote_receiver/__init__.py create mode 100644 tests/component_tests/remote_receiver/config/receiver_bare.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml create mode 100644 tests/component_tests/remote_receiver/test_slot_counts.py create mode 100644 tests/components/remote_receiver/bare-common.yaml create mode 100644 tests/components/remote_receiver/test-bare.esp32-idf.yaml diff --git a/AGENTS.md b/AGENTS.md index 98bdd58ec52..8db3cd3d624 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -629,6 +629,9 @@ file does, and it is the authority when they disagree. The most useful starting _request_listener_slot() cg.add(hub.register_listener(var)) ``` + When several instances each own a list declared at the same size (one per hub of a + `MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`; + the define is then the largest count any one key requested instead of the total. ```cpp #ifdef MY_COMPONENT_LISTENER_COUNT void register_listener(MyComponentListener *listener); diff --git a/esphome/components/coolix/climate.py b/esphome/components/coolix/climate.py index 3eb8dbe2f41..fcca8b89dba 100644 --- a/esphome/components/coolix/climate.py +++ b/esphome/components/coolix/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -12,4 +12,5 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(CoolixClimate) async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("coolix") # used from C++ await climate_ir.new_climate_ir(config) diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 5a909738c6f..83039a5a9bf 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -59,11 +59,6 @@ void Infrared::setup() { // Set up traits based on configuration this->traits_.set_supports_transmitter(this->has_transmitter()); this->traits_.set_supports_receiver(this->has_receiver()); - - // Register as listener for received IR data - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void Infrared::dump_config() { diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index b6863e37ce5..afbde57be2f 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -119,7 +119,8 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } - /// Set the remote receiver component + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } /// Set the remote transmitter component void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 3218889721c..288bd916738 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,7 +3,12 @@ from typing import Any import esphome.codegen as cg -from esphome.components import infrared, remote_receiver, remote_transmitter +from esphome.components import ( + infrared, + remote_base, + remote_receiver, + remote_transmitter, +) from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY @@ -82,8 +87,7 @@ async def to_code(config: dict[str, Any]) -> None: # Link receiver if specified if CONF_REMOTE_RECEIVER_ID in config: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) # Set receiver demodulation frequency if specified (metadata only, no hardware effect) if CONF_RECEIVER_FREQUENCY in config: diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index c13c6198cb6..ceb4c9a67c3 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -97,10 +97,6 @@ void RfProxy::setup() { // remote_transmitter/receiver always uses OOK (on-off keying) this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); - - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void RfProxy::dump_config() { diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 5fc683354ba..1aa4394fe84 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -56,7 +56,8 @@ class RfProxy final : public radio_frequency::RadioFrequency { /// Set the remote transmitter component void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - /// Set the remote receiver component + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index a243909837f..28b8fd5953a 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,7 +1,12 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import radio_frequency, remote_receiver, remote_transmitter +from esphome.components import ( + radio_frequency, + remote_base, + remote_receiver, + remote_transmitter, +) import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -66,5 +71,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transmitter(transmitter)) if CONF_REMOTE_RECEIVER_ID in config: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 0e03bca2336..07ad02d3afc 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import climate, remote_transmitter, sensor, uart +from esphome.components import climate, remote_base, remote_transmitter, sensor, uart from esphome.components.climate import ClimateMode, ClimatePreset, ClimateSwingMode from esphome.components.remote_base import CONF_TRANSMITTER_ID import esphome.config_validation as cv @@ -280,6 +280,7 @@ async def to_code(config): cg.add(var.set_response_timeout(config[CONF_TIMEOUT].total_milliseconds)) cg.add(var.set_request_attempts(config[CONF_NUM_ATTEMPTS])) if CONF_TRANSMITTER_ID in config: + remote_base.request_protocol("midea") # ir_transmitter.h uses it from C++ cg.add_define("USE_REMOTE_TRANSMITTER") transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) cg.add(var.set_transmitter(transmitter_)) diff --git a/esphome/components/midea_ir/climate.py b/esphome/components/midea_ir/climate.py index 84bfeab0d46..e1b2b56ada4 100644 --- a/esphome/components/midea_ir/climate.py +++ b/esphome/components/midea_ir/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base import esphome.config_validation as cv from esphome.const import CONF_USE_FAHRENHEIT from esphome.types import ConfigType @@ -19,5 +19,9 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MideaIR).extend( async def to_code(config: ConfigType) -> None: + # midea_ir uses MideaProtocol from C++ and auto-loads coolix, whose coolix.cpp uses + # CoolixProtocol even when no coolix climate is configured + remote_base.request_protocol("midea") + remote_base.request_protocol("coolix") var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 19b8549f75a..27b6eb9fc82 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1,6 +1,11 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -40,11 +45,14 @@ from esphome.const import ( CONF_ZERO, ) from esphome.core import ID, coroutine +from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] + CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" @@ -90,9 +98,42 @@ REMOTE_TRANSMITTABLE_SCHEMA = cv.Schema( ) -async def register_listener(var, config): +# Listener and dumper lists are StaticVectors sized from these counts, so every registration +# must go through add_listener / add_dumper. Every receiver's list gets the same capacity, so +# the slots are keyed by receiver and the define is the largest count any one receiver needs. +LISTENER_COUNT_DEFINE = "REMOTE_BASE_LISTENER_COUNT" +DUMPER_COUNT_DEFINE = "REMOTE_BASE_DUMPER_COUNT" + + +_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) +_request_dumper_slot = cg.slot_counter(DUMPER_COUNT_DEFINE) + + +def add_listener(receiver: MockObj, listener: MockObj) -> None: + _request_listener_slot(str(receiver)) + cg.add(receiver.register_listener(listener)) + + +def add_dumper(receiver: MockObj, dumper: MockObj) -> None: + _request_dumper_slot(str(receiver)) + cg.add(receiver.register_dumper(dumper)) + + +async def register_listener(var: MockObj, config: ConfigType) -> None: receiver = await cg.get_variable(config[CONF_RECEIVER_ID]) - cg.add(receiver.register_listener(var)) + add_listener(receiver, var) + + +async def attach_receiver( + var: MockObj, config: ConfigType, key: str = CONF_RECEIVER_ID +) -> None: + """Link the configured receiver to an entity and register the entity as its listener. + + The C++ set_receiver() no longer registers the listener; the slot for it is counted here. + """ + receiver = await cg.get_variable(config[key]) + cg.add(var.set_receiver(receiver)) + add_listener(receiver, var) async def register_transmittable(var, config): @@ -100,8 +141,53 @@ async def register_transmittable(var, config): cg.add(var.set_transmitter(transmitter_)) -def register_binary_sensor(name, type, schema): - return BINARY_SENSOR_REGISTRY.register(name, type, schema) +# Registry names that share a protocol source file +def _protocol_stem(name: str) -> str: + if name.startswith("rc_switch"): + return "rc_switch" + if name == "canalsatld": + return "canalsat" + return name + + +def protocol_define(name: str) -> str: + return f"USE_REMOTE_PROTOCOL_{_protocol_stem(name).upper()}" + + +_PROTOCOL_STEMS = sorted( + path.name.removesuffix("_protocol.cpp") + for path in Path(__file__).parent.glob("*_protocol.cpp") +) + + +def request_protocol(name: str) -> None: + """Keep a protocol's source file in the build; components using it from C++ must call this.""" + if _protocol_stem(name) not in _PROTOCOL_STEMS: + raise ValueError( + f"Unknown remote protocol {name!r}; expected one of {', '.join(_PROTOCOL_STEMS)}" + ) + cg.add_define(protocol_define(name)) + + +# Only the protocol sources a configuration uses are compiled +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS} +) + + +def register_binary_sensor( + name: str, type: MockObj, schema: cv.Schema | dict +) -> Callable[[Callable[[MockObj, ConfigType], Any]], Callable]: + registerer = BINARY_SENSOR_REGISTRY.register(name, type, schema) + + def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable: + async def new_func(var: MockObj, config: ConfigType) -> None: + request_protocol(name) + await coroutine(func)(var, config) + + return registerer(new_func) + + return decorator def register_trigger(name, type, data_type): @@ -114,6 +200,7 @@ def register_trigger(name, type, data_type): def decorator(func): async def new_func(config): + request_protocol(name) var = cg.new_Pvariable(config[CONF_TRIGGER_ID]) await coroutine(func)(var, config) await automation.build_automation(var, [(data_type, "x")], config) @@ -131,6 +218,7 @@ def register_dumper(name, type, schema=None): def decorator(func): async def new_func(config, dumper_id): + request_protocol(name) var = cg.new_Pvariable(dumper_id) await coroutine(func)(var, config) return var @@ -171,6 +259,7 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): + request_protocol(name) var = cg.new_Pvariable(action_id, template_arg) await register_transmittable(var, config) if CONF_REPEAT in config: @@ -213,7 +302,13 @@ DUMPER_REGISTRY = Registry() def validate_dumpers(value): if isinstance(value, str) and value.lower() == "all": return validate_dumpers(list(DUMPER_REGISTRY.keys())) - return cv.validate_registry("dumper", DUMPER_REGISTRY)(value) + entries = cv.validate_registry("dumper", DUMPER_REGISTRY)(value) + # a dumper listed twice would register twice; the receiver holds one secondary dumper + return list( + { + next(k for k in entry if k in DUMPER_REGISTRY): entry for entry in entries + }.values() + ) def validate_triggers(base_schema): @@ -1439,7 +1534,7 @@ def validate_rc_switch_raw_code(value): def build_rc_switch_protocol(config): if isinstance(config, int): - return rc_switch_protocols[config] + return rc_switch_protocol(config) pl = config[CONF_PULSE_LENGTH] return RCSwitchBase( config[CONF_SYNC][0] * pl, @@ -1526,7 +1621,7 @@ RC_SWITCH_TRANSMITTER = cv.Schema( } ) -rc_switch_protocols = ns.RC_SWITCH_PROTOCOLS +rc_switch_protocol = ns.rc_switch_protocol RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 7ff32923bef..a309c124eed 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -191,9 +191,9 @@ class ABBWelcomeData { class ABBWelcomeProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ABBWelcomeData &src) override; - optional decode(RemoteReceiveData src) override; - void dump(const ABBWelcomeData &data) override; + void encode(RemoteTransmitData *dst, const ABBWelcomeData &src); + optional decode(RemoteReceiveData src); + void dump(const ABBWelcomeData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t data) const; diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 3f4e98bd438..98a55011552 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,9 +15,9 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const AEHAData &data) override; + void encode(RemoteTransmitData *dst, const AEHAData &data); + optional decode(RemoteReceiveData src); + void dump(const AEHAData &data); private: std::string format_data_(const std::vector &data); diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index 30b99dbeb77..ed9d6aa6712 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -16,9 +16,9 @@ struct Beo4Data { class Beo4Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const Beo4Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const Beo4Data &data) override; + void encode(RemoteTransmitData *dst, const Beo4Data &data); + optional decode(RemoteReceiveData src); + void dump(const Beo4Data &data); }; DECLARE_REMOTE_PROTOCOL(Beo4) diff --git a/esphome/components/remote_base/brennenstuhl_protocol.h b/esphome/components/remote_base/brennenstuhl_protocol.h index 1d5b6217147..bfea463b7d4 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.h +++ b/esphome/components/remote_base/brennenstuhl_protocol.h @@ -13,9 +13,9 @@ struct BrennenstuhlData { class BrennenstuhlProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const BrennenstuhlData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const BrennenstuhlData &data) override; + void encode(RemoteTransmitData *dst, const BrennenstuhlData &data); + optional decode(RemoteReceiveData src); + void dump(const BrennenstuhlData &data); }; DECLARE_REMOTE_PROTOCOL(Brennenstuhl) diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index 674fa99ea10..c71390c267c 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -21,9 +21,9 @@ struct ByronSXData { class ByronSXProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ByronSXData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ByronSXData &data) override; + void encode(RemoteTransmitData *dst, const ByronSXData &data); + optional decode(RemoteReceiveData src); + void dump(const ByronSXData &data); }; DECLARE_REMOTE_PROTOCOL(ByronSX) diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 5ba9115ea86..09bead18b3c 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -19,9 +19,9 @@ struct CanalSatLDData : public CanalSatData {}; class CanalSatBaseProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const CanalSatData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const CanalSatData &data) override; + void encode(RemoteTransmitData *dst, const CanalSatData &data); + optional decode(RemoteReceiveData src); + void dump(const CanalSatData &data); protected: uint16_t frequency_; diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index d9441e84178..29a306ce291 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -21,9 +21,9 @@ struct CoolixData { class CoolixProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const CoolixData &data) override; - optional decode(RemoteReceiveData data) override; - void dump(const CoolixData &data) override; + void encode(RemoteTransmitData *dst, const CoolixData &data); + optional decode(RemoteReceiveData data); + void dump(const CoolixData &data); }; DECLARE_REMOTE_PROTOCOL(Coolix) diff --git a/esphome/components/remote_base/dish_protocol.h b/esphome/components/remote_base/dish_protocol.h index c89f4e78e11..f319b55f432 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -13,9 +13,9 @@ struct DishData { class DishProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DishData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DishData &data) override; + void encode(RemoteTransmitData *dst, const DishData &data); + optional decode(RemoteReceiveData src); + void dump(const DishData &data); }; DECLARE_REMOTE_PROTOCOL(Dish) diff --git a/esphome/components/remote_base/dooya_protocol.h b/esphome/components/remote_base/dooya_protocol.h index 148c7c17bc8..954c3cf1d38 100644 --- a/esphome/components/remote_base/dooya_protocol.h +++ b/esphome/components/remote_base/dooya_protocol.h @@ -20,9 +20,9 @@ struct DooyaData { class DooyaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DooyaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DooyaData &data) override; + void encode(RemoteTransmitData *dst, const DooyaData &data); + optional decode(RemoteReceiveData src); + void dump(const DooyaData &data); }; DECLARE_REMOTE_PROTOCOL(Dooya) diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 693a1bbe85b..4e879f0f75b 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -19,9 +19,9 @@ struct DraytonData { class DraytonProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DraytonData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DraytonData &data) override; + void encode(RemoteTransmitData *dst, const DraytonData &data); + optional decode(RemoteReceiveData src); + void dump(const DraytonData &data); }; DECLARE_REMOTE_PROTOCOL(Drayton) diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index 3473a489b2c..663e50fb4b5 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -21,9 +21,9 @@ struct DysonData { class DysonProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DysonData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DysonData &data) override; + void encode(RemoteTransmitData *dst, const DysonData &data); + optional decode(RemoteReceiveData src); + void dump(const DysonData &data); }; DECLARE_REMOTE_PROTOCOL(Dyson) diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index f6b278771e0..0c8797af70c 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -31,9 +31,9 @@ class GoboxProtocol : public RemoteProtocol { void dump_timings_(const RawTimings &timings) const; public: - void encode(RemoteTransmitData *dst, const GoboxData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const GoboxData &data) override; + void encode(RemoteTransmitData *dst, const GoboxData &data); + optional decode(RemoteReceiveData src); + void dump(const GoboxData &data); }; DECLARE_REMOTE_PROTOCOL(Gobox) diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 9c45ba1a635..e1fd60411fc 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,9 +13,9 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const HaierData &data) override; + void encode(RemoteTransmitData *dst, const HaierData &data); + optional decode(RemoteReceiveData src); + void dump(const HaierData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t item); diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index f6e2548dead..5911664fc39 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -14,9 +14,9 @@ struct JVCData { class JVCProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const JVCData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const JVCData &data) override; + void encode(RemoteTransmitData *dst, const JVCData &data); + optional decode(RemoteReceiveData src); + void dump(const JVCData &data); }; DECLARE_REMOTE_PROTOCOL(JVC) diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 432313b87b2..335fbd164b1 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -24,9 +24,9 @@ struct KeeloqData { class KeeloqProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const KeeloqData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const KeeloqData &data) override; + void encode(RemoteTransmitData *dst, const KeeloqData &data); + optional decode(RemoteReceiveData src); + void dump(const KeeloqData &data); }; DECLARE_REMOTE_PROTOCOL(Keeloq) diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index 97159749956..91dfbadb0c2 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -16,9 +16,9 @@ struct LGData { class LGProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const LGData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const LGData &data) override; + void encode(RemoteTransmitData *dst, const LGData &data); + optional decode(RemoteReceiveData src); + void dump(const LGData &data); }; DECLARE_REMOTE_PROTOCOL(LG) diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index 18662ec7598..f0d2410fe27 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -27,9 +27,9 @@ struct MagiQuestData { class MagiQuestProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MagiQuestData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const MagiQuestData &data) override; + void encode(RemoteTransmitData *dst, const MagiQuestData &data); + optional decode(RemoteReceiveData src); + void dump(const MagiQuestData &data); }; DECLARE_REMOTE_PROTOCOL(MagiQuest) diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 47bad6826fc..85bbef1cb1f 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -67,9 +67,9 @@ class MideaData { class MideaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MideaData &src) override; - optional decode(RemoteReceiveData src) override; - void dump(const MideaData &data) override; + void encode(RemoteTransmitData *dst, const MideaData &src); + optional decode(RemoteReceiveData src); + void dump(const MideaData &data); }; DECLARE_REMOTE_PROTOCOL(Midea) diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index c967e72f134..a37fb93f4fd 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,9 +13,9 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const MirageData &data) override; + void encode(RemoteTransmitData *dst, const MirageData &data); + optional decode(RemoteReceiveData src); + void dump(const MirageData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t item); diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 7b310e8ba5b..1337f7a8b32 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -14,9 +14,9 @@ struct NECData { class NECProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const NECData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const NECData &data) override; + void encode(RemoteTransmitData *dst, const NECData &data); + optional decode(RemoteReceiveData src); + void dump(const NECData &data); }; DECLARE_REMOTE_PROTOCOL(NEC) diff --git a/esphome/components/remote_base/nexa_protocol.h b/esphome/components/remote_base/nexa_protocol.h index ebcd2a2c113..ebf85387b04 100644 --- a/esphome/components/remote_base/nexa_protocol.h +++ b/esphome/components/remote_base/nexa_protocol.h @@ -24,9 +24,9 @@ class NexaProtocol : public RemoteProtocol { void zero(RemoteTransmitData *dst) const; void sync(RemoteTransmitData *dst) const; - void encode(RemoteTransmitData *dst, const NexaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const NexaData &data) override; + void encode(RemoteTransmitData *dst, const NexaData &data); + optional decode(RemoteReceiveData src); + void dump(const NexaData &data); }; DECLARE_REMOTE_PROTOCOL(Nexa) diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index d13c0f27985..84df3c08b72 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -16,9 +16,9 @@ struct PanasonicData { class PanasonicProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const PanasonicData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const PanasonicData &data) override; + void encode(RemoteTransmitData *dst, const PanasonicData &data); + optional decode(RemoteReceiveData src); + void dump(const PanasonicData &data); }; DECLARE_REMOTE_PROTOCOL(Panasonic) diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index 514ab675016..d02bd3451f2 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -13,9 +13,9 @@ struct PioneerData { class PioneerProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const PioneerData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const PioneerData &data) override; + void encode(RemoteTransmitData *dst, const PioneerData &data); + optional decode(RemoteReceiveData src); + void dump(const PioneerData &data); }; DECLARE_REMOTE_PROTOCOL(Pioneer) diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index f4f6b2144d9..bfd04c5cd9a 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -30,9 +30,9 @@ class ProntoProtocol : public RemoteProtocol { std::string compensate_and_dump_sequence_(const RawTimings &data, uint16_t timebase); public: - void encode(RemoteTransmitData *dst, const ProntoData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ProntoData &data) override; + void encode(RemoteTransmitData *dst, const ProntoData &data); + optional decode(RemoteReceiveData src); + void dump(const ProntoData &data); }; DECLARE_REMOTE_PROTOCOL(Pronto) diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index dbb89e41c60..f6f0f33c6e2 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -14,9 +14,9 @@ struct RC5Data { class RC5Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RC5Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RC5Data &data) override; + void encode(RemoteTransmitData *dst, const RC5Data &data); + optional decode(RemoteReceiveData src); + void dump(const RC5Data &data); }; DECLARE_REMOTE_PROTOCOL(RC5) diff --git a/esphome/components/remote_base/rc6_protocol.h b/esphome/components/remote_base/rc6_protocol.h index fda9d98ecbb..c4a2e8529bb 100644 --- a/esphome/components/remote_base/rc6_protocol.h +++ b/esphome/components/remote_base/rc6_protocol.h @@ -15,9 +15,9 @@ struct RC6Data { class RC6Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RC6Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RC6Data &data) override; + void encode(RemoteTransmitData *dst, const RC6Data &data); + optional decode(RemoteReceiveData src); + void dump(const RC6Data &data); }; DECLARE_REMOTE_PROTOCOL(RC6) diff --git a/esphome/components/remote_base/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index 612558ca1c9..de16c55cb02 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -1,29 +1,21 @@ #include "rc_switch_protocol.h" + +#include +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::remote_base { static const char *const TAG = "remote.rc_switch"; -const RCSwitchBase RC_SWITCH_PROTOCOLS[9] = {RCSwitchBase(0, 0, 0, 0, 0, 0, false), - RCSwitchBase(350, 10850, 350, 1050, 1050, 350, false), - RCSwitchBase(650, 6500, 650, 1300, 1300, 650, false), - RCSwitchBase(3000, 7100, 400, 1100, 900, 600, false), - RCSwitchBase(380, 2280, 380, 1140, 1140, 380, false), - RCSwitchBase(3000, 7000, 500, 1000, 1000, 500, false), - RCSwitchBase(10350, 450, 450, 900, 900, 450, true), - RCSwitchBase(300, 9300, 150, 900, 900, 150, false), - RCSwitchBase(250, 2500, 250, 1250, 250, 250, false)}; - -RCSwitchBase::RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, - uint32_t one_high, uint32_t one_low, bool inverted) - : sync_high_(sync_high), - sync_low_(sync_low), - zero_high_(zero_high), - zero_low_(zero_low), - one_high_(one_high), - one_low_(one_low), - inverted_(inverted) {} +RCSwitchBase rc_switch_protocol(uint8_t index) { + RCSwitchBase protocol; + // entry 0 is the all-zero protocol, so an out of range index from a lambda transmits nothing + if (index >= std::size(RC_SWITCH_PROTOCOLS)) + index = 0; + progmem_memcpy(&protocol, &RC_SWITCH_PROTOCOLS[index], sizeof(protocol)); + return protocol; +} void RCSwitchBase::one(RemoteTransmitData *dst) const { if (!this->inverted_) { @@ -133,11 +125,11 @@ bool RCSwitchBase::decode(RemoteReceiveData &src, uint64_t *out_data, uint8_t *o optional RCSwitchBase::decode(RemoteReceiveData &src) const { RCSwitchData out; uint8_t out_nbits; - for (uint8_t i = 1; i <= 8; i++) { + for (size_t i = 1; i < std::size(RC_SWITCH_PROTOCOLS); i++) { src.reset(); const RCSwitchBase *protocol = &RC_SWITCH_PROTOCOLS[i]; if (protocol->decode(src, &out.code, &out_nbits) && out_nbits >= 3) { - out.protocol = i; + out.protocol = static_cast(i); return out; } } @@ -246,7 +238,7 @@ bool RCSwitchRawReceiver::matches(RemoteReceiveData src) { return decoded_nbits == this->nbits_ && (decoded_code & this->mask_) == (this->code_ & this->mask_); } bool RCSwitchDumper::dump(RemoteReceiveData src) { - for (uint8_t i = 1; i <= 8; i++) { + for (size_t i = 1; i < std::size(RC_SWITCH_PROTOCOLS); i++) { src.reset(); uint64_t out_data; uint8_t out_nbits; @@ -257,7 +249,7 @@ bool RCSwitchDumper::dump(RemoteReceiveData src) { buffer[j] = (out_data & ((uint64_t) 1 << (out_nbits - j - 1))) ? '1' : '0'; buffer[out_nbits] = '\0'; - ESP_LOGI(TAG, "Received RCSwitch Raw: protocol=%u data='%s'", i, buffer); + ESP_LOGI(TAG, "Received RCSwitch Raw: protocol=%u data='%s'", static_cast(i), buffer); // only send first decoded protocol return true; diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index 3224c04fb29..9ccea4d15a5 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -16,9 +16,16 @@ class RCSwitchBase { public: using ProtocolData = RCSwitchData; - RCSwitchBase() = default; - RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, uint32_t one_high, - uint32_t one_low, bool inverted); + constexpr RCSwitchBase() = default; + constexpr RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, + uint32_t one_high, uint32_t one_low, bool inverted) + : sync_high_(sync_high), + sync_low_(sync_low), + zero_high_(zero_high), + zero_low_(zero_low), + one_high_(one_high), + one_low_(one_low), + inverted_(inverted) {} void one(RemoteTransmitData *dst) const; @@ -58,10 +65,28 @@ class RCSwitchBase { uint32_t zero_low_{}; uint32_t one_high_{}; uint32_t one_low_{}; - bool inverted_{}; + uint32_t inverted_{}; // bool widened so every field is a word: the table is read from flash }; -extern const RCSwitchBase RC_SWITCH_PROTOCOLS[9]; +// Constant-initialized and kept in flash on every platform. The decoder reads entries in place +// through a pointer, which ESP8266 only allows while every field is a whole word; copies out of +// the table go through rc_switch_protocol() +static_assert(sizeof(RCSwitchBase) == 7 * sizeof(uint32_t), "RCSwitchBase must stay word-only for flash reads"); +inline constexpr RCSwitchBase RC_SWITCH_PROTOCOLS[] PROGMEM = { + {0, 0, 0, 0, 0, 0, false}, + {350, 10850, 350, 1050, 1050, 350, false}, + {650, 6500, 650, 1300, 1300, 650, false}, + {3000, 7100, 400, 1100, 900, 600, false}, + {380, 2280, 380, 1140, 1140, 380, false}, + {3000, 7000, 500, 1000, 1000, 500, false}, + {10350, 450, 450, 900, 900, 450, true}, + {300, 9300, 150, 900, 900, 150, false}, + {250, 2500, 250, 1250, 250, 250, false}, +}; + +/// RAM copy of RC_SWITCH_PROTOCOLS[index] (0 when out of range) for the transmit actions and the dumper, made with +/// progmem_memcpy so no byte load ever touches the flash table on ESP8266 +RCSwitchBase rc_switch_protocol(uint8_t index); uint64_t decode_binary_string(const std::string &data); diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 4d9bc55f216..5d1bba16b61 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -99,29 +99,48 @@ bool RemoteReceiverBinarySensorBase::on_receive(RemoteReceiveData src) { /* RemoteReceiverBase */ +// Slots are counted at code generation; a registration from C++ setup() has none +#ifdef REMOTE_BASE_LISTENER_COUNT +void RemoteReceiverBase::register_listener(RemoteReceiverListener *listener) { + if (this->listeners_.size() == REMOTE_BASE_LISTENER_COUNT) { + ESP_LOGE(TAG, "No %s slot: register it from to_code() with remote_base.add_%s", LOG_STR_LITERAL("listener"), + LOG_STR_LITERAL("listener")); + return; + } + this->listeners_.push_back(listener); +} +#endif + +#ifdef REMOTE_BASE_DUMPER_COUNT void RemoteReceiverBase::register_dumper(RemoteReceiverDumperBase *dumper) { if (dumper->is_secondary()) { - this->secondary_dumpers_.push_back(dumper); - } else { + if (this->secondary_dumper_ == nullptr) { + this->secondary_dumper_ = dumper; + return; + } + } else if (this->dumpers_.size() != REMOTE_BASE_DUMPER_COUNT) { this->dumpers_.push_back(dumper); + return; } + ESP_LOGE(TAG, "No %s slot: register it from to_code() with remote_base.add_%s", LOG_STR_LITERAL("dumper"), + LOG_STR_LITERAL("dumper")); } +#endif -void RemoteReceiverBase::call_listeners_() { +void RemoteReceiverBase::call_listeners_dumpers_() { +#ifdef REMOTE_BASE_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_receive(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); -} - -void RemoteReceiverBase::call_dumpers_() { +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT bool success = false; for (auto *dumper : this->dumpers_) { if (dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_))) success = true; } - if (!success) { - for (auto *dumper : this->secondary_dumpers_) - dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); - } + if (!success && this->secondary_dumper_ != nullptr) + this->secondary_dumper_->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); +#endif } void RemoteReceiverBinarySensorBase::dump_config() { LOG_BINARY_SENSOR("", "Remote Receiver Binary Sensor", this); } diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 4e2ed4b71cb..67e5799bcaf 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -1,12 +1,14 @@ +#pragma once + +#include #include #include -#pragma once - #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::remote_base { @@ -141,6 +143,22 @@ class RemoteRMTChannel { #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 +// Protocol shapes, checked where a protocol is used so a missing method fails at the use site +// instead of deep inside a template body. Receive-only protocols such as RCSwitchBase decode +// without encoding. +template +concept RemoteProtocolDecoder = requires(T proto, RemoteReceiveData src) { + { proto.decode(src) } -> std::same_as>; +}; +template +concept RemoteProtocolDumper = RemoteProtocolDecoder && requires(T proto, const typename T::ProtocolData &data) { + proto.dump(data); +}; +template +concept RemoteProtocolEncoder = requires(T proto, RemoteTransmitData *dst, const typename T::ProtocolData &data) { + proto.encode(dst, data); +}; + class RemoteTransmitterBase : public RemoteComponentBase { public: RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} @@ -162,8 +180,8 @@ class RemoteTransmitterBase : public RemoteComponentBase { this->temp_.reset(); return TransmitCall(this); } - template - void transmit(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + template + void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { auto call = this->transmit(); Protocol().encode(call.get_data(), data); call.set_send_times(send_times); @@ -194,24 +212,37 @@ class RemoteReceiverDumperBase { class RemoteReceiverBase : public RemoteComponentBase { public: RemoteReceiverBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} - void register_listener(RemoteReceiverListener *listener) { this->listeners_.push_back(listener); } + // Slots are counted at code generation; without one the call fails at compile time with the same message + // the runtime check logs +#ifdef REMOTE_BASE_LISTENER_COUNT + void register_listener(RemoteReceiverListener *listener); +#else + template void register_listener(T *) { + static_assert(sizeof(T) == 0, "No listener slot: register it from to_code() with remote_base.add_listener"); + } +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT void register_dumper(RemoteReceiverDumperBase *dumper); +#else + template void register_dumper(T *) { + static_assert(sizeof(T) == 0, "No dumper slot: register it from to_code() with remote_base.add_dumper"); + } +#endif void set_tolerance(uint32_t tolerance, ToleranceMode tolerance_mode) { this->tolerance_ = tolerance; this->tolerance_mode_ = tolerance_mode; } protected: - void call_listeners_(); - void call_dumpers_(); - void call_listeners_dumpers_() { - this->call_listeners_(); - this->call_dumpers_(); - } + void call_listeners_dumpers_(); - std::vector listeners_; - std::vector dumpers_; - std::vector secondary_dumpers_; +#ifdef REMOTE_BASE_LISTENER_COUNT + StaticVector listeners_; +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT + StaticVector dumpers_; + RemoteReceiverDumperBase *secondary_dumper_{nullptr}; // runs only when no primary dumper matched +#endif RawTimings temp_; uint32_t tolerance_{25}; ToleranceMode tolerance_mode_{TOLERANCE_MODE_PERCENTAGE}; @@ -229,15 +260,14 @@ class RemoteReceiverBinarySensorBase : public binary_sensor::BinarySensorInitial /* TEMPLATES */ +// Protocols are used only through their concrete type (see the RemoteProtocol* concepts); encode/decode/dump +// stay non-virtual so unused ones link out template class RemoteProtocol { public: using ProtocolData = T; - virtual void encode(RemoteTransmitData *dst, const ProtocolData &data) = 0; - virtual optional decode(RemoteReceiveData src) = 0; - virtual void dump(const ProtocolData &data) = 0; }; -template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { +template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { public: RemoteReceiverBinarySensor() : RemoteReceiverBinarySensorBase() {} @@ -255,7 +285,7 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin T::ProtocolData data_; }; -template +template class RemoteReceiverTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { @@ -276,8 +306,8 @@ class RemoteTransmittable { void set_transmitter(RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } protected: - template - void transmit_(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + template + void transmit_(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { this->transmitter_->transmit(data, send_times, send_wait); } RemoteTransmitterBase *transmitter_; @@ -298,7 +328,7 @@ template class RemoteTransmitterActionBase : public RemoteTransm virtual void encode(RemoteTransmitData *dst, Ts... x) = 0; }; -template class RemoteReceiverDumper : public RemoteReceiverDumperBase { +template class RemoteReceiverDumper : public RemoteReceiverDumperBase { public: bool dump(RemoteReceiveData src) override { auto proto = T(); diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index 3582dac398b..8db025f812a 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -12,9 +12,9 @@ struct RoombaData { class RoombaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RoombaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RoombaData &data) override; + void encode(RemoteTransmitData *dst, const RoombaData &data); + optional decode(RemoteReceiveData src); + void dump(const RoombaData &data); }; DECLARE_REMOTE_PROTOCOL(Roomba) diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index 4f15d906e76..df4e1af8d8c 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -16,9 +16,9 @@ struct Samsung36Data { class Samsung36Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const Samsung36Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const Samsung36Data &data) override; + void encode(RemoteTransmitData *dst, const Samsung36Data &data); + optional decode(RemoteReceiveData src); + void dump(const Samsung36Data &data); }; DECLARE_REMOTE_PROTOCOL(Samsung36) diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index bb234d681de..dfa22ff85ce 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -14,9 +14,9 @@ struct SamsungData { class SamsungProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SamsungData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SamsungData &data) override; + void encode(RemoteTransmitData *dst, const SamsungData &data); + optional decode(RemoteReceiveData src); + void dump(const SamsungData &data); }; DECLARE_REMOTE_PROTOCOL(Samsung) diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index eb873e8b7dc..f83b2908b61 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -16,9 +16,9 @@ struct SonyData { class SonyProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SonyData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SonyData &data) override; + void encode(RemoteTransmitData *dst, const SonyData &data); + optional decode(RemoteReceiveData src); + void dump(const SonyData &data); }; DECLARE_REMOTE_PROTOCOL(Sony) diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 7caf5eab867..40a5c2daec9 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -17,9 +17,9 @@ struct SymphonyData { class SymphonyProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SymphonyData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SymphonyData &data) override; + void encode(RemoteTransmitData *dst, const SymphonyData &data); + optional decode(RemoteReceiveData src); + void dump(const SymphonyData &data); }; DECLARE_REMOTE_PROTOCOL(Symphony) diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index 8a853005acb..35d5af314cb 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -14,9 +14,9 @@ struct ToshibaAcData { class ToshibaAcProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ToshibaAcData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ToshibaAcData &data) override; + void encode(RemoteTransmitData *dst, const ToshibaAcData &data); + optional decode(RemoteReceiveData src); + void dump(const ToshibaAcData &data); }; DECLARE_REMOTE_PROTOCOL(ToshibaAc) diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 285c9f21257..8e965a5c739 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -16,9 +16,9 @@ struct TotoData { class TotoProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const TotoData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const TotoData &data) override; + void encode(RemoteTransmitData *dst, const TotoData &data); + optional decode(RemoteReceiveData src); + void dump(const TotoData &data); }; DECLARE_REMOTE_PROTOCOL(Toto) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 6e8c73d331a..b2fd87165e4 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -221,11 +221,11 @@ async def to_code(config: ConfigType) -> None: dumpers = await remote_base.build_dumpers(config[CONF_DUMP]) for dumper in dumpers: - cg.add(var.register_dumper(dumper)) + remote_base.add_dumper(var, dumper) triggers = await remote_base.build_triggers(config) for trigger in triggers: - cg.add(var.register_listener(trigger)) + remote_base.add_listener(var, trigger) await cg.register_component(var, config) cg.add( diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 3b1e7352f98..e5f8544f2fe 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base import esphome.config_validation as cv from esphome.const import CONF_MODEL from esphome.types import ConfigType @@ -26,5 +26,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).exten async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("toshiba_ac") # used from C++ var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9144e65576f..6b9b9eda43f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -137,6 +137,43 @@ #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 +#define REMOTE_BASE_DUMPER_COUNT 1 +#define REMOTE_BASE_LISTENER_COUNT 1 +#define USE_REMOTE_PROTOCOL_ABBWELCOME +#define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_BEO4 +#define USE_REMOTE_PROTOCOL_BRENNENSTUHL +#define USE_REMOTE_PROTOCOL_BYRONSX +#define USE_REMOTE_PROTOCOL_CANALSAT +#define USE_REMOTE_PROTOCOL_COOLIX +#define USE_REMOTE_PROTOCOL_DISH +#define USE_REMOTE_PROTOCOL_DOOYA +#define USE_REMOTE_PROTOCOL_DRAYTON +#define USE_REMOTE_PROTOCOL_DYSON +#define USE_REMOTE_PROTOCOL_GOBOX +#define USE_REMOTE_PROTOCOL_HAIER +#define USE_REMOTE_PROTOCOL_JVC +#define USE_REMOTE_PROTOCOL_KEELOQ +#define USE_REMOTE_PROTOCOL_LG +#define USE_REMOTE_PROTOCOL_MAGIQUEST +#define USE_REMOTE_PROTOCOL_MIDEA +#define USE_REMOTE_PROTOCOL_MIRAGE +#define USE_REMOTE_PROTOCOL_NEC +#define USE_REMOTE_PROTOCOL_NEXA +#define USE_REMOTE_PROTOCOL_PANASONIC +#define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PRONTO +#define USE_REMOTE_PROTOCOL_RAW +#define USE_REMOTE_PROTOCOL_RC5 +#define USE_REMOTE_PROTOCOL_RC6 +#define USE_REMOTE_PROTOCOL_RC_SWITCH +#define USE_REMOTE_PROTOCOL_ROOMBA +#define USE_REMOTE_PROTOCOL_SAMSUNG +#define USE_REMOTE_PROTOCOL_SAMSUNG36 +#define USE_REMOTE_PROTOCOL_SONY +#define USE_REMOTE_PROTOCOL_SYMPHONY +#define USE_REMOTE_PROTOCOL_TOSHIBA_AC +#define USE_REMOTE_PROTOCOL_TOTO #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 53b59cb1240..fc44d27f472 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Hashable from dataclasses import dataclass, field import logging @@ -142,9 +142,10 @@ _SLOT_COUNTER_DOMAIN = "slot_counter" @dataclass class _SlotCounterState: - """Per-run slot counter state: requested counts and already-emitted defines.""" + """Per-run slot counter state: requested counts per define and key, and + already-emitted defines.""" - counts: dict[str, int] = field(default_factory=dict) + counts: dict[str, dict[Hashable, int]] = field(default_factory=dict) emitted: set[str] = field(default_factory=set) @@ -156,11 +157,13 @@ def _get_slot_counter_state() -> _SlotCounterState: def get_slot_count(define: str) -> int: - """Number of slots requested so far for `define`.""" - return _get_slot_counter_state().counts.get(define, 0) + """Value `define` would be emitted with so far: the largest count requested + under any one key, which is the plain request count when no key is used.""" + counts = _get_slot_counter_state().counts.get(define) + return max(counts.values()) if counts else 0 -def slot_counter(define: str) -> Callable[[], None]: +def slot_counter(define: str) -> Callable[..., None]: """Create a request_slot function for codegen-sized storage. The pattern behind a StaticVector listener array: a consumer's to_code @@ -169,6 +172,11 @@ def slot_counter(define: str) -> Callable[[], None]: emitted with the requested count. No requests, no define: the guarded storage and its registration method compile out entirely. + When several objects each declare the storage at the same size (one list + per receiver, per hub, ...) the caller passes the owning object as `key` + and the define becomes the largest count any one key requested, not the + total. Requests without a key share one count. + The counts live in a table under CORE.data, which clears between runs. A request arriving after the define was already emitted raises instead of silently undercounting: the define would keep the stale smaller value and @@ -179,10 +187,10 @@ def slot_counter(define: str) -> Callable[[], None]: async def emit_job() -> None: state = _get_slot_counter_state() state.emitted.add(define) - # Scheduled only by the first request, so the count is always >= 1 here. - add_define(define, state.counts[define]) + # Scheduled only by the first request, so there is at least one count here. + add_define(define, max(state.counts[define].values())) - def request_slot() -> None: + def request_slot(key: Hashable = None) -> None: state = _get_slot_counter_state() if define in state.emitted: raise ValueError( @@ -190,10 +198,16 @@ def slot_counter(define: str) -> Callable[[], None]: f"define was emitted; request slots from to_code, not from a " f"job running after FINAL emission" ) - counts = state.counts - counts[define] = (count := counts.get(define, 0) + 1) - if count == 1: + counts = state.counts.get(define) + if counts is None: + counts = state.counts[define] = {} CORE.add_job(emit_job) + elif (key is None) != (None in counts): + # a keyed and an unkeyed request would compare buckets instead of adding up + raise ValueError( + f"slot_counter('{define}'): every request must use a key, or none of them" + ) + counts[key] = counts.get(key, 0) + 1 return request_slot diff --git a/tests/component_tests/remote_receiver/__init__.py b/tests/component_tests/remote_receiver/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/remote_receiver/config/receiver_bare.yaml b/tests/component_tests/remote_receiver/config/receiver_bare.yaml new file mode 100644 index 00000000000..b4741948015 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_bare.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml b/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml new file mode 100644 index 00000000000..32c1b07f579 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +remote_receiver: + - id: rcvr + pin: GPIO4 + dump: + - nec + - rc_switch + on_nec: + then: + - logger.log: nec + +binary_sensor: + - platform: remote_receiver + name: Remote Input + nec: + address: 0x1234 + command: 0x5678 diff --git a/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml b/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml new file mode 100644 index 00000000000..c443a842f23 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr_ir + pin: GPIO4 + - id: rcvr_rf + pin: GPIO5 + +infrared: + - platform: ir_rf_proxy + name: IR Receiver + remote_receiver_id: rcvr_ir + +radio_frequency: + - platform: ir_rf_proxy + name: RF Receiver + frequency: 433.92MHz + remote_receiver_id: rcvr_rf diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py new file mode 100644 index 00000000000..f381a640929 --- /dev/null +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -0,0 +1,91 @@ +"""Listener and dumper StaticVector sizes come from codegen slot counts.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components import remote_base +import esphome.config_validation as cv + +from ..helpers import get_define_value + + +def test_dumper_and_listener_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_dumpers.yaml")) + # nec and rc_switch dumpers + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2" + # on_nec trigger plus the remote_receiver binary sensor + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "2" + + +def test_bare_receiver_emits_no_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_bare.yaml")) + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") is None + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") is None + + +def test_proxy_receivers_count_as_listeners( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_proxies.yaml")) + # one proxy entity listens on each of the two receivers; every receiver's list gets the + # capacity of the busiest one, so this is the largest per receiver count, not the sum + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "1" + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") is None + + +def test_only_used_protocol_sources_are_compiled( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_dumpers.yaml")) + excluded = set(remote_base.FILTER_SOURCE_FILES()) + assert "nec_protocol.cpp" not in excluded + assert "rc_switch_protocol.cpp" not in excluded + assert "sony_protocol.cpp" in excluded + assert "remote_base.cpp" not in excluded + + +def test_every_registry_name_maps_to_a_protocol_source() -> None: + """A registry name must resolve to a source file or request_protocol rejects it.""" + names = ( + set(remote_base.BINARY_SENSOR_REGISTRY) + | set(remote_base.DUMPER_REGISTRY) + | {key.removeprefix("on_") for key in remote_base.TRIGGER_REGISTRY} + | { + key.removeprefix("remote_transmitter.transmit_") + for key in ACTION_REGISTRY + if key.startswith("remote_transmitter.transmit_") + } + ) + assert len(names) > 40 + for name in names: + assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name + + +def test_request_protocol_rejects_unknown_names() -> None: + """A misspelled protocol would otherwise surface only as a link error.""" + with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"): + remote_base.request_protocol("toshiba") + + +def test_dump_list_is_deduplicated_across_forms() -> None: + dumpers = remote_base.validate_dumpers(["raw", {"raw": None}, "nec", "nec"]) + assert [ + next(k for k in entry if k in remote_base.DUMPER_REGISTRY) for entry in dumpers + ] == ["raw", "nec"] + + +@pytest.mark.parametrize("bad", [["nec", None], [5]]) +def test_dump_list_rejects_invalid_entries_with_a_validation_error(bad: list) -> None: + with pytest.raises(cv.Invalid): + remote_base.validate_dumpers(bad) diff --git a/tests/components/remote_receiver/bare-common.yaml b/tests/components/remote_receiver/bare-common.yaml new file mode 100644 index 00000000000..c100c5c2da4 --- /dev/null +++ b/tests/components/remote_receiver/bare-common.yaml @@ -0,0 +1,6 @@ +# A receiver with no dumpers and no listeners compiles both lists out. +# Only built while remote_receiver is tested in isolation: the counts are global defines, +# so this variant cannot be merged with configs that register any. +remote_receiver: + - id: rcvr_bare + pin: ${pin} diff --git a/tests/components/remote_receiver/test-bare.esp32-idf.yaml b/tests/components/remote_receiver/test-bare.esp32-idf.yaml new file mode 100644 index 00000000000..152853b65fb --- /dev/null +++ b/tests/components/remote_receiver/test-bare.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + pin: GPIO2 + +packages: + bare: !include bare-common.yaml diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 1c0e0d0a931..725c1daebb4 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -187,6 +187,31 @@ def test_slot_counter_emits_requested_count() -> None: assert _define_value("TEST_SLOT_COUNT") == "2" +def test_slot_counter_keyed_emits_largest_count() -> None: + """Keyed requests size storage every key declares at the same capacity: + the define is the busiest key's count, not the total over all keys.""" + request = ch.slot_counter("TEST_SLOT_COUNT_KEYED") + request("rx_a") + request("rx_a") + request("rx_a") + request("rx_b") + assert ch.get_slot_count("TEST_SLOT_COUNT_KEYED") == 3 + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_KEYED") == "3" + + +def test_slot_counter_rejects_mixed_keyed_and_unkeyed_requests() -> None: + """A keyed and an unkeyed request for one define cannot be sized together.""" + request = ch.slot_counter("TEST_SLOT_COUNT_MIXED") + request("rx_a") + with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED"): + request() + unkeyed = ch.slot_counter("TEST_SLOT_COUNT_MIXED_2") + unkeyed() + with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED_2"): + unkeyed("rx_a") + + def test_slot_counter_without_requests_emits_nothing() -> None: """No requests, no job, no define — the guarded storage compiles out.""" ch.slot_counter("TEST_SLOT_COUNT_UNUSED") From 2578f17dc8705b0a18ce4a67f6de4bad8b2c6101 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:25:41 -0500 Subject: [PATCH 049/266] Bump bundled esphome-device-builder to 1.14.7 (#19096) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index cfa47fbdad2..6f500dbe6f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 RUN \ platformio settings set enable_telemetry No \ From 2b71d5496d1c415e335a637c0839259d2ba7f39a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:45:02 -0400 Subject: [PATCH 050/266] [const] Centralize definition of `CONF_MANUFACTURER` (#19098) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32_ble_server/__init__.py | 2 +- esphome/components/sendspin/__init__.py | 2 +- tests/component_tests/sendspin/test_device_info.py | 7 ++----- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 49a625e3f1f..256ab5c0a3c 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -30,6 +30,7 @@ CONF_KEYS = "keys" CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" +CONF_MANUFACTURER = "manufacturer" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d8095cd7020..118ae06e420 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,6 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv @@ -41,7 +42,6 @@ CONF_DESCRIPTORS = "descriptors" CONF_ENDIANNESS = "endianness" CONF_FIRMWARE_VERSION = "firmware_version" CONF_INDICATE = "indicate" -CONF_MANUFACTURER = "manufacturer" CONF_MANUFACTURER_DATA = "manufacturer_data" CONF_MAX_CLIENTS = "max_clients" CONF_ON_WRITE = "on_write" diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index c21047c70a7..fda4d4f954c 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg from esphome.components import esp32, network, psram, socket, wifi +from esphome.components.const import CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -33,7 +34,6 @@ CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" CONF_FIRMWARE_VERSION = "firmware_version" -CONF_MANUFACTURER = "manufacturer" # An empty device information string would be sent to the server as an empty value rather than # falling back, so reject it instead of silently substituting the fallback. The 127 byte cap keeps diff --git a/tests/component_tests/sendspin/test_device_info.py b/tests/component_tests/sendspin/test_device_info.py index 833dd398b41..61c10676dad 100644 --- a/tests/component_tests/sendspin/test_device_info.py +++ b/tests/component_tests/sendspin/test_device_info.py @@ -8,11 +8,8 @@ from pathlib import Path import pytest from esphome import config_validation as cv -from esphome.components.sendspin import ( - CONF_FIRMWARE_VERSION, - CONF_MANUFACTURER, - CONFIG_SCHEMA, -) +from esphome.components.const import CONF_MANUFACTURER +from esphome.components.sendspin import CONF_FIRMWARE_VERSION, CONFIG_SCHEMA from esphome.const import CONF_MODEL, PlatformFramework from tests.component_tests.types import SetCoreConfigCallable From 9f9df85aa00bbac9d4c0db6905e538a80f279834 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:04:48 +0000 Subject: [PATCH 051/266] Bump aioesphomeapi from 46.4.0 to 46.4.1 (#19104) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 72c42dad32f..c73887a39d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.4.0 click==8.3.3 -aioesphomeapi==46.4.0 +aioesphomeapi==46.4.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 From ff9b2a1c83edb7b542fa04f93407b86a5c04bcde Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 18:20:40 -0500 Subject: [PATCH 052/266] [remote_receiver] Size the RMT ring buffer from receive symbols by default (#19100) --- .../components/remote_receiver/__init__.py | 14 ++++--- .../remote_receiver/remote_receiver.h | 4 +- .../remote_receiver/remote_receiver_rmt.cpp | 38 +++++++++++-------- .../config/receiver_buffer_size.yaml | 10 +++++ .../config/receiver_esp32_c2.yaml | 12 ++++++ .../config/receiver_esp8266.yaml | 9 +++++ .../remote_receiver/test_buffer_size.py | 28 ++++++++++++++ .../remote_receiver/test_slot_counts.py | 4 +- 8 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_esp8266.yaml create mode 100644 tests/component_tests/remote_receiver/test_buffer_size.py diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index b2fd87165e4..6eaecf7ab00 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -114,15 +114,18 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance, cv.SplitDefault( CONF_BUFFER_SIZE, - esp32="10000b", - esp32_c2="1000b", - esp32_c61="1000b", + esp32=cv.UNDEFINED, + # the pulse ring needs a size; only RMT targets size themselves in setup() + **{ + f"esp32_{variant.removeprefix('ESP32').lower()}": "1000b" + for variant in esp32_rmt.VARIANTS_NO_RMT + }, esp8266="1000b", bk72xx="1000b", ln882x="1000b", rtl87xx="1000b", rp2="1000b", - ): cv.validate_bytes, + ): cv.All(cv.validate_bytes, cv.int_range(min=64)), cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=4294967295)), @@ -233,7 +236,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_TOLERANCE][CONF_VALUE], config[CONF_TOLERANCE][CONF_TYPE] ) ) - cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) + if CONF_BUFFER_SIZE in config: + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) cg.add(var.set_filter_us(config[CONF_FILTER])) cg.add(var.set_idle_us(config[CONF_IDLE])) diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index f9ec054fe31..e59a8b25573 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -47,7 +47,7 @@ struct RemoteReceiverComponentStore { /// The position last read from volatile uint32_t buffer_read{0}; bool overflow{false}; - uint32_t buffer_size{1000}; + uint32_t buffer_size{0}; uint32_t receive_size{0}; uint32_t filter_symbols{0}; esp_err_t error{ESP_OK}; @@ -101,7 +101,7 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, HighFrequencyLoopRequester high_freq_; #endif - uint32_t buffer_size_{}; + uint32_t buffer_size_{}; // 0 on RMT targets: sized from receive_symbols in setup() uint32_t filter_us_{10}; uint32_t idle_us_{10000}; }; diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 632ca9763ae..4eebbbb16f1 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -10,6 +10,7 @@ namespace esphome::remote_receiver { static const char *const TAG = "remote_receiver"; +static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; @@ -104,7 +105,11 @@ void RemoteReceiverComponent::setup() { this->store_.config.signal_range_max_ns = this->idle_us_ * 1000; this->store_.filter_symbols = this->filter_symbols_; this->store_.receive_size = this->receive_symbols_ * sizeof(rmt_symbol_word_t); - this->store_.buffer_size = std::max((event_size + this->store_.receive_size) * 2, this->buffer_size_); + // one slot per pending rmt_receive; two are the floor (one filling while one is decoded), and + // the default of four covers a few frames queued across a stalled loop pass + const uint32_t slot_size = event_size + this->store_.receive_size; + this->store_.buffer_size = + this->buffer_size_ != 0 ? std::max(slot_size * 2, this->buffer_size_) : slot_size * DEFAULT_BUFFER_SLOTS; this->store_.buffer = new uint8_t[this->store_.buffer_size]; error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size, &this->store_.config); @@ -117,20 +122,23 @@ void RemoteReceiverComponent::setup() { } void RemoteReceiverComponent::dump_config() { - ESP_LOGCONFIG(TAG, - "Remote Receiver:\n" - " Clock resolution: %" PRIu32 " hz\n" - " RMT symbols: %" PRIu32 "\n" - " Filter symbols: %" PRIu32 "\n" - " Receive symbols: %" PRIu32 "\n" - " Tolerance: %" PRIu32 "%s\n" - " Carrier frequency: %" PRIu32 " hz\n" - " Carrier duty: %u%%\n" - " Filter out pulses shorter than: %" PRIu32 " us\n" - " Signal is done after %" PRIu32 " us of no changes", - this->clock_resolution_, this->rmt_symbols_, this->filter_symbols_, this->receive_symbols_, - this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", - this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); + ESP_LOGCONFIG( + TAG, + "Remote Receiver:\n" + " Clock resolution: %" PRIu32 " hz\n" + " RMT symbols: %" PRIu32 "\n" + " Filter symbols: %" PRIu32 "\n" + " Receive symbols: %" PRIu32 "\n" + " Buffer size: %" PRIu32 " bytes\n" + " Tolerance: %" PRIu32 "%s\n" + " Carrier frequency: %" PRIu32 " hz\n" + " Carrier duty: %u%%\n" + " Filter out pulses shorter than: %" PRIu32 " us\n" + " Signal is done after %" PRIu32 " us of no changes", + this->clock_resolution_, this->rmt_symbols_, this->filter_symbols_, this->receive_symbols_, + this->store_.buffer_size, this->tolerance_, + (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), + this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); if (this->is_failed()) { ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), diff --git a/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml b/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml new file mode 100644 index 00000000000..0b334954ebb --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr + pin: GPIO4 + buffer_size: 2kb diff --git a/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml b/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml new file mode 100644 index 00000000000..c4497fefd87 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32-c2-devkitm-1 + variant: esp32c2 + framework: + type: esp-idf + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml b/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml new file mode 100644 index 00000000000..f22d00d630f --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/test_buffer_size.py b/tests/component_tests/remote_receiver/test_buffer_size.py new file mode 100644 index 00000000000..9bfd12d9f55 --- /dev/null +++ b/tests/component_tests/remote_receiver/test_buffer_size.py @@ -0,0 +1,28 @@ +"""buffer_size reaches the receiver when set, and always on the pulse ring targets.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_explicit_buffer_size_is_passed_through( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("receiver_buffer_size.yaml")) + assert "rcvr->set_buffer_size(2000);" in main_cpp + + +def test_pulse_ring_target_keeps_a_default( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("receiver_esp8266.yaml")) + assert "rcvr->set_buffer_size(1000);" in main_cpp + + +def test_esp32_variant_without_rmt_keeps_a_default( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("receiver_esp32_c2.yaml")) + assert "rcvr->set_buffer_size(1000);" in main_cpp diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py index f381a640929..4d69e6d923b 100644 --- a/tests/component_tests/remote_receiver/test_slot_counts.py +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -27,7 +27,9 @@ def test_bare_receiver_emits_no_counts( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - generate_main(component_config_path("receiver_bare.yaml")) + main_cpp = generate_main(component_config_path("receiver_bare.yaml")) + # the RMT ring is sized in setup() unless buffer_size is set + assert "set_buffer_size" not in main_cpp assert get_define_value("REMOTE_BASE_DUMPER_COUNT") is None assert get_define_value("REMOTE_BASE_LISTENER_COUNT") is None From eecea15f4f714af7dd7278cd2433ee3c800db92f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:42:08 +1000 Subject: [PATCH 053/266] [core][lvgl] Migrate codegen helpers from LVGL to core code (#19105) Co-authored-by: Claude Sonnet 5 --- esphome/components/lvgl/automation.py | 3 +- esphome/components/lvgl/defines.py | 43 +---------- esphome/components/lvgl/lv_validation.py | 4 +- esphome/components/lvgl/widgets/__init__.py | 3 +- esphome/cpp_generator.py | 39 ++++++++++ tests/unit_tests/test_cpp_generator.py | 79 +++++++++++++++++++++ 6 files changed, 122 insertions(+), 49 deletions(-) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index a62f466413f..c23a36c3892 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -14,7 +14,7 @@ from esphome.const import ( CONF_TIMEOUT, ) from esphome.core import Lambda -from esphome.cpp_generator import TemplateArguments, get_variable +from esphome.cpp_generator import StaticCastExpression, TemplateArguments, get_variable from esphome.cpp_types import nullptr from .defines import ( @@ -30,7 +30,6 @@ from .defines import ( CONF_SHOW_SNOW, CONF_TOP_LAYER, PARTS, - StaticCastExpression, add_warning, get_focused_widgets, get_options, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 1eee8041f9a..73fc58736bb 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -10,12 +10,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import ( - CallExpression, - LambdaExpression, - MockObj, - MockObjClass, -) +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType @@ -157,17 +152,6 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) -class StaticCastExpression(Expression): - __slots__ = ("type", "exp") - - def __init__(self, type: Any, exp: SafeExpType): - self.type = str(type) - self.exp = cg.safe_exp(exp) - - def __str__(self): - return f"static_cast<{self.type}>({self.exp})" - - def add_define(macro: str, value="1"): lv_defines = get_defines() value = str(value) @@ -192,31 +176,6 @@ def addr(arg) -> MockObj: return MockObj(f"&{arg}") -def call_lambda(lamb: LambdaExpression) -> Expression: - """ - Given a lambda, either reduce to a simple expression or call it, possibly with parameters - from the surrounding context - :param lamb: - :return: - """ - expr = lamb.content.strip() - if expr.startswith("return") and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression - expr = cg.RawExpression(expr[6:-1].strip()) - # Don't cast if the return type is a class - if isinstance(lamb.return_type, MockObjClass): - return expr - return StaticCastExpression(lamb.return_type, expr) - # If lambda has parameters, call it with their names - # Parameter names come from hardcoded component code (like "x", "it", "event") - # not from user input, so they're safe to use directly - if lamb.parameters and lamb.parameters.parameters: - return CallExpression( - lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] - ) - return CallExpression(lamb) - - class LValidator: """ A validator for a particular type used in LVGL. Usable in configs as a validator, also diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 42352b96023..6f86e49e511 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.cpp_types import ESPTime, int32, uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -33,9 +33,7 @@ from .defines import ( LV_FONTS, LValidator, LvConstant, - StaticCastExpression, add_lv_use, - call_lambda, get_esphome_fonts_used, get_lv_fonts_used, get_lv_images_used, diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index c9099e3c3a5..a524fe761f9 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( ) from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, call_lambda from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression @@ -42,7 +42,6 @@ from ..defines import ( STATES, LValidator, add_lv_use, - call_lambda, get_styles_used, get_theme_widget_map, get_widget_map, diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index e6b8c0de42c..173002438ad 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1187,3 +1187,42 @@ class MockObjClass(MockObj): def __repr__(self): return f"MockObjClass<{str(self.base)}, parents={self._parents}>" + + +class StaticCastExpression(Expression): + __slots__ = ("type", "exp") + + def __init__(self, type: Any, exp: SafeExpType): + self.type = str(type) + self.exp = safe_exp(exp) + + def __str__(self): + return f"static_cast<{self.type}>({self.exp})" + + +def call_lambda(lamb: LambdaExpression) -> Expression: + """ + Given a lambda, either reduce to a simple expression or call it, possibly with parameters + from the surrounding context. + This is for use only with value-returning lambdas, used in places where the value of a lambda call is needed. + :param lamb: The LambdaExpression to call or reduce + :return: An Expression representing the result of calling the lambda or reducing it to a simple expression + """ + # Developer error if this is called with a lambda that doesn't have a return type + assert lamb.return_type is not None, "Lambda must have a return type to be called" + expr = lamb.content.strip() + if re.match(r"^return\b", expr) and expr.endswith(";"): + # Convert a lambda returning a simple expression to just that expression + expr = RawExpression(expr[6:-1].strip()) + # Don't cast if the return type is a class + if isinstance(lamb.return_type, MockObjClass): + return expr + return StaticCastExpression(lamb.return_type, expr) + # If lambda has parameters, call it with their names + # Parameter names come from hardcoded component code (like "x", "it", "event") + # not from user input, so they're safe to use directly + if lamb.parameters and lamb.parameters.parameters: + return CallExpression( + lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] + ) + return CallExpression(lamb) diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 81ae586e23b..052513ce979 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -85,6 +85,15 @@ class TestCallExpression: assert actual == 'my_function(1, "2", false)' +class TestStaticCastExpression: + def test_str(self): + target = cg.StaticCastExpression(ct.bool_, 42) + + actual = str(target) + + assert actual == "static_cast(42)" + + class TestStructInitializer: def test_str(self): target = cg.StructInitializer( @@ -229,6 +238,76 @@ class TestLambdaExpression: ) +class TestCallLambda: + """Tests for the call_lambda() function.""" + + def test_call_lambda__return_expression_casts_to_return_type(self): + """A lambda body that is just a return statement reduces to the + expression, cast to the lambda's return type.""" + lamb = cg.LambdaExpression(("return foo + 1;",), (), "", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.StaticCastExpression) + assert str(result) == "static_cast(foo + 1)" + + def test_call_lambda__return_expression_with_class_return_type_no_cast(self): + """A class return type is not cast, since static_cast doesn't apply + to arbitrary class types.""" + mock_class = cg.MockObjClass("foo::Bar", parents=()) + lamb = cg.LambdaExpression(("return get_bar();",), (), "", mock_class) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.RawExpression) + assert str(result) == "get_bar()" + + def test_call_lambda__no_return_with_parameters_calls_with_names(self): + """A multi-statement lambda with parameters is called with the + parameter names as arguments.""" + lamb = cg.LambdaExpression( + ("do_something(x, y);",), ((int, "x"), (float, "y")), "=", ct.bool_ + ) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == ( + "[=](int32_t x, float y) -> bool {\n do_something(x, y);\n}(x, y)" + ) + + def test_call_lambda__no_return_type_raises(self): + """Calling a lambda with no declared return type is a developer + error: call_lambda is only for value-returning lambdas.""" + lamb = cg.LambdaExpression(("do_something();",), (), "=") + + with pytest.raises(AssertionError): + cg.call_lambda(lamb) + + def test_call_lambda__identifier_starting_with_return_is_not_a_return_statement( + self, + ): + """A body that merely starts with the substring "return" (e.g. a call + to a function named returnValue()) must not be mistaken for a return + statement -- the match requires a word boundary after "return".""" + lamb = cg.LambdaExpression(("returnValue();",), (), "=", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == "[=]() -> bool {\n returnValue();\n}()" + + def test_call_lambda__no_return_no_parameters_calls_with_no_args(self): + """A multi-statement lambda without parameters is called with no + arguments.""" + lamb = cg.LambdaExpression(("do_something();",), (), "", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == "[]() -> bool {\n do_something();\n}()" + + class TestLiterals: @pytest.mark.parametrize( "target, expected", From ebb9037ea1bf802334299b7c38b26dac352d028a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 11 Sep 2026 22:08:23 -0500 Subject: [PATCH 054/266] [bridge] New component and `cdc_acm_uart` platform (#11689) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/bridge/__init__.py | 4 + esphome/components/cdc_acm_uart/__init__.py | 1 + .../cdc_acm_uart/bridge/__init__.py | 114 +++++ .../bridge/cdc_acm_uart_bridge.cpp | 468 ++++++++++++++++++ .../cdc_acm_uart/bridge/cdc_acm_uart_bridge.h | 117 +++++ esphome/components/usb_cdc_acm/usb_cdc_acm.h | 34 ++ .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 26 +- script/analyze_component_buses.py | 1 + .../component_tests/cdc_acm_uart/__init__.py | 0 .../component_tests/cdc_acm_uart/test_init.py | 154 ++++++ tests/component_tests/conftest.py | 11 +- tests/component_tests/types.py | 3 +- tests/components/cdc_acm_uart/common.yaml | 18 + .../components/cdc_acm_uart/common_dual.yaml | 12 + .../cdc_acm_uart/test.esp32-p4-idf.yaml | 15 + .../cdc_acm_uart/test.esp32-s2-idf.yaml | 14 + .../cdc_acm_uart/test.esp32-s3-idf.yaml | 17 + 18 files changed, 983 insertions(+), 29 deletions(-) create mode 100644 esphome/components/bridge/__init__.py create mode 100644 esphome/components/cdc_acm_uart/__init__.py create mode 100644 esphome/components/cdc_acm_uart/bridge/__init__.py create mode 100644 esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp create mode 100644 esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h create mode 100644 tests/component_tests/cdc_acm_uart/__init__.py create mode 100644 tests/component_tests/cdc_acm_uart/test_init.py create mode 100644 tests/components/cdc_acm_uart/common.yaml create mode 100644 tests/components/cdc_acm_uart/common_dual.yaml create mode 100644 tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml create mode 100644 tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml create mode 100644 tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f91bc00ae52..246a210c7cb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -100,6 +100,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core esphome/components/bytebuffer/* @clydebarrow @@ -111,6 +112,8 @@ esphome/components/captive_portal/* @esphome/core esphome/components/cc1101/* @gabest11 @lygris esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke +esphome/components/cdc_acm_uart/* @kbx81 +esphome/components/cdc_acm_uart/bridge/* @kbx81 esphome/components/ch422g/* @clydebarrow @jesterret esphome/components/ch423/* @dwmw2 esphome/components/chsc6x/* @kkosik20 diff --git a/esphome/components/bridge/__init__.py b/esphome/components/bridge/__init__.py new file mode 100644 index 00000000000..49811b01819 --- /dev/null +++ b/esphome/components/bridge/__init__.py @@ -0,0 +1,4 @@ +CODEOWNERS = ["@kbx81"] +DOMAIN = "bridge" + +IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/cdc_acm_uart/__init__.py b/esphome/components/cdc_acm_uart/__init__.py new file mode 100644 index 00000000000..516af848564 --- /dev/null +++ b/esphome/components/cdc_acm_uart/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@kbx81"] diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py new file mode 100644 index 00000000000..cee048df5da --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -0,0 +1,114 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, uart, usb_cdc_acm +from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"] + +CONF_DTR_PIN = "dtr_pin" +CONF_RTS_PIN = "rts_pin" +CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id" + +cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart") +CDCACMUARTBridge = cdc_acm_uart_ns.class_("CDCACMUARTBridge", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CDCACMUARTBridge), + cv.Required(CONF_UART_ID): cv.use_id(uart.IDFUARTComponent), + cv.Required(CONF_USB_CDC_ACM_ID): cv.use_id(usb_cdc_acm.USBCDCACMInstance), + cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, + } + ).extend(cv.COMPONENT_SCHEMA), + # Narrower than usb_cdc_acm's variant list on purpose: S31/H4 untested on + # hardware; extend once verified. + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _subtree_references_uart(node: object, uart_id: str) -> bool: + """Return True if any dict in the subtree has a uart_id entry naming this bus.""" + if isinstance(node, dict): + return any( + (key == CONF_UART_ID and str(value) == uart_id) + or _subtree_references_uart(value, uart_id) + for key, value in node.items() + ) + if isinstance(node, list): + return any(_subtree_references_uart(item, uart_id) for item in node) + return False + + +def _reject_debug(uart_conf: ConfigType) -> ConfigType: + # The worker tasks use the IDF driver directly, so the uart debugger never sees + # bridge traffic and its dummy_receiver would drain RX bytes on the main loop. + if CONF_DEBUG in uart_conf: + raise cv.Invalid( + "A bridged UART cannot use 'debug'; the bridge bypasses the UART " + "component's read/write path.", + [CONF_DEBUG], + ) + return uart_conf + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + # Bridges of any platform must own their interfaces exclusively; shared ring + # buffers and overwritten callbacks would corrupt both streams silently. The + # seen-set is keyed on the bridge domain so future platforms share it. + # Other components bind either interface through the same uart_id key (the CDC + # instance is itself a uart::UARTComponent) and would race the worker tasks. + # Bare `id:` references (a uart.write action) cannot be distinguished; not caught. + data = full_config.data.setdefault(BRIDGE_DOMAIN, {}) + for conf_key, label in ( + (CONF_UART_ID, "UART"), + (CONF_USB_CDC_ACM_ID, "USB CDC-ACM interface"), + ): + owned_id = str(config[conf_key]) + used = data.setdefault(conf_key, set()) + if owned_id in used: + raise cv.Invalid( + f"The {label} '{owned_id}' is already bridged by another 'bridge' " + f"instance; each bridge requires its own {label}.", + [conf_key], + ) + used.add(owned_id) + for domain, domain_conf in full_config.items(): + if domain == BRIDGE_DOMAIN: + continue + if _subtree_references_uart(domain_conf, owned_id): + raise cv.Invalid( + f"The {label} '{owned_id}' is also used by '{domain}'; a bridge " + f"requires exclusive use of its {label}.", + [conf_key], + ) + + fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID]) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + uart_component = await cg.get_variable(config[CONF_UART_ID]) + usb_cdc = await cg.get_variable(config[CONF_USB_CDC_ACM_ID]) + var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc) + await cg.register_component(var, config) + + if dtr_pin_config := config.get(CONF_DTR_PIN): + dtr_pin = await cg.gpio_pin_expression(dtr_pin_config) + cg.add(var.set_dtr_pin(dtr_pin)) + if rts_pin_config := config.get(CONF_RTS_PIN): + rts_pin = await cg.gpio_pin_expression(rts_pin_config) + cg.add(var.set_rts_pin(rts_pin)) diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp new file mode 100644 index 00000000000..042688bfe61 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp @@ -0,0 +1,468 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "cdc_acm_uart_bridge.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/ringbuf.h" +#include "driver/uart.h" +#include "soc/soc_caps.h" + +namespace esphome::cdc_acm_uart { + +static const char *const TAG = "cdc_acm_uart"; + +static constexpr size_t UART_TASK_STACK_SIZE = 4096; +static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64; +static constexpr uint32_t LOG_THROTTLE_MS = 1000; +static constexpr uint32_t UART_RELOAD_SETTLE_MS = 20; +// Above the default priority but below the USB/Wi-Fi system tasks. +static constexpr UBaseType_t TASK_PRIORITY = 4; + +static bool should_log_now(uint32_t *last_ms, uint32_t interval_ms) { + uint32_t now = millis(); + if ((now - *last_ms) >= interval_ms) { + *last_ms = now; + return true; + } + return false; +} + +static bool ringbuf_send_with_retry(RingbufHandle_t ringbuf, const uint8_t *data, size_t len, uint32_t *log_ms) { + if (len == 0) { + return true; + } + + if (xRingbufferSend(ringbuf, data, len, pdMS_TO_TICKS(1)) == pdTRUE) { + return true; + } + + size_t offset = 0; + while (offset < len) { + size_t chunk = std::min(RINGBUF_RETRY_CHUNK_SIZE, len - offset); + if (xRingbufferSend(ringbuf, data + offset, chunk, pdMS_TO_TICKS(1)) != pdTRUE) { + if (should_log_now(log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "USB TX buffer full; some data is lost"); + } + return false; + } + offset += chunk; + } + return true; +} + +void CDCACMUARTBridge::setup() { + // Line state starts deasserted (no host yet); active-low DTR#/RTS# wiring is + // handled by configuring the pins inverted, so deasserted idles HIGH. + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->setup(); + this->dtr_pin_->digital_write(false); + } + + if (this->rts_pin_ != nullptr) { + this->rts_pin_->setup(); + this->rts_pin_->digital_write(false); + } + + // A failed UART never assigned its port number, so the worker tasks would run + // against an indeterminate port. + if (this->uart_parent_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->configured_baud_rate_ = this->uart_parent_->get_baud_rate(); + this->configured_parity_ = this->uart_parent_->get_parity(); + this->configured_stop_bits_ = this->uart_parent_->get_stop_bits(); + this->configured_data_bits_ = this->uart_parent_->get_data_bits(); + + // usb_cdc_acm sets up first (priority IO > HARDWARE). Any interface failing marks + // the hub failed, and a failed hub no longer runs loop(), so line coding and line + // state events would never reach this bridge even if its own interface is healthy. + if (this->usb_cdc_parent_->get_parent()->is_failed()) { + ESP_LOGE(TAG, "USB CDC ACM failed; aborting"); + this->mark_failed(); + return; + } + + // Per-instance task names (keyed on the CDC interface number) keep task dumps + // unambiguous with multiple bridges. + char tx_task_name[] = "cdc_uart_tx_0"; + char rx_task_name[] = "cdc_uart_rx_0"; + const char itf_char = format_hex_char(this->usb_cdc_parent_->get_itf()); + tx_task_name[sizeof(tx_task_name) - 2] = itf_char; + rx_task_name[sizeof(rx_task_name) - 2] = itf_char; + + xTaskCreate(uart_tx_task_fn, tx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_tx_task_handle_); + if (this->uart_tx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART TX task"); + this->mark_failed(); + return; + } + + xTaskCreate(uart_rx_task_fn, rx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_rx_task_handle_); + if (this->uart_rx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART RX task"); + vTaskDelete(this->uart_tx_task_handle_); + this->uart_tx_task_handle_ = nullptr; + this->mark_failed(); + return; + } + + // Only register callbacks once both tasks exist, so a failed setup never drives + // DTR/RTS from a dead bridge. + this->usb_cdc_parent_->set_line_state_callback([this](bool dtr, bool rts) { this->set_line_state(dtr, rts); }); + this->usb_cdc_parent_->set_line_coding_callback([this](uint32_t, uint8_t, uint8_t, uint8_t) { + this->host_coding_seen_ = true; + // Another component owns the UART's framing while paused; resume() re-syncs. + if (this->paused_ == 0) { + this->set_line_coding(); + } + }); + + // Release the workers only now: until here a failed setup may still delete the TX + // task, which is safe only while it is parked and owns nothing in the driver. + xTaskNotifyGive(this->uart_tx_task_handle_); + xTaskNotifyGive(this->uart_rx_task_handle_); + + // loop() only services line-coding reloads; stay off the main loop until one is + // scheduled. + this->disable_loop(); +} + +void CDCACMUARTBridge::dump_config() { + ESP_LOGCONFIG(TAG, + "CDC-ACM UART Bridge:\n" + " UART Bus: %u\n" + " USB CDC Interface: %u", + this->uart_parent_->get_hw_serial_number(), this->usb_cdc_parent_->get_itf()); + LOG_PIN(" DTR Pin: ", this->dtr_pin_); + LOG_PIN(" RTS Pin: ", this->rts_pin_); +} + +void CDCACMUARTBridge::on_shutdown() { + // The UART (BUS) shuts down after this component (HARDWARE) and deletes its driver, + // freeing the ring buffer and mutexes the worker tasks block on. Suspending the + // tasks unlinks them from those objects first. + if (this->uart_rx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_rx_task_handle_); + } + if (this->uart_tx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_tx_task_handle_); + } +} + +void CDCACMUARTBridge::loop() { + switch (this->state_) { + case MainState::MAIN_STATE_RELOAD_PENDING: + if ((App.get_loop_component_start_time() - this->reload_requested_at_) < UART_RELOAD_SETTLE_MS) { + return; + } + // Deliberately not gated on tx_idle_(): a host that re-codes the line mid-stream + // wants the new framing now, and its own in-flight bytes are its concern. + // apply_settings_live() rewrites the framing registers without reinstalling the + // driver, so the worker tasks blocked inside it are undisturbed. + this->uart_parent_->apply_settings_live(); + this->state_ = MainState::MAIN_STATE_RUNNING; + break; + case MainState::MAIN_STATE_PAUSING: + case MainState::MAIN_STATE_RESUMING: + // Let a host write that was in flight drain, FIFO included, before a reload + // flushes the FIFOs and truncates it. + if (!this->tx_idle_()) { + return; + } + if (this->state_ == MainState::MAIN_STATE_PAUSING) { + this->restore_configured_framing_(); + this->state_ = MainState::MAIN_STATE_PAUSED; + } else { + this->finish_resume_(); + } + break; + default: + break; + } + this->disable_loop(); +} + +void CDCACMUARTBridge::set_line_coding() { + if (!this->sync_host_framing_()) { + return; + } + // Coalesce rapid line-coding updates from the host. + this->reload_requested_at_ = App.get_loop_component_start_time(); + this->state_ = MainState::MAIN_STATE_RELOAD_PENDING; + // Main-loop context (via USBCDCACMInstance::process_events_). + this->enable_loop(); +} + +bool CDCACMUARTBridge::sync_host_framing_() { + // usb_cdc_acm has already translated the wire coding onto the CDC instance (main + // loop); mirror it here so the framing translation has a single source of truth. + bool changed = false; + + // Reject 0 (the CDC B0/hang-up encoding; older IDF revisions divide by the rate) + // and rates above the SoC ceiling. Anything in between is the driver's call, + // matching what a YAML-configured UART accepts. + const uint32_t baud = this->usb_cdc_parent_->get_baud_rate(); + if (baud == 0 || baud > SOC_UART_BITRATE_MAX) { + ESP_LOGW(TAG, "Ignoring unsupported baud rate %" PRIu32 " from host; keeping %" PRIu32, baud, + this->uart_parent_->get_baud_rate()); + } else if (this->uart_parent_->get_baud_rate() != baud) { + this->uart_parent_->set_baud_rate(baud); + changed = true; + } + + const uint8_t stop_bits = this->usb_cdc_parent_->get_stop_bits(); + if (this->uart_parent_->get_stop_bits() != stop_bits) { + this->uart_parent_->set_stop_bits(stop_bits); + changed = true; + } + + const auto parity = this->usb_cdc_parent_->get_parity(); + if (this->uart_parent_->get_parity() != parity) { + this->uart_parent_->set_parity(parity); + changed = true; + } + + // USB CDC permits data-bit counts the UART cannot represent (up to 16). + const uint8_t data_bits = this->usb_cdc_parent_->get_data_bits(); + if (data_bits < 5 || data_bits > 8) { + ESP_LOGW(TAG, "Ignoring unsupported data bits %u from host; keeping %u", data_bits, + this->uart_parent_->get_data_bits()); + } else if (this->uart_parent_->get_data_bits() != data_bits) { + this->uart_parent_->set_data_bits(data_bits); + changed = true; + } + + if (changed) { + ESP_LOGV(TAG, "Line coding: baud=%" PRIu32 ", data_bits=%u, stop_bits=%u, parity=%u", + this->uart_parent_->get_baud_rate(), this->uart_parent_->get_data_bits(), + this->uart_parent_->get_stop_bits(), static_cast(this->uart_parent_->get_parity())); + } + return changed; +} + +void CDCACMUARTBridge::pause() { + if (this->state_ == MainState::MAIN_STATE_PAUSING || this->state_ == MainState::MAIN_STATE_PAUSED) { + return; + } + this->paused_ = 1; + // A null RX task means setup() has not completed (or failed): nothing to stop, and + // the framing snapshot does not exist yet. Should setup() run later, the RX task + // starts parked. + if (this->uart_rx_task_handle_ == nullptr) { + this->state_ = MainState::MAIN_STATE_PAUSED; + return; + } + // Drops a coalesced host reload or a pending resume; loop() restores the framing + // once any host write in flight has drained. + this->state_ = MainState::MAIN_STATE_PAUSING; + this->enable_loop(); +} + +void CDCACMUARTBridge::resume() { + if (this->state_ != MainState::MAIN_STATE_PAUSING && this->state_ != MainState::MAIN_STATE_PAUSED) { + return; + } + if (this->uart_rx_task_handle_ == nullptr) { + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + return; + } + // A restore still waiting on the TX side is moot: the host's framing is kept. + if (!this->tx_idle_()) { + this->state_ = MainState::MAIN_STATE_RESUMING; + this->enable_loop(); + return; + } + this->finish_resume_(); + this->disable_loop(); +} + +void CDCACMUARTBridge::finish_resume_() { + // Take the bus back at a known framing before either task runs again: the host's + // if it ever sent one, else the YAML framing (the other owner may have changed it). + if (this->host_coding_seen_) { + this->sync_host_framing_(); + this->uart_parent_->apply_settings_live(); + } else { + this->restore_configured_framing_(); + } + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + this->drive_line_state_(); + xTaskNotifyGive(this->uart_rx_task_handle_); +} + +bool CDCACMUARTBridge::tx_idle_() { + const auto uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + return this->tx_busy_ == 0 && uart_wait_tx_done(uart_num, 0) == ESP_OK; +} + +void CDCACMUARTBridge::restore_configured_framing_() { + // Always applied: the cached settings can lead the hardware by a pending reload, + // so they are no proof of what is live. + this->uart_parent_->set_baud_rate(this->configured_baud_rate_); + this->uart_parent_->set_parity(this->configured_parity_); + this->uart_parent_->set_stop_bits(this->configured_stop_bits_); + this->uart_parent_->set_data_bits(this->configured_data_bits_); + this->uart_parent_->apply_settings_live(); +} + +void CDCACMUARTBridge::set_line_state(bool dtr, bool rts) { + ESP_LOGV(TAG, "Line state: DTR=%d, RTS=%d", dtr, rts); + this->host_dtr_ = dtr; + this->host_rts_ = rts; + // Frozen while paused: a host opening the port must not reset a peer that another + // component is talking to. + if (this->paused_ == 0) { + this->drive_line_state_(); + } +} + +void CDCACMUARTBridge::drive_line_state_() { + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->digital_write(this->host_dtr_); + } + if (this->rts_pin_ != nullptr) { + this->rts_pin_->digital_write(this->host_rts_); + } +} + +void CDCACMUARTBridge::uart_rx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_rx_task_(); +} + +void CDCACMUARTBridge::uart_tx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_tx_task_(); +} + +void CDCACMUARTBridge::uart_rx_task_() { + TaskHandle_t usb_tx_handle = this->usb_cdc_parent_->get_tx_task_handle(); + RingbufHandle_t usb_tx_ringbuf = this->usb_cdc_parent_->get_tx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t tx_full_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + + uint8_t *data = this->uart_rx_buffer_.data(); + const size_t buf_size = this->uart_rx_buffer_.size(); + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + if (this->paused_ != 0) { + // Parked until resume() notifies; nothing is read, so the other owner sees + // every byte. + this->rx_parked_ = 1; + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + this->rx_parked_ = 0; + continue; + } + + // Block until at least one byte is available from UART. + int total_rx_size = uart_read_bytes(uart_num, data, 1, pdMS_TO_TICKS(UART_RX_WAIT_MS)); + if (total_rx_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", total_rx_size); + } + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + if (total_rx_size == 0) { + continue; + } + // pause() landed during the read: don't forward a byte to a host that is gone. + if (this->paused_ != 0) { + continue; + } + + // Drain the currently buffered burst without waiting. + while (true) { + int rx_data_size = uart_read_bytes(uart_num, data + total_rx_size, buf_size - total_rx_size, 0); + if (rx_data_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", rx_data_size); + } + break; + } + if (rx_data_size == 0) { + break; + } + ESP_LOGV(TAG, "UART RX: %d bytes", rx_data_size); + total_rx_size += rx_data_size; + if (total_rx_size >= (int) buf_size) { + break; + } + } + + ringbuf_send_with_retry(usb_tx_ringbuf, data, total_rx_size, &tx_full_log_ms); + + ESP_LOGV(TAG, "UART RX: waking up USB TX task"); + xTaskNotifyGive(usb_tx_handle); + } +} + +void CDCACMUARTBridge::uart_tx_task_() { + RingbufHandle_t usb_rx_ringbuf = this->usb_cdc_parent_->get_rx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + uint8_t *data_to_uart = this->uart_tx_buffer_.data(); + const size_t buf_size = this->uart_tx_buffer_.size(); + size_t rx_size; + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t drop_log_ms = millis() - LOG_THROTTLE_MS; + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + ESP_LOGV(TAG, "Waiting for data to send to UART"); + esp_err_t ret = usb_cdc_acm::ringbuf_read_bytes(usb_rx_ringbuf, data_to_uart, buf_size, &rx_size, portMAX_DELAY); + + if (ret != ESP_OK) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "USB RX RingBuf read failed"); + } + // Yield: this task runs above the main loop, so a persistent failure must not + // become a tight loop. + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + + // Another component owns the UART; host bytes must not interleave with its traffic. + // tx_busy_ goes up before the check so is_paused() cannot miss a write in flight. + this->tx_busy_ = 1; + if (this->paused_ != 0) { + this->tx_busy_ = 0; + if (should_log_now(&drop_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "Paused; dropping %zu bytes from host", rx_size); + } + continue; + } + + ESP_LOGV(TAG, "Sending %zu bytes to UART", rx_size); + // Signed: uart_write_bytes() returns -1 on error. + int xfer_size = uart_write_bytes(uart_num, data_to_uart, rx_size); + this->tx_busy_ = 0; + + if (xfer_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART write failed: %d", xfer_size); + } + } else if (static_cast(xfer_size) != rx_size) { + ESP_LOGW(TAG, "UART write incomplete (%d/%zu bytes)", xfer_size, rx_size); + } + } +} + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h new file mode 100644 index 00000000000..64522c86bd5 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -0,0 +1,117 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/usb_cdc_acm/usb_cdc_acm.h" +#include "esphome/core/component.h" + +#include +#include +#include "sdkconfig.h" + +namespace esphome::cdc_acm_uart { + +class CDCACMUARTBridge final : public Component { + public: + // Upper bound on the RX task's blocking read, so pause() takes effect without + // aborting the read. Arriving bytes still unblock it immediately. + static constexpr uint32_t UART_RX_WAIT_MS = 250; + + CDCACMUARTBridge(uart::IDFUARTComponent *uart_parent, usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent) + : uart_parent_(uart_parent), usb_cdc_parent_(usb_cdc_parent) {} + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_dtr_pin(GPIOPin *dtr_pin) { this->dtr_pin_ = dtr_pin; } + void set_rts_pin(GPIOPin *rts_pin) { this->rts_pin_ = rts_pin; } + + void set_line_coding(); + void set_line_state(bool dtr, bool rts); + + /** + * Stop forwarding in both directions and hand the UART back to its configured + * framing, so another component may use the bus. Main-loop only. The RX task parks + * within UART_RX_WAIT_MS (a byte it was already reading is discarded). A host write + * already in flight is allowed to drain first, which at low baud rates can take + * seconds; the framing is restored only after that, so poll is_paused() rather than + * waiting a fixed interval. Host bytes not yet written to the UART are discarded. + * The DTR/RTS outputs hold their state while paused and follow the host again on + * resume(). + */ + void pause(); + /** + * Re-apply the host's line coding and line state, then resume forwarding. Main-loop + * only. Deferred until any host write still draining has finished, so the reload + * never truncates it. + */ + void resume(); + /// True once both worker tasks are off the bus and the configured framing is restored. + /// With no RX task (setup() failed or has not run) there is nothing to wait for. + bool is_paused() const { + return this->state_ == MainState::MAIN_STATE_PAUSED && + (this->uart_rx_task_handle_ == nullptr || this->rx_parked_ != 0); + } + + protected: + static void uart_rx_task_fn(void *arg); + static void uart_tx_task_fn(void *arg); + void uart_rx_task_(); + void uart_tx_task_(); + void restore_configured_framing_(); + // True when the TX task has no write in flight and the UART TX FIFO has drained. + bool tx_idle_(); + void finish_resume_(); + void drive_line_state_(); + // Copy the host's line coding onto the UART settings; true if anything changed. + bool sync_host_framing_(); + + TaskHandle_t uart_rx_task_handle_{nullptr}; + TaskHandle_t uart_tx_task_handle_{nullptr}; + + GPIOPin *dtr_pin_{nullptr}; + GPIOPin *rts_pin_{nullptr}; + + uint32_t reload_requested_at_{0}; + + // Worker staging, each sized to the CDC ring buffer it feeds or drains. + std::array uart_rx_buffer_{}; + std::array uart_tx_buffer_{}; + + uart::IDFUARTComponent *uart_parent_; + usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent_; + + // YAML framing, captured at setup; the host's line coding overwrites the UART's + // settings, so pause() needs the original to restore. + uint32_t configured_baud_rate_{0}; + uart::UARTParityOptions configured_parity_{uart::UART_CONFIG_PARITY_NONE}; + uint8_t configured_stop_bits_{0}; + uint8_t configured_data_bits_{0}; + + // Written on the main loop, read by both worker tasks. uint8_t rather than bool: + // GCC on Xtensa emits an out-of-line call for atomic. + std::atomic paused_{0}; + // Raised by the RX task while parked and by the TX task around each UART write, so + // the pause hand-off knows when the bus is actually free. + std::atomic rx_parked_{0}; + std::atomic tx_busy_{0}; + // Main-loop state; paused_ mirrors it for the worker tasks. + enum class MainState : uint8_t { + MAIN_STATE_RUNNING, + MAIN_STATE_RELOAD_PENDING, // host line coding debounced, forwarding continues + MAIN_STATE_PAUSING, // waiting for TX idle to restore the configured framing + MAIN_STATE_PAUSED, + MAIN_STATE_RESUMING, // resume() requested while a host write still drains + }; + MainState state_{MainState::MAIN_STATE_RUNNING}; + // Host line state, recorded even while paused so resume() can re-drive the pins. + bool host_dtr_{false}; + bool host_rts_{false}; + // True once the host has sent any line coding; resume() then re-syncs to it. + bool host_coding_seen_{false}; +}; + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index d8eb91586a8..83cb5de89f8 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -7,15 +7,47 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/components/uart/uart_component.h" +#include #include +#include #include #include "freertos/ringbuf.h" +#include "esp_err.h" #include "tinyusb_cdc_acm.h" namespace esphome::usb_cdc_acm { static const uint8_t EVENT_QUEUE_SIZE = 12; +// Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped +// case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker +// tasks drain the same ring buffers. +inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, + TickType_t x_ticks_to_wait) { + size_t read_sz; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); + + if (buf == nullptr) { + return ESP_FAIL; + } + + memcpy(out_buf, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size = read_sz; + + // Buffer's data can be wrapped, in which case we should perform another read + if (*rx_data_size < out_buf_sz) { + buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); + if (buf != nullptr) { + memcpy(out_buf + *rx_data_size, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size += read_sz; + } + } + + return ESP_OK; +} + // Callback types for line coding and line state changes using LineCodingCallback = std::function; using LineStateCallback = std::function; @@ -103,6 +135,8 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented usb_tx_staging_{}; // Non-zero while the TX task holds bytes it has pulled from the ring buffer but not // yet handed to TinyUSB; lets flush() account for data that is in neither the ring // buffer nor TinyUSB's FIFO. diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index e46369660dd..7aa7b46b7b9 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -104,30 +104,6 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits); } -static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, - TickType_t x_ticks_to_wait) { - size_t read_sz; - uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); - - if (buf == nullptr) { - return ESP_FAIL; - } - - memcpy(out_buf, buf, read_sz); - vRingbufferReturnItem(ring_buf, (void *) buf); - *rx_data_size = read_sz; - - // Buffer's data can be wrapped, in which case we should perform another read - buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); - if (buf != nullptr) { - memcpy(out_buf + *rx_data_size, buf, read_sz); - vRingbufferReturnItem(ring_buf, (void *) buf); - *rx_data_size += read_sz; - } - - return ESP_OK; -} - //============================================================================== // USBCDCACMInstance Implementation //============================================================================== @@ -192,7 +168,7 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) { } void USBCDCACMInstance::usb_tx_task() { - uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; + uint8_t *data = this->usb_tx_staging_.data(); size_t tx_data_size = 0; // Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs // immediately (unsigned arithmetic keeps this wrap-safe). diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index b8ee3066bd2..b805d5155a2 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -81,6 +81,7 @@ ISOLATED_SIGNATURE_PREFIX = "isolated_" # NOTE: This should be kept in sync with both test_build_components and split_components_for_ci.py ISOLATED_COMPONENTS = { "animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display", + "cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host", "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", diff --git a/tests/component_tests/cdc_acm_uart/__init__.py b/tests/component_tests/cdc_acm_uart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/cdc_acm_uart/test_init.py b/tests/component_tests/cdc_acm_uart/test_init.py new file mode 100644 index 00000000000..7bbf163fc32 --- /dev/null +++ b/tests/component_tests/cdc_acm_uart/test_init.py @@ -0,0 +1,154 @@ +"""Tests for the bridge cdc_acm_uart platform's final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.cdc_acm_uart import bridge +from esphome.components.cdc_acm_uart.bridge import CONF_USB_CDC_ACM_ID +from esphome.config import Config +from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +_final_validate = bridge._final_validate + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable, **kwargs) -> None: + from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3 + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + **kwargs, + ) + + +def _full_config(uarts: list[ConfigType] | None = None, **domains) -> Config: + """A full config declaring uart_0 and uart_1 (plus any extra entries), as the ID + pass leaves it, so the debug check can resolve a uart_id to its declaration.""" + uarts = uarts or [{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1")}] + full = Config() + full["uart"] = uarts + for index, uart_conf in enumerate(uarts): + full.declare_ids.append((uart_conf[CONF_ID], ["uart", index, CONF_ID])) + full.update(domains) + return full + + +def _bridge_config(uart_id: str, cdc_id: str) -> dict: + return {CONF_UART_ID: ID(uart_id), CONF_USB_CDC_ACM_ID: ID(cdc_id)} + + +def test_accepts_distinct_uart_and_cdc_interfaces( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + _final_validate(_bridge_config("uart_1", "cdc_acm_2")) + + +def test_rejects_two_bridges_sharing_a_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + with pytest.raises(cv.Invalid, match="already bridged"): + _final_validate(_bridge_config("uart_0", "cdc_acm_2")) + + +def test_rejects_two_bridges_sharing_a_cdc_interface( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + with pytest.raises(cv.Invalid, match="already bridged"): + _final_validate(_bridge_config("uart_1", "cdc_acm_1")) + + +def test_rejects_uart_shared_with_another_component( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_0")}], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_cdc_interface_shared_with_another_component( + set_core_config: SetCoreConfigCallable, +) -> None: + # The CDC instance is itself a uart::UARTComponent, so other components can bind + # it as a plain UART via uart_id -- that must be rejected just like UART sharing. + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("cdc_acm_1")}], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_uart_referenced_from_nested_config( + set_core_config: SetCoreConfigCallable, +) -> None: + # References can sit arbitrarily deep, e.g. inside an automation's action list. + _set_esp32_s3( + set_core_config, + full_config=_full_config( + binary_sensor=[ + { + "platform": "gpio", + "on_press": [{"then": [{CONF_UART_ID: ID("uart_0")}]}], + } + ], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_ignores_other_components_on_other_uarts( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_1")}], + # The bridge domain itself is skipped: this bridge's own entry (and any + # bridge-vs-bridge sharing, which the seen-set already rejects) must not + # trip the exclusivity scan. + bridge=[_bridge_config("uart_0", "cdc_acm_1")], + ), + ) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_debug_on_bridged_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + # The bridge talks to the IDF driver directly, so the uart debugger would see + # nothing and its dummy_receiver would steal RX bytes. + _set_esp32_s3( + set_core_config, + full_config=_full_config(uarts=[{CONF_ID: ID("uart_0"), CONF_DEBUG: {}}]), + ) + with pytest.raises(cv.Invalid, match="debug"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_allows_debug_on_other_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + uarts=[{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1"), CONF_DEBUG: {}}] + ), + ) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 4f0b786cc28..b5eceeedf67 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -60,7 +60,7 @@ def reset_core() -> Generator[None]: @pytest.fixture(autouse=True) def reset_full_config() -> Generator[None]: """Give each test a clean final-validate config and restore it after.""" - token = final_validate.full_config.set({}) + token = final_validate.full_config.set(Config()) yield final_validate.full_config.reset(token) @@ -75,7 +75,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, - full_config: dict[str, ConfigType] | None = None, + full_config: dict[str, ConfigType] | Config | None = None, ) -> None: platform, framework = platform_framework.value @@ -94,7 +94,12 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: CORE.data[platform.value] = platform_data config.path_context.set([]) - final_validate.full_config.set(full_config or Config()) + # Production always installs a Config (a FinalValidateConfig), never a plain dict. + if not isinstance(full_config, Config): + full = Config() + full.update(full_config or {}) + full_config = full + final_validate.full_config.set(full_config) yield setter diff --git a/tests/component_tests/types.py b/tests/component_tests/types.py index ee9d3173398..3587517bde0 100644 --- a/tests/component_tests/types.py +++ b/tests/component_tests/types.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Protocol +from esphome.config import Config from esphome.const import PlatformFramework from esphome.types import ConfigType @@ -18,5 +19,5 @@ class SetCoreConfigCallable(Protocol): *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, - full_config: dict[str, ConfigType] | None = None, + full_config: dict[str, ConfigType] | Config | None = None, ) -> None: ... diff --git a/tests/components/cdc_acm_uart/common.yaml b/tests/components/cdc_acm_uart/common.yaml new file mode 100644 index 00000000000..6c43dfc18b3 --- /dev/null +++ b/tests/components/cdc_acm_uart/common.yaml @@ -0,0 +1,18 @@ +tinyusb: + id: tinyusb_test + usb_lang_id: 0x0123 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_serial_str: ESPHomeTestSerialNumber + usb_vendor_id: 0x2345 + +uart: + - id: uart_0 + tx_pin: 14 + rx_pin: 13 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_1 diff --git a/tests/components/cdc_acm_uart/common_dual.yaml b/tests/components/cdc_acm_uart/common_dual.yaml new file mode 100644 index 00000000000..0ce817fbc20 --- /dev/null +++ b/tests/components/cdc_acm_uart/common_dual.yaml @@ -0,0 +1,12 @@ +# Second UART/CDC pair for a two-bridge setup. Kept out of common.yaml because the +# ESP32-S2 has only two UART controllers and the logger occupies one, so a second +# uart there would fail at runtime. +uart: + - id: uart_1 + tx_pin: 15 + rx_pin: 16 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_2 diff --git a/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..aa9ec8079f2 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml @@ -0,0 +1,15 @@ +packages: + cdc_acm_uart: !include common.yaml + cdc_acm_uart_dual: !include common_dual.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 + - platform: cdc_acm_uart + uart_id: uart_1 + usb_cdc_acm_id: cdc_acm_2 + dtr_pin: 20 + rts_pin: 21 diff --git a/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml new file mode 100644 index 00000000000..0beeb80bfa4 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml @@ -0,0 +1,14 @@ +# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares +# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead. +logger: + hardware_uart: UART0 + +packages: + cdc_acm_uart: !include common.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 diff --git a/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..cbb1fc2a3a5 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml @@ -0,0 +1,17 @@ +packages: + cdc_acm_uart: !include common.yaml + cdc_acm_uart_dual: !include common_dual.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 + - platform: cdc_acm_uart + uart_id: uart_1 + usb_cdc_acm_id: cdc_acm_2 + # GPIO19/20 are USB D-/D+ on the S3 (which the CDC side itself uses); use + # unrelated free pins here. + dtr_pin: 17 + rts_pin: 18 From 533002c41e58d48fbc87b65e7c13122996ff5ff2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:12:08 +1000 Subject: [PATCH 055/266] [lvgl] Fix crash when using lvgl.list.add (#19177) --- esphome/components/lvgl/widgets/lv_list.py | 4 ++++ tests/components/lvgl/lvgl-package.yaml | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 83cbfb5ef99..7711e8bfe4f 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -227,6 +227,7 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)}) ) async def list_add_text_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add_text(w: Widget): text = await lv_text.process(config[CONF_TEXT]) @@ -370,6 +371,7 @@ async def list_add_to_code(config, action_id, template_arg, args): _register_lv_uses(w_type_name, w_conf) _register_dynamic_widget_style_uses(w_conf) widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add(w: Widget): index = None @@ -503,6 +505,7 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend( ) async def list_remove_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_remove(w: Widget): index = await lv_int.process(config[CONF_INDEX]) @@ -536,6 +539,7 @@ async def list_remove_to_code(config, action_id, template_arg, args): ) async def list_clear_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_clear(w: Widget): await _wait_list_triggers_completed() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 07c492db356..bd2e77ee8c7 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -30,6 +30,18 @@ binary_sensor: widget: button_button state: pressed +globals: + - id: counter + type: int + +script: + - id: add_row + then: + - lvgl.list.add: + id: test_list_id + label: + text: row + lvgl: id: lvgl_id rotation: 90 @@ -1291,7 +1303,7 @@ lvgl: then: - logger.log: format: "table selected row %u col %u" - args: [row, column] + args: [(unsigned)row, (unsigned)column] on_click: then: - lvgl.table.cell.update: @@ -1347,10 +1359,12 @@ lvgl: - logger.log: format: "list entry added at %d" args: [list_index] + - lambda: "id(counter)++;" on_remove: - logger.log: format: "list entry removed at %d" args: [list_index] + - lambda: "id(counter)--;" on_click: - lvgl.list.add_text: id: test_list_id From 5bbfe12e4ff5bc24603f1d9e23c6cade81f2096e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:38:27 +1000 Subject: [PATCH 056/266] [core] Isolate contextvars per task in the coroutine runner (#19238) --- esphome/coroutine.py | 18 ++++++++++-- tests/unit_tests/test_coroutine.py | 45 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/esphome/coroutine.py b/esphome/coroutine.py index 3ce94cc9791..8a825362270 100644 --- a/esphome/coroutine.py +++ b/esphome/coroutine.py @@ -45,6 +45,7 @@ the last `yield` expression defines what is returned. from __future__ import annotations from collections.abc import Awaitable, Callable, Generator, Iterator +import contextvars import enum import functools import heapq @@ -277,14 +278,22 @@ class _Task: id_number: int, iterator: Iterator[None], original_function: Any, + context: contextvars.Context, ): self.priority = priority self.id_number = id_number self.iterator = iterator self.original_function = original_function + self.context = context def with_priority(self, priority: float) -> _Task: - return _Task(priority, self.id_number, self.iterator, self.original_function) + return _Task( + priority, + self.id_number, + self.iterator, + self.original_function, + self.context, + ) @property def _cmp_tuple(self) -> tuple[float, int]: @@ -321,7 +330,10 @@ class FakeEventLoop: coro = coroutine(func) gen = coro(*args, **kwargs) prio = getattr(coro, "priority", 0.0) - task = _Task(prio, self._task_counter, gen, func) + # Each task gets its own copy of the current context, isolating any + # contextvars it sets from other tasks the scheduler interleaves it with + # (mirrors what asyncio.Task does internally). + task = _Task(prio, self._task_counter, gen, func, contextvars.copy_context()) self._task_counter += 1 heapq.heappush(self._pending_tasks, task) @@ -352,7 +364,7 @@ class FakeEventLoop: ) try: - next(task.iterator) + task.context.run(next, task.iterator) # Decrease priority over time, so that if this task is blocked # due to a dependency others will clear the dependency # This could be improved with a less naive approach diff --git a/tests/unit_tests/test_coroutine.py b/tests/unit_tests/test_coroutine.py index e12c273294b..0a8fb59cb81 100644 --- a/tests/unit_tests/test_coroutine.py +++ b/tests/unit_tests/test_coroutine.py @@ -1,5 +1,7 @@ """Tests for the coroutine module.""" +import contextvars + import pytest from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority @@ -217,3 +219,46 @@ def test_custom_priority_between_enum_values() -> None: # Check execution order assert execution_order == ["core", "custom", "diagnostics"] + + +def test_context_isolated_between_interleaved_tasks() -> None: + """Test that a contextvar set in one task does not leak into another task that the scheduler interleaves with it.""" + my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var") + seen: dict[str, str] = {} + + def task_a(): + my_var.set("a") + yield # suspend so task_b can run before task_a resumes + seen["a"] = my_var.get() + + def task_b(): + my_var.set("b") + yield + seen["b"] = my_var.get() + + loop = FakeEventLoop() + loop.add_job(task_a) + loop.add_job(task_b) + loop.flush_tasks() + + assert seen == {"a": "a", "b": "b"} + + +def test_context_inherits_ambient_value_at_schedule_time() -> None: + """Test that a job sees whatever contextvar value was set before it was scheduled.""" + my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var") + token = my_var.set("ambient") + seen: dict[str, str] = {} + + def task(): + seen["value"] = my_var.get() + yield + + try: + loop = FakeEventLoop() + loop.add_job(task) + loop.flush_tasks() + finally: + my_var.reset(token) + + assert seen == {"value": "ambient"} From eea66fc32b3b971d55573c69939d1885035a2d93 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:06:35 -0500 Subject: [PATCH 057/266] Bump bundled esphome-device-builder to 1.14.8 (#19250) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 6f500dbe6f4..bdbbe798cea 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 RUN \ platformio settings set enable_telemetry No \ From 6b08aa60e660a238ae710415827fdd7bcd1d8438 Mon Sep 17 00:00:00 2001 From: David Coulson <23066302+davidcoulson@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:25:27 -0400 Subject: [PATCH 058/266] [bluetooth_proxy] Add an advertisement filter hook (#19220) Co-authored-by: Claude Opus 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/bluetooth_proxy/__init__.py | 11 +++++++ .../bluetooth_proxy/bluetooth_proxy.cpp | 12 +++++++ .../bluetooth_proxy/bluetooth_proxy.h | 32 +++++++++++++++++++ esphome/core/defines.h | 2 ++ .../test_advertisement_filter.py | 13 ++++++++ ...est-advertisement-filter.esp32-s3-idf.yaml | 12 +++++++ 6 files changed, 82 insertions(+) create mode 100644 tests/component_tests/bluetooth_proxy/test_advertisement_filter.py create mode 100644 tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 1b761849a52..c87ad7f5957 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -395,6 +395,17 @@ async def _to_code_ble_hub(config: ConfigType) -> None: await _connections_to_code(var, config) +def enable_advertisement_filter() -> None: + """Compile the advertisement filter hook into bluetooth_proxy. + + Called by external filtering components from to_code(). The define behind + this is an implementation detail; do not emit it directly. + + Public API for external components. Do not remove. + """ + cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER") + + async def to_code(config: ConfigType) -> None: if CORE.is_esp32: await _to_code_esp32(config) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 878d3cd44e9..cb37057cd4b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -94,6 +94,15 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) return; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + // Ask the filter before the packet is queued, so a dropped advertisement never + // reaches the batch or the network. + if (this->advertisement_filter_.is_set() && !this->advertisement_filter_.should_forward(raw)) { + ESP_LOGVV(TAG, "Filtered packet from %012" PRIX64, raw.address); + return; + } +#endif + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; adv.address = raw.address; adv.rssi = raw.rssi; @@ -184,6 +193,9 @@ void BluetoothProxy::dump_config() { " Adapter MAC: %s", scan_mode, mac_out); #endif +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + ESP_LOGCONFIG(TAG, " Advertisement filter: %s", YESNO(this->advertisement_filter_.is_set())); +#endif } #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index e233c38b567..567109dc60b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -97,6 +97,29 @@ static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x000011223344556 static_assert(PendingReply{}.empty()); #endif +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER +/// Predicate slot letting an external component drop advertisements before they +/// are queued for the API. Same shape as +/// ble_device_base::RawAdvertisementCallback. Runs on the advertisement hot +/// path, so it must be cheap and must not block. +/// +/// Usage: +/// proxy->set_advertisement_filter({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { +/// return static_cast(self)->should_forward(adv); +/// }}); +/// +/// Returning false drops the advertisement. Not called at all while the API is +/// disconnected, which matters to a stateful filter. Compiled in only when an +/// external component calls bluetooth_proxy.enable_advertisement_filter(). +struct AdvertisementFilter { + void *instance{nullptr}; + bool (*fn)(void *instance, const ble_device_base::RawAdvertisement &adv){nullptr}; + /// A default-constructed slot is "no filter"; the proxy guards on this. + bool is_set() const { return this->fn != nullptr; } + bool should_forward(const ble_device_base::RawAdvertisement &adv) const { return this->fn(this->instance, adv); } +}; +#endif // USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + class BluetoothProxy final : public Component { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Allow the connection to update connections_free_response_ @@ -162,6 +185,11 @@ class BluetoothProxy final : public Component { void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + /// One subscriber; a later call replaces an earlier one. + void set_advertisement_filter(AdvertisementFilter filter) { this->advertisement_filter_ = filter; } +#endif + uint32_t get_legacy_version() const { if (!this->active_) { return LEGACY_PASSIVE_ONLY_VERSION; @@ -330,6 +358,10 @@ class BluetoothProxy final : public Component { // start on an even word, closing two alignment holes. uint32_t last_advertisement_flush_time_{0}; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + AdvertisementFilter advertisement_filter_{}; +#endif + // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6b9b9eda43f..fe06cc3418f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -329,6 +329,8 @@ #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif +// Defined here so static analysis parses the slot and its call site. +#define USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER #define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #endif diff --git a/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py b/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py new file mode 100644 index 00000000000..84d8b0677f7 --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py @@ -0,0 +1,13 @@ +"""The codegen hook external filtering components use to turn on the filter slot.""" + +from esphome.components import bluetooth_proxy +from esphome.core import CORE + + +def test_enable_advertisement_filter_emits_define() -> None: + """External components call this rather than emitting the define.""" + bluetooth_proxy.enable_advertisement_filter() + + assert "USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" in { + define.name for define in CORE.defines + } diff --git a/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml b/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml new file mode 100644 index 00000000000..f46f4814c2e --- /dev/null +++ b/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml @@ -0,0 +1,12 @@ +# Compile the gated filter path; no external component is in-tree to call +# enable_advertisement_filter(), so the define is forced here. +<<: !include common.yaml + +esphome: + build_flags: + - "-DUSE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" + +esp32_ble_tracker: + +bluetooth_proxy: + active: true From fc8611a2122c77f94861d7b320a30b86654cbe74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:14:26 -0500 Subject: [PATCH 059/266] [noise] Bump noise-c to 0.1.30 and libsodium to 1.10021.11 (#19062) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index d17ebf235e5..6067fde1642 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.26") + cg.add_library("esphome/noise-c", "0.1.30") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.8") + cg.add_library("esphome/libsodium", "1.10021.11") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 738773d1b56..0e334ac5b4c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.26 ; used by noise (api, ota) + esphome/noise-c@0.1.30 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 00f22ca1389..0dce00785bf 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 1.0") == "noise-c" + assert mod.spec_key("esphome/noise-c@1.0") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.26"] + assert libs == ["esphome/noise-c @ 1.0"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.26", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", + "esphome/noise-c @ 1.0", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.26"] + assert cls.calls == ["esphome/noise-c @ 1.0"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.26"] is None + assert compats["esphome/noise-c @ 1.0"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 1.0"}) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index b03bff19a27..774493ecf41 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 4f0faac1486d03e33f0e18bef2301ea77a3c31db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:16:37 -0500 Subject: [PATCH 060/266] [core] Add FixedVector::try_init so callers can handle an exhausted heap (#19253) --- esphome/core/helpers.h | 52 ++++++++++++++++++++------ script/cpp_unit_test.py | 3 +- tests/components/core/test_helpers.cpp | 19 ++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a0afb03124e..987c54a5b03 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #endif #ifdef USE_ESP32 +#include #include #endif @@ -539,7 +541,15 @@ template inline void init_array_from(std::array &des } } -/// Fixed-capacity vector - allocates once at runtime, never reallocates +// Abort with a reason that reaches the panic output on ESP32. Elsewhere the literal is dropped +// before it can land in rodata, which is RAM on ESP8266 +#ifdef USE_ESP32 +#define ESPHOME_ABORT_WITH_REASON(reason) esp_system_abort(reason) +#else +#define ESPHOME_ABORT_WITH_REASON(reason) abort() +#endif + +/// Fixed-capacity vector - sized once through init() or try_init(); push_back never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time template class FixedVector { @@ -562,8 +572,7 @@ template class FixedVector { void cleanup_() { if (data_ != nullptr) { destroy_elements_(); - // Free raw memory - ::operator delete(data_); + free(data_); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } } @@ -632,16 +641,27 @@ template class FixedVector { // Allocate capacity - can be called multiple times to reinit // IMPORTANT: After calling init(), you MUST use push_back() to add elements. // Direct assignment via operator[] does NOT update the size counter. + // Aborts on exhaustion; use try_init() to handle failure. void init(size_t n) { + if (!try_init(n)) + ESPHOME_ABORT_WITH_REASON("FixedVector: out of memory"); + } + + // Same as init(), but returns false when memory is exhausted; the previous storage is freed either way + bool try_init(size_t n) { cleanup_(); reset_(); - if (n > 0) { - // Allocate raw memory without calling constructors - // sizeof(T) is correct here for any type T (value types, pointers, etc.) - // NOLINTNEXTLINE(bugprone-sizeof-expression) - data_ = static_cast(::operator new(n * sizeof(T))); - capacity_ = n; - } + if (n == 0) + return true; + if (n > SIZE_MAX / sizeof(T)) + return false; // the byte count would wrap into a small block + // sizeof(T) is correct here for any type T (value types, pointers, etc.) + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + data_ = static_cast(malloc(n * sizeof(T))); + if (data_ == nullptr) + return false; + capacity_ = n; + return true; } // Clear the vector (destroy all elements, reset size to 0, keep capacity) @@ -738,14 +758,22 @@ template class FixedVector { template class SmallBufferWithHeapFallback { public: explicit SmallBufferWithHeapFallback(size_t size) { + static_assert(std::is_trivially_default_constructible_v && std::is_trivially_destructible_v, + "the heap fallback leaves elements unconstructed"); if (size <= STACK_SIZE) { this->buffer_ = this->stack_buffer_; } else { - this->heap_buffer_ = new T[size]; + if (size <= SIZE_MAX / sizeof(T)) { + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + this->heap_buffer_ = static_cast(malloc(size * sizeof(T))); + } + // Callers write through get() unchecked, so exhaustion aborts like the new[] it replaces + if (this->heap_buffer_ == nullptr) + ESPHOME_ABORT_WITH_REASON("SmallBufferWithHeapFallback: out of memory"); this->buffer_ = this->heap_buffer_; } } - ~SmallBufferWithHeapFallback() { delete[] this->heap_buffer_; } + ~SmallBufferWithHeapFallback() { free(this->heap_buffer_); } // NOLINT(cppcoreguidelines-no-malloc) // Delete copy and move operations to prevent double-delete SmallBufferWithHeapFallback(const SmallBufferWithHeapFallback &) = delete; diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index f8bab394149..8cb18d08757 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -36,7 +36,8 @@ PLATFORMIO_OPTIONS = { def run_tests(selected_components: list[str]) -> int: - os.environ["ASAN_OPTIONS"] = "detect_leaks=0" + # allocator_may_return_null: an oversized request must come back empty, not abort the run + os.environ["ASAN_OPTIONS"] = "detect_leaks=0:allocator_may_return_null=1" return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index baf688fc8a3..d6b31508d17 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -348,4 +348,23 @@ TEST(StepToAccuracyDecimals, NonFiniteAndZero) { EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0); } +// --- FixedVector::try_init() --- + +// Keeps the block observable, else the compiler may drop the malloc and free pair and fold the check +static void escape(const void *p) { asm volatile("" : : "g"(p) : "memory"); } + +TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { + FixedVector v; + const bool ok = v.try_init(SIZE_MAX / sizeof(uint32_t)); + escape(&v); + EXPECT_FALSE(ok); + EXPECT_EQ(v.capacity(), 0u); + EXPECT_FALSE(v.try_init(SIZE_MAX / sizeof(uint32_t) + 1)); // byte count would wrap + EXPECT_EQ(v.capacity(), 0u); + EXPECT_TRUE(v.try_init(0)); + EXPECT_TRUE(v.try_init(4)); + v.push_back(7); + EXPECT_EQ(v.size(), 1u); +} + } // namespace esphome::core::testing From ce8fad14359660e46aa999fa7c872a54fa47dea7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:44:03 +0000 Subject: [PATCH 061/266] Bump bundled esphome-device-builder to 1.14.9 (#19263) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bdbbe798cea..e00570c8ff2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9 RUN \ platformio settings set enable_telemetry No \ From b1bfc512ac1fd41a29570eab641d2d3458de96c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:13 -0500 Subject: [PATCH 062/266] [wifi] Drop a scan instead of aborting when its results cannot be allocated, filter ESP32 scans by SSID in the driver (#19254) --- esphome/components/wifi/__init__.py | 3 + esphome/components/wifi/wifi_component.cpp | 6 +- esphome/components/wifi/wifi_component.h | 16 ++++-- .../wifi/wifi_component_esp8266.cpp | 6 +- .../wifi/wifi_component_esp_idf.cpp | 57 +++++++++++++++---- .../wifi/wifi_component_libretiny.cpp | 6 +- esphome/core/defines.h | 2 + 7 files changed, 74 insertions(+), 22 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 58803a8cdff..1e57c03b7b0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -626,6 +626,9 @@ async def to_code(config): networks = config.get(CONF_NETWORKS, []) if networks: cg.add(var.init_sta(len(networks))) + if len(networks) > 1: + # The ESP32 scan can filter one SSID in the driver; with several the whole list is kept + cg.add_define("USE_WIFI_MULTI_SSID") def add_sta(ap: cg.MockObj, network: dict) -> None: ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f9e80995e1b..5ba36143945 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1499,8 +1499,8 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; - this->has_completed_scan_after_captive_portal_start_ = - true; // Track that we've done a scan since captive portal started + // A driver filtered scan saw one SSID; a portal that started during it still needs a full scan + this->has_completed_scan_after_captive_portal_start_ = !this->is_scan_driver_filtered_(); this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED; if (this->scan_result_.empty()) { @@ -2416,7 +2416,7 @@ void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); -#if defined(USE_RP2) || defined(USE_ESP32) +#if defined(USE_RP2) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 94fdd9bc142..77a4773a279 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -178,12 +178,12 @@ struct EAPAuth { using bssid_t = std::array; -/// Initial reserve size for filtered scan results (typical: 1-3 matching networks per SSID) -static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; +// ESP32 with one configured network: the driver filters the scan by its SSID and only this many of +// its BSSIDs are kept, the strongest ones +static constexpr size_t WIFI_SCAN_RESULT_BOUND = 12; -// Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) -// Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2) || defined(USE_ESP32) +// RP2040's callback delivers results one at a time with no count, so it needs a growable vector +#if defined(USE_RP2) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -954,6 +954,12 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; +#if defined(USE_ESP32) && !defined(USE_WIFI_MULTI_SSID) + bool scan_driver_filtered_{false}; + bool is_scan_driver_filtered_() const { return this->scan_driver_filtered_; } +#else + constexpr bool is_scan_driver_filtered_() const { return false; } +#endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) // Platform-specific STA state enum, defined in platform cpp file. // On ESP8266, written from SDK system context (wifi_event_callback) — diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 031da1b355f..60ec3f9a4d5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -773,7 +773,11 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + this->scan_done_ = true; + return; + } // Second pass: store matching networks for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index ce75d213301..24bf64a99ce 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -909,7 +909,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); uint16_t number = it.number; - bool needs_full = this->needs_full_scan_results_(); + const bool filtered = this->is_scan_driver_filtered_(); + const bool needs_full = this->needs_full_scan_results_(); { // Mutate in place under the lock; blocking a portal request is fine and // avoids scratch buffers @@ -926,8 +927,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { return; } - // Smart reserve: full capacity if needed, small reserve otherwise - this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE); + const size_t wanted = filtered ? std::min(number, WIFI_SCAN_RESULT_BOUND) : number; + // Storage is reused across the scans of one retry cycle and freed on connect; an exhausted + // heap drops this scan and the retry logic scans again + if (this->scan_result_.capacity() < wanted && !this->scan_result_.try_init(wanted)) { + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "No memory for %zu scan results", wanted); + return; + } #ifdef USE_ESP32_HOSTED // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor @@ -955,22 +962,38 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } #endif // USE_ESP32_HOSTED - // Check C string first - avoid std::string construction for non-matching networks const char *ssid_cstr = reinterpret_cast(record.ssid); - - // Only construct std::string and store if needed - if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { - bssid_t bssid; - std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (!needs_full && !this->matches_configured_network_(ssid_cstr, record.bssid)) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + bssid_t bssid; + std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (this->scan_result_.size() < wanted) { this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); - } else { - this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; } + // Records arrive in scan order, not by signal, so a bounded store keeps the strongest by + // replacing its weakest entry. Only SSID and signal decide here; a channel or auth constrained + // network hidden behind 12 stronger APs of its own SSID is not a real deployment + WiFiScanResult *weakest = &this->scan_result_[0]; + for (auto &res : this->scan_result_) { + if (res.get_rssi() < weakest->get_rssi()) + weakest = &res; + } + if (record.rssi <= weakest->get_rssi()) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + // Rebuilt in place rather than assigned; assignment pulls in CompactString's operators, 104 B of flash + weakest->~WiFiScanResult(); + new (weakest) WiFiScanResult(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, + record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); } } ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(), - needs_full ? "" : " (filtered)"); + filtered ? LOG_STR_LITERAL(" (driver filtered)") : LOG_STR_LITERAL("")); #ifdef USE_WIFI_SCAN_RESULTS_LISTENERS this->notify_scan_results_listeners_(); #endif @@ -1047,6 +1070,16 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { wifi_scan_config_t config{}; config.ssid = nullptr; config.bssid = nullptr; +#ifndef USE_WIFI_MULTI_SSID + // One configured network with an SSID: let the driver keep only its APs, so the WiFi library + // holds fewer records during the scan. Full results (portal, provisioning, listeners) and a + // network configured by BSSID alone still scan everything + this->scan_driver_filtered_ = + !this->needs_full_scan_results_() && this->sta_.size() == 1 && !this->sta_[0].get_ssid().empty(); + if (this->scan_driver_filtered_) { + config.ssid = const_cast(reinterpret_cast(this->sta_[0].get_ssid().c_str())); + } +#endif config.channel = 0; config.show_hidden = true; config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 63a63e7342a..940f2a07830 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -709,7 +709,11 @@ void WiFiComponent::wifi_scan_done_callback_() { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + WiFi.scanDelete(); + return; + } // Second pass: store matching networks for (int i = 0; i < num; i++) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fe06cc3418f..f6010fd7fa0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,8 @@ #ifdef USE_ARDUINO #define USE_PROMETHEUS #define USE_WIFI_WPA2_EAP +// Kept in the Arduino block so clang-tidy sees both scan storage paths +#define USE_WIFI_MULTI_SSID #endif // Platforms with native 64-bit time sources (no rollover tracking needed) From eb1ea4aefe9c6d6abacbec52222cb68ad3ed41c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:23 -0500 Subject: [PATCH 063/266] [esp32_ble_tracker] Re-register GATT clients after ble.disable and ble.enable (#19068) --- .../bluetooth_connection_bluedroid.cpp | 37 +++++++++++++------ .../bluetooth_connection_bluedroid.h | 1 + esphome/components/esp32_ble/ble.cpp | 35 +++++++++++------- esphome/components/esp32_ble/ble.h | 13 ++++++- .../esp32_ble_client/ble_client_base.cpp | 35 +++++++++++++++++- .../esp32_ble_client/ble_client_base.h | 12 +++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 28 ++++++++++++-- .../esp32_ble_tracker/esp32_ble_tracker.h | 3 ++ 8 files changed, 126 insertions(+), 38 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 15f854239d4..986a67c7a8f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -45,15 +45,7 @@ void BluedroidGattClient::setup() { void BluedroidGattClient::loop() { if (!esp32_ble::global_ble->is_active()) { - // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer - // frees its slot, then re-register the app on the next enable. - auto down_st = this->state(); - if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { - this->release_services(); - this->set_idle_(); - this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); - } - this->set_state(ClientState::INIT); + // ble_before_disabled_event_handler() settles the slot. return; } auto st = this->state(); @@ -65,7 +57,7 @@ void BluedroidGattClient::loop() { ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); this->mark_failed(); } - // Do not wait for REG_EVT; a dropped event must not wedge the slot. + // Do not wait for REG_EVT; connect() rejects until it lands. this->set_idle_(); } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { // The one teardown safety net: a lost CLOSE_EVT, or a scheduled @@ -78,8 +70,8 @@ void BluedroidGattClient::loop() { this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); } } else { - // The loop stays on while a link exists (stack-down watch, pre-started - // search flush); it settles only back at IDLE. + // The loop stays on while a link exists (pre-started search flush); it + // settles only back at IDLE. this->deliver_pending_search_(); if (this->state() == ClientState::IDLE) { this->disable_loop(); @@ -87,6 +79,22 @@ void BluedroidGattClient::loop() { } } +// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer +// frees its slot, then register the app again on the next enable. +void BluedroidGattClient::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != ClientState::IDLE && st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(ClientState::INIT); + // An idle slot runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BluedroidGattClient::dump_config() { ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); if (this->is_failed()) { @@ -97,6 +105,11 @@ void BluedroidGattClient::dump_config() { // ---- contract ops ---- int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } // Only from idle: clobbering DISCONNECTING would open a new link the // stale CLOSE_EVT then tears down. if (this->state() != ClientState::IDLE) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 0d0b4fed5b6..f285260e763 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -56,6 +56,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; void connect() override; void disconnect() override; + void ble_before_disabled_event_handler() override; bool wants_parsed_advertisements() override { return false; } void on_scan_end() override {} bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fc95760cf82..81fa328c160 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -83,18 +83,23 @@ void ESP32BLE::setup() { } } -void ESP32BLE::enable() { - if (this->state_ != BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_ENABLE; -} - -void ESP32BLE::disable() { - if (this->state_ == BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_DISABLE; +// Queue the transition for loop(). A pending transition the other way is +// cancelled instead, since nothing was torn down or brought up yet; any other +// state is already there or on its way. +void ESP32BLE::request_state_(bool enable) { + if (enable) { + if (this->state_ == BLE_COMPONENT_STATE_DISABLED) { + this->state_ = BLE_COMPONENT_STATE_ENABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) { + this->state_ = BLE_COMPONENT_STATE_ACTIVE; + } + } else { + if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) { + this->state_ = BLE_COMPONENT_STATE_DISABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { + this->state_ = BLE_COMPONENT_STATE_DISABLED; + } + } } #ifdef USE_ESP32_BLE_ADVERTISING @@ -580,7 +585,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { this->mark_failed(); return; } - this->state_ = BLE_COMPONENT_STATE_DISABLED; + this->drain_ble_events_(); + // A status callback may have asked for BLE back; the stack is down now, so + // that request becomes a bring-up. + this->state_ = + this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED; } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { ESP_LOGD(TAG, "Enabling"); this->state_ = BLE_COMPONENT_STATE_OFF; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 7d2d0438a46..fd4fb15ff69 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -102,8 +102,8 @@ class ESP32BLE final : public Component { } uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; } - void enable(); - void disable(); + void enable() { this->request_state_(true); } + void disable() { this->request_state_(false); } ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; @@ -176,6 +176,15 @@ class ESP32BLE final : public Component { bool ble_setup_(); bool ble_dismantle_(); + void request_state_(bool enable); + // Drop what the old stack queued; the next stack reuses the same interface ids. + void drain_ble_events_() { + BLEEvent *ble_event; + while ((ble_event = this->ble_events_.pop()) != nullptr) { + this->ble_event_pool_.release(ble_event); + } + this->ble_events_.get_and_reset_dropped_count(); + } bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e6cdde9cda6..88454f7bdbf 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -42,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) { void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { - this->set_state(espbt::ClientState::INIT); + // ble_before_disabled_event_handler() resets the client. return; } if (this->state() == espbt::ClientState::INIT) { @@ -72,6 +72,21 @@ void BLEClientBase::loop() { float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +void BLEClientBase::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) { + // No CLOSE_EVT will come: free the services and settle the link. + this->release_services(); + this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(espbt::ClientState::INIT); + // An idle client runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" @@ -93,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->state() != espbt::ClientState::IDLE) return false; + // Not registered on this stack yet; promoting now would stop the scan for a + // connect that connect() rejects anyway. + if (this->gattc_if_ == ESP_GATT_IF_NONE) + return false; this->log_event_("Found device"); if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) @@ -117,6 +136,15 @@ void BLEClientBase::connect() { this->connection_index_, this->address_str_); return; } + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + this->log_warning_("Connect rejected, GATT app not registered"); + // INIT stays so loop() still registers; only a promoted client goes back. + if (this->state() == espbt::ClientState::DISCOVERED) { + this->set_state(espbt::ClientState::IDLE); + } + return; + } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; // A registration whose event never arrived must not block this connection's release. @@ -199,7 +227,10 @@ void BLEClientBase::release_services() { #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH // Only the cache clean makes the stack's database unsafe to walk. this->services_released_ = true; - esp_ble_gattc_cache_clean(this->remote_bda_); + // A stack on its way down frees its own cache. + if (esp32_ble::global_ble->is_active()) { + esp_ble_gattc_cache_clean(this->remote_bda_); + } #endif } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index e4b9cd51005..fbd405156ae 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -41,6 +41,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void connect() override; esp_err_t pair(); void disconnect() override; + void ble_before_disabled_event_handler() override; void unconditional_disconnect(); void release_services(); @@ -114,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { #endif // Group 3: 4-byte types - int gattc_if_; + int gattc_if_{ESP_GATT_IF_NONE}; esp_gatt_status_t status_{ESP_GATT_OK}; // Group 4: Arrays @@ -139,7 +140,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + // Set by release_services() on RAM-cache builds; the stack's GATT database must not be walked after it bool services_released_{false}; // 8 bytes used, no padding @@ -155,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); /// Hook called once a connection has been fully torn down (after release_services() and - /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down. /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) - /// override this to release that state. `reason` is the controller reason code, or - /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + /// override this to release that state. `reason` is the controller reason code, + /// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST + /// for the stack going down. virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5339565a324..b4b793b4d0b 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -74,11 +74,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { - this->ble_was_disabled_ = true; return; - } else if (this->ble_was_disabled_) { + } + if (this->ble_was_disabled_) { this->ble_was_disabled_ = false; - // If the BLE stack was disabled, we need to start the scan again. + // First start after boot or after the stack came back. if (this->scan_continuous_) { this->start_scan(); } @@ -218,7 +218,27 @@ void ESP32BLETracker::stop_scan() { this->stop_scan_(); } -void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); } +void ESP32BLETracker::ble_before_disabled_event_handler() { + // Tell the controller to stop; a scan still starting has nothing to stop yet. + if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) { + this->stop_scan_(); + } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->ble_before_disabled_event_handler(); + } + this->skip_next_scan_end_ = false; +#endif + // The stop above never completes (stack torn down, events dropped); settle + // here so start_scan_() sees IDLE once the stack is back. + if (this->scanner_state_ != ScannerState::IDLE) { + this->cleanup_scan_state_(true); + } + // A failure latched by the old stack must not be handled against the next. + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + this->ble_was_disabled_ = true; +} bool ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 618444e626d..1a424a4a8e8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -113,6 +113,9 @@ class ESPBTClient : public ESPBTDeviceListener { virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; virtual void connect() = 0; virtual void disconnect() = 0; + /// Called right before the BLE stack is dismantled. Nothing in flight will + /// complete, and the GATT app must register again once the stack is back. + virtual void ble_before_disabled_event_handler() {} bool disconnect_pending() const { return this->want_disconnect_; } void cancel_pending_disconnect() { this->want_disconnect_ = false; } From c51020dbaf91051e03e7bbaa50df66eaab4e1ff3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:56:50 -0500 Subject: [PATCH 064/266] [core] Add RAMAllocator::make_unique for objects whose allocation may fail (#19245) --- esphome/core/helpers.h | 40 ++++++++++++++++++++ tests/components/core/test_helpers.cpp | 52 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 987c54a5b03..b1f24b25a3d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,9 +14,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -2123,6 +2126,10 @@ void delay_microseconds_safe(uint32_t us); /// @name Memory management ///@{ +template struct RAMDeleter; +/// unique_ptr over RAMAllocator storage +template using RAMUniquePtr = std::unique_ptr>; + /** An STL allocator that uses SPI or internal RAM. * Returns `nullptr` in case no memory is available. * @@ -2193,6 +2200,26 @@ template class RAMAllocator { free(p); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } + /// Value initialize one T; empty on exhaustion. new (std::nothrow) aborts on ESP-IDF instead. + /// Default flags prefer PSRAM; pass PREFER_INTERNAL to keep an object where plain new put it. + template RAMUniquePtr make_unique(Args &&...args) { + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + T *p = this->allocate(1); + if (p == nullptr) + return {}; + // ::new so a class scoped operator new cannot hide the global placement form + return RAMUniquePtr(::new (p) T(std::forward(args)...)); + } + + /// n elements left uninitialized, as std::make_unique_for_overwrite does; empty on exhaustion, overflow, and n == 0 + RAMUniquePtr make_unique_array_for_overwrite(size_t n) { + static_assert(std::is_trivially_default_constructible_v, "elements are left unconstructed"); + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + if (n == 0 || n > SIZE_MAX / sizeof(T)) + return {}; + return RAMUniquePtr(this->allocate(n)); + } + /** * Return the total heap space available via this allocator */ @@ -2255,6 +2282,19 @@ template class RAMAllocator { template using ExternalRAMAllocator = RAMAllocator; +/// Destroys and frees RAMAllocator storage. Not convertible: free() needs the address malloc returned +template struct RAMDeleter { + void operator()(T *p) const { + p->~T(); + RAMAllocator().deallocate(p, 1); + } +}; +/// Array form: elements must be trivial, the count is not stored so only the storage is freed +template struct RAMDeleter { + static_assert(std::is_trivially_destructible_v, "RAMUniquePtr is for trivially destructible elements"); + void operator()(T *p) const { RAMAllocator().deallocate(p, 1); } +}; + /** * Functions to constrain the range of arithmetic values. */ diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index d6b31508d17..72af605d61f 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -367,4 +367,56 @@ TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { EXPECT_EQ(v.size(), 1u); } +// --- RAMAllocator::make_unique() --- + +namespace { +struct Probe { + static inline int live = 0; + int a; + int b; + Probe(int a, int b) : a(a), b(b) { live++; } + ~Probe() { live--; } +}; +} // namespace + +static_assert(sizeof(RAMUniquePtr) == sizeof(Probe *), "the deleter must not add storage"); + +TEST(RAMAllocatorMakeUnique, ForwardsArgsAndDestroysOnce) { + auto p = RAMAllocator().make_unique(3, 4); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->a, 3); + EXPECT_EQ(p->b, 4); + EXPECT_EQ(Probe::live, 1); + p.reset(); + EXPECT_EQ(Probe::live, 0); +} + +TEST(RAMAllocatorMakeUnique, ValueInitializesLikeMakeUnique) { + struct Plain { + uint32_t words[8]; + }; + // Dirty a block of the same size first so a recycled allocation is not zero by chance + auto dirty = RAMAllocator().make_unique_array_for_overwrite(sizeof(Plain)); + std::memset(dirty.get(), 0xFF, sizeof(Plain)); + dirty.reset(); + auto p = RAMAllocator().make_unique(); + ASSERT_NE(p, nullptr); + // Under ASan fresh blocks are filled with 0xbe, so this holds even when the dirtied block is not reused + EXPECT_TRUE(std::all_of(std::begin(p->words), std::end(p->words), [](uint32_t w) { return w == 0; })); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormRejectsOverflowAndZero) { + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(SIZE_MAX / sizeof(uint32_t) + 1), nullptr); + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(0), nullptr); + EXPECT_NE(RAMAllocator().make_unique_array_for_overwrite(1), nullptr); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormAllocatesElements) { + RAMUniquePtr buf = RAMAllocator().make_unique_array_for_overwrite(256); + ASSERT_NE(buf, nullptr); + std::memset(buf.get(), 0xA5, 256); + EXPECT_EQ(buf[0], 0xA5); + EXPECT_EQ(buf[255], 0xA5); +} + } // namespace esphome::core::testing From 67871bcf55b47355944e35a11fab96386b3f7e78 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 13 Sep 2026 18:01:58 -0400 Subject: [PATCH 065/266] [i2s_audio][router] Loop thread controls all state changes (#19089) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 14 ++++++++----- .../router/speaker/router_speaker.cpp | 21 ++++++++++++++++--- .../router/speaker/router_speaker.h | 3 +++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1382a870465..9feaf39ffff 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -53,6 +53,13 @@ void I2SAudioSpeakerBase::dump_config() { void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); + } + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { this->state_ = speaker::STATE_STARTING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); @@ -239,8 +246,6 @@ void I2SAudioSpeakerBase::start() { if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; - // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. - this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -249,11 +254,10 @@ void I2SAudioSpeakerBase::stop() { this->stop_(false); } void I2SAudioSpeakerBase::finish() { this->stop_(true); } void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { - if (this->is_failed()) - return; - if (this->state_ == speaker::STATE_STOPPED) + if (!this->is_ready() || this->is_failed()) return; + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending if (wait_on_empty) { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); } else { diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp index f4bf7420ab0..dd2428e4df4 100644 --- a/esphome/components/router/speaker/router_speaker.cpp +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esp_timer.h" @@ -12,6 +14,9 @@ namespace esphome::router { static const char *const TAG = "router.speaker"; +// Maximum time to wait for the active output to report running after start() before giving up +static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; + static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { uint32_t current = var.load(std::memory_order_acquire); uint32_t subtracted = 0; @@ -72,6 +77,7 @@ void Router::loop() { this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + this->state_start_ms_ = App.get_loop_component_start_time(); active->start(); } return; @@ -86,10 +92,17 @@ void Router::loop() { // set_audio_stream_info() and never reaches the output on its own; if the format // changed while stopped, only start()'s apply_cached_state_to_active_() pushes it // down before the output's play()-side auto-start locks in the stale format. - if (active->is_stopped()) { + // While STARTING, ignore a transient stopped report as speaker running state + // is set asynchronously from start(). Timeout if the speaker never transitions. + if (this->state_ == speaker::STATE_STARTING) { + if (active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } else if ((App.get_loop_component_start_time() - this->state_start_ms_) > STATE_TRANSITION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Active output did not start; giving up"); + this->state_ = speaker::STATE_STOPPED; + } + } else if (active->is_stopped()) { this->state_ = speaker::STATE_STOPPED; - } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { - this->state_ = speaker::STATE_RUNNING; } } @@ -133,6 +146,8 @@ void Router::start() { this->frames_in_pipeline_.store(0, std::memory_order_release); this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + // May run on a producer task, so the cached loop timestamp is not usable here + this->state_start_ms_ = millis(); this->get_active_output()->start(); } diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 801d0906cee..31f3f906301 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -59,6 +59,9 @@ class Router final : public Component, public speaker::Speaker { // frames_in_pipeline_. std::atomic frames_in_pipeline_{0}; + // Set when entering STATE_STARTING; used to time out a start the output never acts on + uint32_t state_start_ms_{0}; + bool cached_pause_{false}; void apply_cached_state_to_active_(); From 16df92212e307096f71ef492b82ba0b16c8daee3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:02:42 -0500 Subject: [PATCH 066/266] [esp32_ble_tracker] Revert coexistence preference to balanced when OTA starts (#19082) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index b4b793b4d0b..e25b6f59fa0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -62,6 +62,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u for (auto *client : this->clients_) { client->disconnect(); } +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + // The OTA transfer blocks the main loop, so the revert in loop() cannot run. No + // active-connection gate here: every client was just told to disconnect. + this->update_coex_preference_(false); +#endif #endif } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; From ed5a570e1784057770519095d37de0dcec9359eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:14 -0500 Subject: [PATCH 067/266] [nextion] Allocate queue components through RAMAllocator and free entries the way they were allocated (#19246) --- esphome/components/nextion/nextion.cpp | 152 +++++++++--------- esphome/components/nextion/nextion.h | 2 + .../nextion/nextion_component_base.h | 5 +- 3 files changed, 78 insertions(+), 81 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 97910ba3d55..625c915e732 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -13,6 +13,11 @@ namespace esphome::nextion { static const char *const TAG = "nextion"; +// A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag +static bool is_sleep_wake_command(const NextionComponentBase *component) { + return component->get_queue_type() == NextionQueueType::NO_RESULT && component->get_variable_name() == "sleep_wake"; +} + // Nextion command terminator: three consecutive 0xFF bytes (per Nextion Instruction Set v1.1). static constexpr uint8_t COMMAND_DELIMITER[3] = {0xFF, 0xFF, 0xFF}; static constexpr size_t DELIMITER_SIZE = sizeof(COMMAND_DELIMITER); @@ -163,6 +168,17 @@ bool Nextion::check_connect_() { #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE } +// NO_RESULT components are owned by their entry; every other component is a user entity. Entry and +// component storage comes from RAMAllocator, so delete is not valid for either. +void Nextion::release_queue_entry_(NextionQueue *nb) { + if (nb->component != nullptr && nb->component->get_queue_type() == NextionQueueType::NO_RESULT) { + nb->component->~NextionComponentBase(); + RAMAllocator().deallocate(nb->component, 1); + } + nb->~NextionQueue(); + RAMAllocator().deallocate(nb, 1); +} + void Nextion::reset_(bool reset_nextion) { uint8_t d; @@ -170,15 +186,12 @@ void Nextion::reset_(bool reset_nextion) { this->read_byte(&d); } for (auto *entry : this->nextion_queue_) { - if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) { - delete entry->component; // NOLINT(cppcoreguidelines-owning-memory) - } - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->nextion_queue_.clear(); #ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->waveform_queue_.clear(); #endif // USE_NEXTION_WAVEFORM @@ -421,6 +434,9 @@ bool Nextion::remove_from_q_(bool report_empty) { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return false; } @@ -428,13 +444,10 @@ bool Nextion::remove_from_q_(bool report_empty) { ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); return true; } @@ -544,7 +557,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); } #else // USE_NEXTION_WAVEFORM @@ -647,6 +660,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue entry"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -660,7 +676,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_string(to_process, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -687,6 +703,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -703,7 +722,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_int(value, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -890,7 +909,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); component->clear_wave_buffer(buffer_to_send); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); #else // USE_NEXTION_WAVEFORM ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); @@ -920,14 +939,10 @@ void Nextion::purge_stale_queue_entries_() { ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(), component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - - delete *it; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(*it); it = this->nextion_queue_.erase(it); } else { @@ -1079,6 +1094,34 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool return response.length(); } +// Allocates a queue entry owning a bare NO_RESULT component; nullptr when the queue is full or memory is out +NextionQueue *Nextion::make_no_result_entry_(const std::string &variable_name) { +#ifdef USE_NEXTION_MAX_QUEUE_SIZE + if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { + ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + return nullptr; + } +#endif + + auto *nextion_queue = RAMAllocator().allocate(1); + if (nextion_queue == nullptr) { + ESP_LOGW(TAG, "Queue alloc failed"); + return nullptr; + } + new (nextion_queue) nextion::NextionQueue; + + nextion_queue->component = RAMAllocator().allocate(1); + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + this->release_queue_entry_(nextion_queue); + return nullptr; + } + new (nextion_queue->component) nextion::NextionComponentBase; + nextion_queue->component->set_variable_name(variable_name); + nextion_queue->queue_time = App.get_loop_component_start_time(); + return nextion_queue; +} + /** * @brief Add a command to the Nextion queue that expects no response. * @@ -1090,36 +1133,11 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool * @param variable_name Name of the variable or component associated with the command. */ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - - nextion_queue->queue_time = App.get_loop_component_start_time(); - this->nextion_queue_.push_back(nextion_queue); - - ESP_LOGN(TAG, "Queue NORESULT: %s", nextion_queue->component->get_variable_name().c_str()); + ESP_LOGN(TAG, "Queue NORESULT: %s", variable_name.c_str()); } /** @@ -1153,32 +1171,10 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n #ifdef USE_NEXTION_COMMAND_SPACING void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &variable_name, const std::string &command) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry - this->nextion_queue_.push_back(nextion_queue); ESP_LOGVV(TAG, "Queue with pending command: %s", variable_name.c_str()); } @@ -1312,7 +1308,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1334,7 +1330,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { if (this->send_command_(command)) { this->nextion_queue_.push_back(nextion_queue); } else { - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); } #endif // USE_NEXTION_COMMAND_SPACING } @@ -1355,14 +1351,14 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); if (!this->waveform_queue_.push(nextion_queue)) { ESP_LOGW(TAG, "Waveform queue full, drop"); - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); return; } if (this->waveform_queue_.size() == 1) diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index aa9fe8abb3f..6c9c8760f8a 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1469,6 +1469,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; bool remove_from_q_(bool report_empty = true); + void release_queue_entry_(NextionQueue *nb); + NextionQueue *make_no_result_entry_(const std::string &variable_name); /** * @brief Status flags for Nextion display state management diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 6676d019201..5e84291b168 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -23,8 +23,7 @@ class NextionComponentBase; class NextionQueue { public: - virtual ~NextionQueue() = default; - NextionComponentBase *component; + NextionComponentBase *component{nullptr}; uint32_t queue_time = 0; // Store command for retry if spacing blocked it @@ -105,6 +104,6 @@ class NextionComponentBase { int wave_max_length_ = 255; #endif // USE_NEXTION_WAVEFORM - bool needs_to_send_update_; + bool needs_to_send_update_{false}; }; } // namespace esphome::nextion From bc1841c1b53f33248eb419ff949d469466b4782a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:28 -0500 Subject: [PATCH 068/266] [esphome] Allocate the OTA noise session and auth buffer through RAMAllocator (#19249) --- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++++++++- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- esphome/components/esphome/ota/ota_esphome_noise.cpp | 6 ++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f853ed6a2db..3010df10561 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -842,7 +842,14 @@ bool ESPHomeOTAComponent::handle_auth_send_() { const size_t hex_size = hasher.get_size() * 2; const size_t nonce_len = hasher.get_size() / 4; const size_t auth_buf_size = 1 + 3 * hex_size; - this->auth_buf_ = std::make_unique(auth_buf_size); + // Internal RAM first: 128 of these bytes go straight into the hardware SHA engine + this->auth_buf_ = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(auth_buf_size); + if (!this->auth_buf_) { + this->log_auth_warning_(LOG_STR("No memory")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } this->auth_buf_pos_ = 0; char *buf = reinterpret_cast(this->auth_buf_.get() + 1); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c6f710b3fcb..68dd0ffb9ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -145,13 +145,13 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; - std::unique_ptr auth_buf_; + RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION #ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; #endif - std::unique_ptr noise_; + RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION socket::ListenSocket *server_{nullptr}; diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7401413d6d0..65476572a1e 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -7,7 +7,6 @@ #include "esphome/core/log.h" #include -#include #ifdef USE_ESP8266 #include @@ -43,9 +42,8 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { // A provisioned key cleared between the offer and here is not guarded: the // session runs on the zero key load_psk fills in and fails the client's MAC. - // Default-init: the frame buffer is written before it is read - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) - this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession); + // Default placement, PSRAM first where present: the session only lives for one upload + this->noise_ = RAMAllocator().make_unique(); static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1; static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags From cf398ea8b212c55db7b5c70e219f6e589eb527e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:27:03 -0500 Subject: [PATCH 069/266] [core] Resolve file paths against the YAML file that declares them (#19259) --- esphome/config_validation.py | 66 +++++++----- tests/unit_tests/test_config_validation.py | 120 ++++++++++++++++++++- 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a38fb2ed82c..1623117a367 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -16,6 +16,7 @@ from ipaddress import ( ip_network, ) import logging +import os from pathlib import Path import re from string import ascii_letters, digits @@ -1999,38 +2000,51 @@ def _remap_bundle_path(value: str) -> Path | None: return remap_bundle_path(value) -def directory(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) +def _declaring_document(value: str) -> Path | None: + """Return the on-disk YAML file *value* was loaded from, absolute, or None.""" + esp_range = getattr(value, "esp_range", None) + if esp_range is None: + return None + document = Path(esp_range.start_mark.document).absolute() + return document if document.is_file() else None - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: + +def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path: + """Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap.""" + path = CORE.relative_config_path(value) + if is_kind(path): + return path + candidates = [path] + tried_document: Path | None = None + if (document := _declaring_document(value)) is not None: + beside_document = document.parent / Path(value).expanduser() + if os.path.normpath(beside_document) != os.path.normpath(path): + candidates.append(beside_document) + tried_document = document + if (remapped := _remap_bundle_path(value)) is not None: + candidates.append(remapped) + for candidate in candidates: + if is_kind(candidate): + return candidate + for candidate in candidates: + if candidate.exists(): raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})." ) - path = remapped - if not path.is_dir(): - raise Invalid( - f"Path '{path}' is not a directory (full path: {path.resolve()})." - ) - return path + also = ( + f" Also looked next to {tried_document}." if tried_document is not None else "" + ) + raise Invalid( + f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}" + ) + + +def directory(value: object) -> Path: + return _existing_path(string(value), "directory", Path.is_dir) def file_(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) - - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) - path = remapped - if not path.is_file(): - raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") - return path + return _existing_path(string(value), "file", Path.is_file) ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 4092b4c0d5c..230a8e1f9ef 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,4 +1,5 @@ import importlib +import io import json import logging from pathlib import Path @@ -20,6 +21,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.substitutions import do_substitution_pass from esphome.config_validation import Invalid from esphome.const import ( CONF_DAY, @@ -65,7 +67,13 @@ from esphome.core import ( ) from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry -from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + SensitiveStr, + load_yaml, + make_data_base, + parse_yaml, +) def test_check_not_templatable__invalid(): @@ -3174,6 +3182,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None: assert cv.file_("partitions.csv") == setup_core / "partitions.csv" +def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]: + """Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value.""" + package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor" + (package_dir / "assets").mkdir(parents=True) + (package_dir / "assets" / "ui.js").write_text("js\n") + (package_dir / "device.yaml").write_text(f"path: {path}\n") + return package_dir, load_yaml(package_dir / "device.yaml")["path"] + + +def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None: + """A package's own asset path resolves against the package file when the config dir lacks it.""" + package_dir, value = _package_value(setup_core) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__resolves_a_substituted_path_against_the_use_site( + setup_core: Path, +) -> None: + package_dir, _ = _package_value(setup_core) + (package_dir / "device.yaml").write_text( + "substitutions:\n ui: assets/ui.js\npath: ${ui}\n" + ) + config = do_substitution_pass(load_yaml(package_dir / "device.yaml")) + + assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js" + + +def test_file__result_is_absolute_for_a_relative_document( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A document loaded by a cwd-relative path still yields an absolute result.""" + package_dir, _ = _package_value(setup_core) + monkeypatch.chdir(setup_core) + value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"] + + result = cv.file_(value) + + assert result.is_absolute() + assert result == package_dir / "assets" / "ui.js" + + +def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core) + (setup_core / "assets" / "ui.js").mkdir(parents=True) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__miss_names_the_declaring_document(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets/other.js") + + with pytest.raises(Invalid, match="Could not find file") as excinfo: + cv.file_(value) + + assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value) + + +def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint( + setup_core: Path, +) -> None: + (setup_core / "sub").mkdir() + (setup_core / "device.yaml").write_text("path: assets/other.js\n") + value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"] + + with pytest.raises(Invalid) as excinfo: + cv.file_(value) + + assert "Also looked" not in str(excinfo.value) + + +def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets") + + with pytest.raises(Invalid, match="is not a file") as excinfo: + cv.file_(value) + + assert str(package_dir / "assets") in str(excinfo.value) + + +def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None: + _, value = _package_value(setup_core) + (setup_core / "assets").mkdir() + (setup_core / "assets" / "ui.js").write_text("local\n") + + assert cv.file_(value) == setup_core / "assets" / "ui.js" + + +def test_file__declared_in_an_in_memory_document_is_not_resolved( + setup_core: Path, +) -> None: + """A value whose source document isn't on disk falls through to the config-dir error.""" + value = parse_yaml(Path(""), io.StringIO("path: assets/ui.js\n"))[ + "path" + ] + + with pytest.raises(Invalid, match="Could not find file"): + cv.file_(value) + + +def test_directory_resolves_relative_to_the_declaring_document( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core, "assets") + + assert cv.directory(value) == package_dir / "assets" + + def test_file__missing_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="Could not find file"): cv.file_("partitions.csv") From e38ee343b407373d1df7849bb1322e66119245f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:35:38 -0500 Subject: [PATCH 070/266] [ethernet] Keep the W5500 SPI context in a static instance instead of the heap (#19248) --- .../components/ethernet/w5500_custom_spi.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp index ed4f149738f..9c6b59582a3 100644 --- a/esphome/components/ethernet/w5500_custom_spi.cpp +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -6,17 +6,21 @@ #include #include #include -#include namespace esphome::ethernet { namespace { -// Per-device context returned by init() and handed back to read/write/deinit. +// Context returned by init() and handed back to read/write/deinit. There is one W5500 per device, so a +// single static instance replaces a heap allocation that could fail. It is always clear when init() runs: +// esp_eth_mac_new_w5500() calls deinit() on every failure after init() succeeded, and nothing else +// uninstalls the driver struct W5500CustomSpiContext { spi_device_handle_t handle; SemaphoreHandle_t lock; }; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state +W5500CustomSpiContext w5500_context{}; // Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger // transfers (the frame payloads) use the blocking, DMA-backed transmit. @@ -25,23 +29,20 @@ constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; void *w5500_custom_spi_init(const void *spi_config) { const auto *config = static_cast(spi_config); - auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; - if (ctx == nullptr) { - return nullptr; - } + auto *ctx = &w5500_context; // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control // byte in the address phase; mirror what the stock driver configures. spi_device_interface_config_t devcfg = *config->spi_devcfg; devcfg.command_bits = 16; devcfg.address_bits = 8; if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { - delete ctx; + ctx->handle = nullptr; return nullptr; } ctx->lock = xSemaphoreCreateMutex(); if (ctx->lock == nullptr) { spi_bus_remove_device(ctx->handle); - delete ctx; + ctx->handle = nullptr; return nullptr; } return ctx; @@ -51,7 +52,7 @@ esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { auto *ctx = static_cast(spi_ctx); spi_bus_remove_device(ctx->handle); vSemaphoreDelete(ctx->lock); - delete ctx; + *ctx = {}; return ESP_OK; } From f5afd141de41203c4a4b31465b295d3121c1db8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:37:04 -0500 Subject: [PATCH 071/266] [ota] Allocate the signature block through RAMAllocator (#19251) --- esphome/components/ota/ota_signature_esp_idf.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 501d6ac241d..2192a794410 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -235,9 +234,11 @@ bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer // and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens // a thin margin. One short-lived allocation right before reboot is not the - // fragmentation pattern the project guards against. nothrow so an OOM here - // fails closed like every other error path, rather than aborting. - std::unique_ptr block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]); + // fragmentation pattern the project guards against. An OOM returns nullptr + // and fails closed like every other error path. Internal RAM first: the + // block is an esp_partition_read target. + auto block = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(SIG_BLOCK_SIZE); if (!block) { OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory"); return false; From e431bfcb38f0393521b984de4480dcfa1d8918aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 19:28:24 -0500 Subject: [PATCH 072/266] [spi] Send ESP8266 writes through transferBytes instead of a heap copy (#19265) --- esphome/components/spi/spi_arduino.cpp | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index 14428bed629..ae2d2906edf 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -44,16 +44,8 @@ class SPIDelegateHw : public SPIDelegate { #ifdef USE_RP2 this->channel_->transfer(ptr, nullptr, length); #elif defined(USE_ESP8266) - // ESP8266 SPI library requires the pointer to be word aligned, but the data may not be - // so we need to copy the data to a temporary buffer - if (reinterpret_cast(ptr) & 0x3) { - ESP_LOGVV(TAG, "SPI write buffer not word aligned, copying to temporary buffer"); - auto txbuf = std::vector(length); - memcpy(txbuf.data(), ptr, length); - this->channel_->writeBytes(txbuf.data(), length); - } else { - this->channel_->writeBytes(ptr, length); - } + // writeBytes() needs a word aligned pointer; transferBytes() bounces unaligned chunks through a stack buffer + this->channel_->transferBytes(ptr, nullptr, length); #else this->channel_->writeBytes(ptr, length); #endif From 7801cf4a8ac103a88e64c9855ae2560c2f5b9648 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:55:13 +1200 Subject: [PATCH 073/266] [core] Clear loaded_platforms on CORE.reset() (#19268) --- esphome/core/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 6e3f91af22f..5fcad90a81a 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -715,6 +715,7 @@ class EsphomeCore: self.defines = set() self.platformio_options = {} self.loaded_integrations = set() + self.loaded_platforms = set() self.component_ids = set() self.platform_counts = defaultdict(int) self.unique_ids = {} From 91b1a82a66aa30ed9a7c6c3f8fc54d9c11266dc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 20:03:00 -0500 Subject: [PATCH 074/266] [api] Reuse overflow buffer storage instead of allocating per stalled write (#19093) --- esphome/components/api/__init__.py | 5 +- esphome/components/api/api_buffer.cpp | 35 +- esphome/components/api/api_buffer.h | 24 +- esphome/components/api/api_connection.cpp | 5 +- esphome/components/api/api_frame_helper.cpp | 2 +- esphome/components/api/api_frame_helper.h | 3 + .../components/api/api_frame_helper_noise.cpp | 20 +- .../components/api/api_overflow_buffer.cpp | 121 ++--- esphome/components/api/api_overflow_buffer.h | 93 ++-- tests/components/api/__init__.py | 17 + tests/components/api/test_api_buffer.cpp | 65 +++ tests/components/api/test_overflow_buffer.cpp | 510 ++++++++++++++++++ 12 files changed, 755 insertions(+), 145 deletions(-) create mode 100644 tests/components/api/__init__.py create mode 100644 tests/components/api/test_api_buffer.cpp create mode 100644 tests/components/api/test_overflow_buffer.cpp diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6202e127bfc..272b0786905 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -350,10 +350,9 @@ CONFIG_SCHEMA = cv.All( ln882x=5, # Moderate RAM nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), - # Maximum queued send buffers per connection before dropping connection - # Each buffer uses ~8-12 bytes overhead plus actual message size + # Max queued messages per connection, and 2 KB of backlog per slot up + # to 64 KB (a lone message is exempt), before the connection is dropped # Platform defaults based on available RAM and typical message rates: - # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp index fc45a4e971f..62a544b1a41 100644 --- a/esphome/components/api/api_buffer.cpp +++ b/esphome/components/api/api_buffer.cpp @@ -1,20 +1,37 @@ #include "api_buffer.h" -#include +#ifdef ESPHOME_DEBUG_API +#include "esphome/core/log.h" +#endif namespace esphome::api { +#ifdef ESPHOME_DEBUG_API +void APIBuffer::debug_check_drop_(size_t drop) const { + if (drop > this->size_) { + ESP_LOGE("api.buffer", "drop_front: drop=%zu size=%u", drop, this->size_); + abort(); + } +} +#endif + bool APIBuffer::grow_(size_t n) { - // nothrow (no zero-fill) so OOM is reportable; plain new aborts instead - // (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF). - // RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory. - std::unique_ptr new_data(new (std::nothrow) uint8_t[n]); - if (new_data == nullptr) + if (n > MAX_SIZE) return false; - if (this->size_) - std::memcpy(new_data.get(), this->data_.get(), this->size_); - this->data_ = std::move(new_data); + // realloc extends in place when it can, avoiding the copy + uint8_t *grown = RAMAllocator().reallocate(this->data_.get(), n); + if (grown == nullptr) + return false; + (void) this->data_.release(); // realloc already freed or reused the old block + this->data_.reset(grown); this->capacity_ = n; return true; } +uint8_t *APIBuffer::append(size_t n) { + const size_t old_size = this->size_; + if (!this->resize(old_size + n)) + return nullptr; + return this->data_.get() + old_size; +} + } // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 396dadbe587..7caa68aa4d5 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -25,6 +25,7 @@ namespace esphome::api { /// writes in debug builds. class APIBuffer { public: + static constexpr size_t MAX_SIZE = UINT16_MAX; // API frames carry 16 bit lengths void clear() { this->size_ = 0; } /// Returns false if allocation fails; the buffer is left unchanged. [[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); } @@ -36,9 +37,19 @@ class APIBuffer { [[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { if (!this->reserve(std::max(reserve_size, new_size))) return false; - this->size_ = new_size; + this->size_ = static_cast(new_size); return true; } + /// Grow by n bytes; returns the new bytes, or nullptr on allocation failure. + [[nodiscard]] uint8_t *append(size_t n); + /// Drop the first `drop` bytes, sliding the rest down. Precondition: drop <= size(). + void drop_front(size_t drop) { +#ifdef ESPHOME_DEBUG_API + this->debug_check_drop_(drop); +#endif + this->size_ -= drop; + std::memmove(this->data_.get(), this->data_.get() + drop, this->size_); + } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } @@ -55,9 +66,14 @@ class APIBuffer { protected: bool grow_(size_t n); - std::unique_ptr data_; - size_t size_{0}; - size_t capacity_{0}; +#ifdef ESPHOME_DEBUG_API + void debug_check_drop_(size_t drop) const; +#endif + // RAMAllocator: PSRAM when available, and it reports failure where + // new (std::nothrow) still aborts on ESP-IDF without exceptions + RAMUniquePtr data_; + uint16_t size_{0}; + uint16_t capacity_{0}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d910f6fc67a..749eaeb3929 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -364,7 +364,10 @@ void APIConnection::check_keepalive_(uint32_t now) { ESP_LOGVV(TAG, "Sending keepalive PING"); PingRequest req; this->flags_.sent_ping = this->send_message(req); - if (!this->flags_.sent_ping) { + if (this->flags_.sent_ping) { + // Quiet for a keepalive period and the ping is on its way: a one-off stall's storage can go + this->helper_->release_overflow_buffer(); + } else { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority ESP_LOGW(TAG, "Buffer full, ping queued"); diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 38da444a189..41d1230aaa6 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -171,7 +171,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin return APIError::OK; // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) { HELPER_LOG("Overflow buffer full or out of memory, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ff8aa7834c0..a68a0ad0d87 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -219,7 +219,10 @@ class APIFrameHelper { if (this->rx_buf_len_ == 0) { this->rx_buf_.release(); } + this->release_overflow_buffer(); } + // Free the send backlog storage once it has drained + void release_overflow_buffer() { this->overflow_buf_.release(); } protected: // Drain backlogged overflow data to the socket and handle errors. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 29b2858aee8..400cd1d9b86 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -67,15 +67,15 @@ APIError APINoiseFrameHelper::init() { } // init prologue - size_t old_size = prologue_.size(); - if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] { + uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } #ifdef USE_ESP8266 - memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #else - std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #endif state_ = State::CLIENT_HELLO; @@ -272,17 +272,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { return handle_handshake_frame_error_(aerr); } // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); + // Append 2 size bytes + frame data to the prologue size_t rx_size = this->rx_buf_.size(); - if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] { + uint8_t *dst = this->prologue_.append(2 + rx_size); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; + dst[0] = (uint8_t) (rx_size >> 8); + dst[1] = (uint8_t) rx_size; if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } state_ = State::SERVER_HELLO; diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index 48d8fe18ba8..0b5a874d4b5 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -1,98 +1,91 @@ #include "api_overflow_buffer.h" #ifdef USE_API #include -#include namespace esphome::api { -APIOverflowBuffer::~APIOverflowBuffer() { - for (auto *entry : this->queue_) { - if (entry != nullptr) - Entry::destroy(entry); - } -} - ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { - // socket->write() can re-enter this function: a log message emitted from an - // lwip callback during the write goes out over the API and lands back in the - // frame helper's write/drain path. If a nested drain ran here it would send - // and free the entry the outer drain is still holding, causing a double free. - // Report "no progress" instead; the outer drain keeps draining, and the - // nested send is enqueued behind the existing backlog. + // Nested call from inside socket->write(); see draining_ if (this->draining_) return 0; - // RAII so the flag is cleared on every return path struct DrainGuard { - explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } - ~DrainGuard() { this->flag_ = false; } - bool &flag_; - } guard(this->draining_); + APIOverflowBuffer &owner; + ~DrainGuard() { this->owner.draining_ = false; } + } guard{*this}; + this->draining_ = true; while (this->count_ > 0) { - Entry *front = this->queue_[this->head_]; + uint8_t *msg = this->buf_.data() + this->head_; + size_t len = msg[0] | (msg[1] << 8); - ssize_t sent = socket->write(front->current_data(), front->remaining()); - - if (sent <= 0) { - // -1 = error (caller checks errno for EWOULDBLOCK vs hard error) - // 0 = nothing sent (treat as no progress) + ssize_t sent = socket->write(msg + LEN_PREFIX, len); + if (sent <= 0) + return sent; + if (static_cast(sent) < len) { + // Step past the sent bytes and rewrite the prefix there; it lands on bytes already sent + this->head_ += sent; + len -= sent; + msg += sent; + msg[0] = len; + msg[1] = len >> 8; return sent; } - - if (static_cast(sent) < front->remaining()) { - // Partially sent, update offset and stop - front->offset += static_cast(sent); - return sent; - } - - // Entry fully sent — unlink it before freeing so a freed pointer is never - // reachable from the queue - this->queue_[this->head_] = nullptr; - this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + this->head_ += LEN_PREFIX + len; this->count_--; - Entry::destroy(front); } - return 0; // All drained + this->head_ = 0; + if (this->release_when_drained_) { + this->release_when_drained_ = false; + this->buf_.release(); + } else { + this->buf_.clear(); + } + return 0; } -bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { +bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip) { if (this->count_ >= API_MAX_SEND_QUEUE) return false; - uint16_t buffer_size = total_len - skip; - // nothrow: a failed allocation returns nullptr so the connection is dropped - // cleanly instead of plain new's crash or abort on OOM - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *data = new (std::nothrow) uint8_t[buffer_size]; - if (data == nullptr) - return false; - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new (std::nothrow) Entry{data, buffer_size, 0}; - if (entry == nullptr) { - delete[] data; + const size_t new_len = total_len - skip; + const size_t new_bytes = LEN_PREFIX + new_len; + const size_t live = this->buf_.size() - this->head_; + // A lone message is only bound by the buffer; refusing it would just drop the connection + if (live + new_bytes > (this->count_ > 0 ? MAX_BYTES : MAX_LONE_BYTES)) return false; + + if (this->buf_.size() + new_bytes > this->buf_.capacity()) { + // Storage would move under an outer drain's write() + if (this->draining_) + return false; + if (this->head_ > 0) { + // Reclaim the sent prefix before growing + this->buf_.drop_front(this->head_); + this->head_ = 0; + } + if (!this->buf_.reserve(reserve_for(live + new_bytes))) + return false; } - uint16_t to_skip = skip; - uint16_t write_pos = 0; - - for (int i = 0; i < iovcnt; i++) { - if (to_skip >= iov[i].iov_len) { - to_skip -= static_cast(iov[i].iov_len); + uint8_t *dst = this->buf_.append(new_bytes); + if (dst == nullptr) + return false; + dst[0] = new_len; + dst[1] = new_len >> 8; + dst += LEN_PREFIX; + for (const struct iovec *end = iov + iovcnt; iov != end; iov++) { + if (skip >= iov->iov_len) { + skip -= iov->iov_len; } else { - const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; - uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(entry->data + write_pos, src, len); - write_pos += len; - to_skip = 0; + const size_t len = iov->iov_len - skip; + std::memcpy(dst, static_cast(iov->iov_base) + skip, len); + dst += len; + skip = 0; } } - // Publish only after the copy completes so a half-built entry is never reachable - this->queue_[this->tail_] = entry; - this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; } diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 03a334b281a..e2e4b9c3c37 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -1,5 +1,6 @@ #pragma once -#include +#include +#include #include #include @@ -8,71 +9,57 @@ #include "esphome/components/socket/headers.h" #include "esphome/components/socket/socket.h" -#include "esphome/core/helpers.h" +#include "api_buffer.h" namespace esphome::api { -/// Circular queue of heap-allocated byte buffers used as a TCP send backlog. -/// -/// Under normal operation this buffer is **never used** — data goes straight -/// from the frame helper to the socket. It only fills when the LWIP TCP -/// send buffer is full (slow client, congested network, heavy logging). -/// The queue drains automatically on subsequent write/loop calls once the -/// socket becomes writable again. -/// -/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python -/// config). If the queue fills completely the connection is marked failed. +/// TCP send backlog, only used when the socket send buffer is full. +/// One contiguous buffer per connection, allocated on the first stall and +/// kept at its high-water mark so a lossy link does not churn the heap. +/// Messages are stored as a 2 byte length prefix plus payload. +/// API_MAX_SEND_QUEUE bounds queued messages and, at 2 KB per slot, queued +/// bytes; exceeding either fails the connection. class APIOverflowBuffer { public: - /// A single heap-allocated send-backlog entry. - /// Lifetime is manually managed — see destroy(). - struct Entry { - uint8_t *data; - uint16_t size; // Total size of the buffer - uint16_t offset; // Current send offset within the buffer - - uint16_t remaining() const { return this->size - this->offset; } - const uint8_t *current_data() const { return this->data + this->offset; } - - /// Free this entry and its data buffer. - static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) { - delete[] entry->data; - delete entry; // NOLINT(cppcoreguidelines-owning-memory) - } - }; - - ~APIOverflowBuffer(); - /// True when no backlogged data is waiting. bool empty() const { return this->count_ == 0; } - /// True when the queue has no room for another entry. - bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; } - - /// Number of entries currently queued. - uint8_t count() const { return this->count_; } - - /// Try to drain queued data to the socket. - /// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress, - /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). - /// Callers only need to act on -1; 0 and positive values both mean "no error". - /// Frees entries as they are fully sent. + /// Drain queued messages to the socket. + /// Returns bytes written, 0 for a re-entrant call, -1 on error (check errno + /// for EWOULDBLOCK); callers only need to act on -1. ssize_t try_drain(socket::Socket *socket); - /// Enqueue unsent IOV data into the backlog. - /// Copies iov data starting at byte offset `skip` into a new entry. - /// Returns false if the queue is full or allocation fails (caller should fail the connection). - bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); + /// Queue iov data from byte offset `skip` as one message. + /// Returns false when a limit is hit, allocation fails, or storage would move + /// during a drain; the caller should fail the connection. + bool enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip); + + /// Free the retained storage, now if empty, otherwise once it has drained. + void release() { + if (this->count_ == 0) { + this->buf_.release(); + } else { + this->release_when_drained_ = true; + } + } protected: - std::array queue_{}; - uint8_t head_{0}; - uint8_t tail_{0}; + static constexpr size_t LEN_PREFIX = 2; + static constexpr size_t BYTES_PER_SLOT = 2048; + // Reserve in 256 byte steps so a creeping high-water mark settles quickly + static constexpr size_t GROW_QUANTUM = 256; + // Lone message ceiling, rounded down so reserve_for() never exceeds the buffer limit + static constexpr size_t MAX_LONE_BYTES = APIBuffer::MAX_SIZE & ~(GROW_QUANTUM - 1); + static constexpr size_t MAX_BYTES = std::min(API_MAX_SEND_QUEUE * BYTES_PER_SLOT, MAX_LONE_BYTES); + static constexpr size_t reserve_for(size_t want) { return (want + GROW_QUANTUM - 1) & ~(GROW_QUANTUM - 1); } + + APIBuffer buf_; + uint16_t head_{0}; // offset of the front message's length prefix; bytes before it are sent uint8_t count_{0}; - // Guards against re-entrant drains: socket->write() can re-enter the API - // send path (e.g. a log message emitted from an lwip callback), and a nested - // drain would free the entry the outer drain is still holding. - bool draining_{false}; + // socket->write() can re-enter the send path (log from an lwip callback): + // a nested drain makes no progress and a nested enqueue never moves storage + bool draining_ : 1 {false}; + bool release_when_drained_ : 1 {false}; }; } // namespace esphome::api diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py new file mode 100644 index 00000000000..2aa558726c3 --- /dev/null +++ b/tests/components/api/__init__.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.core import CORE +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # USE_API compiles every api source, so emit what they need. No socket + # override: an __init__.py there makes pytest import its conftest as socket.conftest. + async def to_code_testing(config): + cg.add_define("USE_API") + cg.add_define("USE_API_PLAINTEXT") + cg.add_define("API_MAX_SEND_QUEUE", 8) + cg.add_define("MAX_API_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + CORE.register_controller() # api_server registers with the controller registry + + manifest.to_code = to_code_testing diff --git a/tests/components/api/test_api_buffer.cpp b/tests/components/api/test_api_buffer.cpp new file mode 100644 index 00000000000..c54780050e3 --- /dev/null +++ b/tests/components/api/test_api_buffer.cpp @@ -0,0 +1,65 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::testing { + +// Pointer plus two 16 bit sizes +static_assert(sizeof(APIBuffer) <= 2 * sizeof(void *)); + +TEST(APIBuffer, RefusesSizesAbove16Bits) { + APIBuffer buf; + ASSERT_TRUE(buf.resize(16)); + EXPECT_FALSE(buf.reserve(UINT16_MAX + 1)); + EXPECT_EQ(buf.size(), 16u); + EXPECT_EQ(buf.capacity(), 16u); + EXPECT_TRUE(buf.reserve(UINT16_MAX)); + EXPECT_EQ(buf.capacity(), UINT16_MAX); +} + +static const uint8_t BYTES[] = {1, 2, 3, 4, 5, 6}; + +TEST(APIBuffer, AppendReturnsTheNewBytes) { + APIBuffer buf; + ASSERT_TRUE(buf.reserve(8)); + uint8_t *first = buf.append(3); + ASSERT_NE(first, nullptr); + std::memcpy(first, BYTES, 3); + EXPECT_EQ(buf.size(), 3u); + EXPECT_EQ(buf.capacity(), 8u); + + // Grows through realloc and keeps what was there + uint8_t *second = buf.append(6); + ASSERT_EQ(second, buf.data() + 3); + std::memcpy(second, BYTES + 3, 3); + EXPECT_EQ(buf.size(), 9u); + EXPECT_EQ(buf.capacity(), 9u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES, 6), 0); +} + +TEST(APIBuffer, DropFrontSlidesTheRestDown) { + APIBuffer buf; + uint8_t *bytes = buf.append(6); + ASSERT_NE(bytes, nullptr); + std::memcpy(bytes, BYTES, 6); + + buf.drop_front(2); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(buf.capacity(), 6u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Growing afterwards keeps the slid bytes + ASSERT_TRUE(buf.reserve(64)); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Dropping everything leaves an empty buffer with its capacity + buf.drop_front(4); + EXPECT_EQ(buf.size(), 0u); + EXPECT_EQ(buf.capacity(), 64u); +} + +} // namespace esphome::api::testing diff --git a/tests/components/api/test_overflow_buffer.cpp b/tests/components/api/test_overflow_buffer.cpp new file mode 100644 index 00000000000..4b27e544963 --- /dev/null +++ b/tests/components/api/test_overflow_buffer.cpp @@ -0,0 +1,510 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/api/api_overflow_buffer.h" + +#ifdef USE_HOST +namespace esphome::api::testing { + +// Idle cost is the buffer plus one word of bookkeeping +static_assert(sizeof(APIOverflowBuffer) <= sizeof(APIBuffer) + sizeof(void *)); + +// Exposes storage so tests can check it is reused, not reallocated +class TestOverflowBuffer : public APIOverflowBuffer { + public: + using APIOverflowBuffer::LEN_PREFIX; + using APIOverflowBuffer::MAX_BYTES; + using APIOverflowBuffer::MAX_LONE_BYTES; + struct Storage { + size_t capacity; + const uint8_t *data; + bool operator==(const Storage &) const = default; + }; + size_t capacity() const { return this->buf_.capacity(); } + Storage storage() const { return {this->buf_.capacity(), this->buf_.data()}; } + uint8_t count() const { return this->count_; } + size_t live() const { return this->buf_.size() - this->head_; } + /// Simulates a socket write inside try_drain() re-entering the send path + void set_draining(bool draining) { this->draining_ = draining; } +}; + +static std::vector make_message(size_t len, uint8_t seed) { + std::vector msg(len); + for (size_t i = 0; i < len; i++) + msg[i] = static_cast(seed + i); + return msg; +} + +static bool enqueue(TestOverflowBuffer &buf, const std::vector &msg, uint16_t skip = 0) { + struct iovec iov = {const_cast(msg.data()), msg.size()}; + return buf.enqueue_iov(&iov, 1, static_cast(msg.size()), skip); +} + +static void append(std::vector &dst, const std::vector &src, size_t skip = 0) { + dst.insert(dst.end(), src.begin() + skip, src.end()); +} + +static std::vector concat(std::initializer_list> parts) { + std::vector out; + for (const auto &part : parts) + append(out, part); + return out; +} + +/// The pipe delivers the filler first, then the drained messages. +static void expect_after_filler(const std::vector &received, size_t filler, + const std::vector &expected) { + ASSERT_EQ(received.size(), filler + expected.size()); + EXPECT_TRUE(std::equal(expected.begin(), expected.end(), received.begin() + filler)); +} + +// Non-blocking socket pair with small buffers, so the writer fills like a stalled TCP connection +class OverflowBufferTest : public ::testing::Test { + protected: + void SetUp() override { + int fds[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + int size = 4096; + ASSERT_EQ(::setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::fcntl(fds[1], F_SETFL, O_NONBLOCK), 0); + this->reader_ = fds[1]; + this->sock_ = std::make_unique(fds[0]); + ASSERT_EQ(this->sock_->setblocking(false), 0); + } + void TearDown() override { ::close(this->reader_); } + + /// Write filler until the socket refuses; returns the bytes accepted + size_t fill_pipe_() { + uint8_t junk[512]; + std::memset(junk, 0xEE, sizeof(junk)); + size_t total = 0; + for (;;) { + ssize_t written = this->sock_->write(junk, sizeof(junk)); + if (written <= 0) + break; + total += static_cast(written); + } + return total; + } + + /// Append whatever the pipe currently holds. + void read_into_(std::vector &out) { + uint8_t tmp[1024]; + for (;;) { + ssize_t n = ::read(this->reader_, tmp, sizeof(tmp)); + if (n <= 0) + break; + out.insert(out.end(), tmp, tmp + n); + } + } + + /// Drain once; a refusal must be a would-block, never a hard error. + ssize_t drain_(TestOverflowBuffer &buf) { + ssize_t sent = buf.try_drain(this->sock_.get()); + if (sent == -1) { + EXPECT_TRUE(errno == EWOULDBLOCK || errno == EAGAIN); + } + return sent; + } + + /// Read and drain until the backlog is empty; returns all bytes received + std::vector drain_all_(TestOverflowBuffer &buf) { + std::vector received; + for (int i = 0; i < 10000 && !buf.empty(); i++) { + this->read_into_(received); + // A hard socket error would never clear the backlog; stop instead of spinning + if (this->drain_(buf) == -1 && errno != EWOULDBLOCK && errno != EAGAIN) + break; + } + EXPECT_TRUE(buf.empty()); + this->read_into_(received); + return received; + } + + struct Stall { + size_t filler; + std::vector first, second, received; + TestOverflowBuffer::Storage before; + }; + /// Park two messages, then drain the first fully and the second part way + void stall_mid_message_(TestOverflowBuffer &buf, Stall &s) { + s.filler = this->fill_pipe_(); + s.first = make_message(1500, 20); + ASSERT_GT(s.filler, s.first.size()); // the first message must drain in one go + // Larger than the whole pipe, so a drain always stops inside it + s.second = make_message(std::max(s.filler + 1, std::min(s.filler * 3, 12000)), 60); + ASSERT_GT(s.second.size(), s.filler); + ASSERT_TRUE(enqueue(buf, s.first)); + ASSERT_TRUE(enqueue(buf, s.second)); + s.before = buf.storage(); + this->read_into_(s.received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + } + + int reader_{-1}; + std::unique_ptr sock_; +}; + +TEST_F(OverflowBufferTest, IdleBufferOwnsNoStorage) { + TestOverflowBuffer buf; + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, StorageIsReusedAcrossStalls) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 1); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const auto storage = buf.storage(); + EXPECT_GE(storage.capacity, msg.size() + TestOverflowBuffer::LEN_PREFIX); + + for (int stall = 0; stall < 5; stall++) { + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + // Same allocation every time: no free, no new allocation + EXPECT_EQ(buf.storage(), storage); + + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.storage(), storage); + } +} + +TEST_F(OverflowBufferTest, ReleaseWhileQueuedFreesOnceDrained) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 7); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const size_t capacity = buf.capacity(); + + // Requested while the backlog still holds data: storage must stay until sent + buf.release(); + EXPECT_FALSE(buf.empty()); + EXPECT_EQ(buf.capacity(), capacity); + + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); + + // A later stall allocates again and keeps it, since nobody asked for a release + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_GT(buf.capacity(), 0u); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, ReleaseWhenEmptyFreesImmediately) { + TestOverflowBuffer buf; + auto msg = make_message(100, 3); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); + + buf.release(); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, PreservesOrderAndSkipsSentPrefix) { + TestOverflowBuffer buf; + auto first = make_message(700, 10); + auto second_a = make_message(300, 50); + auto second_b = make_message(400, 90); + auto third = make_message(200, 130); + + size_t filler = this->fill_pipe_(); + // 100 bytes of the first message were already accepted by the socket + ASSERT_TRUE(enqueue(buf, first, 100)); + // Two iovecs with the skip covering all of the first one plus part of the second + struct iovec iov[2] = {{second_a.data(), second_a.size()}, {second_b.data(), second_b.size()}}; + const uint16_t second_skip = static_cast(second_a.size() + 5); + ASSERT_TRUE(buf.enqueue_iov(iov, 2, static_cast(second_a.size() + second_b.size()), second_skip)); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 3); + + // Nothing can go out while the pipe is full + EXPECT_EQ(this->drain_(buf), -1); + EXPECT_EQ(buf.count(), 3); + + std::vector expected; + append(expected, first, 100); + append(expected, second_b, 5); + append(expected, third); + expect_after_filler(this->drain_all_(buf), filler, expected); +} + +TEST_F(OverflowBufferTest, RefusesWhenQueueIsFull) { + TestOverflowBuffer buf; + auto msg = make_message(16, 1); + + size_t filler = this->fill_pipe_(); + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) { + ASSERT_TRUE(enqueue(buf, msg)) << "message " << i; + } + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), API_MAX_SEND_QUEUE); + + // Draining frees the slots again + std::vector expected; + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) + append(expected, msg); + expect_after_filler(this->drain_all_(buf), filler, expected); + this->fill_pipe_(); + EXPECT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 1); +} + +TEST_F(OverflowBufferTest, SkipAtIovecBoundary) { + TestOverflowBuffer buf; + auto sent = make_message(300, 50); + auto unsent = make_message(400, 90); + + size_t filler = this->fill_pipe_(); + // The skip covers the first iovec exactly, so only the second is copied + struct iovec iov[2] = {{sent.data(), sent.size()}, {unsent.data(), unsent.size()}}; + ASSERT_TRUE( + buf.enqueue_iov(iov, 2, static_cast(sent.size() + unsent.size()), static_cast(sent.size()))); + EXPECT_EQ(buf.live(), unsent.size() + TestOverflowBuffer::LEN_PREFIX); + expect_after_filler(this->drain_all_(buf), filler, unsent); +} + +TEST_F(OverflowBufferTest, AppendsBehindSentPrefixWhenItFits) { + TestOverflowBuffer buf; + size_t filler = this->fill_pipe_(); + auto first = make_message(200, 20); + // Size the second message so the two land half way into a 256 byte step, + // leaving exactly 128 bytes of slack whatever the pipe accepted + const size_t base = std::max(filler + 1, std::min(filler * 3, 12000)); + const size_t second_len = (base / 256 + 1) * 256 + 128 - first.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + auto second = make_message(second_len, 60); + ASSERT_GT(second.size(), filler); + ASSERT_TRUE(enqueue(buf, first)); + ASSERT_TRUE(enqueue(buf, second)); + const auto storage = buf.storage(); + const size_t slack = storage.capacity - first.size() - second.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + ASSERT_EQ(slack, 128u); + auto third = make_message(slack - TestOverflowBuffer::LEN_PREFIX, 200); + + std::vector received; + this->read_into_(received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + const size_t live = buf.live(); + + // Fits in the tail, so the sent prefix is left alone + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), storage); + EXPECT_EQ(buf.live(), live + third.size() + TestOverflowBuffer::LEN_PREFIX); + + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, concat({first, second, third})); +} + +TEST_F(OverflowBufferTest, ReleaseSurvivesFurtherEnqueues) { + TestOverflowBuffer buf; + auto first = make_message(300, 7); + auto second = make_message(300, 70); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + buf.release(); + ASSERT_TRUE(enqueue(buf, second)); + EXPECT_GT(buf.capacity(), 0u); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, RefusesWhenByteLimitIsExceeded) { + TestOverflowBuffer buf; + // Two of these fill the byte budget exactly, well before the slot count is reached + static_assert(API_MAX_SEND_QUEUE >= 3); + auto msg = make_message(TestOverflowBuffer::MAX_BYTES / 2 - TestOverflowBuffer::LEN_PREFIX, 1); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 2); +} + +TEST_F(OverflowBufferTest, LoneMessageMayExceedByteLimit) { + TestOverflowBuffer buf; + // The oversized message must still fit under the lone message ceiling + static_assert(TestOverflowBuffer::MAX_BYTES + 100 + TestOverflowBuffer::LEN_PREFIX <= + TestOverflowBuffer::MAX_LONE_BYTES); + auto big = make_message(TestOverflowBuffer::MAX_BYTES + 100, 5); + auto small = make_message(16, 9); + + // Refusing the only message would drop the connection for nothing + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, big)); + EXPECT_EQ(buf.count(), 1); + // With a backlog present the byte limit applies again + EXPECT_FALSE(enqueue(buf, small)); + EXPECT_EQ(buf.count(), 1); + + expect_after_filler(this->drain_all_(buf), filler, big); +} + +TEST_F(OverflowBufferTest, LoneMessageAboveOffsetLimitIsRefused) { + TestOverflowBuffer buf; + // Payload plus prefix is past the lone message ceiling + auto msg = make_message(TestOverflowBuffer::MAX_LONE_BYTES, 3); + + this->fill_pipe_(); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, HardSocketErrorLeavesBacklogIntact) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + // A closed socket fails every write outright, unlike a full one + ASSERT_EQ(this->sock_->close(), 0); + + errno = 0; + EXPECT_EQ(buf.try_drain(this->sock_.get()), -1); + EXPECT_NE(errno, EWOULDBLOCK); + EXPECT_NE(errno, EAGAIN); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.live(), msg.size() + TestOverflowBuffer::LEN_PREFIX); +} + +TEST_F(OverflowBufferTest, GrowsWhileReclaimingSentPrefix) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + + // One byte too many to fit even after the sent prefix is reclaimed: grows in one copy + auto third = make_message(s.before.capacity - buf.live() + 1, 200); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_GT(buf.capacity(), s.before.capacity); + EXPECT_EQ(buf.count(), 2); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, NestedDrainMakesNoProgress) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + std::vector received; + this->read_into_(received); + + // Room is available, but a nested drain must leave the outer one's message alone + buf.set_draining(true); + EXPECT_EQ(this->drain_(buf), 0); + EXPECT_EQ(buf.count(), 1); + std::vector nothing; + this->read_into_(nothing); + EXPECT_TRUE(nothing.empty()); + + buf.set_draining(false); + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, msg); +} + +TEST_F(OverflowBufferTest, NestedEnqueueAppendsWithinCapacity) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(4, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_GE(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + buf.set_draining(true); + EXPECT_TRUE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 2); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToGrow) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(100, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_LT(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + // Growing would free the bytes the outer write() is sending from + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, first); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToCompact) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // Sliding the remainder down would move the bytes the outer write() points at + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), s.before); + buf.set_draining(false); + + // Once the drain is over the same enqueue compacts and succeeds + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, CompactsInsteadOfGrowingAfterPartialDrain) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // The sent first message is reclaimed by sliding the remainder down, not by reallocating + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +} // namespace esphome::api::testing +#endif // USE_HOST From ccec6e72bfbcb8ff498a3467840e591662885958 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 9 Sep 2026 14:22:23 +0200 Subject: [PATCH 075/266] [sendspin] Fix codec enum codegen when codecs is not set (#19055) --- esphome/components/sendspin/__init__.py | 2 +- tests/components/sendspin/common-media_source.yaml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 8ef11a7f909..c1970ab1325 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -307,7 +307,7 @@ async def to_code(config: ConfigType) -> None: player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - codecs = player_cfg[CONF_CODECS] + codecs = [CODECS[codec] for codec in player_cfg[CONF_CODECS]] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 0c136fbd43d..1977b79c04d 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,4 +9,3 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal - codecs: [pcm, opus, flac] From ad4ee1d34e957cb867a291c24fcb20824031471e Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Thu, 10 Sep 2026 06:06:44 +0200 Subject: [PATCH 076/266] [network] Improve `network::is_connected()` to better handle multiple interfaces (#18999) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/network/util.h | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 65a578c22ff..57c5a66833b 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -26,30 +26,34 @@ namespace esphome::network { /// Return whether the node is connected to the network (through wifi, eth, ...) ESPHOME_ALWAYS_INLINE inline bool is_connected() { + // With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which + // clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked. + // NOLINTBEGIN(readability-simplify-boolean-expr) #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) return true; #endif #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_connected(); + if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected()) + return true; #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_connected(); + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) + return true; #endif #ifdef USE_OPENTHREAD - if (openthread::global_openthread_component != nullptr) - return openthread::global_openthread_component->is_connected(); + if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) + return true; #endif #ifdef USE_HOST return true; // Assume it's connected #endif return false; + // NOLINTEND(readability-simplify-boolean-expr) } /// Return whether the network is disabled: every configured interface with a From 6bd6603d523160dfa2a7f0ec0b997cc606c498de Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:22:30 +0000 Subject: [PATCH 077/266] Bump bundled esphome-device-builder to 1.14.6 (#19072) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ac84ee4689f..cfa47fbdad2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 RUN \ platformio settings set enable_telemetry No \ From 3b499ecb3e538c0f5bb20a4f166053004147840c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 03:20:30 -0500 Subject: [PATCH 078/266] [core] Support set_internal() during setup, log error after setup (#19069) --- esphome/core/entity_base.cpp | 9 ++++ esphome/core/entity_base.h | 27 ++++++++---- .../fixtures/set_internal_at_boot.yaml | 34 +++++++++++++++ .../integration/test_set_internal_at_boot.py | 41 +++++++++++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/set_internal_at_boot.yaml create mode 100644 tests/integration/test_set_internal_at_boot.py diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 21a5fc3706c..dc27c1e56a2 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -56,6 +56,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3; } +void EntityBase::set_internal(bool internal) { + // Remove the after-setup path in 2027.3.0 and ignore the call instead. + if (App.is_setup_complete()) { + ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0", + this->get_name().c_str()); + } + this->flags_.internal = internal; +} + // Weak default lookup functions — overridden by generated code in main.cpp __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index f38e30bf52d..8796e9f067a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -88,13 +88,26 @@ class EntityBase { // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } - // Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients - // are NOT notified of the change, the flag may have already been read during setup, and there - // is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead. - ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT " - "notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.", - "2026.3.0") - void set_internal(bool internal) { this->flags_.internal = internal; } + // Set whether this Entity should be hidden outside ESPHome. Prefer the 'internal:' YAML key + // whenever possible: it is guaranteed and has none of the limitations below. Use this only when + // the decision can only be made at boot. Must be called before MQTT and the API read the flag: + // from on_boot at the default priority, or a setup() that runs above setup_priority::AFTER_WIFI. + // If the answer comes from a device handshake, hold setup with can_proceed() until it arrives. + // Calls after setup finishes are undefined behavior: the flag is still written and an error is + // logged, and from 2027.3.0 the call will be ignored. + // + // Known limitations. Not bugs, so no issue reports please; a PR that removes one with no RAM + // or performance cost would be considered. + // - No consumer is notified of a change, so the flag can only be decided once per boot. + // - The guard is coarse: a call from a priority below AFTER_WIFI (an on_boot with a low priority, + // or a setup() at LATE) still passes, but the API camera listener is already registered, MQTT + // (AFTER_CONNECTION) has cached the flag, and an API client that connected while setup was + // stalled on a slow component has already listed the entities, so they keep the old value. + // - Un-hiding an entity declared 'internal: true' in YAML skips the duplicate name check that + // codegen runs for exposed entities, so a name collision can surface at runtime. Entities with + // only an 'id:' are forced internal and use the id as their name. + // - Zigbee codegen skips YAML internal entities entirely, so un-hiding cannot add them to Zigbee. + void set_internal(bool internal); // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should diff --git a/tests/integration/fixtures/set_internal_at_boot.yaml b/tests/integration/fixtures/set_internal_at_boot.yaml new file mode 100644 index 00000000000..b3007e9dbda --- /dev/null +++ b/tests/integration/fixtures/set_internal_at_boot.yaml @@ -0,0 +1,34 @@ +esphome: + name: set-internal-at-boot + on_boot: + then: + - lambda: |- + id(hidden_at_boot).set_internal(true); + id(shown_at_boot).set_internal(false); + +host: + +api: + actions: + - action: set_internal_late + then: + - lambda: id(untouched).set_internal(true); + +logger: + +sensor: + - platform: template + name: "Hidden At Boot" + id: hidden_at_boot + lambda: return 1.0; + + - platform: template + name: "Shown At Boot" + id: shown_at_boot + internal: true + lambda: return 2.0; + + - platform: template + name: "Untouched" + id: untouched + lambda: return 3.0; diff --git a/tests/integration/test_set_internal_at_boot.py b/tests/integration/test_set_internal_at_boot.py new file mode 100644 index 00000000000..68b0bd10802 --- /dev/null +++ b/tests/integration/test_set_internal_at_boot.py @@ -0,0 +1,41 @@ +"""Integration test for set_internal() called during and after setup.""" + +from __future__ import annotations + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_set_internal_at_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """set_internal() in on_boot changes API exposure, later calls log an error.""" + waiter = LineWaiter() + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + names = {entity.name for entity in entities} + + assert "Hidden At Boot" not in names + assert "Shown At Boot" in names + assert "Untouched" in names + + late = next(s for s in services if s.name == "set_internal_late") + await client.execute_service(late, {}) + await waiter.wait_for( + "'Untouched'", + "set_internal() after setup is undefined behavior", + timeout=5.0, + ) + + # Still written during the deprecation window, ignored from 2027.3.0 + entities, _ = await client.list_entities_services() + assert "Untouched" not in {entity.name for entity in entities} From 1975b17eac18d1d03778a753835d48c71a58dda9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:25:41 -0500 Subject: [PATCH 079/266] Bump bundled esphome-device-builder to 1.14.7 (#19096) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index cfa47fbdad2..6f500dbe6f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 RUN \ platformio settings set enable_telemetry No \ From 6cc2b9bf1740a1cc004050b5c6e6be48e55ea65a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:12:08 +1000 Subject: [PATCH 080/266] [lvgl] Fix crash when using lvgl.list.add (#19177) --- esphome/components/lvgl/widgets/lv_list.py | 4 ++++ tests/components/lvgl/lvgl-package.yaml | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 83cbfb5ef99..7711e8bfe4f 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -227,6 +227,7 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)}) ) async def list_add_text_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add_text(w: Widget): text = await lv_text.process(config[CONF_TEXT]) @@ -370,6 +371,7 @@ async def list_add_to_code(config, action_id, template_arg, args): _register_lv_uses(w_type_name, w_conf) _register_dynamic_widget_style_uses(w_conf) widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add(w: Widget): index = None @@ -503,6 +505,7 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend( ) async def list_remove_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_remove(w: Widget): index = await lv_int.process(config[CONF_INDEX]) @@ -536,6 +539,7 @@ async def list_remove_to_code(config, action_id, template_arg, args): ) async def list_clear_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_clear(w: Widget): await _wait_list_triggers_completed() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 07c492db356..bd2e77ee8c7 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -30,6 +30,18 @@ binary_sensor: widget: button_button state: pressed +globals: + - id: counter + type: int + +script: + - id: add_row + then: + - lvgl.list.add: + id: test_list_id + label: + text: row + lvgl: id: lvgl_id rotation: 90 @@ -1291,7 +1303,7 @@ lvgl: then: - logger.log: format: "table selected row %u col %u" - args: [row, column] + args: [(unsigned)row, (unsigned)column] on_click: then: - lvgl.table.cell.update: @@ -1347,10 +1359,12 @@ lvgl: - logger.log: format: "list entry added at %d" args: [list_index] + - lambda: "id(counter)++;" on_remove: - logger.log: format: "list entry removed at %d" args: [list_index] + - lambda: "id(counter)--;" on_click: - lvgl.list.add_text: id: test_list_id From 3eda3060b8f938e231cc1b2a63e7b8e07f6f381f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:06:35 -0500 Subject: [PATCH 081/266] Bump bundled esphome-device-builder to 1.14.8 (#19250) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 6f500dbe6f4..bdbbe798cea 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 RUN \ platformio settings set enable_telemetry No \ From 9814966fe7a8f48b29dac6d3f2a50bb681abe534 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:14:26 -0500 Subject: [PATCH 082/266] [noise] Bump noise-c to 0.1.30 and libsodium to 1.10021.11 (#19062) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index d17ebf235e5..6067fde1642 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.26") + cg.add_library("esphome/noise-c", "0.1.30") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.8") + cg.add_library("esphome/libsodium", "1.10021.11") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 738773d1b56..0e334ac5b4c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.26 ; used by noise (api, ota) + esphome/noise-c@0.1.30 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 00f22ca1389..0dce00785bf 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 1.0") == "noise-c" + assert mod.spec_key("esphome/noise-c@1.0") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.26"] + assert libs == ["esphome/noise-c @ 1.0"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.26", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", + "esphome/noise-c @ 1.0", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.26"] + assert cls.calls == ["esphome/noise-c @ 1.0"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.26"] is None + assert compats["esphome/noise-c @ 1.0"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 1.0"}) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index b03bff19a27..774493ecf41 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 0803d7b37ce8c0e52960ca43180cb2ad30d4c7a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:16:37 -0500 Subject: [PATCH 083/266] [core] Add FixedVector::try_init so callers can handle an exhausted heap (#19253) --- esphome/core/helpers.h | 52 ++++++++++++++++++++------ script/cpp_unit_test.py | 3 +- tests/components/core/test_helpers.cpp | 19 ++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a0afb03124e..987c54a5b03 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #endif #ifdef USE_ESP32 +#include #include #endif @@ -539,7 +541,15 @@ template inline void init_array_from(std::array &des } } -/// Fixed-capacity vector - allocates once at runtime, never reallocates +// Abort with a reason that reaches the panic output on ESP32. Elsewhere the literal is dropped +// before it can land in rodata, which is RAM on ESP8266 +#ifdef USE_ESP32 +#define ESPHOME_ABORT_WITH_REASON(reason) esp_system_abort(reason) +#else +#define ESPHOME_ABORT_WITH_REASON(reason) abort() +#endif + +/// Fixed-capacity vector - sized once through init() or try_init(); push_back never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time template class FixedVector { @@ -562,8 +572,7 @@ template class FixedVector { void cleanup_() { if (data_ != nullptr) { destroy_elements_(); - // Free raw memory - ::operator delete(data_); + free(data_); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } } @@ -632,16 +641,27 @@ template class FixedVector { // Allocate capacity - can be called multiple times to reinit // IMPORTANT: After calling init(), you MUST use push_back() to add elements. // Direct assignment via operator[] does NOT update the size counter. + // Aborts on exhaustion; use try_init() to handle failure. void init(size_t n) { + if (!try_init(n)) + ESPHOME_ABORT_WITH_REASON("FixedVector: out of memory"); + } + + // Same as init(), but returns false when memory is exhausted; the previous storage is freed either way + bool try_init(size_t n) { cleanup_(); reset_(); - if (n > 0) { - // Allocate raw memory without calling constructors - // sizeof(T) is correct here for any type T (value types, pointers, etc.) - // NOLINTNEXTLINE(bugprone-sizeof-expression) - data_ = static_cast(::operator new(n * sizeof(T))); - capacity_ = n; - } + if (n == 0) + return true; + if (n > SIZE_MAX / sizeof(T)) + return false; // the byte count would wrap into a small block + // sizeof(T) is correct here for any type T (value types, pointers, etc.) + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + data_ = static_cast(malloc(n * sizeof(T))); + if (data_ == nullptr) + return false; + capacity_ = n; + return true; } // Clear the vector (destroy all elements, reset size to 0, keep capacity) @@ -738,14 +758,22 @@ template class FixedVector { template class SmallBufferWithHeapFallback { public: explicit SmallBufferWithHeapFallback(size_t size) { + static_assert(std::is_trivially_default_constructible_v && std::is_trivially_destructible_v, + "the heap fallback leaves elements unconstructed"); if (size <= STACK_SIZE) { this->buffer_ = this->stack_buffer_; } else { - this->heap_buffer_ = new T[size]; + if (size <= SIZE_MAX / sizeof(T)) { + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + this->heap_buffer_ = static_cast(malloc(size * sizeof(T))); + } + // Callers write through get() unchecked, so exhaustion aborts like the new[] it replaces + if (this->heap_buffer_ == nullptr) + ESPHOME_ABORT_WITH_REASON("SmallBufferWithHeapFallback: out of memory"); this->buffer_ = this->heap_buffer_; } } - ~SmallBufferWithHeapFallback() { delete[] this->heap_buffer_; } + ~SmallBufferWithHeapFallback() { free(this->heap_buffer_); } // NOLINT(cppcoreguidelines-no-malloc) // Delete copy and move operations to prevent double-delete SmallBufferWithHeapFallback(const SmallBufferWithHeapFallback &) = delete; diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index f8bab394149..8cb18d08757 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -36,7 +36,8 @@ PLATFORMIO_OPTIONS = { def run_tests(selected_components: list[str]) -> int: - os.environ["ASAN_OPTIONS"] = "detect_leaks=0" + # allocator_may_return_null: an oversized request must come back empty, not abort the run + os.environ["ASAN_OPTIONS"] = "detect_leaks=0:allocator_may_return_null=1" return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index baf688fc8a3..d6b31508d17 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -348,4 +348,23 @@ TEST(StepToAccuracyDecimals, NonFiniteAndZero) { EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0); } +// --- FixedVector::try_init() --- + +// Keeps the block observable, else the compiler may drop the malloc and free pair and fold the check +static void escape(const void *p) { asm volatile("" : : "g"(p) : "memory"); } + +TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { + FixedVector v; + const bool ok = v.try_init(SIZE_MAX / sizeof(uint32_t)); + escape(&v); + EXPECT_FALSE(ok); + EXPECT_EQ(v.capacity(), 0u); + EXPECT_FALSE(v.try_init(SIZE_MAX / sizeof(uint32_t) + 1)); // byte count would wrap + EXPECT_EQ(v.capacity(), 0u); + EXPECT_TRUE(v.try_init(0)); + EXPECT_TRUE(v.try_init(4)); + v.push_back(7); + EXPECT_EQ(v.size(), 1u); +} + } // namespace esphome::core::testing From 9064bfcc85fc99cd2403dc109c308c9782295784 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:44:03 +0000 Subject: [PATCH 084/266] Bump bundled esphome-device-builder to 1.14.9 (#19263) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bdbbe798cea..e00570c8ff2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9 RUN \ platformio settings set enable_telemetry No \ From b17cd89469498a698cf68f441dc5dcc09c3fce15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:13 -0500 Subject: [PATCH 085/266] [wifi] Drop a scan instead of aborting when its results cannot be allocated, filter ESP32 scans by SSID in the driver (#19254) --- esphome/components/wifi/__init__.py | 3 + esphome/components/wifi/wifi_component.cpp | 6 +- esphome/components/wifi/wifi_component.h | 16 ++++-- .../wifi/wifi_component_esp8266.cpp | 6 +- .../wifi/wifi_component_esp_idf.cpp | 57 +++++++++++++++---- .../wifi/wifi_component_libretiny.cpp | 6 +- esphome/core/defines.h | 2 + 7 files changed, 74 insertions(+), 22 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b8c6d774ac5..d4b39c029b7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -623,6 +623,9 @@ async def to_code(config): networks = config.get(CONF_NETWORKS, []) if networks: cg.add(var.init_sta(len(networks))) + if len(networks) > 1: + # The ESP32 scan can filter one SSID in the driver; with several the whole list is kept + cg.add_define("USE_WIFI_MULTI_SSID") def add_sta(ap: cg.MockObj, network: dict) -> None: ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 694e6164769..f290832a184 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1498,8 +1498,8 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; - this->has_completed_scan_after_captive_portal_start_ = - true; // Track that we've done a scan since captive portal started + // A driver filtered scan saw one SSID; a portal that started during it still needs a full scan + this->has_completed_scan_after_captive_portal_start_ = !this->is_scan_driver_filtered_(); this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED; if (this->scan_result_.empty()) { @@ -2415,7 +2415,7 @@ void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); -#if defined(USE_RP2) || defined(USE_ESP32) +#if defined(USE_RP2) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 63df9fbfa51..16b62a5bb0e 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -178,12 +178,12 @@ struct EAPAuth { using bssid_t = std::array; -/// Initial reserve size for filtered scan results (typical: 1-3 matching networks per SSID) -static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; +// ESP32 with one configured network: the driver filters the scan by its SSID and only this many of +// its BSSIDs are kept, the strongest ones +static constexpr size_t WIFI_SCAN_RESULT_BOUND = 12; -// Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) -// Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2) || defined(USE_ESP32) +// RP2040's callback delivers results one at a time with no count, so it needs a growable vector +#if defined(USE_RP2) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -948,6 +948,12 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; +#if defined(USE_ESP32) && !defined(USE_WIFI_MULTI_SSID) + bool scan_driver_filtered_{false}; + bool is_scan_driver_filtered_() const { return this->scan_driver_filtered_; } +#else + constexpr bool is_scan_driver_filtered_() const { return false; } +#endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) // Platform-specific STA state enum, defined in platform cpp file. // On ESP8266, written from SDK system context (wifi_event_callback) — diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 031da1b355f..60ec3f9a4d5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -773,7 +773,11 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + this->scan_done_ = true; + return; + } // Second pass: store matching networks for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index ce75d213301..24bf64a99ce 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -909,7 +909,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); uint16_t number = it.number; - bool needs_full = this->needs_full_scan_results_(); + const bool filtered = this->is_scan_driver_filtered_(); + const bool needs_full = this->needs_full_scan_results_(); { // Mutate in place under the lock; blocking a portal request is fine and // avoids scratch buffers @@ -926,8 +927,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { return; } - // Smart reserve: full capacity if needed, small reserve otherwise - this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE); + const size_t wanted = filtered ? std::min(number, WIFI_SCAN_RESULT_BOUND) : number; + // Storage is reused across the scans of one retry cycle and freed on connect; an exhausted + // heap drops this scan and the retry logic scans again + if (this->scan_result_.capacity() < wanted && !this->scan_result_.try_init(wanted)) { + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "No memory for %zu scan results", wanted); + return; + } #ifdef USE_ESP32_HOSTED // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor @@ -955,22 +962,38 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } #endif // USE_ESP32_HOSTED - // Check C string first - avoid std::string construction for non-matching networks const char *ssid_cstr = reinterpret_cast(record.ssid); - - // Only construct std::string and store if needed - if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { - bssid_t bssid; - std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (!needs_full && !this->matches_configured_network_(ssid_cstr, record.bssid)) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + bssid_t bssid; + std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (this->scan_result_.size() < wanted) { this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); - } else { - this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; } + // Records arrive in scan order, not by signal, so a bounded store keeps the strongest by + // replacing its weakest entry. Only SSID and signal decide here; a channel or auth constrained + // network hidden behind 12 stronger APs of its own SSID is not a real deployment + WiFiScanResult *weakest = &this->scan_result_[0]; + for (auto &res : this->scan_result_) { + if (res.get_rssi() < weakest->get_rssi()) + weakest = &res; + } + if (record.rssi <= weakest->get_rssi()) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + // Rebuilt in place rather than assigned; assignment pulls in CompactString's operators, 104 B of flash + weakest->~WiFiScanResult(); + new (weakest) WiFiScanResult(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, + record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); } } ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(), - needs_full ? "" : " (filtered)"); + filtered ? LOG_STR_LITERAL(" (driver filtered)") : LOG_STR_LITERAL("")); #ifdef USE_WIFI_SCAN_RESULTS_LISTENERS this->notify_scan_results_listeners_(); #endif @@ -1047,6 +1070,16 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { wifi_scan_config_t config{}; config.ssid = nullptr; config.bssid = nullptr; +#ifndef USE_WIFI_MULTI_SSID + // One configured network with an SSID: let the driver keep only its APs, so the WiFi library + // holds fewer records during the scan. Full results (portal, provisioning, listeners) and a + // network configured by BSSID alone still scan everything + this->scan_driver_filtered_ = + !this->needs_full_scan_results_() && this->sta_.size() == 1 && !this->sta_[0].get_ssid().empty(); + if (this->scan_driver_filtered_) { + config.ssid = const_cast(reinterpret_cast(this->sta_[0].get_ssid().c_str())); + } +#endif config.channel = 0; config.show_hidden = true; config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 63a63e7342a..940f2a07830 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -709,7 +709,11 @@ void WiFiComponent::wifi_scan_done_callback_() { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + WiFi.scanDelete(); + return; + } // Second pass: store matching networks for (int i = 0; i < num; i++) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index eaece6d5ffa..b78516c6ef8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -260,6 +260,8 @@ #ifdef USE_ARDUINO #define USE_PROMETHEUS #define USE_WIFI_WPA2_EAP +// Kept in the Arduino block so clang-tidy sees both scan storage paths +#define USE_WIFI_MULTI_SSID #endif // Platforms with native 64-bit time sources (no rollover tracking needed) From 1a555d58489a4c4e8e1bb30e13a5e8e30285e59f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:23 -0500 Subject: [PATCH 086/266] [esp32_ble_tracker] Re-register GATT clients after ble.disable and ble.enable (#19068) --- .../bluetooth_connection_bluedroid.cpp | 37 +++++++++++++------ .../bluetooth_connection_bluedroid.h | 1 + esphome/components/esp32_ble/ble.cpp | 35 +++++++++++------- esphome/components/esp32_ble/ble.h | 13 ++++++- .../esp32_ble_client/ble_client_base.cpp | 35 +++++++++++++++++- .../esp32_ble_client/ble_client_base.h | 12 +++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 28 ++++++++++++-- .../esp32_ble_tracker/esp32_ble_tracker.h | 3 ++ 8 files changed, 126 insertions(+), 38 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 15f854239d4..986a67c7a8f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -45,15 +45,7 @@ void BluedroidGattClient::setup() { void BluedroidGattClient::loop() { if (!esp32_ble::global_ble->is_active()) { - // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer - // frees its slot, then re-register the app on the next enable. - auto down_st = this->state(); - if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { - this->release_services(); - this->set_idle_(); - this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); - } - this->set_state(ClientState::INIT); + // ble_before_disabled_event_handler() settles the slot. return; } auto st = this->state(); @@ -65,7 +57,7 @@ void BluedroidGattClient::loop() { ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); this->mark_failed(); } - // Do not wait for REG_EVT; a dropped event must not wedge the slot. + // Do not wait for REG_EVT; connect() rejects until it lands. this->set_idle_(); } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { // The one teardown safety net: a lost CLOSE_EVT, or a scheduled @@ -78,8 +70,8 @@ void BluedroidGattClient::loop() { this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); } } else { - // The loop stays on while a link exists (stack-down watch, pre-started - // search flush); it settles only back at IDLE. + // The loop stays on while a link exists (pre-started search flush); it + // settles only back at IDLE. this->deliver_pending_search_(); if (this->state() == ClientState::IDLE) { this->disable_loop(); @@ -87,6 +79,22 @@ void BluedroidGattClient::loop() { } } +// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer +// frees its slot, then register the app again on the next enable. +void BluedroidGattClient::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != ClientState::IDLE && st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(ClientState::INIT); + // An idle slot runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BluedroidGattClient::dump_config() { ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); if (this->is_failed()) { @@ -97,6 +105,11 @@ void BluedroidGattClient::dump_config() { // ---- contract ops ---- int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } // Only from idle: clobbering DISCONNECTING would open a new link the // stale CLOSE_EVT then tears down. if (this->state() != ClientState::IDLE) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 0d0b4fed5b6..f285260e763 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -56,6 +56,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; void connect() override; void disconnect() override; + void ble_before_disabled_event_handler() override; bool wants_parsed_advertisements() override { return false; } void on_scan_end() override {} bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fc95760cf82..81fa328c160 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -83,18 +83,23 @@ void ESP32BLE::setup() { } } -void ESP32BLE::enable() { - if (this->state_ != BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_ENABLE; -} - -void ESP32BLE::disable() { - if (this->state_ == BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_DISABLE; +// Queue the transition for loop(). A pending transition the other way is +// cancelled instead, since nothing was torn down or brought up yet; any other +// state is already there or on its way. +void ESP32BLE::request_state_(bool enable) { + if (enable) { + if (this->state_ == BLE_COMPONENT_STATE_DISABLED) { + this->state_ = BLE_COMPONENT_STATE_ENABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) { + this->state_ = BLE_COMPONENT_STATE_ACTIVE; + } + } else { + if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) { + this->state_ = BLE_COMPONENT_STATE_DISABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { + this->state_ = BLE_COMPONENT_STATE_DISABLED; + } + } } #ifdef USE_ESP32_BLE_ADVERTISING @@ -580,7 +585,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { this->mark_failed(); return; } - this->state_ = BLE_COMPONENT_STATE_DISABLED; + this->drain_ble_events_(); + // A status callback may have asked for BLE back; the stack is down now, so + // that request becomes a bring-up. + this->state_ = + this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED; } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { ESP_LOGD(TAG, "Enabling"); this->state_ = BLE_COMPONENT_STATE_OFF; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 7d2d0438a46..fd4fb15ff69 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -102,8 +102,8 @@ class ESP32BLE final : public Component { } uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; } - void enable(); - void disable(); + void enable() { this->request_state_(true); } + void disable() { this->request_state_(false); } ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; @@ -176,6 +176,15 @@ class ESP32BLE final : public Component { bool ble_setup_(); bool ble_dismantle_(); + void request_state_(bool enable); + // Drop what the old stack queued; the next stack reuses the same interface ids. + void drain_ble_events_() { + BLEEvent *ble_event; + while ((ble_event = this->ble_events_.pop()) != nullptr) { + this->ble_event_pool_.release(ble_event); + } + this->ble_events_.get_and_reset_dropped_count(); + } bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e6cdde9cda6..88454f7bdbf 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -42,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) { void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { - this->set_state(espbt::ClientState::INIT); + // ble_before_disabled_event_handler() resets the client. return; } if (this->state() == espbt::ClientState::INIT) { @@ -72,6 +72,21 @@ void BLEClientBase::loop() { float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +void BLEClientBase::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) { + // No CLOSE_EVT will come: free the services and settle the link. + this->release_services(); + this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(espbt::ClientState::INIT); + // An idle client runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" @@ -93,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->state() != espbt::ClientState::IDLE) return false; + // Not registered on this stack yet; promoting now would stop the scan for a + // connect that connect() rejects anyway. + if (this->gattc_if_ == ESP_GATT_IF_NONE) + return false; this->log_event_("Found device"); if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) @@ -117,6 +136,15 @@ void BLEClientBase::connect() { this->connection_index_, this->address_str_); return; } + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + this->log_warning_("Connect rejected, GATT app not registered"); + // INIT stays so loop() still registers; only a promoted client goes back. + if (this->state() == espbt::ClientState::DISCOVERED) { + this->set_state(espbt::ClientState::IDLE); + } + return; + } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; // A registration whose event never arrived must not block this connection's release. @@ -199,7 +227,10 @@ void BLEClientBase::release_services() { #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH // Only the cache clean makes the stack's database unsafe to walk. this->services_released_ = true; - esp_ble_gattc_cache_clean(this->remote_bda_); + // A stack on its way down frees its own cache. + if (esp32_ble::global_ble->is_active()) { + esp_ble_gattc_cache_clean(this->remote_bda_); + } #endif } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index e4b9cd51005..fbd405156ae 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -41,6 +41,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void connect() override; esp_err_t pair(); void disconnect() override; + void ble_before_disabled_event_handler() override; void unconditional_disconnect(); void release_services(); @@ -114,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { #endif // Group 3: 4-byte types - int gattc_if_; + int gattc_if_{ESP_GATT_IF_NONE}; esp_gatt_status_t status_{ESP_GATT_OK}; // Group 4: Arrays @@ -139,7 +140,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + // Set by release_services() on RAM-cache builds; the stack's GATT database must not be walked after it bool services_released_{false}; // 8 bytes used, no padding @@ -155,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); /// Hook called once a connection has been fully torn down (after release_services() and - /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down. /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) - /// override this to release that state. `reason` is the controller reason code, or - /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + /// override this to release that state. `reason` is the controller reason code, + /// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST + /// for the stack going down. virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5339565a324..b4b793b4d0b 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -74,11 +74,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { - this->ble_was_disabled_ = true; return; - } else if (this->ble_was_disabled_) { + } + if (this->ble_was_disabled_) { this->ble_was_disabled_ = false; - // If the BLE stack was disabled, we need to start the scan again. + // First start after boot or after the stack came back. if (this->scan_continuous_) { this->start_scan(); } @@ -218,7 +218,27 @@ void ESP32BLETracker::stop_scan() { this->stop_scan_(); } -void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); } +void ESP32BLETracker::ble_before_disabled_event_handler() { + // Tell the controller to stop; a scan still starting has nothing to stop yet. + if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) { + this->stop_scan_(); + } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->ble_before_disabled_event_handler(); + } + this->skip_next_scan_end_ = false; +#endif + // The stop above never completes (stack torn down, events dropped); settle + // here so start_scan_() sees IDLE once the stack is back. + if (this->scanner_state_ != ScannerState::IDLE) { + this->cleanup_scan_state_(true); + } + // A failure latched by the old stack must not be handled against the next. + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + this->ble_was_disabled_ = true; +} bool ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 618444e626d..1a424a4a8e8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -113,6 +113,9 @@ class ESPBTClient : public ESPBTDeviceListener { virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; virtual void connect() = 0; virtual void disconnect() = 0; + /// Called right before the BLE stack is dismantled. Nothing in flight will + /// complete, and the GATT app must register again once the stack is back. + virtual void ble_before_disabled_event_handler() {} bool disconnect_pending() const { return this->want_disconnect_; } void cancel_pending_disconnect() { this->want_disconnect_ = false; } From cd5d4ff25422e7a0142e73095f50c5a29f1a03fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:56:50 -0500 Subject: [PATCH 087/266] [core] Add RAMAllocator::make_unique for objects whose allocation may fail (#19245) --- esphome/core/helpers.h | 40 ++++++++++++++++++++ tests/components/core/test_helpers.cpp | 52 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 987c54a5b03..b1f24b25a3d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,9 +14,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -2123,6 +2126,10 @@ void delay_microseconds_safe(uint32_t us); /// @name Memory management ///@{ +template struct RAMDeleter; +/// unique_ptr over RAMAllocator storage +template using RAMUniquePtr = std::unique_ptr>; + /** An STL allocator that uses SPI or internal RAM. * Returns `nullptr` in case no memory is available. * @@ -2193,6 +2200,26 @@ template class RAMAllocator { free(p); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } + /// Value initialize one T; empty on exhaustion. new (std::nothrow) aborts on ESP-IDF instead. + /// Default flags prefer PSRAM; pass PREFER_INTERNAL to keep an object where plain new put it. + template RAMUniquePtr make_unique(Args &&...args) { + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + T *p = this->allocate(1); + if (p == nullptr) + return {}; + // ::new so a class scoped operator new cannot hide the global placement form + return RAMUniquePtr(::new (p) T(std::forward(args)...)); + } + + /// n elements left uninitialized, as std::make_unique_for_overwrite does; empty on exhaustion, overflow, and n == 0 + RAMUniquePtr make_unique_array_for_overwrite(size_t n) { + static_assert(std::is_trivially_default_constructible_v, "elements are left unconstructed"); + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + if (n == 0 || n > SIZE_MAX / sizeof(T)) + return {}; + return RAMUniquePtr(this->allocate(n)); + } + /** * Return the total heap space available via this allocator */ @@ -2255,6 +2282,19 @@ template class RAMAllocator { template using ExternalRAMAllocator = RAMAllocator; +/// Destroys and frees RAMAllocator storage. Not convertible: free() needs the address malloc returned +template struct RAMDeleter { + void operator()(T *p) const { + p->~T(); + RAMAllocator().deallocate(p, 1); + } +}; +/// Array form: elements must be trivial, the count is not stored so only the storage is freed +template struct RAMDeleter { + static_assert(std::is_trivially_destructible_v, "RAMUniquePtr is for trivially destructible elements"); + void operator()(T *p) const { RAMAllocator().deallocate(p, 1); } +}; + /** * Functions to constrain the range of arithmetic values. */ diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index d6b31508d17..72af605d61f 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -367,4 +367,56 @@ TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { EXPECT_EQ(v.size(), 1u); } +// --- RAMAllocator::make_unique() --- + +namespace { +struct Probe { + static inline int live = 0; + int a; + int b; + Probe(int a, int b) : a(a), b(b) { live++; } + ~Probe() { live--; } +}; +} // namespace + +static_assert(sizeof(RAMUniquePtr) == sizeof(Probe *), "the deleter must not add storage"); + +TEST(RAMAllocatorMakeUnique, ForwardsArgsAndDestroysOnce) { + auto p = RAMAllocator().make_unique(3, 4); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->a, 3); + EXPECT_EQ(p->b, 4); + EXPECT_EQ(Probe::live, 1); + p.reset(); + EXPECT_EQ(Probe::live, 0); +} + +TEST(RAMAllocatorMakeUnique, ValueInitializesLikeMakeUnique) { + struct Plain { + uint32_t words[8]; + }; + // Dirty a block of the same size first so a recycled allocation is not zero by chance + auto dirty = RAMAllocator().make_unique_array_for_overwrite(sizeof(Plain)); + std::memset(dirty.get(), 0xFF, sizeof(Plain)); + dirty.reset(); + auto p = RAMAllocator().make_unique(); + ASSERT_NE(p, nullptr); + // Under ASan fresh blocks are filled with 0xbe, so this holds even when the dirtied block is not reused + EXPECT_TRUE(std::all_of(std::begin(p->words), std::end(p->words), [](uint32_t w) { return w == 0; })); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormRejectsOverflowAndZero) { + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(SIZE_MAX / sizeof(uint32_t) + 1), nullptr); + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(0), nullptr); + EXPECT_NE(RAMAllocator().make_unique_array_for_overwrite(1), nullptr); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormAllocatesElements) { + RAMUniquePtr buf = RAMAllocator().make_unique_array_for_overwrite(256); + ASSERT_NE(buf, nullptr); + std::memset(buf.get(), 0xA5, 256); + EXPECT_EQ(buf[0], 0xA5); + EXPECT_EQ(buf[255], 0xA5); +} + } // namespace esphome::core::testing From 64fd87f43670a0112a8b1b21eed346f8d315fd72 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 13 Sep 2026 18:01:58 -0400 Subject: [PATCH 088/266] [i2s_audio][router] Loop thread controls all state changes (#19089) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 14 ++++++++----- .../router/speaker/router_speaker.cpp | 21 ++++++++++++++++--- .../router/speaker/router_speaker.h | 3 +++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1382a870465..9feaf39ffff 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -53,6 +53,13 @@ void I2SAudioSpeakerBase::dump_config() { void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); + } + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { this->state_ = speaker::STATE_STARTING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); @@ -239,8 +246,6 @@ void I2SAudioSpeakerBase::start() { if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; - // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. - this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -249,11 +254,10 @@ void I2SAudioSpeakerBase::stop() { this->stop_(false); } void I2SAudioSpeakerBase::finish() { this->stop_(true); } void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { - if (this->is_failed()) - return; - if (this->state_ == speaker::STATE_STOPPED) + if (!this->is_ready() || this->is_failed()) return; + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending if (wait_on_empty) { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); } else { diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp index f4bf7420ab0..dd2428e4df4 100644 --- a/esphome/components/router/speaker/router_speaker.cpp +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esp_timer.h" @@ -12,6 +14,9 @@ namespace esphome::router { static const char *const TAG = "router.speaker"; +// Maximum time to wait for the active output to report running after start() before giving up +static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; + static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { uint32_t current = var.load(std::memory_order_acquire); uint32_t subtracted = 0; @@ -72,6 +77,7 @@ void Router::loop() { this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + this->state_start_ms_ = App.get_loop_component_start_time(); active->start(); } return; @@ -86,10 +92,17 @@ void Router::loop() { // set_audio_stream_info() and never reaches the output on its own; if the format // changed while stopped, only start()'s apply_cached_state_to_active_() pushes it // down before the output's play()-side auto-start locks in the stale format. - if (active->is_stopped()) { + // While STARTING, ignore a transient stopped report as speaker running state + // is set asynchronously from start(). Timeout if the speaker never transitions. + if (this->state_ == speaker::STATE_STARTING) { + if (active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } else if ((App.get_loop_component_start_time() - this->state_start_ms_) > STATE_TRANSITION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Active output did not start; giving up"); + this->state_ = speaker::STATE_STOPPED; + } + } else if (active->is_stopped()) { this->state_ = speaker::STATE_STOPPED; - } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { - this->state_ = speaker::STATE_RUNNING; } } @@ -133,6 +146,8 @@ void Router::start() { this->frames_in_pipeline_.store(0, std::memory_order_release); this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + // May run on a producer task, so the cached loop timestamp is not usable here + this->state_start_ms_ = millis(); this->get_active_output()->start(); } diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 801d0906cee..31f3f906301 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -59,6 +59,9 @@ class Router final : public Component, public speaker::Speaker { // frames_in_pipeline_. std::atomic frames_in_pipeline_{0}; + // Set when entering STATE_STARTING; used to time out a start the output never acts on + uint32_t state_start_ms_{0}; + bool cached_pause_{false}; void apply_cached_state_to_active_(); From be2c3dda94ea17d596e80b3332f920d783921503 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:02:42 -0500 Subject: [PATCH 089/266] [esp32_ble_tracker] Revert coexistence preference to balanced when OTA starts (#19082) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index b4b793b4d0b..e25b6f59fa0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -62,6 +62,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u for (auto *client : this->clients_) { client->disconnect(); } +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + // The OTA transfer blocks the main loop, so the revert in loop() cannot run. No + // active-connection gate here: every client was just told to disconnect. + this->update_coex_preference_(false); +#endif #endif } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; From b0b75f705a6cda23e12ed33fac7c6f1e307e3eca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:14 -0500 Subject: [PATCH 090/266] [nextion] Allocate queue components through RAMAllocator and free entries the way they were allocated (#19246) --- esphome/components/nextion/nextion.cpp | 152 +++++++++--------- esphome/components/nextion/nextion.h | 2 + .../nextion/nextion_component_base.h | 5 +- 3 files changed, 78 insertions(+), 81 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 97910ba3d55..625c915e732 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -13,6 +13,11 @@ namespace esphome::nextion { static const char *const TAG = "nextion"; +// A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag +static bool is_sleep_wake_command(const NextionComponentBase *component) { + return component->get_queue_type() == NextionQueueType::NO_RESULT && component->get_variable_name() == "sleep_wake"; +} + // Nextion command terminator: three consecutive 0xFF bytes (per Nextion Instruction Set v1.1). static constexpr uint8_t COMMAND_DELIMITER[3] = {0xFF, 0xFF, 0xFF}; static constexpr size_t DELIMITER_SIZE = sizeof(COMMAND_DELIMITER); @@ -163,6 +168,17 @@ bool Nextion::check_connect_() { #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE } +// NO_RESULT components are owned by their entry; every other component is a user entity. Entry and +// component storage comes from RAMAllocator, so delete is not valid for either. +void Nextion::release_queue_entry_(NextionQueue *nb) { + if (nb->component != nullptr && nb->component->get_queue_type() == NextionQueueType::NO_RESULT) { + nb->component->~NextionComponentBase(); + RAMAllocator().deallocate(nb->component, 1); + } + nb->~NextionQueue(); + RAMAllocator().deallocate(nb, 1); +} + void Nextion::reset_(bool reset_nextion) { uint8_t d; @@ -170,15 +186,12 @@ void Nextion::reset_(bool reset_nextion) { this->read_byte(&d); } for (auto *entry : this->nextion_queue_) { - if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) { - delete entry->component; // NOLINT(cppcoreguidelines-owning-memory) - } - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->nextion_queue_.clear(); #ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->waveform_queue_.clear(); #endif // USE_NEXTION_WAVEFORM @@ -421,6 +434,9 @@ bool Nextion::remove_from_q_(bool report_empty) { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return false; } @@ -428,13 +444,10 @@ bool Nextion::remove_from_q_(bool report_empty) { ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); return true; } @@ -544,7 +557,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); } #else // USE_NEXTION_WAVEFORM @@ -647,6 +660,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue entry"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -660,7 +676,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_string(to_process, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -687,6 +703,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -703,7 +722,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_int(value, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -890,7 +909,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); component->clear_wave_buffer(buffer_to_send); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); #else // USE_NEXTION_WAVEFORM ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); @@ -920,14 +939,10 @@ void Nextion::purge_stale_queue_entries_() { ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(), component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - - delete *it; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(*it); it = this->nextion_queue_.erase(it); } else { @@ -1079,6 +1094,34 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool return response.length(); } +// Allocates a queue entry owning a bare NO_RESULT component; nullptr when the queue is full or memory is out +NextionQueue *Nextion::make_no_result_entry_(const std::string &variable_name) { +#ifdef USE_NEXTION_MAX_QUEUE_SIZE + if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { + ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + return nullptr; + } +#endif + + auto *nextion_queue = RAMAllocator().allocate(1); + if (nextion_queue == nullptr) { + ESP_LOGW(TAG, "Queue alloc failed"); + return nullptr; + } + new (nextion_queue) nextion::NextionQueue; + + nextion_queue->component = RAMAllocator().allocate(1); + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + this->release_queue_entry_(nextion_queue); + return nullptr; + } + new (nextion_queue->component) nextion::NextionComponentBase; + nextion_queue->component->set_variable_name(variable_name); + nextion_queue->queue_time = App.get_loop_component_start_time(); + return nextion_queue; +} + /** * @brief Add a command to the Nextion queue that expects no response. * @@ -1090,36 +1133,11 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool * @param variable_name Name of the variable or component associated with the command. */ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - - nextion_queue->queue_time = App.get_loop_component_start_time(); - this->nextion_queue_.push_back(nextion_queue); - - ESP_LOGN(TAG, "Queue NORESULT: %s", nextion_queue->component->get_variable_name().c_str()); + ESP_LOGN(TAG, "Queue NORESULT: %s", variable_name.c_str()); } /** @@ -1153,32 +1171,10 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n #ifdef USE_NEXTION_COMMAND_SPACING void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &variable_name, const std::string &command) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry - this->nextion_queue_.push_back(nextion_queue); ESP_LOGVV(TAG, "Queue with pending command: %s", variable_name.c_str()); } @@ -1312,7 +1308,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1334,7 +1330,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { if (this->send_command_(command)) { this->nextion_queue_.push_back(nextion_queue); } else { - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); } #endif // USE_NEXTION_COMMAND_SPACING } @@ -1355,14 +1351,14 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); if (!this->waveform_queue_.push(nextion_queue)) { ESP_LOGW(TAG, "Waveform queue full, drop"); - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); return; } if (this->waveform_queue_.size() == 1) diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index aa9fe8abb3f..6c9c8760f8a 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1469,6 +1469,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; bool remove_from_q_(bool report_empty = true); + void release_queue_entry_(NextionQueue *nb); + NextionQueue *make_no_result_entry_(const std::string &variable_name); /** * @brief Status flags for Nextion display state management diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 6676d019201..5e84291b168 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -23,8 +23,7 @@ class NextionComponentBase; class NextionQueue { public: - virtual ~NextionQueue() = default; - NextionComponentBase *component; + NextionComponentBase *component{nullptr}; uint32_t queue_time = 0; // Store command for retry if spacing blocked it @@ -105,6 +104,6 @@ class NextionComponentBase { int wave_max_length_ = 255; #endif // USE_NEXTION_WAVEFORM - bool needs_to_send_update_; + bool needs_to_send_update_{false}; }; } // namespace esphome::nextion From 977542061d285694ab94967757d70f0a698a2269 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:28 -0500 Subject: [PATCH 091/266] [esphome] Allocate the OTA noise session and auth buffer through RAMAllocator (#19249) --- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++++++++- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- esphome/components/esphome/ota/ota_esphome_noise.cpp | 6 ++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f853ed6a2db..3010df10561 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -842,7 +842,14 @@ bool ESPHomeOTAComponent::handle_auth_send_() { const size_t hex_size = hasher.get_size() * 2; const size_t nonce_len = hasher.get_size() / 4; const size_t auth_buf_size = 1 + 3 * hex_size; - this->auth_buf_ = std::make_unique(auth_buf_size); + // Internal RAM first: 128 of these bytes go straight into the hardware SHA engine + this->auth_buf_ = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(auth_buf_size); + if (!this->auth_buf_) { + this->log_auth_warning_(LOG_STR("No memory")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } this->auth_buf_pos_ = 0; char *buf = reinterpret_cast(this->auth_buf_.get() + 1); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c6f710b3fcb..68dd0ffb9ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -145,13 +145,13 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; - std::unique_ptr auth_buf_; + RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION #ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; #endif - std::unique_ptr noise_; + RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION socket::ListenSocket *server_{nullptr}; diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7401413d6d0..65476572a1e 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -7,7 +7,6 @@ #include "esphome/core/log.h" #include -#include #ifdef USE_ESP8266 #include @@ -43,9 +42,8 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { // A provisioned key cleared between the offer and here is not guarded: the // session runs on the zero key load_psk fills in and fails the client's MAC. - // Default-init: the frame buffer is written before it is read - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) - this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession); + // Default placement, PSRAM first where present: the session only lives for one upload + this->noise_ = RAMAllocator().make_unique(); static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1; static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags From ebe72c3cefaaad5e664a0f9f400d88da5b11262d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:27:03 -0500 Subject: [PATCH 092/266] [core] Resolve file paths against the YAML file that declares them (#19259) --- esphome/config_validation.py | 66 +++++++----- tests/unit_tests/test_config_validation.py | 120 ++++++++++++++++++++- 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 685a9d04b3f..2346c28ccef 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -15,6 +15,7 @@ from ipaddress import ( ip_network, ) import logging +import os from pathlib import Path import re from string import ascii_letters, digits @@ -1967,38 +1968,51 @@ def _remap_bundle_path(value: str) -> Path | None: return remap_bundle_path(value) -def directory(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) +def _declaring_document(value: str) -> Path | None: + """Return the on-disk YAML file *value* was loaded from, absolute, or None.""" + esp_range = getattr(value, "esp_range", None) + if esp_range is None: + return None + document = Path(esp_range.start_mark.document).absolute() + return document if document.is_file() else None - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: + +def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path: + """Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap.""" + path = CORE.relative_config_path(value) + if is_kind(path): + return path + candidates = [path] + tried_document: Path | None = None + if (document := _declaring_document(value)) is not None: + beside_document = document.parent / Path(value).expanduser() + if os.path.normpath(beside_document) != os.path.normpath(path): + candidates.append(beside_document) + tried_document = document + if (remapped := _remap_bundle_path(value)) is not None: + candidates.append(remapped) + for candidate in candidates: + if is_kind(candidate): + return candidate + for candidate in candidates: + if candidate.exists(): raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})." ) - path = remapped - if not path.is_dir(): - raise Invalid( - f"Path '{path}' is not a directory (full path: {path.resolve()})." - ) - return path + also = ( + f" Also looked next to {tried_document}." if tried_document is not None else "" + ) + raise Invalid( + f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}" + ) + + +def directory(value: object) -> Path: + return _existing_path(string(value), "directory", Path.is_dir) def file_(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) - - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) - path = remapped - if not path.is_file(): - raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") - return path + return _existing_path(string(value), "file", Path.is_file) ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 457b9d017b8..52070e7abac 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,4 +1,5 @@ import importlib +import io import json import logging from pathlib import Path @@ -20,6 +21,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.substitutions import do_substitution_pass from esphome.config_validation import Invalid from esphome.const import ( CONF_DAY, @@ -65,7 +67,13 @@ from esphome.core import ( ) from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry -from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + SensitiveStr, + load_yaml, + make_data_base, + parse_yaml, +) def test_check_not_templatable__invalid(): @@ -3145,6 +3153,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None: assert cv.file_("partitions.csv") == setup_core / "partitions.csv" +def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]: + """Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value.""" + package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor" + (package_dir / "assets").mkdir(parents=True) + (package_dir / "assets" / "ui.js").write_text("js\n") + (package_dir / "device.yaml").write_text(f"path: {path}\n") + return package_dir, load_yaml(package_dir / "device.yaml")["path"] + + +def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None: + """A package's own asset path resolves against the package file when the config dir lacks it.""" + package_dir, value = _package_value(setup_core) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__resolves_a_substituted_path_against_the_use_site( + setup_core: Path, +) -> None: + package_dir, _ = _package_value(setup_core) + (package_dir / "device.yaml").write_text( + "substitutions:\n ui: assets/ui.js\npath: ${ui}\n" + ) + config = do_substitution_pass(load_yaml(package_dir / "device.yaml")) + + assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js" + + +def test_file__result_is_absolute_for_a_relative_document( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A document loaded by a cwd-relative path still yields an absolute result.""" + package_dir, _ = _package_value(setup_core) + monkeypatch.chdir(setup_core) + value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"] + + result = cv.file_(value) + + assert result.is_absolute() + assert result == package_dir / "assets" / "ui.js" + + +def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core) + (setup_core / "assets" / "ui.js").mkdir(parents=True) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__miss_names_the_declaring_document(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets/other.js") + + with pytest.raises(Invalid, match="Could not find file") as excinfo: + cv.file_(value) + + assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value) + + +def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint( + setup_core: Path, +) -> None: + (setup_core / "sub").mkdir() + (setup_core / "device.yaml").write_text("path: assets/other.js\n") + value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"] + + with pytest.raises(Invalid) as excinfo: + cv.file_(value) + + assert "Also looked" not in str(excinfo.value) + + +def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets") + + with pytest.raises(Invalid, match="is not a file") as excinfo: + cv.file_(value) + + assert str(package_dir / "assets") in str(excinfo.value) + + +def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None: + _, value = _package_value(setup_core) + (setup_core / "assets").mkdir() + (setup_core / "assets" / "ui.js").write_text("local\n") + + assert cv.file_(value) == setup_core / "assets" / "ui.js" + + +def test_file__declared_in_an_in_memory_document_is_not_resolved( + setup_core: Path, +) -> None: + """A value whose source document isn't on disk falls through to the config-dir error.""" + value = parse_yaml(Path(""), io.StringIO("path: assets/ui.js\n"))[ + "path" + ] + + with pytest.raises(Invalid, match="Could not find file"): + cv.file_(value) + + +def test_directory_resolves_relative_to_the_declaring_document( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core, "assets") + + assert cv.directory(value) == package_dir / "assets" + + def test_file__missing_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="Could not find file"): cv.file_("partitions.csv") From e28b4eb2a0584792102702af9f1a47fc2b5d751f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:35:38 -0500 Subject: [PATCH 093/266] [ethernet] Keep the W5500 SPI context in a static instance instead of the heap (#19248) --- .../components/ethernet/w5500_custom_spi.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp index ed4f149738f..9c6b59582a3 100644 --- a/esphome/components/ethernet/w5500_custom_spi.cpp +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -6,17 +6,21 @@ #include #include #include -#include namespace esphome::ethernet { namespace { -// Per-device context returned by init() and handed back to read/write/deinit. +// Context returned by init() and handed back to read/write/deinit. There is one W5500 per device, so a +// single static instance replaces a heap allocation that could fail. It is always clear when init() runs: +// esp_eth_mac_new_w5500() calls deinit() on every failure after init() succeeded, and nothing else +// uninstalls the driver struct W5500CustomSpiContext { spi_device_handle_t handle; SemaphoreHandle_t lock; }; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state +W5500CustomSpiContext w5500_context{}; // Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger // transfers (the frame payloads) use the blocking, DMA-backed transmit. @@ -25,23 +29,20 @@ constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; void *w5500_custom_spi_init(const void *spi_config) { const auto *config = static_cast(spi_config); - auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; - if (ctx == nullptr) { - return nullptr; - } + auto *ctx = &w5500_context; // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control // byte in the address phase; mirror what the stock driver configures. spi_device_interface_config_t devcfg = *config->spi_devcfg; devcfg.command_bits = 16; devcfg.address_bits = 8; if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { - delete ctx; + ctx->handle = nullptr; return nullptr; } ctx->lock = xSemaphoreCreateMutex(); if (ctx->lock == nullptr) { spi_bus_remove_device(ctx->handle); - delete ctx; + ctx->handle = nullptr; return nullptr; } return ctx; @@ -51,7 +52,7 @@ esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { auto *ctx = static_cast(spi_ctx); spi_bus_remove_device(ctx->handle); vSemaphoreDelete(ctx->lock); - delete ctx; + *ctx = {}; return ESP_OK; } From 81a54ea9dbbd7c5482057f9993ce8bda1a4f7047 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:37:04 -0500 Subject: [PATCH 094/266] [ota] Allocate the signature block through RAMAllocator (#19251) --- esphome/components/ota/ota_signature_esp_idf.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 501d6ac241d..2192a794410 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -235,9 +234,11 @@ bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer // and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens // a thin margin. One short-lived allocation right before reboot is not the - // fragmentation pattern the project guards against. nothrow so an OOM here - // fails closed like every other error path, rather than aborting. - std::unique_ptr block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]); + // fragmentation pattern the project guards against. An OOM returns nullptr + // and fails closed like every other error path. Internal RAM first: the + // block is an esp_partition_read target. + auto block = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(SIG_BLOCK_SIZE); if (!block) { OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory"); return false; From 501009073d1f6da6245c4100fc2de1a44a202922 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:55:13 +1200 Subject: [PATCH 095/266] [core] Clear loaded_platforms on CORE.reset() (#19268) --- esphome/core/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 6e3f91af22f..5fcad90a81a 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -715,6 +715,7 @@ class EsphomeCore: self.defines = set() self.platformio_options = {} self.loaded_integrations = set() + self.loaded_platforms = set() self.component_ids = set() self.platform_counts = defaultdict(int) self.unique_ids = {} From 93fa95c8335585fc5d6492ca01039b252ae3820e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 20:03:00 -0500 Subject: [PATCH 096/266] [api] Reuse overflow buffer storage instead of allocating per stalled write (#19093) --- esphome/components/api/__init__.py | 5 +- esphome/components/api/api_buffer.cpp | 35 +- esphome/components/api/api_buffer.h | 24 +- esphome/components/api/api_connection.cpp | 5 +- esphome/components/api/api_frame_helper.cpp | 2 +- esphome/components/api/api_frame_helper.h | 3 + .../components/api/api_frame_helper_noise.cpp | 20 +- .../components/api/api_overflow_buffer.cpp | 121 ++--- esphome/components/api/api_overflow_buffer.h | 93 ++-- tests/components/api/__init__.py | 17 + tests/components/api/test_api_buffer.cpp | 65 +++ tests/components/api/test_overflow_buffer.cpp | 510 ++++++++++++++++++ 12 files changed, 755 insertions(+), 145 deletions(-) create mode 100644 tests/components/api/__init__.py create mode 100644 tests/components/api/test_api_buffer.cpp create mode 100644 tests/components/api/test_overflow_buffer.cpp diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6202e127bfc..272b0786905 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -350,10 +350,9 @@ CONFIG_SCHEMA = cv.All( ln882x=5, # Moderate RAM nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), - # Maximum queued send buffers per connection before dropping connection - # Each buffer uses ~8-12 bytes overhead plus actual message size + # Max queued messages per connection, and 2 KB of backlog per slot up + # to 64 KB (a lone message is exempt), before the connection is dropped # Platform defaults based on available RAM and typical message rates: - # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp index fc45a4e971f..62a544b1a41 100644 --- a/esphome/components/api/api_buffer.cpp +++ b/esphome/components/api/api_buffer.cpp @@ -1,20 +1,37 @@ #include "api_buffer.h" -#include +#ifdef ESPHOME_DEBUG_API +#include "esphome/core/log.h" +#endif namespace esphome::api { +#ifdef ESPHOME_DEBUG_API +void APIBuffer::debug_check_drop_(size_t drop) const { + if (drop > this->size_) { + ESP_LOGE("api.buffer", "drop_front: drop=%zu size=%u", drop, this->size_); + abort(); + } +} +#endif + bool APIBuffer::grow_(size_t n) { - // nothrow (no zero-fill) so OOM is reportable; plain new aborts instead - // (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF). - // RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory. - std::unique_ptr new_data(new (std::nothrow) uint8_t[n]); - if (new_data == nullptr) + if (n > MAX_SIZE) return false; - if (this->size_) - std::memcpy(new_data.get(), this->data_.get(), this->size_); - this->data_ = std::move(new_data); + // realloc extends in place when it can, avoiding the copy + uint8_t *grown = RAMAllocator().reallocate(this->data_.get(), n); + if (grown == nullptr) + return false; + (void) this->data_.release(); // realloc already freed or reused the old block + this->data_.reset(grown); this->capacity_ = n; return true; } +uint8_t *APIBuffer::append(size_t n) { + const size_t old_size = this->size_; + if (!this->resize(old_size + n)) + return nullptr; + return this->data_.get() + old_size; +} + } // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 396dadbe587..7caa68aa4d5 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -25,6 +25,7 @@ namespace esphome::api { /// writes in debug builds. class APIBuffer { public: + static constexpr size_t MAX_SIZE = UINT16_MAX; // API frames carry 16 bit lengths void clear() { this->size_ = 0; } /// Returns false if allocation fails; the buffer is left unchanged. [[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); } @@ -36,9 +37,19 @@ class APIBuffer { [[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { if (!this->reserve(std::max(reserve_size, new_size))) return false; - this->size_ = new_size; + this->size_ = static_cast(new_size); return true; } + /// Grow by n bytes; returns the new bytes, or nullptr on allocation failure. + [[nodiscard]] uint8_t *append(size_t n); + /// Drop the first `drop` bytes, sliding the rest down. Precondition: drop <= size(). + void drop_front(size_t drop) { +#ifdef ESPHOME_DEBUG_API + this->debug_check_drop_(drop); +#endif + this->size_ -= drop; + std::memmove(this->data_.get(), this->data_.get() + drop, this->size_); + } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } @@ -55,9 +66,14 @@ class APIBuffer { protected: bool grow_(size_t n); - std::unique_ptr data_; - size_t size_{0}; - size_t capacity_{0}; +#ifdef ESPHOME_DEBUG_API + void debug_check_drop_(size_t drop) const; +#endif + // RAMAllocator: PSRAM when available, and it reports failure where + // new (std::nothrow) still aborts on ESP-IDF without exceptions + RAMUniquePtr data_; + uint16_t size_{0}; + uint16_t capacity_{0}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index da4b7d7702f..cc0543a690b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -364,7 +364,10 @@ void APIConnection::check_keepalive_(uint32_t now) { ESP_LOGVV(TAG, "Sending keepalive PING"); PingRequest req; this->flags_.sent_ping = this->send_message(req); - if (!this->flags_.sent_ping) { + if (this->flags_.sent_ping) { + // Quiet for a keepalive period and the ping is on its way: a one-off stall's storage can go + this->helper_->release_overflow_buffer(); + } else { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority ESP_LOGW(TAG, "Buffer full, ping queued"); diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 38da444a189..41d1230aaa6 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -171,7 +171,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin return APIError::OK; // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) { HELPER_LOG("Overflow buffer full or out of memory, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ff8aa7834c0..a68a0ad0d87 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -219,7 +219,10 @@ class APIFrameHelper { if (this->rx_buf_len_ == 0) { this->rx_buf_.release(); } + this->release_overflow_buffer(); } + // Free the send backlog storage once it has drained + void release_overflow_buffer() { this->overflow_buf_.release(); } protected: // Drain backlogged overflow data to the socket and handle errors. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 29b2858aee8..400cd1d9b86 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -67,15 +67,15 @@ APIError APINoiseFrameHelper::init() { } // init prologue - size_t old_size = prologue_.size(); - if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] { + uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } #ifdef USE_ESP8266 - memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #else - std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #endif state_ = State::CLIENT_HELLO; @@ -272,17 +272,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { return handle_handshake_frame_error_(aerr); } // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); + // Append 2 size bytes + frame data to the prologue size_t rx_size = this->rx_buf_.size(); - if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] { + uint8_t *dst = this->prologue_.append(2 + rx_size); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; + dst[0] = (uint8_t) (rx_size >> 8); + dst[1] = (uint8_t) rx_size; if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } state_ = State::SERVER_HELLO; diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index 48d8fe18ba8..0b5a874d4b5 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -1,98 +1,91 @@ #include "api_overflow_buffer.h" #ifdef USE_API #include -#include namespace esphome::api { -APIOverflowBuffer::~APIOverflowBuffer() { - for (auto *entry : this->queue_) { - if (entry != nullptr) - Entry::destroy(entry); - } -} - ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { - // socket->write() can re-enter this function: a log message emitted from an - // lwip callback during the write goes out over the API and lands back in the - // frame helper's write/drain path. If a nested drain ran here it would send - // and free the entry the outer drain is still holding, causing a double free. - // Report "no progress" instead; the outer drain keeps draining, and the - // nested send is enqueued behind the existing backlog. + // Nested call from inside socket->write(); see draining_ if (this->draining_) return 0; - // RAII so the flag is cleared on every return path struct DrainGuard { - explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } - ~DrainGuard() { this->flag_ = false; } - bool &flag_; - } guard(this->draining_); + APIOverflowBuffer &owner; + ~DrainGuard() { this->owner.draining_ = false; } + } guard{*this}; + this->draining_ = true; while (this->count_ > 0) { - Entry *front = this->queue_[this->head_]; + uint8_t *msg = this->buf_.data() + this->head_; + size_t len = msg[0] | (msg[1] << 8); - ssize_t sent = socket->write(front->current_data(), front->remaining()); - - if (sent <= 0) { - // -1 = error (caller checks errno for EWOULDBLOCK vs hard error) - // 0 = nothing sent (treat as no progress) + ssize_t sent = socket->write(msg + LEN_PREFIX, len); + if (sent <= 0) + return sent; + if (static_cast(sent) < len) { + // Step past the sent bytes and rewrite the prefix there; it lands on bytes already sent + this->head_ += sent; + len -= sent; + msg += sent; + msg[0] = len; + msg[1] = len >> 8; return sent; } - - if (static_cast(sent) < front->remaining()) { - // Partially sent, update offset and stop - front->offset += static_cast(sent); - return sent; - } - - // Entry fully sent — unlink it before freeing so a freed pointer is never - // reachable from the queue - this->queue_[this->head_] = nullptr; - this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + this->head_ += LEN_PREFIX + len; this->count_--; - Entry::destroy(front); } - return 0; // All drained + this->head_ = 0; + if (this->release_when_drained_) { + this->release_when_drained_ = false; + this->buf_.release(); + } else { + this->buf_.clear(); + } + return 0; } -bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { +bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip) { if (this->count_ >= API_MAX_SEND_QUEUE) return false; - uint16_t buffer_size = total_len - skip; - // nothrow: a failed allocation returns nullptr so the connection is dropped - // cleanly instead of plain new's crash or abort on OOM - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *data = new (std::nothrow) uint8_t[buffer_size]; - if (data == nullptr) - return false; - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new (std::nothrow) Entry{data, buffer_size, 0}; - if (entry == nullptr) { - delete[] data; + const size_t new_len = total_len - skip; + const size_t new_bytes = LEN_PREFIX + new_len; + const size_t live = this->buf_.size() - this->head_; + // A lone message is only bound by the buffer; refusing it would just drop the connection + if (live + new_bytes > (this->count_ > 0 ? MAX_BYTES : MAX_LONE_BYTES)) return false; + + if (this->buf_.size() + new_bytes > this->buf_.capacity()) { + // Storage would move under an outer drain's write() + if (this->draining_) + return false; + if (this->head_ > 0) { + // Reclaim the sent prefix before growing + this->buf_.drop_front(this->head_); + this->head_ = 0; + } + if (!this->buf_.reserve(reserve_for(live + new_bytes))) + return false; } - uint16_t to_skip = skip; - uint16_t write_pos = 0; - - for (int i = 0; i < iovcnt; i++) { - if (to_skip >= iov[i].iov_len) { - to_skip -= static_cast(iov[i].iov_len); + uint8_t *dst = this->buf_.append(new_bytes); + if (dst == nullptr) + return false; + dst[0] = new_len; + dst[1] = new_len >> 8; + dst += LEN_PREFIX; + for (const struct iovec *end = iov + iovcnt; iov != end; iov++) { + if (skip >= iov->iov_len) { + skip -= iov->iov_len; } else { - const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; - uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(entry->data + write_pos, src, len); - write_pos += len; - to_skip = 0; + const size_t len = iov->iov_len - skip; + std::memcpy(dst, static_cast(iov->iov_base) + skip, len); + dst += len; + skip = 0; } } - // Publish only after the copy completes so a half-built entry is never reachable - this->queue_[this->tail_] = entry; - this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; } diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 03a334b281a..e2e4b9c3c37 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -1,5 +1,6 @@ #pragma once -#include +#include +#include #include #include @@ -8,71 +9,57 @@ #include "esphome/components/socket/headers.h" #include "esphome/components/socket/socket.h" -#include "esphome/core/helpers.h" +#include "api_buffer.h" namespace esphome::api { -/// Circular queue of heap-allocated byte buffers used as a TCP send backlog. -/// -/// Under normal operation this buffer is **never used** — data goes straight -/// from the frame helper to the socket. It only fills when the LWIP TCP -/// send buffer is full (slow client, congested network, heavy logging). -/// The queue drains automatically on subsequent write/loop calls once the -/// socket becomes writable again. -/// -/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python -/// config). If the queue fills completely the connection is marked failed. +/// TCP send backlog, only used when the socket send buffer is full. +/// One contiguous buffer per connection, allocated on the first stall and +/// kept at its high-water mark so a lossy link does not churn the heap. +/// Messages are stored as a 2 byte length prefix plus payload. +/// API_MAX_SEND_QUEUE bounds queued messages and, at 2 KB per slot, queued +/// bytes; exceeding either fails the connection. class APIOverflowBuffer { public: - /// A single heap-allocated send-backlog entry. - /// Lifetime is manually managed — see destroy(). - struct Entry { - uint8_t *data; - uint16_t size; // Total size of the buffer - uint16_t offset; // Current send offset within the buffer - - uint16_t remaining() const { return this->size - this->offset; } - const uint8_t *current_data() const { return this->data + this->offset; } - - /// Free this entry and its data buffer. - static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) { - delete[] entry->data; - delete entry; // NOLINT(cppcoreguidelines-owning-memory) - } - }; - - ~APIOverflowBuffer(); - /// True when no backlogged data is waiting. bool empty() const { return this->count_ == 0; } - /// True when the queue has no room for another entry. - bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; } - - /// Number of entries currently queued. - uint8_t count() const { return this->count_; } - - /// Try to drain queued data to the socket. - /// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress, - /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). - /// Callers only need to act on -1; 0 and positive values both mean "no error". - /// Frees entries as they are fully sent. + /// Drain queued messages to the socket. + /// Returns bytes written, 0 for a re-entrant call, -1 on error (check errno + /// for EWOULDBLOCK); callers only need to act on -1. ssize_t try_drain(socket::Socket *socket); - /// Enqueue unsent IOV data into the backlog. - /// Copies iov data starting at byte offset `skip` into a new entry. - /// Returns false if the queue is full or allocation fails (caller should fail the connection). - bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); + /// Queue iov data from byte offset `skip` as one message. + /// Returns false when a limit is hit, allocation fails, or storage would move + /// during a drain; the caller should fail the connection. + bool enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip); + + /// Free the retained storage, now if empty, otherwise once it has drained. + void release() { + if (this->count_ == 0) { + this->buf_.release(); + } else { + this->release_when_drained_ = true; + } + } protected: - std::array queue_{}; - uint8_t head_{0}; - uint8_t tail_{0}; + static constexpr size_t LEN_PREFIX = 2; + static constexpr size_t BYTES_PER_SLOT = 2048; + // Reserve in 256 byte steps so a creeping high-water mark settles quickly + static constexpr size_t GROW_QUANTUM = 256; + // Lone message ceiling, rounded down so reserve_for() never exceeds the buffer limit + static constexpr size_t MAX_LONE_BYTES = APIBuffer::MAX_SIZE & ~(GROW_QUANTUM - 1); + static constexpr size_t MAX_BYTES = std::min(API_MAX_SEND_QUEUE * BYTES_PER_SLOT, MAX_LONE_BYTES); + static constexpr size_t reserve_for(size_t want) { return (want + GROW_QUANTUM - 1) & ~(GROW_QUANTUM - 1); } + + APIBuffer buf_; + uint16_t head_{0}; // offset of the front message's length prefix; bytes before it are sent uint8_t count_{0}; - // Guards against re-entrant drains: socket->write() can re-enter the API - // send path (e.g. a log message emitted from an lwip callback), and a nested - // drain would free the entry the outer drain is still holding. - bool draining_{false}; + // socket->write() can re-enter the send path (log from an lwip callback): + // a nested drain makes no progress and a nested enqueue never moves storage + bool draining_ : 1 {false}; + bool release_when_drained_ : 1 {false}; }; } // namespace esphome::api diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py new file mode 100644 index 00000000000..2aa558726c3 --- /dev/null +++ b/tests/components/api/__init__.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.core import CORE +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # USE_API compiles every api source, so emit what they need. No socket + # override: an __init__.py there makes pytest import its conftest as socket.conftest. + async def to_code_testing(config): + cg.add_define("USE_API") + cg.add_define("USE_API_PLAINTEXT") + cg.add_define("API_MAX_SEND_QUEUE", 8) + cg.add_define("MAX_API_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + CORE.register_controller() # api_server registers with the controller registry + + manifest.to_code = to_code_testing diff --git a/tests/components/api/test_api_buffer.cpp b/tests/components/api/test_api_buffer.cpp new file mode 100644 index 00000000000..c54780050e3 --- /dev/null +++ b/tests/components/api/test_api_buffer.cpp @@ -0,0 +1,65 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::testing { + +// Pointer plus two 16 bit sizes +static_assert(sizeof(APIBuffer) <= 2 * sizeof(void *)); + +TEST(APIBuffer, RefusesSizesAbove16Bits) { + APIBuffer buf; + ASSERT_TRUE(buf.resize(16)); + EXPECT_FALSE(buf.reserve(UINT16_MAX + 1)); + EXPECT_EQ(buf.size(), 16u); + EXPECT_EQ(buf.capacity(), 16u); + EXPECT_TRUE(buf.reserve(UINT16_MAX)); + EXPECT_EQ(buf.capacity(), UINT16_MAX); +} + +static const uint8_t BYTES[] = {1, 2, 3, 4, 5, 6}; + +TEST(APIBuffer, AppendReturnsTheNewBytes) { + APIBuffer buf; + ASSERT_TRUE(buf.reserve(8)); + uint8_t *first = buf.append(3); + ASSERT_NE(first, nullptr); + std::memcpy(first, BYTES, 3); + EXPECT_EQ(buf.size(), 3u); + EXPECT_EQ(buf.capacity(), 8u); + + // Grows through realloc and keeps what was there + uint8_t *second = buf.append(6); + ASSERT_EQ(second, buf.data() + 3); + std::memcpy(second, BYTES + 3, 3); + EXPECT_EQ(buf.size(), 9u); + EXPECT_EQ(buf.capacity(), 9u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES, 6), 0); +} + +TEST(APIBuffer, DropFrontSlidesTheRestDown) { + APIBuffer buf; + uint8_t *bytes = buf.append(6); + ASSERT_NE(bytes, nullptr); + std::memcpy(bytes, BYTES, 6); + + buf.drop_front(2); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(buf.capacity(), 6u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Growing afterwards keeps the slid bytes + ASSERT_TRUE(buf.reserve(64)); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Dropping everything leaves an empty buffer with its capacity + buf.drop_front(4); + EXPECT_EQ(buf.size(), 0u); + EXPECT_EQ(buf.capacity(), 64u); +} + +} // namespace esphome::api::testing diff --git a/tests/components/api/test_overflow_buffer.cpp b/tests/components/api/test_overflow_buffer.cpp new file mode 100644 index 00000000000..4b27e544963 --- /dev/null +++ b/tests/components/api/test_overflow_buffer.cpp @@ -0,0 +1,510 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/api/api_overflow_buffer.h" + +#ifdef USE_HOST +namespace esphome::api::testing { + +// Idle cost is the buffer plus one word of bookkeeping +static_assert(sizeof(APIOverflowBuffer) <= sizeof(APIBuffer) + sizeof(void *)); + +// Exposes storage so tests can check it is reused, not reallocated +class TestOverflowBuffer : public APIOverflowBuffer { + public: + using APIOverflowBuffer::LEN_PREFIX; + using APIOverflowBuffer::MAX_BYTES; + using APIOverflowBuffer::MAX_LONE_BYTES; + struct Storage { + size_t capacity; + const uint8_t *data; + bool operator==(const Storage &) const = default; + }; + size_t capacity() const { return this->buf_.capacity(); } + Storage storage() const { return {this->buf_.capacity(), this->buf_.data()}; } + uint8_t count() const { return this->count_; } + size_t live() const { return this->buf_.size() - this->head_; } + /// Simulates a socket write inside try_drain() re-entering the send path + void set_draining(bool draining) { this->draining_ = draining; } +}; + +static std::vector make_message(size_t len, uint8_t seed) { + std::vector msg(len); + for (size_t i = 0; i < len; i++) + msg[i] = static_cast(seed + i); + return msg; +} + +static bool enqueue(TestOverflowBuffer &buf, const std::vector &msg, uint16_t skip = 0) { + struct iovec iov = {const_cast(msg.data()), msg.size()}; + return buf.enqueue_iov(&iov, 1, static_cast(msg.size()), skip); +} + +static void append(std::vector &dst, const std::vector &src, size_t skip = 0) { + dst.insert(dst.end(), src.begin() + skip, src.end()); +} + +static std::vector concat(std::initializer_list> parts) { + std::vector out; + for (const auto &part : parts) + append(out, part); + return out; +} + +/// The pipe delivers the filler first, then the drained messages. +static void expect_after_filler(const std::vector &received, size_t filler, + const std::vector &expected) { + ASSERT_EQ(received.size(), filler + expected.size()); + EXPECT_TRUE(std::equal(expected.begin(), expected.end(), received.begin() + filler)); +} + +// Non-blocking socket pair with small buffers, so the writer fills like a stalled TCP connection +class OverflowBufferTest : public ::testing::Test { + protected: + void SetUp() override { + int fds[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + int size = 4096; + ASSERT_EQ(::setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::fcntl(fds[1], F_SETFL, O_NONBLOCK), 0); + this->reader_ = fds[1]; + this->sock_ = std::make_unique(fds[0]); + ASSERT_EQ(this->sock_->setblocking(false), 0); + } + void TearDown() override { ::close(this->reader_); } + + /// Write filler until the socket refuses; returns the bytes accepted + size_t fill_pipe_() { + uint8_t junk[512]; + std::memset(junk, 0xEE, sizeof(junk)); + size_t total = 0; + for (;;) { + ssize_t written = this->sock_->write(junk, sizeof(junk)); + if (written <= 0) + break; + total += static_cast(written); + } + return total; + } + + /// Append whatever the pipe currently holds. + void read_into_(std::vector &out) { + uint8_t tmp[1024]; + for (;;) { + ssize_t n = ::read(this->reader_, tmp, sizeof(tmp)); + if (n <= 0) + break; + out.insert(out.end(), tmp, tmp + n); + } + } + + /// Drain once; a refusal must be a would-block, never a hard error. + ssize_t drain_(TestOverflowBuffer &buf) { + ssize_t sent = buf.try_drain(this->sock_.get()); + if (sent == -1) { + EXPECT_TRUE(errno == EWOULDBLOCK || errno == EAGAIN); + } + return sent; + } + + /// Read and drain until the backlog is empty; returns all bytes received + std::vector drain_all_(TestOverflowBuffer &buf) { + std::vector received; + for (int i = 0; i < 10000 && !buf.empty(); i++) { + this->read_into_(received); + // A hard socket error would never clear the backlog; stop instead of spinning + if (this->drain_(buf) == -1 && errno != EWOULDBLOCK && errno != EAGAIN) + break; + } + EXPECT_TRUE(buf.empty()); + this->read_into_(received); + return received; + } + + struct Stall { + size_t filler; + std::vector first, second, received; + TestOverflowBuffer::Storage before; + }; + /// Park two messages, then drain the first fully and the second part way + void stall_mid_message_(TestOverflowBuffer &buf, Stall &s) { + s.filler = this->fill_pipe_(); + s.first = make_message(1500, 20); + ASSERT_GT(s.filler, s.first.size()); // the first message must drain in one go + // Larger than the whole pipe, so a drain always stops inside it + s.second = make_message(std::max(s.filler + 1, std::min(s.filler * 3, 12000)), 60); + ASSERT_GT(s.second.size(), s.filler); + ASSERT_TRUE(enqueue(buf, s.first)); + ASSERT_TRUE(enqueue(buf, s.second)); + s.before = buf.storage(); + this->read_into_(s.received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + } + + int reader_{-1}; + std::unique_ptr sock_; +}; + +TEST_F(OverflowBufferTest, IdleBufferOwnsNoStorage) { + TestOverflowBuffer buf; + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, StorageIsReusedAcrossStalls) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 1); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const auto storage = buf.storage(); + EXPECT_GE(storage.capacity, msg.size() + TestOverflowBuffer::LEN_PREFIX); + + for (int stall = 0; stall < 5; stall++) { + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + // Same allocation every time: no free, no new allocation + EXPECT_EQ(buf.storage(), storage); + + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.storage(), storage); + } +} + +TEST_F(OverflowBufferTest, ReleaseWhileQueuedFreesOnceDrained) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 7); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const size_t capacity = buf.capacity(); + + // Requested while the backlog still holds data: storage must stay until sent + buf.release(); + EXPECT_FALSE(buf.empty()); + EXPECT_EQ(buf.capacity(), capacity); + + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); + + // A later stall allocates again and keeps it, since nobody asked for a release + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_GT(buf.capacity(), 0u); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, ReleaseWhenEmptyFreesImmediately) { + TestOverflowBuffer buf; + auto msg = make_message(100, 3); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); + + buf.release(); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, PreservesOrderAndSkipsSentPrefix) { + TestOverflowBuffer buf; + auto first = make_message(700, 10); + auto second_a = make_message(300, 50); + auto second_b = make_message(400, 90); + auto third = make_message(200, 130); + + size_t filler = this->fill_pipe_(); + // 100 bytes of the first message were already accepted by the socket + ASSERT_TRUE(enqueue(buf, first, 100)); + // Two iovecs with the skip covering all of the first one plus part of the second + struct iovec iov[2] = {{second_a.data(), second_a.size()}, {second_b.data(), second_b.size()}}; + const uint16_t second_skip = static_cast(second_a.size() + 5); + ASSERT_TRUE(buf.enqueue_iov(iov, 2, static_cast(second_a.size() + second_b.size()), second_skip)); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 3); + + // Nothing can go out while the pipe is full + EXPECT_EQ(this->drain_(buf), -1); + EXPECT_EQ(buf.count(), 3); + + std::vector expected; + append(expected, first, 100); + append(expected, second_b, 5); + append(expected, third); + expect_after_filler(this->drain_all_(buf), filler, expected); +} + +TEST_F(OverflowBufferTest, RefusesWhenQueueIsFull) { + TestOverflowBuffer buf; + auto msg = make_message(16, 1); + + size_t filler = this->fill_pipe_(); + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) { + ASSERT_TRUE(enqueue(buf, msg)) << "message " << i; + } + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), API_MAX_SEND_QUEUE); + + // Draining frees the slots again + std::vector expected; + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) + append(expected, msg); + expect_after_filler(this->drain_all_(buf), filler, expected); + this->fill_pipe_(); + EXPECT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 1); +} + +TEST_F(OverflowBufferTest, SkipAtIovecBoundary) { + TestOverflowBuffer buf; + auto sent = make_message(300, 50); + auto unsent = make_message(400, 90); + + size_t filler = this->fill_pipe_(); + // The skip covers the first iovec exactly, so only the second is copied + struct iovec iov[2] = {{sent.data(), sent.size()}, {unsent.data(), unsent.size()}}; + ASSERT_TRUE( + buf.enqueue_iov(iov, 2, static_cast(sent.size() + unsent.size()), static_cast(sent.size()))); + EXPECT_EQ(buf.live(), unsent.size() + TestOverflowBuffer::LEN_PREFIX); + expect_after_filler(this->drain_all_(buf), filler, unsent); +} + +TEST_F(OverflowBufferTest, AppendsBehindSentPrefixWhenItFits) { + TestOverflowBuffer buf; + size_t filler = this->fill_pipe_(); + auto first = make_message(200, 20); + // Size the second message so the two land half way into a 256 byte step, + // leaving exactly 128 bytes of slack whatever the pipe accepted + const size_t base = std::max(filler + 1, std::min(filler * 3, 12000)); + const size_t second_len = (base / 256 + 1) * 256 + 128 - first.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + auto second = make_message(second_len, 60); + ASSERT_GT(second.size(), filler); + ASSERT_TRUE(enqueue(buf, first)); + ASSERT_TRUE(enqueue(buf, second)); + const auto storage = buf.storage(); + const size_t slack = storage.capacity - first.size() - second.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + ASSERT_EQ(slack, 128u); + auto third = make_message(slack - TestOverflowBuffer::LEN_PREFIX, 200); + + std::vector received; + this->read_into_(received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + const size_t live = buf.live(); + + // Fits in the tail, so the sent prefix is left alone + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), storage); + EXPECT_EQ(buf.live(), live + third.size() + TestOverflowBuffer::LEN_PREFIX); + + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, concat({first, second, third})); +} + +TEST_F(OverflowBufferTest, ReleaseSurvivesFurtherEnqueues) { + TestOverflowBuffer buf; + auto first = make_message(300, 7); + auto second = make_message(300, 70); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + buf.release(); + ASSERT_TRUE(enqueue(buf, second)); + EXPECT_GT(buf.capacity(), 0u); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, RefusesWhenByteLimitIsExceeded) { + TestOverflowBuffer buf; + // Two of these fill the byte budget exactly, well before the slot count is reached + static_assert(API_MAX_SEND_QUEUE >= 3); + auto msg = make_message(TestOverflowBuffer::MAX_BYTES / 2 - TestOverflowBuffer::LEN_PREFIX, 1); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 2); +} + +TEST_F(OverflowBufferTest, LoneMessageMayExceedByteLimit) { + TestOverflowBuffer buf; + // The oversized message must still fit under the lone message ceiling + static_assert(TestOverflowBuffer::MAX_BYTES + 100 + TestOverflowBuffer::LEN_PREFIX <= + TestOverflowBuffer::MAX_LONE_BYTES); + auto big = make_message(TestOverflowBuffer::MAX_BYTES + 100, 5); + auto small = make_message(16, 9); + + // Refusing the only message would drop the connection for nothing + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, big)); + EXPECT_EQ(buf.count(), 1); + // With a backlog present the byte limit applies again + EXPECT_FALSE(enqueue(buf, small)); + EXPECT_EQ(buf.count(), 1); + + expect_after_filler(this->drain_all_(buf), filler, big); +} + +TEST_F(OverflowBufferTest, LoneMessageAboveOffsetLimitIsRefused) { + TestOverflowBuffer buf; + // Payload plus prefix is past the lone message ceiling + auto msg = make_message(TestOverflowBuffer::MAX_LONE_BYTES, 3); + + this->fill_pipe_(); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, HardSocketErrorLeavesBacklogIntact) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + // A closed socket fails every write outright, unlike a full one + ASSERT_EQ(this->sock_->close(), 0); + + errno = 0; + EXPECT_EQ(buf.try_drain(this->sock_.get()), -1); + EXPECT_NE(errno, EWOULDBLOCK); + EXPECT_NE(errno, EAGAIN); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.live(), msg.size() + TestOverflowBuffer::LEN_PREFIX); +} + +TEST_F(OverflowBufferTest, GrowsWhileReclaimingSentPrefix) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + + // One byte too many to fit even after the sent prefix is reclaimed: grows in one copy + auto third = make_message(s.before.capacity - buf.live() + 1, 200); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_GT(buf.capacity(), s.before.capacity); + EXPECT_EQ(buf.count(), 2); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, NestedDrainMakesNoProgress) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + std::vector received; + this->read_into_(received); + + // Room is available, but a nested drain must leave the outer one's message alone + buf.set_draining(true); + EXPECT_EQ(this->drain_(buf), 0); + EXPECT_EQ(buf.count(), 1); + std::vector nothing; + this->read_into_(nothing); + EXPECT_TRUE(nothing.empty()); + + buf.set_draining(false); + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, msg); +} + +TEST_F(OverflowBufferTest, NestedEnqueueAppendsWithinCapacity) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(4, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_GE(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + buf.set_draining(true); + EXPECT_TRUE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 2); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToGrow) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(100, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_LT(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + // Growing would free the bytes the outer write() is sending from + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, first); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToCompact) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // Sliding the remainder down would move the bytes the outer write() points at + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), s.before); + buf.set_draining(false); + + // Once the drain is over the same enqueue compacts and succeeds + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, CompactsInsteadOfGrowingAfterPartialDrain) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // The sent first message is reclaimed by sliding the remainder down, not by reallocating + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +} // namespace esphome::api::testing +#endif // USE_HOST From 7fe0689fb8e1b0b94ce2f0e3284533ecb61d9981 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:12:27 +1200 Subject: [PATCH 097/266] Bump version to 2026.9.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 97ce92240c7..331d2f7984b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b3 +PROJECT_NUMBER = 2026.9.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index b013098f336..56961253557 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b3" +__version__ = "2026.9.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a0821c225af41045f426b41e851972e07c6ddd9f Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sun, 13 Sep 2026 18:18:52 -0700 Subject: [PATCH 098/266] [pmsa003i] Fix read from uninitialized stack memory (#19053) --- esphome/components/pmsa003i/pmsa003i.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 15f5d3e8793..0b5c72a94d2 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -88,7 +88,11 @@ void PMSA003IComponent::update() { bool PMSA003IComponent::read_data_(PM25AQIData *data) { uint8_t buffer[COUNT_DATA_BYTES]; - this->read_bytes_raw(buffer, COUNT_DATA_BYTES); + const i2c::ErrorCode error = this->read(buffer, COUNT_DATA_BYTES); + if (error != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C error %d", error); + return false; + } // https://github.com/adafruit/Adafruit_PM25AQI From 41e34c19eb433ca620c80bcef842d447c1d5f491 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 13 Sep 2026 18:36:39 -0700 Subject: [PATCH 099/266] [template] Stop water heater republishing when a temperature is unknown (#19013) --- .../water_heater/template_water_heater.cpp | 10 ++++-- ...r_heater_template_unknown_temperature.yaml | 16 +++++++++ .../integration/test_water_heater_template.py | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/water_heater_template_unknown_temperature.yaml diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 092df6fdca3..9d6a3523d28 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -1,6 +1,8 @@ #include "template_water_heater.h" #include "esphome/core/log.h" +#include + namespace esphome::template_ { static const char *const TAG = "template.water_heater"; @@ -45,9 +47,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { void TemplateWaterHeater::loop() { bool changed = false; + // NAN is passed through so a source that has no value yet shows as unknown, but NAN never + // equals NAN, so an already-NAN value must not count as a change or it would republish forever. auto curr_temp = this->current_temperature_f_.call(); if (curr_temp.has_value()) { - if (*curr_temp != this->current_temperature_) { + if (*curr_temp != this->current_temperature_ && + !(std::isnan(*curr_temp) && std::isnan(this->current_temperature_))) { this->current_temperature_ = *curr_temp; changed = true; } @@ -55,7 +60,8 @@ void TemplateWaterHeater::loop() { auto target_temp = this->target_temperature_f_.call(); if (target_temp.has_value()) { - if (*target_temp != this->target_temperature_) { + if (*target_temp != this->target_temperature_ && + !(std::isnan(*target_temp) && std::isnan(this->target_temperature_))) { this->target_temperature_ = *target_temp; changed = true; } diff --git a/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml new file mode 100644 index 00000000000..a70ed25bd7f --- /dev/null +++ b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml @@ -0,0 +1,16 @@ +esphome: + name: wh-template-unknown-test +host: +api: +logger: + +water_heater: + - platform: template + id: unknown_boiler + name: Unknown Boiler + # Both temperatures stay unknown, as they do before an upstream component reports a value. + current_temperature: !lambda "return NAN;" + target_temperature: !lambda "return NAN;" + supported_modes: + - "off" + - eco diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index d63d1d69845..3d7f8851605 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -155,3 +155,36 @@ async def test_water_heater_template( client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) eco_state = await wait_for_state() assert eco_state.mode == WaterHeaterMode.ECO + + +@pytest.mark.asyncio +async def test_water_heater_template_unknown_temperature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a template water heater whose temperature lambdas stay unknown. + + NAN never compares equal to itself, so a lambda that keeps returning NAN must not be + mistaken for a changed value and republish the state on every loop iteration. + """ + async with run_compiled(yaml_config), api_client_connected() as client: + state_count = 0 + + def on_state(state: aioesphomeapi.EntityState) -> None: + nonlocal state_count + if isinstance(state, WaterHeaterState): + state_count += 1 + + entities, _ = await client.list_entities_services() + water_heater_infos = [e for e in entities if isinstance(e, WaterHeaterInfo)] + assert len(water_heater_infos) == 1 + + client.subscribe_states(on_state) + + # Let the device run for a while; only the single initial state may arrive. + await asyncio.sleep(1.0) + assert state_count <= 1, ( + f"Expected at most 1 state publish, got {state_count} - " + "an unknown (NAN) temperature is republishing every loop" + ) From 02648d3547401d6478149c9c9f322a5b99a467fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 21:17:58 -0500 Subject: [PATCH 100/266] [bluetooth_connection] Keep USE_BLUETOOTH_PROXY out of the shared host test binary (#19271) --- tests/components/bluetooth_connection/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index 9c1ad4e74d4..45bf77b4e84 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): - # These defines are global to the merged host test binary; safe - # because no co-compiled test observes them. + # These defines are global to the merged host test binary. The api sources are + # compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make + # them include and call bluetooth_proxy, which has no host build without a BLE hub. cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") - cg.add_define("USE_BLUETOOTH_PROXY") # Gates the connection half of the API surface, which is what # close_service_batch and the GATT response types live behind. cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") - cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) manifest.to_code = to_code_testing From 82b608706cd49a231017190de0bbb8120ccc2fbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 21:17:58 -0500 Subject: [PATCH 101/266] [bluetooth_connection] Keep USE_BLUETOOTH_PROXY out of the shared host test binary (#19271) --- tests/components/bluetooth_connection/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index 9c1ad4e74d4..45bf77b4e84 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): - # These defines are global to the merged host test binary; safe - # because no co-compiled test observes them. + # These defines are global to the merged host test binary. The api sources are + # compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make + # them include and call bluetooth_proxy, which has no host build without a BLE hub. cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") - cg.add_define("USE_BLUETOOTH_PROXY") # Gates the connection half of the API surface, which is what # close_service_batch and the GATT response types live behind. cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") - cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) manifest.to_code = to_code_testing From abadfbfd20eb16d9272ef225f160e55adad2824b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:32:31 +1200 Subject: [PATCH 102/266] [core] Mark filters, manual_ip and interlock as advanced (#19272) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/ethernet/__init__.py | 4 +- esphome/components/gpio/switch/__init__.py | 8 ++- esphome/components/sensor/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/wifi/__init__.py | 8 ++- .../test_advanced_visibility.py | 53 +++++++++++++++++++ 7 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/config_validation/test_advanced_visibility.py diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1ab6f7103f7..9ef7efc96a3 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -452,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 0454440f142..3e7d345805c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -420,7 +420,9 @@ def _validate(config: ConfigType) -> ConfigType: BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(EthernetComponent), - cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): MANUAL_IP_SCHEMA, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 2e0b0969bc7..766cdc4afb3 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -15,9 +15,13 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)), cv.Optional( - CONF_INTERLOCK_WAIT_TIME, default="0ms" + CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED + ): cv.ensure_list(cv.use_id(switch.Switch)), + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, + default="0ms", + visibility=cv.Visibility.ADVANCED, ): cv.positive_time_period_milliseconds, } ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79d4ce5e0c0..3b632a1847f 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -344,7 +344,9 @@ _SENSOR_SCHEMA = ( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 29399a51b72..5c8d71696f5 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -148,7 +148,9 @@ _TEXT_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1e57c03b7b0..95f627596d1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -288,7 +288,9 @@ WIFI_NETWORK_BASE = cv.Schema( cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, } ) @@ -487,7 +489,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, diff --git a/tests/component_tests/config_validation/test_advanced_visibility.py b/tests/component_tests/config_validation/test_advanced_visibility.py new file mode 100644 index 00000000000..f7e03743198 --- /dev/null +++ b/tests/component_tests/config_validation/test_advanced_visibility.py @@ -0,0 +1,53 @@ +"""Power-user fields are marked as advanced on the shared schemas. + +``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs +whose defaults suit nearly every user, so a schema-aware editor should keep +them behind its "advanced settings" disclosure rather than on the main form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +def _gpio_switch_schema() -> cv.Schema: + return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA + + +@pytest.mark.parametrize( + ("label", "schema_factory", "fields"), + [ + ("sensor", sensor.sensor_schema, ["filters"]), + ("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]), + ("text_sensor", text_sensor.text_sensor_schema, ["filters"]), + ("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]), + ("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]), + ("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]), + ("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]), + ], +) +def test_power_user_fields_are_advanced( + label: str, schema_factory, fields: list[str] +) -> None: + markers = _markers(schema_factory()) + for field in fields: + assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}" + + +def test_interlock_wait_time_keeps_its_default() -> None: + """Marking the field advanced must not drop its default.""" + markers = _markers(_gpio_switch_schema()) + assert markers["interlock_wait_time"].default() == "0ms" From 282be54d1eb4c4a1ab00288229c247a06ce7975f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:44:36 -0500 Subject: [PATCH 103/266] [number] Fix the default mode check so mode auto is no longer emitted (#19231) --- esphome/components/number/__init__.py | 14 ++++++---- esphome/components/number/number_traits.h | 2 +- tests/component_tests/number/__init__.py | 0 tests/component_tests/number/config/mode.yaml | 28 +++++++++++++++++++ tests/component_tests/number/test_number.py | 16 +++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/number/__init__.py create mode 100644 tests/component_tests/number/config/mode.yaml create mode 100644 tests/component_tests/number/test_number.py diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ea0c2d77f66..fc0893323be 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -174,6 +174,10 @@ NumberInRangeCondition = number_ns.class_( NumberMode = number_ns.enum("NumberMode") +# Schema default that also matches the C++ initializer in number_traits.h; codegen +# skips the setter when the config equals it. +DEFAULT_MODE = "AUTO" + NUMBER_MODES = { "AUTO": NumberMode.NUMBER_MODE_AUTO, "BOX": NumberMode.NUMBER_MODE_BOX, @@ -216,7 +220,7 @@ _NUMBER_SCHEMA = ( CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED ): validate_unit_of_measurement, cv.Optional( - CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED ): cv.enum(NUMBER_MODES, upper=True), cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED @@ -286,10 +290,10 @@ async def setup_number_core_( cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) - # Only set if non-default to avoid bloating setup() function - # (mode_ is initialized to NUMBER_MODE_AUTO in the header) - if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: - cg.add(var.traits.set_mode(config[CONF_MODE])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_MODE). + # The validated value is the enum key string, not the C++ enum expression. + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.traits.set_mode(mode)) CORE.add_job(_build_number_automations, var, config) diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index f855813c9bf..3c7942b9a36 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -31,7 +31,7 @@ class NumberTraits { float min_value_ = NAN; float max_value_ = NAN; float step_ = NAN; - NumberMode mode_{NUMBER_MODE_AUTO}; + NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py }; } // namespace esphome::number diff --git a/tests/component_tests/number/__init__.py b/tests/component_tests/number/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/number/config/mode.yaml b/tests/component_tests/number/config/mode.yaml new file mode 100644 index 00000000000..b3eae34436f --- /dev/null +++ b/tests/component_tests/number/config/mode.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +number: + - platform: template + id: auto_number + min_value: 0 + max_value: 10 + step: 1 + optimistic: true + - platform: template + id: box_number + min_value: 0 + max_value: 10 + step: 1 + mode: box + optimistic: true + - platform: template + id: explicit_auto_number + min_value: 0 + max_value: 10 + step: 1 + mode: auto + optimistic: true diff --git a/tests/component_tests/number/test_number.py b/tests/component_tests/number/test_number.py new file mode 100644 index 00000000000..b33508602af --- /dev/null +++ b/tests/component_tests/number/test_number.py @@ -0,0 +1,16 @@ +"""Tests for the number component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_mode_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Mode auto is the C++ initializer, so only a non default mode is set.""" + main_cpp = generate_main(component_config_path("mode.yaml")) + + assert "auto_number->traits.set_mode(" not in main_cpp + assert "explicit_auto_number->traits.set_mode(" not in main_cpp + assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp From 1e22861d11ddcd27096239b2d7d4ea130b83883c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:00 -0500 Subject: [PATCH 104/266] [web_server] Skip setters that pass the default port, log and include internal values (#19226) --- esphome/components/web_server/__init__.py | 20 ++++++++--- .../web_server_base/web_server_base.h | 2 +- .../web_server/config/bare.yaml | 12 +++++++ .../web_server/config/custom.yaml | 15 ++++++++ .../web_server/config/defaults.yaml | 15 ++++++++ .../web_server/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/web_server/config/bare.yaml create mode 100644 tests/component_tests/web_server/config/custom.yaml create mode 100644 tests/component_tests/web_server/config/defaults.yaml create mode 100644 tests/component_tests/web_server/test_default_setters.py diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index a50c14a2f72..2459163786d 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -56,6 +56,10 @@ CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" CONF_ALLOWED_ORIGINS = "allowed_origins" +# Schema default that also matches the C++ initializer in web_server_base.h; codegen +# skips the setter when the config equals it. +DEFAULT_PORT = 80 + web_server_ns = cg.esphome_ns.namespace("web_server") WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) @@ -251,7 +255,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(WebServer), - cv.Optional(CONF_PORT, default=80): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True), cv.Optional(CONF_CSS_URL): cv.string, cv.Optional(CONF_CSS_INCLUDE): cv.file_, @@ -379,9 +383,11 @@ async def to_code(config: ConfigType) -> None: version = config[CONF_VERSION] - cg.add(paren.set_port(config[CONF_PORT])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_PORT). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") - cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT]) + cg.add_define("USE_WEBSERVER_PORT", port) cg.add_define("USE_WEBSERVER_VERSION", version) if version >= 2: # Don't compress the index HTML as the data sizes are almost the same. @@ -395,9 +401,11 @@ async def to_code(config: ConfigType) -> None: # Captive portal will still be able to perform OTA updates even when this is set if config.get(CONF_OTA) is False: cg.add_define("USE_WEBSERVER_OTA_DISABLED") - cg.add(var.set_expose_log(config[CONF_LOG])) + # expose_log_ is true in C++; only emit the setter to turn it off. if config[CONF_LOG]: request_log_listener() # Request a log listener slot for web server log streaming + else: + cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: @@ -433,7 +441,9 @@ async def to_code(config: ConfigType) -> None: path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) - cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) + # include_internal_ is false in C++; only emit the setter to turn it on. + if config[CONF_INCLUDE_INTERNAL]: + cg.add(var.set_include_internal(True)) if CONF_LOCAL in config and config[CONF_LOCAL]: cg.add_define("USE_WEBSERVER_LOCAL") if config[CONF_COMPRESSION] == "gzip": diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 94579de70f8..72d3bf75b1c 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -170,7 +170,7 @@ class WebServerBase final { protected: uint8_t initialized_{0}; - uint16_t port_{80}; + uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py AsyncWebServer *server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH diff --git a/tests/component_tests/web_server/config/bare.yaml b/tests/component_tests/web_server/config/bare.yaml new file mode 100644 index 00000000000..dae1c488832 --- /dev/null +++ b/tests/component_tests/web_server/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: diff --git a/tests/component_tests/web_server/config/custom.yaml b/tests/component_tests/web_server/config/custom.yaml new file mode 100644 index 00000000000..2d37d7ae19d --- /dev/null +++ b/tests/component_tests/web_server/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 8080 + log: false + include_internal: true diff --git a/tests/component_tests/web_server/config/defaults.yaml b/tests/component_tests/web_server/config/defaults.yaml new file mode 100644 index 00000000000..3c34da43ac1 --- /dev/null +++ b/tests/component_tests/web_server/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 80 + log: true + include_internal: false diff --git a/tests/component_tests/web_server/test_default_setters.py b/tests/component_tests/web_server/test_default_setters.py new file mode 100644 index 00000000000..2b13ed966b5 --- /dev/null +++ b/tests/component_tests/web_server/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that web_server only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 80, log on and include_internal off already live in the C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_port(" not in main_cpp + assert "set_expose_log(" not in main_cpp + assert "set_include_internal(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_port(8080);" in main_cpp + assert "set_expose_log(false);" in main_cpp + assert "set_include_internal(true);" in main_cpp From 4067f572cc953f5f98d1f2770222a4245ab21dcc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:56 -0500 Subject: [PATCH 105/266] [output] Skip the power limit setters when they match the defaults (#19225) --- esphome/components/output/__init__.py | 13 +++++--- esphome/components/output/float_output.h | 1 + tests/component_tests/output/__init__.py | 0 .../config/ac_dimmer_min_power_zero.yaml | 13 ++++++++ .../output/config/power_limits.yaml | 18 +++++++++++ tests/component_tests/output/test_output.py | 31 +++++++++++++++++++ tests/components/ac_dimmer/common.yaml | 1 + 7 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/output/__init__.py create mode 100644 tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml create mode 100644 tests/component_tests/output/config/power_limits.yaml create mode 100644 tests/component_tests/output/test_output.py diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 4f6c8943f5e..10d5e5eb593 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -53,12 +53,17 @@ async def setup_output_platform_(obj, config): if CONF_POWER_SUPPLY in config: power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) - if CONF_MAX_POWER in config: + # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when + # the config matches them. The define stays whenever the key is present because + # platforms such as ac_dimmer read the scaling fields directly. + if (max_power := config.get(CONF_MAX_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_max_power(config[CONF_MAX_POWER])) - if CONF_MIN_POWER in config: + if max_power != 1.0: + cg.add(obj.set_max_power(max_power)) + if (min_power := config.get(CONF_MIN_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_min_power(config[CONF_MIN_POWER])) + if min_power != 0.0: + cg.add(obj.set_min_power(min_power)) # Only emit when zero_means_zero is actually enabled. The schema defaults to False # so this key is always present; emitting unconditionally would force # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 673f4235728..57c8c553f65 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput { virtual void write_state(float state) = 0; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING + // Codegen skips the setters for these values; keep in sync with output/__init__.py float max_power_{1.0f}; float min_power_{0.0f}; bool zero_means_zero_{false}; diff --git a/tests/component_tests/output/__init__.py b/tests/component_tests/output/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml new file mode 100644 index 00000000000..84c5eafc5ab --- /dev/null +++ b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ac_dimmer + id: dimmer + gate_pin: GPIO4 + zero_cross_pin: GPIO5 + min_power: 0% diff --git a/tests/component_tests/output/config/power_limits.yaml b/tests/component_tests/output/config/power_limits.yaml new file mode 100644 index 00000000000..682ae9de511 --- /dev/null +++ b/tests/component_tests/output/config/power_limits.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: default_power + pin: GPIO4 + max_power: 100% + min_power: 0% + - platform: ledc + id: custom_power + pin: GPIO5 + max_power: 90% + min_power: 1% diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py new file mode 100644 index 00000000000..172715aef08 --- /dev/null +++ b/tests/component_tests/output/test_output.py @@ -0,0 +1,31 @@ +"""Tests for the output platform codegen.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_default_power_limits_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """max_power 100% and min_power 0% already live in the C++ initializers.""" + main_cpp = generate_main(component_config_path("power_limits.yaml")) + + assert "default_power->set_max_power(" not in main_cpp + assert "default_power->set_min_power(" not in main_cpp + assert "custom_power->set_max_power(0.9f);" in main_cpp + assert "custom_power->set_min_power(0.01f);" in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} + + +def test_default_min_power_keeps_scaling_fields( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%.""" + main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml")) + + assert "dimmer->set_min_power(" not in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index c16e2e834a9..8fa62c0636b 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -4,3 +4,4 @@ output: gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} zero_cross_interrupt_type: ANY + min_power: 0% From 8109aa96f628711482501d2bd5de1406eb764bb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:51:42 -0500 Subject: [PATCH 106/266] [light] Skip the flash transition setter and the empty effect list (#19228) --- esphome/components/light/__init__.py | 14 +++++++-- esphome/components/light/light_state.h | 2 +- .../light/config/transitions.yaml | 29 +++++++++++++++++++ .../light/test_default_setters.py | 19 ++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/light/config/transitions.yaml create mode 100644 tests/component_tests/light/test_default_setters.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dbcc28d64a3..ab9624c3649 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -340,6 +340,10 @@ RESTORE_MODES = { "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, } +# Schema default that also matches the C++ initializer in light_state.h; codegen +# skips the setter when the config equals it. +DEFAULT_FLASH_TRANSITION_LENGTH = "0s" + LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -387,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( CONF_DEFAULT_TRANSITION_LENGTH, default="1s" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_FLASH_TRANSITION_LENGTH, default="0s" + CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } @@ -502,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var): default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) ) is not None: cg.add(light_var.set_default_transition_length(default_transition_length)) + # Skip the setter when the config matches the C++ initializer. if ( flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH) - ) is not None: + ) is not None and flash_transition_length != cv.time_period( + DEFAULT_FLASH_TRANSITION_LENGTH + ): cg.add(light_var.set_flash_transition_length(flash_transition_length)) if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: cg.add(light_var.set_gamma_correct(gamma_correct)) @@ -514,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var): effects = await cg.build_registry_list( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) - cg.add(light_var.add_effects(effects)) + if effects: + cg.add(light_var.add_effects(effects)) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 3a3f8fc368c..eafa161f51e 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -356,7 +356,7 @@ class LightState : public EntityBase, public Component { /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; /// Transition length to use for flash transitions. - uint32_t flash_transition_length_{}; + uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py /// Gamma correction factor for the light. float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT diff --git a/tests/component_tests/light/config/transitions.yaml b/tests/component_tests/light/config/transitions.yaml new file mode 100644 index 00000000000..ecb33b0ea80 --- /dev/null +++ b/tests/component_tests/light/config/transitions.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: out_a + pin: GPIO4 + - platform: ledc + id: out_b + pin: GPIO5 + +light: + - platform: monochromatic + id: plain_light + output: out_a + flash_transition_length: 0s + - platform: monochromatic + id: fancy_light + output: out_b + flash_transition_length: 500ms + effects: + - pulse: + - platform: monochromatic + id: bare_light + output: out_a diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py new file mode 100644 index 00000000000..a4fc24a7cbf --- /dev/null +++ b/tests/component_tests/light/test_default_setters.py @@ -0,0 +1,19 @@ +"""Tests that light codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flash_length_and_empty_effects_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A 0 ms flash transition and an empty effect list match the C++ defaults.""" + main_cpp = generate_main(component_config_path("transitions.yaml")) + + assert "plain_light->set_flash_transition_length(" not in main_cpp + assert "plain_light->add_effects(" not in main_cpp + assert "bare_light->set_flash_transition_length(" not in main_cpp + assert "bare_light->add_effects(" not in main_cpp + assert "fancy_light->set_flash_transition_length(500);" in main_cpp + assert "fancy_light->add_effects({" in main_cpp From cba4f5bc05dfad191c27131a7b731caf7a1df53a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:52:57 -0500 Subject: [PATCH 107/266] [wifi] Skip setters that pass the default priority, timeouts, power save and auth mode (#19229) --- esphome/components/wifi/__init__.py | 28 +++++++++---- esphome/components/wifi/wifi_component.h | 4 +- tests/component_tests/wifi/__init__.py | 0 tests/component_tests/wifi/config/bare.yaml | 12 ++++++ tests/component_tests/wifi/config/custom.yaml | 18 +++++++++ .../component_tests/wifi/config/defaults.yaml | 18 +++++++++ .../wifi/test_default_setters.py | 39 +++++++++++++++++++ 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/wifi/__init__.py create mode 100644 tests/component_tests/wifi/config/bare.yaml create mode 100644 tests/component_tests/wifi/config/custom.yaml create mode 100644 tests/component_tests/wifi/config/defaults.yaml create mode 100644 tests/component_tests/wifi/test_default_setters.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 95f627596d1..81b90766b9e 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -169,6 +169,9 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_REBOOT_TIMEOUT = "15min" +# Both defaults also match the C++ initializers in wifi_component.h; codegen skips +# the setter when the config equals them. wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") @@ -496,7 +499,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.SplitDefault( CONF_POWER_SAVE_MODE, @@ -606,7 +609,8 @@ def wifi_network(config, ap, static_ip): cg.add(ap.set_channel(config[CONF_CHANNEL])) if static_ip is not None: cg.add(ap.set_manual_ip(manual_ip(static_ip))) - if CONF_PRIORITY in config: + # priority_ is 0 in C++; skip the setter when the config matches it. + if config.get(CONF_PRIORITY, 0) != 0: cg.add(ap.set_priority(config[CONF_PRIORITY])) return ap @@ -655,7 +659,9 @@ async def to_code(config): WiFiAP(), lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) - cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) + # Skip the setter when the config matches the C++ initializer. + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + cg.add(var.set_ap_timeout(ap_timeout)) cg.add_define("USE_WIFI_AP") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which @@ -677,10 +683,18 @@ async def to_code(config): if has_manual_ip: cg.add_define("USE_WIFI_MANUAL_IP") - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) - if CONF_MIN_AUTH_MODE in config: - cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) + # The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum + # auth WPA2; skip the setters when the config matches them. + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": + cg.add(var.set_power_save_mode(power_save_mode)) + if ( + min_auth_mode := config.get(CONF_MIN_AUTH_MODE) + ) is not None and min_auth_mode != "WPA2": + cg.add(var.set_min_auth_mode(min_auth_mode)) fast_connect = config[CONF_FAST_CONNECT] if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 77a4773a279..a0983545fbd 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -919,11 +919,11 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; - uint32_t reboot_timeout_{}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP - uint32_t ap_timeout_{}; + uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py #endif // 1-byte enums and integers diff --git a/tests/component_tests/wifi/__init__.py b/tests/component_tests/wifi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/wifi/config/bare.yaml b/tests/component_tests/wifi/config/bare.yaml new file mode 100644 index 00000000000..94e5de47a0f --- /dev/null +++ b/tests/component_tests/wifi/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + ap: + ssid: fallback diff --git a/tests/component_tests/wifi/config/custom.yaml b/tests/component_tests/wifi/config/custom.yaml new file mode 100644 index 00000000000..068479a5404 --- /dev/null +++ b/tests/component_tests/wifi/config/custom.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 5 + ap: + ssid: fallback + ap_timeout: 2min + reboot_timeout: 0s + power_save_mode: light + min_auth_mode: wpa diff --git a/tests/component_tests/wifi/config/defaults.yaml b/tests/component_tests/wifi/config/defaults.yaml new file mode 100644 index 00000000000..1b5e7d7dba9 --- /dev/null +++ b/tests/component_tests/wifi/config/defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 0 + ap: + ssid: fallback + ap_timeout: 90s + reboot_timeout: 15min + power_save_mode: none + min_auth_mode: wpa2 diff --git a/tests/component_tests/wifi/test_default_setters.py b/tests/component_tests/wifi/test_default_setters.py new file mode 100644 index 00000000000..b326f3eaeeb --- /dev/null +++ b/tests/component_tests/wifi/test_default_setters.py @@ -0,0 +1,39 @@ +"""Tests that wifi codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_priority(" not in main_cpp + assert "set_ap_timeout(" not in main_cpp + assert "set_reboot_timeout(" not in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "set_min_auth_mode(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_priority(5);" in main_cpp + assert "set_ap_timeout(120000);" in main_cpp + assert "set_reboot_timeout(0);" in main_cpp + assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp + assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp From c378ea13001066844f84b13fd8a86c016525536b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:53:41 -0500 Subject: [PATCH 108/266] [logger] Skip the hardware UART setter when it matches the default (#19230) --- esphome/components/logger/__init__.py | 13 ++++---- esphome/components/logger/logger.h | 4 +-- tests/component_tests/logger/test_logger.py | 32 +++++++++++++++++++ .../logger/test_logger_libretiny_default.yaml | 8 +++++ .../logger/test_logger_libretiny_uart0.yaml | 9 ++++++ .../logger/test_logger_uart1.yaml | 9 ++++++ 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/logger/test_logger_libretiny_default.yaml create mode 100644 tests/component_tests/logger/test_logger_libretiny_uart0.yaml create mode 100644 tests/component_tests/logger/test_logger_uart1.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 07b8b030840..138db75ad10 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -362,12 +362,13 @@ async def to_code(config: ConfigType) -> None: # pre_setup() switches on uart_ to decide which hardware to initialize # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the # default UART_SELECTION_UART0 and the wrong hardware gets initialized. - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) + # uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the + # setter when the config matches it. + cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0 + if ( + hardware_uart := config.get(CONF_HARDWARE_UART) + ) is not None and hardware_uart != cpp_default_uart: + cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart])) # pre_setup() sets global_logger and must run before any other code # that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV). cg.add(log.pre_setup()) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9c26814f7ec..ae55f4145a9 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -352,10 +352,10 @@ class Logger final : public Component { // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) - UARTSelection uart_{UART_SELECTION_UART0}; + UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py #endif #ifdef USE_LIBRETINY - UARTSelection uart_{UART_SELECTION_DEFAULT}; + UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py #endif #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) bool main_task_recursion_guard_{false}; diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 94a6f7ac7bc..4ce30afb946 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main): f"Component allocation '{alloc.group()}' at position {alloc.start()} " f"appears before logger pre_setup() at position {logger_pre_setup.start()}" ) + + +def test_default_uart_selection_is_not_emitted(generate_main): + """UART0 is the C++ initializer on ESP8266, so the setter is skipped.""" + main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "set_uart_selection(" not in main_cpp + + +def test_custom_uart_selection_is_emitted(generate_main): + """A non default UART still reaches the setter before pre_setup().""" + main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml") + + assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp + + +def test_libretiny_default_uart_selection_is_not_emitted(generate_main): + """DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_default.yaml" + ) + + assert "set_uart_selection(" not in main_cpp + + +def test_libretiny_uart0_is_emitted(generate_main): + """UART0 is not the LibreTiny initializer, so it must still be set.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_uart0.yaml" + ) + + assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp diff --git a/tests/component_tests/logger/test_logger_libretiny_default.yaml b/tests/component_tests/logger/test_logger_libretiny_default.yaml new file mode 100644 index 00000000000..1f11ea4580c --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: diff --git a/tests/component_tests/logger/test_logger_libretiny_uart0.yaml b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml new file mode 100644 index 00000000000..dc25fe99ce2 --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_uart1.yaml b/tests/component_tests/logger/test_logger_uart1.yaml new file mode 100644 index 00000000000..ce45a6ae3fb --- /dev/null +++ b/tests/component_tests/logger/test_logger_uart1.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + hardware_uart: UART1 From e5eb577b49ed824d3fd1a82b633f52e94c51f7cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Sep 2026 01:05:27 -0500 Subject: [PATCH 109/266] [esp8266_pwm] Skip the frequency setter when it matches the default (#19224) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 2 +- esphome/components/esp8266_pwm/output.py | 10 ++++++++-- tests/component_tests/esp8266_pwm/__init__.py | 0 .../esp8266_pwm/config/frequency.yaml | 19 +++++++++++++++++++ .../esp8266_pwm/test_esp8266_pwm.py | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp8266_pwm/__init__.py create mode 100644 tests/component_tests/esp8266_pwm/config/frequency.yaml create mode 100644 tests/component_tests/esp8266_pwm/test_esp8266_pwm.py diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index be58a098b6e..79c2e509848 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; - float frequency_{1000.0}; + float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index dd151a3e044..be6e63b154d 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -22,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) +# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen +# skips the setter when the config equals it. +DEFAULT_FREQUENCY = 1000.0 + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -29,7 +33,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): cv.All( pins.internal_gpio_output_pin_schema, valid_pwm_pin ), - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency, } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version( @@ -48,7 +52,9 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). + if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: + cg.add(var.set_frequency(frequency)) @automation.register_action( diff --git a/tests/component_tests/esp8266_pwm/__init__.py b/tests/component_tests/esp8266_pwm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp8266_pwm/config/frequency.yaml b/tests/component_tests/esp8266_pwm/config/frequency.yaml new file mode 100644 index 00000000000..9ffc8af736e --- /dev/null +++ b/tests/component_tests/esp8266_pwm/config/frequency.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +output: + - platform: esp8266_pwm + id: default_frequency + pin: GPIO4 + frequency: 1kHz + - platform: esp8266_pwm + id: custom_frequency + pin: GPIO5 + frequency: 2kHz + - platform: esp8266_pwm + id: schema_default_frequency + pin: GPIO12 diff --git a/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py new file mode 100644 index 00000000000..771e5133459 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py @@ -0,0 +1,16 @@ +"""Tests for the esp8266_pwm output codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_frequency_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The 1 kHz default already lives in the C++ initializer.""" + main_cpp = generate_main(component_config_path("frequency.yaml")) + + assert "default_frequency->set_frequency(" not in main_cpp + assert "schema_default_frequency->set_frequency(" not in main_cpp + assert "custom_frequency->set_frequency(2000.0f);" in main_cpp From 1e627a31f1027478206e15c3fe27d2334026e034 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:36:30 -0500 Subject: [PATCH 110/266] [ci] Refresh integration test durations (#19275) --- .../integration_test_durations.json | 305 +++++++++--------- 1 file changed, 153 insertions(+), 152 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index b4a7f4e1aeb..16748b4537e 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,154 +1,155 @@ { - "tests/integration/test_action_concurrent_reentry.py": 30.48, - "tests/integration/test_addressable_light_transition.py": 42.1, - "tests/integration/test_alarm_control_panel_state_transitions.py": 35.76, - "tests/integration/test_api_action_metadata.py": 22.35, - "tests/integration/test_api_action_responses.py": 30.31, - "tests/integration/test_api_action_timeout.py": 34.73, - "tests/integration/test_api_conditional_memory.py": 18.35, - "tests/integration/test_api_custom_services.py": 15.99, - "tests/integration/test_api_get_time_response_timezone.py": 24.21, - "tests/integration/test_api_homeassistant.py": 33.77, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 20.8, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 23.55, - "tests/integration/test_api_list_entities_backpressure.py": 23.04, - "tests/integration/test_api_message_size_batching.py": 27.31, - "tests/integration/test_api_reboot_timeout.py": 29.32, - "tests/integration/test_api_string_lambda.py": 14.9, - "tests/integration/test_api_vv_logging.py": 26.25, - "tests/integration/test_api_zero_psk_provisioning.py": 38.19, - "tests/integration/test_areas_and_devices.py": 27.52, - "tests/integration/test_automation_wait_actions.py": 24.25, - "tests/integration/test_automations.py": 36.02, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 18.46, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 17.47, - "tests/integration/test_binary_sensor_invalidate_state.py": 14.79, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 21.52, - "tests/integration/test_build_info.py": 21.42, - "tests/integration/test_camera_mock.py": 17.02, - "tests/integration/test_climate_control_action.py": 26.56, - "tests/integration/test_climate_custom_modes.py": 18.82, - "tests/integration/test_continuation_actions.py": 20.39, - "tests/integration/test_cover_control_action.py": 19.91, - "tests/integration/test_crc8_helper.py": 16.73, - "tests/integration/test_device_id_in_state.py": 58.41, - "tests/integration/test_duplicate_entities.py": 30.76, - "tests/integration/test_entity_icon.py": 25.34, - "tests/integration/test_fan_turn_on_action.py": 23.64, - "tests/integration/test_fnv1_hash_object_id.py": 25.44, - "tests/integration/test_fnv1a_hash.py": 20.85, - "tests/integration/test_gpio_expander_cache.py": 14.42, - "tests/integration/test_host_logger_thread_safety.py": 21.31, - "tests/integration/test_host_mode_basic.py": 2.65, - "tests/integration/test_host_mode_batch_delay.py": 22.21, - "tests/integration/test_host_mode_climate_basic_state.py": 27.12, - "tests/integration/test_host_mode_climate_control.py": 21.57, - "tests/integration/test_host_mode_empty_string_options.py": 27.17, - "tests/integration/test_host_mode_entity_fields.py": 30.1, - "tests/integration/test_host_mode_fan_preset.py": 17.55, - "tests/integration/test_host_mode_many_entities.py": 38.98, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.82, - "tests/integration/test_host_mode_noise_encryption.py": 39.84, - "tests/integration/test_host_mode_reconnect.py": 13.1, - "tests/integration/test_host_mode_sensor.py": 22.17, - "tests/integration/test_host_ota.py": 92.05, - "tests/integration/test_host_preferences.py": 20.29, - "tests/integration/test_host_preferences_suspend_resume.py": 15.02, - "tests/integration/test_improv_serial_uart.py": 30.15, - "tests/integration/test_large_message_batching.py": 25.84, - "tests/integration/test_legacy_area.py": 21.24, - "tests/integration/test_legacy_climate_compat.py": 17.34, - "tests/integration/test_legacy_fan_compat.py": 22.6, - "tests/integration/test_light_automations.py": 29.13, - "tests/integration/test_light_binary_effect_off_phase.py": 33.99, - "tests/integration/test_light_calls.py": 26.81, - "tests/integration/test_light_constant_brightness.py": 25.0, - "tests/integration/test_light_control_action.py": 25.57, - "tests/integration/test_light_dim_relative_action.py": 21.4, - "tests/integration/test_light_effect_zero_brightness.py": 19.65, - "tests/integration/test_light_initial_state.py": 17.58, - "tests/integration/test_light_toggle_action.py": 28.28, - "tests/integration/test_lock_automations.py": 23.3, - "tests/integration/test_logger_buffered_recursion_guard.py": 22.96, - "tests/integration/test_loop_disable_enable.py": 16.18, - "tests/integration/test_loop_interval_decoupling.py": 25.19, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 20.59, - "tests/integration/test_lvgl_headless_render.py": 87.78, - "tests/integration/test_micros_to_millis.py": 18.73, - "tests/integration/test_multi_click_trigger.py": 24.2, - "tests/integration/test_multi_device_preferences.py": 20.52, - "tests/integration/test_noise_encryption_key_protection.py": 19.1, - "tests/integration/test_object_id_api_verification.py": 26.24, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 14.88, - "tests/integration/test_object_id_no_friendly_name.py": 61.27, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 82.32, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 46.03, - "tests/integration/test_online_image_bmp.py": 34.21, - "tests/integration/test_oversized_payloads.py": 62.75, - "tests/integration/test_preference_key_stability.py": 26.8, - "tests/integration/test_runtime_stats.py": 28.26, - "tests/integration/test_safe_mode_loop_runs.py": 18.14, - "tests/integration/test_scheduler_blocking_warning.py": 28.7, - "tests/integration/test_scheduler_bulk_cleanup.py": 20.73, - "tests/integration/test_scheduler_defer_cancel.py": 22.99, - "tests/integration/test_scheduler_defer_cancel_regular.py": 21.97, - "tests/integration/test_scheduler_defer_fifo_simple.py": 24.15, - "tests/integration/test_scheduler_defer_stress.py": 23.11, - "tests/integration/test_scheduler_heap_stress.py": 20.2, - "tests/integration/test_scheduler_internal_id_no_collision.py": 23.75, - "tests/integration/test_scheduler_interval_reschedule.py": 15.32, - "tests/integration/test_scheduler_interval_zero_coerced.py": 20.1, - "tests/integration/test_scheduler_null_name.py": 17.43, - "tests/integration/test_scheduler_numeric_id_test.py": 25.51, - "tests/integration/test_scheduler_pool.py": 24.22, - "tests/integration/test_scheduler_rapid_cancellation.py": 24.01, - "tests/integration/test_scheduler_recursive_timeout.py": 22.94, - "tests/integration/test_scheduler_removed_item_race.py": 23.07, - "tests/integration/test_scheduler_self_keyed.py": 18.43, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 21.99, - "tests/integration/test_scheduler_string_test.py": 17.27, - "tests/integration/test_script_array_params.py": 4.59, - "tests/integration/test_script_delay_params.py": 22.46, - "tests/integration/test_script_queued.py": 25.24, - "tests/integration/test_script_queued_idle_loop.py": 5.04, - "tests/integration/test_script_wait_on_boot.py": 21.77, - "tests/integration/test_sdl_headless_screenshot.py": 19.23, - "tests/integration/test_select_stringref_trigger.py": 19.31, - "tests/integration/test_sensor_filters_delta.py": 25.92, - "tests/integration/test_sensor_filters_ring_buffer.py": 22.39, - "tests/integration/test_sensor_filters_sliding_window.py": 57.93, - "tests/integration/test_sensor_filters_value_list.py": 20.32, - "tests/integration/test_sensor_timeout_filter.py": 25.35, - "tests/integration/test_snapshot_display.py": 19.7, - "tests/integration/test_socket_wake_gate_tcp.py": 14.5, - "tests/integration/test_status_flags.py": 33.83, - "tests/integration/test_strftime_to.py": 17.64, - "tests/integration/test_syslog.py": 24.49, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.81, - "tests/integration/test_template_climate_basic.py": 15.28, - "tests/integration/test_template_climate_custom_modes.py": 25.07, - "tests/integration/test_template_climate_nonoptimistic.py": 24.25, - "tests/integration/test_template_climate_on_control_ordering.py": 24.09, - "tests/integration/test_template_climate_publish_all_fields.py": 17.78, - "tests/integration/test_template_climate_sensor_push.py": 17.42, - "tests/integration/test_template_climate_set_actions.py": 23.63, - "tests/integration/test_template_climate_two_point_temperature.py": 25.19, - "tests/integration/test_template_text_save.py": 17.88, - "tests/integration/test_text_command.py": 22.71, - "tests/integration/test_text_sensor_raw_state.py": 25.17, - "tests/integration/test_uart_mock_ld2410.py": 58.15, - "tests/integration/test_uart_mock_ld2412.py": 61.14, - "tests/integration/test_uart_mock_ld2420.py": 33.87, - "tests/integration/test_uart_mock_ld2450.py": 26.06, - "tests/integration/test_uart_mock_modbus.py": 391.79, - "tests/integration/test_udp.py": 7.38, - "tests/integration/test_use_address_runtime.py": 24.09, - "tests/integration/test_valve_control_action.py": 23.22, - "tests/integration/test_varint_five_byte_device_id.py": 17.93, - "tests/integration/test_wait_until_mid_loop_timing.py": 22.26, - "tests/integration/test_wait_until_on_boot.py": 17.46, - "tests/integration/test_wait_until_ordering.py": 11.89, - "tests/integration/test_wait_until_reentrant_restart.py": 22.88, - "tests/integration/test_wake_loop_forces_phase_b.py": 16.6, - "tests/integration/test_water_heater_template.py": 19.66 + "tests/integration/test_action_concurrent_reentry.py": 34.72, + "tests/integration/test_addressable_light_transition.py": 33.71, + "tests/integration/test_alarm_control_panel_state_transitions.py": 38.96, + "tests/integration/test_api_action_metadata.py": 33.36, + "tests/integration/test_api_action_responses.py": 26.22, + "tests/integration/test_api_action_timeout.py": 25.41, + "tests/integration/test_api_conditional_memory.py": 20.74, + "tests/integration/test_api_custom_services.py": 23.21, + "tests/integration/test_api_get_time_response_timezone.py": 25.04, + "tests/integration/test_api_homeassistant.py": 24.18, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 23.47, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 22.86, + "tests/integration/test_api_list_entities_backpressure.py": 18.8, + "tests/integration/test_api_message_size_batching.py": 28.8, + "tests/integration/test_api_reboot_timeout.py": 9.47, + "tests/integration/test_api_string_lambda.py": 16.88, + "tests/integration/test_api_vv_logging.py": 17.99, + "tests/integration/test_api_zero_psk_provisioning.py": 47.07, + "tests/integration/test_areas_and_devices.py": 20.77, + "tests/integration/test_automation_wait_actions.py": 20.07, + "tests/integration/test_automations.py": 27.41, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 20.5, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 26.54, + "tests/integration/test_binary_sensor_invalidate_state.py": 16.09, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.7, + "tests/integration/test_build_info.py": 18.07, + "tests/integration/test_camera_mock.py": 20.44, + "tests/integration/test_climate_control_action.py": 27.97, + "tests/integration/test_climate_custom_modes.py": 26.77, + "tests/integration/test_continuation_actions.py": 12.09, + "tests/integration/test_cover_control_action.py": 19.77, + "tests/integration/test_crc8_helper.py": 12.64, + "tests/integration/test_device_id_in_state.py": 63.19, + "tests/integration/test_duplicate_entities.py": 29.26, + "tests/integration/test_entity_icon.py": 34.95, + "tests/integration/test_fan_turn_on_action.py": 25.98, + "tests/integration/test_fnv1_hash_object_id.py": 4.85, + "tests/integration/test_fnv1a_hash.py": 5.14, + "tests/integration/test_gpio_expander_cache.py": 21.0, + "tests/integration/test_host_logger_thread_safety.py": 17.51, + "tests/integration/test_host_mode_basic.py": 21.2, + "tests/integration/test_host_mode_batch_delay.py": 26.68, + "tests/integration/test_host_mode_climate_basic_state.py": 18.04, + "tests/integration/test_host_mode_climate_control.py": 29.64, + "tests/integration/test_host_mode_empty_string_options.py": 28.8, + "tests/integration/test_host_mode_entity_fields.py": 28.68, + "tests/integration/test_host_mode_fan_preset.py": 16.98, + "tests/integration/test_host_mode_many_entities.py": 39.8, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 33.12, + "tests/integration/test_host_mode_noise_encryption.py": 52.53, + "tests/integration/test_host_mode_reconnect.py": 14.48, + "tests/integration/test_host_mode_sensor.py": 17.38, + "tests/integration/test_host_ota.py": 94.96, + "tests/integration/test_host_preferences.py": 27.11, + "tests/integration/test_host_preferences_suspend_resume.py": 21.48, + "tests/integration/test_improv_serial_uart.py": 19.43, + "tests/integration/test_large_message_batching.py": 25.67, + "tests/integration/test_legacy_area.py": 15.59, + "tests/integration/test_legacy_climate_compat.py": 18.59, + "tests/integration/test_legacy_fan_compat.py": 18.34, + "tests/integration/test_light_automations.py": 16.74, + "tests/integration/test_light_binary_effect_off_phase.py": 57.7, + "tests/integration/test_light_calls.py": 25.88, + "tests/integration/test_light_constant_brightness.py": 22.32, + "tests/integration/test_light_control_action.py": 18.15, + "tests/integration/test_light_dim_relative_action.py": 30.35, + "tests/integration/test_light_effect_zero_brightness.py": 18.38, + "tests/integration/test_light_initial_state.py": 23.93, + "tests/integration/test_light_toggle_action.py": 26.16, + "tests/integration/test_lock_automations.py": 34.13, + "tests/integration/test_logger_buffered_recursion_guard.py": 25.77, + "tests/integration/test_loop_disable_enable.py": 14.71, + "tests/integration/test_loop_interval_decoupling.py": 26.16, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 28.47, + "tests/integration/test_lvgl_headless_render.py": 96.36, + "tests/integration/test_micros_to_millis.py": 28.76, + "tests/integration/test_multi_click_trigger.py": 19.8, + "tests/integration/test_multi_device_preferences.py": 38.85, + "tests/integration/test_noise_encryption_key_protection.py": 25.81, + "tests/integration/test_object_id_api_verification.py": 28.46, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.14, + "tests/integration/test_object_id_no_friendly_name.py": 18.82, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 30.16, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.67, + "tests/integration/test_online_image_bmp.py": 7.41, + "tests/integration/test_oversized_payloads.py": 59.52, + "tests/integration/test_preference_key_stability.py": 27.24, + "tests/integration/test_runtime_stats.py": 20.53, + "tests/integration/test_safe_mode_loop_runs.py": 10.17, + "tests/integration/test_scheduler_blocking_warning.py": 51.45, + "tests/integration/test_scheduler_bulk_cleanup.py": 22.59, + "tests/integration/test_scheduler_defer_cancel.py": 17.64, + "tests/integration/test_scheduler_defer_cancel_regular.py": 21.61, + "tests/integration/test_scheduler_defer_fifo_simple.py": 24.73, + "tests/integration/test_scheduler_defer_stress.py": 23.91, + "tests/integration/test_scheduler_heap_stress.py": 25.77, + "tests/integration/test_scheduler_internal_id_no_collision.py": 19.83, + "tests/integration/test_scheduler_interval_reschedule.py": 23.4, + "tests/integration/test_scheduler_interval_zero_coerced.py": 5.11, + "tests/integration/test_scheduler_null_name.py": 17.36, + "tests/integration/test_scheduler_numeric_id_test.py": 20.81, + "tests/integration/test_scheduler_pool.py": 17.42, + "tests/integration/test_scheduler_rapid_cancellation.py": 25.28, + "tests/integration/test_scheduler_recursive_timeout.py": 16.48, + "tests/integration/test_scheduler_removed_item_race.py": 16.14, + "tests/integration/test_scheduler_self_keyed.py": 26.19, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 23.26, + "tests/integration/test_scheduler_string_test.py": 27.09, + "tests/integration/test_script_array_params.py": 3.6, + "tests/integration/test_script_delay_params.py": 24.74, + "tests/integration/test_script_queued.py": 17.21, + "tests/integration/test_script_queued_idle_loop.py": 3.4, + "tests/integration/test_script_wait_on_boot.py": 23.7, + "tests/integration/test_sdl_headless_screenshot.py": 19.53, + "tests/integration/test_select_stringref_trigger.py": 18.93, + "tests/integration/test_sensor_filters_delta.py": 20.85, + "tests/integration/test_sensor_filters_ring_buffer.py": 16.82, + "tests/integration/test_sensor_filters_sliding_window.py": 54.78, + "tests/integration/test_sensor_filters_value_list.py": 19.46, + "tests/integration/test_sensor_timeout_filter.py": 18.39, + "tests/integration/test_set_internal_at_boot.py": 21.69, + "tests/integration/test_snapshot_display.py": 12.64, + "tests/integration/test_socket_wake_gate_tcp.py": 13.08, + "tests/integration/test_status_flags.py": 29.54, + "tests/integration/test_strftime_to.py": 25.62, + "tests/integration/test_syslog.py": 16.25, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 27.18, + "tests/integration/test_template_climate_basic.py": 20.51, + "tests/integration/test_template_climate_custom_modes.py": 27.73, + "tests/integration/test_template_climate_nonoptimistic.py": 26.35, + "tests/integration/test_template_climate_on_control_ordering.py": 26.55, + "tests/integration/test_template_climate_publish_all_fields.py": 17.59, + "tests/integration/test_template_climate_sensor_push.py": 22.04, + "tests/integration/test_template_climate_set_actions.py": 16.82, + "tests/integration/test_template_climate_two_point_temperature.py": 25.13, + "tests/integration/test_template_text_save.py": 25.36, + "tests/integration/test_text_command.py": 18.79, + "tests/integration/test_text_sensor_raw_state.py": 17.07, + "tests/integration/test_uart_mock_ld2410.py": 59.58, + "tests/integration/test_uart_mock_ld2412.py": 59.4, + "tests/integration/test_uart_mock_ld2420.py": 45.27, + "tests/integration/test_uart_mock_ld2450.py": 27.96, + "tests/integration/test_uart_mock_modbus.py": 562.45, + "tests/integration/test_udp.py": 7.48, + "tests/integration/test_use_address_runtime.py": 17.27, + "tests/integration/test_valve_control_action.py": 18.33, + "tests/integration/test_varint_five_byte_device_id.py": 17.59, + "tests/integration/test_wait_until_mid_loop_timing.py": 16.93, + "tests/integration/test_wait_until_on_boot.py": 19.96, + "tests/integration/test_wait_until_ordering.py": 16.19, + "tests/integration/test_wait_until_reentrant_restart.py": 23.67, + "tests/integration/test_wake_loop_forces_phase_b.py": 17.58, + "tests/integration/test_water_heater_template.py": 21.96 } From 651863323b0b67626c91b798dacf51c7e29ac8bd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:40:00 +1000 Subject: [PATCH 111/266] [issues] Add AI usage guidance to the bug report template (#19122) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .github/ISSUE_TEMPLATE/bug_report.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 44722ec85c7..2244963a79d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,29 @@ body: If you have a feature request or enhancement, please [request them here instead][fr]. [fr]: https://github.com/orgs/esphome/discussions + - type: markdown + attributes: + value: | + ## Use of AI in bug reports + + AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting. + Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved + your problem, its wild guesses are not likely to help. + + Please DO include your own words and observations, compile/boot logs, and + especially a minimal reproducible example of your YAML configuration that demonstrates the problem. + + It is however quite acceptable to use AI to translate your *own* report, + if you aren't a competent English speaker. + + If you really think it will be useful to include an AI's analysis, preferably wrap it in a `
` block which will be collapsed by default. + + If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is, + it can be more useful to ask it to create a step-by-step troubleshooting procedure. + AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML + configuration that demonstrates the problem. + + Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way. - type: textarea validations: required: true From 54b8e2e6dc7078657da75414fc8bd5b5967a8c59 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 09:56:14 -0400 Subject: [PATCH 112/266] [audio] Update esp-audio-libs to 4.0.0 (#19300) --- esphome/components/audio/__init__.py | 5 +---- esphome/components/mixer/speaker/mixer_speaker.cpp | 8 ++++---- esphome/components/mixer/speaker/mixer_speaker.h | 4 ++-- esphome/idf_component.yml | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2a5304be77e..14a08188949 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -339,10 +339,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component( - name="esphome/esp-audio-libs", - ref="3.2.1", - ) + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0") data = _get_data() diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index ef21da65c5a..7d33b6c49f8 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -306,9 +306,9 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - esp_audio_libs::ducking::apply(audio_source->mutable_data(), - static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), - samples_to_duck, this->ducking_state_); + this->ducking_ramp_.process(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck); } return bytes_read; @@ -316,7 +316,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; - esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); + this->ducking_ramp_.set_target_db_reduction_over(decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 00e89d17826..494443d6951 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,7 +11,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" -#include // esp-audio-libs +#include // esp-audio-libs #include @@ -108,7 +108,7 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool pause_state_{false}; - esp_audio_libs::ducking::DuckingState ducking_state_{}; + esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index e817a253d9c..b3cd5ee09b5 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: - version: 3.2.1 + version: 4.0.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From df481eab006054ff6e7a00541cb762c82915d163 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:28:05 +0000 Subject: [PATCH 113/266] Bump astral-sh/setup-uv from 10.0.1 to 10.1.0 in /.github/actions/restore-python (#19313) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index ce14b0152a8..fa42372ac81 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From 358e240d422c0474a170c2d587615cfa565a7224 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:14 -0500 Subject: [PATCH 114/266] Bump github/codeql-action/init from 4.37.9 to 4.38.0 (#19315) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aab3dea592c..ca70ec9c111 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 5d89480dbacf4883225a15d9636c2e5960703609 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:53 -0500 Subject: [PATCH 115/266] Bump github/codeql-action/analyze from 4.37.9 to 4.38.0 (#19314) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ca70ec9c111..0daae69ccf0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: "/language:${{matrix.language}}" From 2c0e97421d088e73629972c3c9c5236abfe7e13f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:31:22 +0000 Subject: [PATCH 116/266] Bump astral-sh/setup-uv from 10.0.1 to 10.1.0 (#19312) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 63219a1dbcd..c4c1ab072ff 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 173d2c227ad..689baa1292e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -413,7 +413,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1274,7 +1274,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 9100064176c..84d5e229d91 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 00dfe0f712419c3df5951a4cd42e82c9bcfd5e2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:33:20 +0000 Subject: [PATCH 117/266] Bump ruff from 0.16.6 to 0.16.7 (#19310) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95e6f0f73e4..0c7600ec127 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.6 + rev: v0.16.7 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index cd0427f33e1..010c8243e7e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.6 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.7 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.2 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From e66b59084239910b627fbcc784a93b0263908aec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:34:01 +0000 Subject: [PATCH 118/266] Bump platformdirs from 4.11.7 to 4.11.8 (#19309) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c73887a39d2..1bb8d04ac88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.7 # native esp-idf toolchain global cache dir +platformdirs==4.11.8 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 5d713ad9ad3478732b1722f2e4d8464ee26ac0e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:34:19 +0000 Subject: [PATCH 119/266] Bump filelock from 3.32.5 to 3.32.6 (#19311) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1bb8d04ac88..bfbf0aa321a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.8 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver -filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==3.32.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 588ad529e0e2ad263d131ad34311d2dedcdf67d6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 16:55:08 -0400 Subject: [PATCH 120/266] [i2s_audio] Ramp software volume changes (#19302) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 88 ++++++++----------- .../i2s_audio/speaker/i2s_audio_speaker.h | 25 ++++-- 2 files changed, 55 insertions(+), 58 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 9feaf39ffff..daef662a640 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -14,17 +14,19 @@ #include "esp_timer.h" -// esp-audio-libs -#include +#include namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker"; -// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor. -// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB. +// Software volume control maps the user-facing (0.0, 1.0) range linearly to a dB reduction in +// [-49.0, 0.0] dB; 0.0 is silence. static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f; +// Rate at which the software gain moves toward a new target. +static constexpr uint32_t GAIN_RAMP_MS_PER_DB = 1; + void I2SAudioSpeakerBase::setup() { this->event_group_ = xEventGroupCreate(); @@ -34,9 +36,10 @@ void I2SAudioSpeakerBase::setup() { return; } - // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // Initialize volume control. When audio_dac is configured, this sets the DAC volume and mute state. // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); + this->set_mute_state(this->mute_state_); } void I2SAudioSpeakerBase::dump_config() { @@ -136,6 +139,10 @@ void I2SAudioSpeakerBase::loop() { break; } + // Seed the ramp at the live target so this run adopts it instantly rather than fading to it + // from wherever the previous run left off. Posted here, not in the task: the ramp's mailbox + // allows one writer, and that is the main loop. + this->post_software_gain_(0); xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, &this->speaker_task_handle_); @@ -153,50 +160,31 @@ void I2SAudioSpeakerBase::loop() { } void I2SAudioSpeakerBase::set_volume(float volume) { - this->volume_ = volume; -#ifdef USE_AUDIO_DAC - if (this->audio_dac_ != nullptr) { - if (volume > 0.0f) { - this->audio_dac_->set_mute_off(); - } - this->audio_dac_->set_volume(volume); - } else -#endif // USE_AUDIO_DAC - { - // Fallback to software volume control by using a Q31 fixed point scaling factor. - // At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely - // and avoid any floating-point precision issues that could cause slight volume reduction. - if (volume >= 1.0f) { - this->q31_volume_factor_ = INT32_MAX; - } else if (volume <= 0.0f) { - this->q31_volume_factor_ = 0; - } else { - this->q31_volume_factor_ = - esp_audio_libs::gain::db_to_q31(remap(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f)); - } - } + speaker::Speaker::set_volume(volume); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); } void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { - this->mute_state_ = mute_state; + speaker::Speaker::set_mute_state(mute_state); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); +} + +void I2SAudioSpeakerBase::post_software_gain_(uint32_t rate_samples) { #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } else -#endif // USE_AUDIO_DAC - { - if (mute_state) { - // Fallback to software volume control and scale by 0 - this->q31_volume_factor_ = 0; - } else { - // Revert to previous volume when unmuting - this->set_volume(this->volume_); - } + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp stays at unity } +#endif // USE_AUDIO_DAC + // Software volume control. The ramp treats 0 dB as unity and skips processing there. + float target_db; + if (this->mute_state_ || this->volume_ <= 0.0f) { + target_db = -INFINITY; + } else if (this->volume_ >= 1.0f) { + target_db = 0.0f; + } else { + target_db = remap(this->volume_, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f); + } + this->gain_ramp_.set_target_db_at_rate(target_db, rate_samples); } size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { @@ -355,14 +343,14 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s } void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { - if (this->q31_volume_factor_ == INT32_MAX) { - return; // Max volume, no processing needed +#ifdef USE_AUDIO_DAC + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp is never targeted } - +#endif // USE_AUDIO_DAC const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); - const uint32_t len = bytes_read / bytes_per_sample; - - esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); + this->gain_ramp_.process(data, static_cast(bytes_per_sample), + this->current_stream_info_.bytes_to_samples(bytes_read)); } void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index adb6ca5e3f7..5812cc211b2 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -16,6 +16,8 @@ #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include // esp-audio-libs + namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations @@ -77,19 +79,23 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool has_buffered_data() const override; - /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to convert the volume to a dB target for + /// the gain ramp. /// @param volume between 0.0 and 1.0 void set_volume(float volume) override; - /// @brief Mutes or unmute the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Mutes or unmutes the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to post the mute state to the gain ramp. /// @param mute_state true for muting, false for unmuting void set_mute_state(bool mute_state) override; protected: + /// @brief Posts the ramp target derived from the current volume and mute state. No-op when an audio dac owns + /// volume. Main loop only. + /// @param rate_samples Samples the ramp takes per dB of change; 0 adopts the target at once + void post_software_gain_(uint32_t rate_samples); + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). /// @param params I2SAudioSpeakerBase component pointer static void speaker_task(void *params); @@ -128,7 +134,8 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} - /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the + /// speaker task only. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); @@ -155,7 +162,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool pause_state_{false}; - int32_t q31_volume_factor_{INT32_MAX}; + // Smooths software gain changes. The main loop posts targets, the speaker task processes; + // GainRamp's mailbox makes that safe. The main loop is the only poster. + esp_audio_libs::gain::GainRamp gain_ramp_; audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as From 328077c4f890923db9cd538101dc99ed8db89ad4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 14 Sep 2026 16:16:14 -0500 Subject: [PATCH 121/266] [tinyusb] Add on_mount/on_unmount triggers and is_mounted condition (#19067) Co-authored-by: Claude Fable 5.1 --- esphome/components/tinyusb/__init__.py | 52 ++++++++++++++++++- .../components/tinyusb/tinyusb_component.cpp | 34 +++++++++++- .../components/tinyusb/tinyusb_component.h | 27 ++++++++++ tests/components/tinyusb/common.yaml | 9 ++++ .../components/tinyusb/test.esp32-p4-idf.yaml | 8 ++- .../components/tinyusb/test.esp32-s2-idf.yaml | 8 ++- .../components/tinyusb/test.esp32-s3-idf.yaml | 8 ++- 7 files changed, 141 insertions(+), 5 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 53c4ab00734..7ad88d3018d 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,4 +1,4 @@ -from esphome import final_validate as fv +from esphome import automation, final_validate as fv, pins import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -12,17 +12,22 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] +CONF_ON_MOUNT = "on_mount" +CONF_ON_UNMOUNT = "on_unmount" CONF_USB_LANG_ID = "usb_lang_id" CONF_USB_MANUFACTURER_STR = "usb_manufacturer_str" CONF_USB_PRODUCT_ID = "usb_product_id" CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # Components that provide a USB device class (CDC, HID, MSC, ...) on top of # tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in @@ -33,6 +38,20 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) +IsMountedCondition = tinyusb_ns.class_("IsMountedCondition", automation.Condition) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_MOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_UNMOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), +) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -44,6 +63,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_USB_MANUFACTURER_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_PRODUCT_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_SERIAL_STR, default=""): cv.string, + # esp_tinyusb monitors VBUS on the S31 through a GPIO interrupt and needs + # the GPIO ISR service installed first, which would collide with the esp32 + # platform's own lazy install and disable other interrupts. The other + # variants watch the pin in the OTG hardware. + cv.Optional(CONF_VBUS_MONITOR_PIN): cv.All( + pins.internal_gpio_input_pin_number, + esp32.only_on_variant( + unsupported=[VARIANT_ESP32S31], msg_prefix=CONF_VBUS_MONITOR_PIN + ), + ), + cv.Optional(CONF_ON_MOUNT): automation.validate_automation({}), + cv.Optional(CONF_ON_UNMOUNT): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( @@ -93,9 +124,28 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_usb_desc_product(config[CONF_USB_PRODUCT_STR])) if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) + if (vbus_pin := config.get(CONF_VBUS_MONITOR_PIN)) is not None: + cg.add(var.set_vbus_monitor_pin(vbus_pin)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) + + +@automation.register_condition( + "tinyusb.is_mounted", + IsMountedCondition, + cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), +) +async def tinyusb_is_mounted_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index c8c36f0ffb6..3fab9de0086 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -9,6 +9,14 @@ namespace esphome::tinyusb { static const char *const TAG = "tinyusb"; +// Runs on the TinyUSB task: only wake the main loop, which reads the state and runs +// the automations. +static void tinyusb_event_cb(tinyusb_event_t *event, void *arg) { + if (event->id == TINYUSB_EVENT_ATTACHED || event->id == TINYUSB_EVENT_DETACHED) { + static_cast(arg)->enable_loop_soon_any_context(); + } +} + void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { @@ -21,6 +29,12 @@ void TinyUSB::setup() { this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; + // Without VBUS monitoring the OTG core only sees a cable pull as the bus going idle + // (a suspend), so TinyUSB never reports a detach and stays "mounted". + if (this->vbus_monitor_pin_ >= 0) { + this->tusb_cfg_.phy.self_powered = true; + this->tusb_cfg_.phy.vbus_monitor_io = this->vbus_monitor_pin_; + } this->tusb_cfg_.descriptor = { .device = &this->usb_descriptor_, .string = this->string_descriptor_, @@ -42,11 +56,26 @@ void TinyUSB::setup() { } #endif + this->tusb_cfg_.event_cb = tinyusb_event_cb; + this->tusb_cfg_.event_arg = this; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); this->mark_failed(); + return; } + // loop() only reports mount changes; the mount hooks wake it when one happens. + this->disable_loop(); +} + +void TinyUSB::loop() { + const bool mounted = tud_mounted(); + if (mounted != this->last_reported_mounted_) { + this->last_reported_mounted_ = mounted; + ESP_LOGD(TAG, "USB host %s", mounted ? LOG_STR_LITERAL("mounted") : LOG_STR_LITERAL("unmounted")); + this->mount_state_callback_.call(mounted); + } + this->disable_loop(); } void TinyUSB::dump_config() { @@ -56,9 +85,12 @@ void TinyUSB::dump_config() { " Vendor ID: 0x%04X\n" " Manufacturer: '%s'\n" " Product: '%s'\n" - " Serial: '%s'\n", + " Serial: '%s'", this->usb_descriptor_.idProduct, this->usb_descriptor_.idVendor, this->string_descriptor_[MANUFACTURER], this->string_descriptor_[PRODUCT], this->string_descriptor_[SERIAL_NUMBER]); + if (this->vbus_monitor_pin_ >= 0) { + ESP_LOGCONFIG(TAG, " VBUS Monitor Pin: GPIO%d", this->vbus_monitor_pin_); + } } } // namespace esphome::tinyusb diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index e85fea9d21a..f7f574ec6d5 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,8 +1,11 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) +#include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include #include "tinyusb.h" #include "tusb.h" @@ -23,9 +26,17 @@ static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } + /// True while a USB host has enumerated and configured the device. + bool is_mounted() const { return tud_mounted(); } + /// Called with the new mount state whenever a host mounts or unmounts the device. + template void add_on_mount_state_callback(F &&callback) { + this->mount_state_callback_.add(std::forward(callback)); + } + void set_usb_desc_product_id(uint16_t product_id) { this->usb_descriptor_.idProduct = product_id; } void set_usb_desc_vendor_id(uint16_t vendor_id) { this->usb_descriptor_.idVendor = vendor_id; } void set_usb_desc_lang_id(uint16_t lang_id) { @@ -37,6 +48,8 @@ class TinyUSB final : public Component { } void set_usb_desc_product(const char *usb_desc_product) { this->string_descriptor_[PRODUCT] = usb_desc_product; } void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } + /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. + void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english @@ -50,6 +63,11 @@ class TinyUSB final : public Component { nullptr, // 5: Terminator }; + LazyCallbackManager mount_state_callback_; + // Edge-detection baseline for loop(); is_mounted() reads the live state instead. + bool last_reported_mounted_{false}; + int8_t vbus_monitor_pin_{-1}; + tinyusb_config_t tusb_cfg_{}; tusb_desc_device_t usb_descriptor_{ .bLength = sizeof(tusb_desc_device_t), @@ -69,6 +87,15 @@ class TinyUSB final : public Component { }; }; +template class IsMountedCondition final : public Condition { + public: + explicit IsMountedCondition(TinyUSB *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_mounted(); } + + protected: + TinyUSB *parent_; +}; + } // namespace esphome::tinyusb #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index 674e89dbe87..32db1999afe 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,6 +6,15 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + on_mount: + - logger.log: USB host mounted + - if: + condition: + tinyusb.is_mounted: + then: + - logger.log: USB host is mounted + on_unmount: + - logger.log: USB host unmounted # tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. usb_cdc_acm: diff --git a/tests/components/tinyusb/test.esp32-p4-idf.yaml b/tests/components/tinyusb/test.esp32-p4-idf.yaml index dade44d145b..7a37fcf41b8 100644 --- a/tests/components/tinyusb/test.esp32-p4-idf.yaml +++ b/tests/components/tinyusb/test.esp32-p4-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index 09b98ada401..67ea24f2c6c 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1,4 +1,10 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 # S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared # USB OTG peripheral; route the logger to UART0 so the fixture builds. diff --git a/tests/components/tinyusb/test.esp32-s3-idf.yaml b/tests/components/tinyusb/test.esp32-s3-idf.yaml index dade44d145b..7a37fcf41b8 100644 --- a/tests/components/tinyusb/test.esp32-s3-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s3-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 From 2472838e130fe597f7326a0ec94712b92acb8b15 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 17:46:30 -0400 Subject: [PATCH 122/266] [speaker][speaker_source] Make a volume of zero silent with an audio DAC (#19305) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../media_player/speaker_media_player.cpp | 2 +- esphome/components/speaker/speaker.h | 34 ++++++++++++++----- .../speaker_source_media_player.cpp | 2 +- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index daef662a640..0c1140da0c6 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -177,7 +177,7 @@ void I2SAudioSpeakerBase::post_software_gain_(uint32_t rate_samples) { #endif // USE_AUDIO_DAC // Software volume control. The ramp treats 0 dB as unity and skips processing there. float target_db; - if (this->mute_state_ || this->volume_ <= 0.0f) { + if (this->is_silent_()) { target_db = -INFINITY; } else if (this->volume_ >= 1.0f) { target_db = 0.0f; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index fe994f440df..f40d0f4a1a3 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -612,7 +612,7 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { } // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001f) { + if (volume < speaker::SILENT_VOLUME_THRESHOLD) { this->set_mute_state_(true); } else { this->set_mute_state_(false); diff --git a/esphome/components/speaker/speaker.h b/esphome/components/speaker/speaker.h index c89b6c588c7..01e9ca042e0 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -18,6 +18,9 @@ namespace esphome::speaker { +/// Volumes below this are treated as zero +static constexpr float SILENT_VOLUME_THRESHOLD = 0.001f; + enum State : uint8_t { STATE_STOPPED = 0, STATE_STARTING, @@ -65,13 +68,15 @@ class Speaker { bool is_running() const { return this->state_ == STATE_RUNNING; } bool is_stopped() const { return this->state_ == STATE_STOPPED; } - // Volume control is handled by a configured audio dac component. Individual speaker components can - // override and implement in software if an audio dac isn't available. + // Volume and mute are independent: changing one never alters the other's stored state. Volume control is + // handled by a configured audio dac component. Individual speaker components can override and implement in + // software if an audio dac isn't available. virtual void set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { this->audio_dac_->set_volume(volume); + this->apply_audio_dac_mute_(); } #endif }; @@ -80,13 +85,7 @@ class Speaker { virtual void set_mute_state(bool mute_state) { this->mute_state_ = mute_state; #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } + this->apply_audio_dac_mute_(); #endif } virtual bool get_mute_state() { return this->mute_state_; } @@ -110,6 +109,23 @@ class Speaker { } protected: + /// @brief Whether the output should be silent: muted, or the volume is effectively zero. + /// Volume steps from media players can leave a positive value near float epsilon instead of exactly zero. + bool is_silent_() const { return this->mute_state_ || this->volume_ < SILENT_VOLUME_THRESHOLD; } + +#ifdef USE_AUDIO_DAC + /// @brief Uses the audio dac's mute as the silence mechanism, since a dac's minimum volume is often audible. + void apply_audio_dac_mute_() { + if (this->audio_dac_ == nullptr) + return; + if (this->is_silent_()) { + this->audio_dac_->set_mute_on(); + } else { + this->audio_dac_->set_mute_off(); + } + } +#endif + State state_{STATE_STOPPED}; audio::AudioStreamInfo audio_stream_info_; float volume_{1.0f}; diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index a33a1a16509..661146ee49c 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -831,7 +831,7 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { // Turn on the mute state if the volume is effectively zero, off otherwise. // Pass publish=false to avoid saving twice. - if (volume < 0.001f) { + if (volume < speaker::SILENT_VOLUME_THRESHOLD) { this->set_mute_state_(true, false); } else { this->set_mute_state_(false, false); From 7a25ca074156203c979ac268f8248b90989c31d4 Mon Sep 17 00:00:00 2001 From: rexmoriarty Date: Mon, 14 Sep 2026 17:11:25 -0500 Subject: [PATCH 123/266] [speaker_source] Don't remap a requested volume of zero (#19063) Co-authored-by: rexmoriarty <181678468+rexmoriarty@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Kevin Ahrendt --- .../speaker_source/speaker_source_media_player.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 661146ee49c..cee203a6991 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -809,8 +809,11 @@ void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) { } void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit within the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < speaker::SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); for (auto &ps : this->pipelines_) { if (ps.is_configured()) { From 0af1f22e77985a91c229b8da6ca27f461ea7f993 Mon Sep 17 00:00:00 2001 From: Carrie Watts Date: Tue, 15 Sep 2026 00:57:57 +0200 Subject: [PATCH 124/266] [speaker_source] keep mute when volume changes (#18412) Signed-off-by: carriewattsmake Co-authored-by: carriewattsmake Co-authored-by: Kevin Ahrendt --- .../speaker_source/speaker_source_media_player.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index cee203a6991..215f3942d58 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -832,15 +832,6 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { } } - // Turn on the mute state if the volume is effectively zero, off otherwise. - // Pass publish=false to avoid saving twice. - if (volume < speaker::SILENT_VOLUME_THRESHOLD) { - this->set_mute_state_(true, false); - } else { - this->set_mute_state_(false, false); - } - - // Save after mute mutation so the restored state has the correct is_muted_ value if (publish) { this->save_volume_restore_state_(); } From 8d61b35cdc5063fd6e267d85667963d98ec17fed Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 18:59:05 -0400 Subject: [PATCH 125/266] [speaker] Make media player volume and mute independent (#19307) --- .../speaker/media_player/speaker_media_player.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index f40d0f4a1a3..9ce50d7b762 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -595,8 +595,11 @@ void SpeakerMediaPlayer::set_mute_state_(bool mute_state) { } void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit with in the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); if (this->media_speaker_ != nullptr) { this->media_speaker_->set_volume(bounded_volume); @@ -611,13 +614,6 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { this->save_volume_restore_state_(); } - // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < speaker::SILENT_VOLUME_THRESHOLD) { - this->set_mute_state_(true); - } else { - this->set_mute_state_(false); - } - this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } From e737dc8daca325f459f0f8045d0fcc0c055761fe Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:33:24 +0000 Subject: [PATCH 126/266] Synchronise Device Classes from Home Assistant (#19318) --- esphome/components/binary_sensor/__init__.py | 2 ++ esphome/const.py | 1 + 2 files changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 9ef7efc96a3..a114ab42051 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -39,6 +39,7 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, @@ -81,6 +82,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, diff --git a/esphome/const.py b/esphome/const.py index e1d875f94bf..fd95df41965 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1345,6 +1345,7 @@ DEVICE_CLASS_GARAGE = "garage" DEVICE_CLASS_GARAGE_DOOR = "garage_door" DEVICE_CLASS_GAS = "gas" DEVICE_CLASS_GATE = "gate" +DEVICE_CLASS_GLASS_BREAK = "glass_break" DEVICE_CLASS_HEAT = "heat" DEVICE_CLASS_HUMIDITY = "humidity" DEVICE_CLASS_IDENTIFY = "identify" From e163ae5299a4bcb61a904b450b8d38ca32895a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 15 Sep 2026 16:15:06 +0300 Subject: [PATCH 127/266] [bk72xx_ble] Keep wifi power save off while BLE is compiled in (#19317) --- esphome/components/bk72xx_ble/__init__.py | 11 ++++- esphome/components/wifi/__init__.py | 32 ++++++++++++- .../bk72xx_ble/config/test_power_save.yaml | 12 +++++ .../bk72xx_ble/test_power_save.py | 20 ++++++++ .../wifi/test_power_save_off.py | 46 +++++++++++++++++++ .../validate-power-save.bk72xx-ard.yaml | 9 ++++ 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_power_save.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_power_save.py create mode 100644 tests/component_tests/wifi/test_power_save_off.py create mode 100644 tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 74b9cb59548..38cba56c623 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -23,7 +23,7 @@ public ble_api.h. import logging import esphome.codegen as cg -from esphome.components import libretiny +from esphome.components import libretiny, wifi from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, @@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None: # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) + # Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU + # sleep. With the BLE controller running, that sleep never wakes up once the + # station is stopped (adapter restart after failed roams, wifi.disable): the + # device is dead until a power cycle (esphome#18592). Keep power save off + # until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414). + wifi.force_power_save_off( + "with BLE running, the Beken SDK's MCU sleep halts the device once the " + "station is stopped (https://github.com/esphome/esphome/issues/18592)" + ) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 81b90766b9e..a1a3436d470 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -690,7 +690,16 @@ async def to_code(config): ): cg.add(var.set_reboot_timeout(reboot_timeout)) if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": - cg.add(var.set_power_save_mode(power_save_mode)) + if reasons := CORE.data.get(POWER_SAVE_OFF_REASONS_KEY): + _LOGGER.warning( + "power_save_mode %s is not applied: %s", + power_save_mode, + "; ".join(reasons), + ) + else: + cg.add(var.set_power_save_mode(power_save_mode)) + # From here on force_power_save_off() can no longer take effect + CORE.data[POWER_SAVE_APPLIED_KEY] = True if ( min_auth_mode := config.get(CONF_MIN_AUTH_MODE) ) is not None and min_auth_mode != "WPA2": @@ -864,6 +873,8 @@ async def wifi_roam_to_code( KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +POWER_SAVE_OFF_REASONS_KEY = "wifi_power_save_off_reasons" +POWER_SAVE_APPLIED_KEY = "wifi_power_save_applied" RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" @@ -896,6 +907,25 @@ def request_wifi_scan_results_lock() -> None: CORE.data[SCAN_RESULTS_LOCK_KEY] = True +def force_power_save_off(reason: str) -> None: + """Keep the station out of WiFi power save regardless of power_save_mode. + + Components whose platform cannot run power save safely call this from their + final validation (FINAL_VALIDATE_SCHEMA), which always runs before any code + generation. Every distinct reason is kept; when the configured mode is not + NONE, wifi's code generation logs them and skips the mode. Calling it once + wifi has generated its code is too late and raises. + """ + if POWER_SAVE_APPLIED_KEY in CORE.data: + raise EsphomeError( + "wifi.force_power_save_off() must be called from final validation, " + "before wifi generates its code" + ) + reasons: list[str] = CORE.data.setdefault(POWER_SAVE_OFF_REASONS_KEY, []) + if reason not in reasons: + reasons.append(reason) + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. diff --git a/tests/component_tests/bk72xx_ble/config/test_power_save.yaml b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml new file mode 100644 index 00000000000..87f599c66e8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml @@ -0,0 +1,12 @@ +esphome: + name: bk-power-save + +bk72xx: + board: cb2s + +wifi: + ssid: test + password: testtest + power_save_mode: high + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_power_save.py b/tests/component_tests/bk72xx_ble/test_power_save.py new file mode 100644 index 00000000000..6973e6e26f8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_power_save.py @@ -0,0 +1,20 @@ +"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not +wake up once the station is stopped while the BLE controller runs.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_power_save_mode_is_not_applied_with_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("test_power_save.yaml")) + + assert "bk72xx_ble::BK72xxBLE" in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "power_save_mode HIGH is not applied" in caplog.text + assert "issues/18592" in caplog.text diff --git a/tests/component_tests/wifi/test_power_save_off.py b/tests/component_tests/wifi/test_power_save_off.py new file mode 100644 index 00000000000..2b4200968a7 --- /dev/null +++ b/tests/component_tests/wifi/test_power_save_off.py @@ -0,0 +1,46 @@ +"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the +station out of power save.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import wifi +from esphome.core import CORE, EsphomeError + + +def test_reasons_accumulate_without_duplicates() -> None: + """Every caller's reason is kept once; a repeated reason is not duplicated.""" + wifi.force_power_save_off("first") + wifi.force_power_save_off("first") + wifi.force_power_save_off("second") + + assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"] + + +def test_forced_off_skips_the_setter_and_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """With a reason recorded, power_save_mode is reported and not applied.""" + wifi.force_power_save_off("the platform cannot sleep") + + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_power_save_mode(" not in main_cpp + assert ( + "power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text + ) + + +def test_call_after_wifi_codegen_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Once wifi has generated its code the hook cannot take effect any more.""" + generate_main(component_config_path("custom.yaml")) + + with pytest.raises(EsphomeError, match="before wifi generates its code"): + wifi.force_power_save_off("too late") diff --git a/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml new file mode 100644 index 00000000000..20b69b6c64b --- /dev/null +++ b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# A wifi power_save_mode other than NONE is forced off with a warning while +# bk72xx_ble is configured (esphome#18592); this config must still validate. +packages: + bk72xx_ble: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + power_save_mode: high From 6362ae71c09c29297c0b08c925be1cd15dd3076b Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 15 Sep 2026 09:29:03 -0700 Subject: [PATCH 128/266] [modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304) --- esphome/components/modbus/__init__.py | 158 +++++++++-- esphome/components/modbus/modbus.cpp | 26 +- esphome/components/modbus/modbus.h | 37 ++- esphome/components/modbus_client/__init__.py | 56 ++-- .../components/modbus_client/modbus_client.h | 81 ++++-- .../components/modbus_controller/__init__.py | 66 ++++- .../modbus_controller/modbus_controller.cpp | 13 +- .../modbus_controller/modbus_controller.h | 25 +- .../modbus_controller/number/__init__.py | 8 +- .../modbus_controller/output/__init__.py | 9 +- .../modbus_controller/select/__init__.py | 8 +- .../modbus_controller/switch/__init__.py | 8 +- tests/component_tests/modbus/test_modbus.py | 3 +- .../modbus_client/test_modbus_client.py | 146 +++++++++- .../test_broadcast_address.py | 79 ++++++ .../modbus_controller/test_custom_pdu.py | 75 +++++- .../modbus/modbus_client_hub_test.cpp | 255 ++++++++++++++++++ tests/components/modbus_client/common.yaml | 4 +- .../validate-broadcast.esp32-idf.yaml | 36 +++ .../components/modbus_controller/common.yaml | 1 - .../validate-broadcast.esp32-idf.yaml | 29 ++ 21 files changed, 994 insertions(+), 129 deletions(-) create mode 100644 tests/component_tests/modbus_controller/test_broadcast_address.py create mode 100644 tests/components/modbus_client/validate-broadcast.esp32-idf.yaml create mode 100644 tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 76cfdbed706..0a34ed037d5 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, Literal, NamedTuple @@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") CommandOptions = modbus_ns.struct("CommandOptions") MULTI_CONF = True +CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" +CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" @@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 +# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. +_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) + +# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable(). +_NON_BROADCASTABLE_FUNCTION_CODES = frozenset( + {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18} +) + + +def is_function_code_write(function_code: int) -> bool: + """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, + so an exception-flagged code still classifies by its base code (the runtime hub never queues one: + queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" + return function_code & 0x7F in _WRITE_FUNCTION_CODES + + +def is_function_code_broadcastable(function_code: int) -> bool: + """True if the hub accepts the function code at address 0 without allow_broadcast_read.""" + return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES + + class _CommandOption(NamedTuple): """One per-command option forwarded to the hub (modbus::CommandOptions).""" @@ -64,14 +89,47 @@ class _CommandOption(NamedTuple): validator: Any # the static (non-templatable) validator for the key cpp_type: Any # the C++ type the value is generated as default: Any + # Function codes the hub honours the option on; it is stripped from any other. + applies_to: Callable[[int], bool] + requires_broadcast_address: bool = False -# Per-direction command options. Single-sourcing the schema and the setter generation here keeps -# them from drifting; the C++ side must add the matching field per the rules documented on -# CommandOptions (modbus.h). +def _not_write(function_code: int) -> bool: + return not is_function_code_write(function_code) + + +def _not_broadcastable(function_code: int) -> bool: + return not is_function_code_broadcastable(function_code) + + +# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot +# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h). _COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { - "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], - "write": [], + "read": [ + _CommandOption( + CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write + ), + _CommandOption( + CONF_ALLOW_BROADCAST_READ, + "allow_broadcast_read", + cv.boolean, + bool, + False, + _not_broadcastable, + requires_broadcast_address=True, + ), + ], + "write": [ + _CommandOption( + CONF_EXPECT_BROADCAST_WRITE_RESPONSE, + "expect_broadcast_write_response", + cv.boolean, + bool, + False, + is_function_code_broadcastable, + requires_broadcast_address=True, + ), + ], } @@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]: raise ValueError(f"unknown command-options direction {direction!r}") from None -# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 -# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. -_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) +def broadcast_only_option_keys() -> list[str]: + return [ + option.conf_key + for options in _COMMAND_OPTIONS.values() + for option in options + if option.requires_broadcast_address + ] -def is_function_code_write(function_code: int) -> bool: - """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, - so an exception-flagged code still classifies by its base code (the runtime hub never queues one: - queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" - return function_code & 0x7F in _WRITE_FUNCTION_CODES +def reject_broadcast_options_for_unicast( + address_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a broadcast-only option set true on a literal address other than 0.""" + + def validator(config: ConfigType) -> ConfigType: + address = config.get(address_key) + if not isinstance(address, int) or address == BROADCAST_ADDRESS: + return config + for key in broadcast_only_option_keys(): + if config.get(key) is True: + raise cv.Invalid( + f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or " + f"remove the option.", + path=[key], + ) + return config + + return validator + + +def reject_inapplicable_command_options( + pdu_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject an option set true that the hub would strip from a literal PDU's function code.""" + + def validator(config: ConfigType) -> ConfigType: + pdu = config[pdu_key] + if not isinstance(pdu, list): + return config + for direction in _COMMAND_OPTIONS: + for option in _command_options(direction): + if config.get(option.conf_key) is True and not option.applies_to( + pdu[0] + ): + raise cv.Invalid( + f"'{option.conf_key}: true' does not apply to function code " + f"0x{pdu[0]:02X}", + path=[option.conf_key], + ) + return config + + return validator def command_options_schema( - *, direction: Literal["read", "write"], templatable: bool = False + *, + direction: Literal["read", "write"], + templatable: bool = False, + function_code: int | None = None, ) -> dict[cv.Optional, Any]: - """Schema fragment for the per-command options a component forwards to the hub - (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are - direction-specific so a schema never offers an option the hub would strip (e.g. - continuous on a write); the write side has no options yet. For actions (templatable=True the - keys also accept lambdas), register the values with register_templatable_command_options(). + """Schema fragment for the per-command options of one direction; `function_code` (a typed + action's fixed code) leaves out the options that do not apply to it. """ return { cv.Optional(option.conf_key, default=option.default): ( cv.templatable(option.validator) if templatable else option.validator ) for option in _command_options(direction) + if function_code is None or option.applies_to(function_code) } @@ -130,6 +231,25 @@ def command_options_expression( ) +def add_command_options( + var: MockObj, + setter: str, + config: ConfigType, + *, + direction: Literal["read", "write"], +) -> None: + """Emit `var.()` for a config validated with command_options_schema() of the + same direction, skipped when every option is at its C++ default.""" + if all( + config.get(option.conf_key, option.default) == option.default + for option in _command_options(direction) + ): + return + cg.add( + getattr(var, setter)(command_options_expression(config, direction=direction)) + ) + + async def register_templatable_command_options( var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str ) -> None: diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index f428236a821..037901a8733 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); - if (cmd->frame.address() == BROADCAST_ADDRESS) { + if (cmd->fire_and_forget()) { // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above // reports the transmission, and the entry then retires with no terminal callback instead of // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already @@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M return false; } - if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) { - ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); - return false; - } - // Normalize the caller's options in place (the param is a by-value copy) so everything stored or // merged below carries effective options, never the raw request. // continuous is ignored for every mutating code (re-writing a value forever is never intended). @@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); options.continuous = false; } + if (address != BROADCAST_ADDRESS) { + options.allow_broadcast_read = false; + options.expect_broadcast_write_response = false; + } else { + const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]); + if (options.allow_broadcast_read && broadcastable) { + ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]); + options.allow_broadcast_read = false; + } + if (options.expect_broadcast_write_response && !broadcastable) { + ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]); + options.expect_broadcast_write_response = false; + } + if (!broadcastable && !options.allow_broadcast_read) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } + } // A duplicate of a live entry with the same owner is not queued twice; it resolves against that // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a @@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, item.pending); } + item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response; return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 7d7818239d5..1623c099a34 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -111,11 +111,15 @@ enum class FrameState : uint8_t { // Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). // A new field reaches the queue with no plumbing but arrives inert until it defines three rules: // normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in -// retire()/silent_retire(). +// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value. struct CommandOptions { // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. - bool continuous{false}; + bool continuous : 1 {false}; + // Wait for the reply to a read sent to address 0, for a device that answers the broadcast address. + bool allow_broadcast_read : 1 {false}; + bool expect_broadcast_write_response : 1 {false}; }; +static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte"); struct ModbusDeviceCommand { ModbusClientDevice *device; @@ -158,6 +162,10 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + bool fire_and_forget() const { + return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read && + !this->options.expect_broadcast_write_response; + } // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback. void complete_broadcast() { @@ -191,7 +199,8 @@ struct ModbusDeviceCommand { } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED this->state = FrameState::RETIRED; } - this->options = {}; // reset every option + // Only continuous ends with the clear; the delivery flags must survive for a granted retry. + this->options.continuous = false; } // True while the entry is still waiting for a response @@ -534,27 +543,27 @@ class ModbusClientDevice { return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } - bool write_single_register(uint16_t start_address, uint16_t value) { - return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); + bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options); } - bool write_single_coil(uint16_t address, bool value) { - return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); + bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options); } - bool write_multiple_registers(uint16_t start_address, std::span values) { + bool write_multiple_registers(uint16_t start_address, std::span values, CommandOptions options = {}) { // Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's. if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS) - return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values)); - return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. - bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); + bool write_multiple_coils(uint16_t start_address, std::span values, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. - bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); + bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options); } /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception /// (typically a rejected write half) arrives there too via its status - one callback handles both diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index a59eb910664..66ddcd7722d 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -7,7 +7,6 @@ from esphome.components import modbus import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_CONTINUOUS, CONF_COUNT, CONF_ID, CONF_ON_ERROR, @@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema( ) -def _no_continuous_on_write(config: ConfigType) -> ConfigType: - """Reject `continuous: true` on a static write PDU: continuous polling only applies to reads. - Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at - runtime, so a templated pdu or continuous falls through to that backstop.""" - pdu = config[CONF_PDU] - if ( - isinstance(pdu, list) - and config.get(CONF_CONTINUOUS) is True - and modbus.is_function_code_write(pdu[0]) - ): - raise cv.Invalid( - f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code " - f"0x{pdu[0]:02X}); continuous polling only applies to reads", - path=[CONF_CONTINUOUS], - ) - return config - - MODBUS_CLIENT_SEND_SCHEMA = cv.All( _ACTION_BASE_SCHEMA.extend( { @@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All( ) ), **modbus.command_options_schema(direction="read", templatable=True), + **modbus.command_options_schema(direction="write", templatable=True), cv.Optional(CONF_ON_RESPONSE): _handler_schema(), } ), - _no_continuous_on_write, + modbus.reject_inapplicable_command_options(CONF_PDU), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -261,8 +244,7 @@ async def register_client_action( var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf ) # Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the - # matching direction so a write action never generates a read option's setter; the write side - # has no options yet, so this is a no-op there. + # matching direction so a write action never generates a read option's setter. await modbus.register_templatable_command_options( var, config, args, command_direction ) @@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) cg.add(var.set_pdu(template_)) + # The read set is wired by register_client_action() below. + await modbus.register_templatable_command_options(var, config, args, "write") return await register_client_action( var, config, @@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All: } ), _no_address_overflow(CONF_COUNT), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al cv.Required(CONF_VALUES): cv.templatable( cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) ), + **modbus.command_options_schema(direction="write", templatable=True), } ), _no_address_overflow(CONF_VALUES), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) _READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) -_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +_WRITE_SINGLE_REGISTER_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) # A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. -_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +_WRITE_SINGLE_COIL_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), ) ), + # 0x17 counts as a read at address 0, so it takes allow_broadcast_read only. + **modbus.command_options_schema( + direction="read", templatable=True, function_code=0x17 + ), } ), _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 03744239a9b..4c1d11da838 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -85,18 +85,36 @@ template class ClientActionBase : public Action, public m /// builds its static struct; declaring the values here instead of per action means a new read option /// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up. /// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python -/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option -/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members. +/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin. template class ReadCommandOptions { public: // Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips // it for mutating function codes at the door (see modbus::CommandOptions). TEMPLATABLE_VALUE(bool, continuous) + TEMPLATABLE_VALUE(bool, allow_broadcast_read) protected: /// The options for this send, with every templatable value resolved against the action's arguments. modbus::CommandOptions command_options_(const Ts &...x) const { - return {.continuous = this->continuous_.value(x...)}; + return {.continuous = this->continuous_.value(x...), + .allow_broadcast_read = this->allow_broadcast_read_.value(x...)}; + } +}; + +/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys). +template class WriteCommandOptions { + public: + TEMPLATABLE_VALUE(bool, expect_broadcast_write_response) + + protected: + /// Resolves every write option into `options`, so send's merge of both sets stays exhaustive. + void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const { + options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...); + } + modbus::CommandOptions write_command_options_(const Ts &...x) const { + modbus::CommandOptions options{}; + this->apply_write_command_options_(options, x...); + return options; } }; @@ -107,8 +125,11 @@ template class ReadCommandOptions { /// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). /// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check /// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +/// A raw PDU may be a read or a write, so this action carries both option sets. template -class ModbusClientSendAction : public ClientActionBase, public ReadCommandOptions { +class ModbusClientSendAction : public ClientActionBase, + public ReadCommandOptions, + public WriteCommandOptions { public: TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) @@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase, public ReadComman return &this->response_trigger_; } - void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); } + void play(const Ts &...x) override { + modbus::CommandOptions options = this->command_options_(x...); + this->apply_write_command_options_(options, x...); + this->send_or_resolve_(this->pdu_.value(x...), options); + } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -218,7 +243,8 @@ template class ReadBitsAction : public TypedClientActionBase class WriteSingleRegisterAction : public TypedClientActionBase { +template +class WriteSingleRegisterAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(uint16_t, value) @@ -227,7 +253,8 @@ template class WriteSingleRegisterAction : public TypedClientAct void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -240,7 +267,8 @@ template class WriteSingleRegisterAction : public TypedClientAct /// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one /// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. -template class WriteSingleCoilAction : public TypedClientActionBase { +template +class WriteSingleCoilAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(bool, value) @@ -249,7 +277,8 @@ template class WriteSingleCoilAction : public TypedClientActionB void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -264,7 +293,8 @@ template class WriteSingleCoilAction : public TypedClientActionB /// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a /// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static /// list must not allocate on every play(). -template class WriteMultipleRegistersAction : public TypedClientActionBase { +template +class WriteMultipleRegistersAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -288,11 +318,13 @@ template class WriteMultipleRegistersAction : public TypedClient // the empty PDU then resolves via on_not_sent like any refused send. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( - start, std::span(this->values_.data, static_cast(this->len_)))); + start, std::span(this->values_.data, static_cast(this->len_))), + this->write_command_options_(x...)); return; } const std::vector values = this->values_.func(x...); - this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values)), + this->write_command_options_(x...)); } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { @@ -313,7 +345,8 @@ template class WriteMultipleRegistersAction : public TypedClient /// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() /// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than /// a byte - and is packed into a stack buffer on the way to the builder. -template class WriteMultipleCoilsAction : public TypedClientActionBase { +template +class WriteMultipleCoilsAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -334,13 +367,16 @@ template class WriteMultipleCoilsAction : public TypedClientActi const uint16_t start = this->start_address_.value(x...); if (this->count_ >= 0) { const auto count = static_cast(this->count_); - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( - start, - modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + this->send_or_resolve_( + modbus::helpers::create_write_coils_pdu( + start, modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), + count)), + this->write_command_options_(x...)); return; } // The builder packs and bound-checks; an over-long set is rejected and logged there. - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)), + this->write_command_options_(x...)); } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { @@ -359,7 +395,8 @@ template class WriteMultipleCoilsAction : public TypedClientActi /// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in /// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. -template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { +template +class ReadWriteMultipleRegistersAction : public TypedClientActionBase, public ReadCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, read_address) TEMPLATABLE_VALUE(uint16_t, read_count) @@ -385,13 +422,15 @@ template class ReadWriteMultipleRegistersAction : public TypedCl // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, - std::span(this->values_.data, static_cast(this->len_)))); + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_))), + this->command_options_(x...)); return; } const std::vector values = this->values_.func(x...); this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, std::span(values))); + read_start, read_count, write_start, std::span(values)), + this->command_options_(x...)); } // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index f888cc060e3..aa72a08a60b 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType: def _reject_broadcast_address(config: ConfigType) -> ConfigType: - """A modbus_controller polls one device, so its address cannot be the broadcast address (0): - a broadcast is never answered (Modbus 4.1), so no register could ever read back.""" + """Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0.""" + if config[modbus.CONF_ALLOW_BROADCAST_READ]: + if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; " + f"set 'address: 0' or remove the option.", + [modbus.CONF_ALLOW_BROADCAST_READ], + ) + return config modbus.reject_broadcast_address( config.get(CONF_ADDRESS), "a modbus_controller device address", - "Assign the unit address of the device you want to poll.", + "Assign the unit address of the device you want to poll, or set allow_broadcast_read if " + "it answers address 0.", [CONF_ADDRESS], ) return config @@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None: ) +def _reject_broadcastable_custom_pdu(config: ConfigType) -> None: + """A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered.""" + pdu = config.get(CONF_CUSTOM_PDU) + if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if ( + controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS + and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True + ): + raise cv.Invalid( + f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at " + f"address 0 and is never answered, so it can't be polled through the " + f"'{controller[CONF_ID]}' modbus_controller; use a read function code.", + [CONF_CUSTOM_PDU], + ) + + def validate_custom_pdu_item(config: ConfigType) -> None: - """Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor, - text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a - continuously-polling controller.""" + """Final-validate for the platforms that accept custom_pdu.""" migrate_custom_command(config) _reject_continuous_write_custom_pdu(config) + _reject_broadcastable_custom_pdu(config) + + +def _reject_write_option_off_broadcast(config: ConfigType) -> None: + if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the " + f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.", + [modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], + ) + + +def validate_writer_item(config: ConfigType) -> None: + """Final-validate for the writer platforms (number, output, select, switch).""" + if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config: + validate_custom_pdu_item(config) + _reject_write_option_off_broadcast(config) def _final_validate(config: ConfigType) -> None: @@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - cg.add( - var.set_read_options( - modbus.command_options_expression(config, direction="read") - ) - ) + modbus.add_command_options(var, "set_read_options", config, direction="read") await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c7fc10a0bb0..b8d06d3d5a3 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1 bool WriterDevice::send_raw_frame_deprecated(std::span frame) { if (frame.empty()) return false; - return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); + return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_); } void ControllerDevice::set_controller(ModbusController *controller) { @@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span request_pdu) { // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) // A custom polling command sends its PDU to this controller's own address, so only a factory custom // command (a raw frame staged in payload) can carry a different address byte. + // An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback. uint8_t wire_address = this->address_; if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty()) wire_address = this->payload.data()[0]; - if (wire_address == modbus::BROADCAST_ADDRESS) + const bool answered = this->controller_->read_options().allow_broadcast_read && + !modbus::helpers::is_function_code_broadcastable(request_pdu[0]); + if (wire_address == modbus::BROADCAST_ADDRESS && !answered) this->controller_->unqueue_command(this); } @@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) { this->one_shot_command_items_.push_back(make_unique(std::move(command))); // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. auto &item = this->one_shot_command_items_.back(); - // We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling. - if (!item->send()) { + // One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes). + if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) { // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), item->register_address()); @@ -340,7 +343,7 @@ void ModbusController::update() { if (this->can_send()) { for (auto &poll : this->polling_devices_) { ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address()); - // read_options_ carries the controller's continuous flag (the offline probe above sends it too). + // read_options_ carries the controller's read-side flags (the offline probe above sends them too). // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. if (!poll.queue(this->read_options_)) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address()); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 821c500a31e..741d4f6f00d 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice { void notify_online_(std::span request_pdu); - /// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's - /// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0. - bool dispatched_{false}; - bool write_buffer_deprecated_warned_{false}; + /// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag + /// leaves in 2027.3.0. + bool dispatched_ : 1 {false}; + bool write_buffer_deprecated_warned_ : 1 {false}; + modbus::CommandOptions write_options_{}; ModbusController *controller_{nullptr}; }; @@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice { bool dispatched() const { return this->dispatched_; } void set_dispatched() { this->dispatched_ = true; } void clear_dispatched() { this->dispatched_ = false; } + modbus::CommandOptions write_options() const { return this->write_options_; } + void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; } /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); @@ -326,27 +329,29 @@ class WriterEntity { /// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records /// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write. bool dispatched() const { return this->device_.dispatched(); } + void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); } bool write_single_register(uint16_t address, uint16_t value) { this->device_.set_dispatched(); - return this->device_.write_single_register(address, value); + return this->device_.write_single_register(address, value, this->device_.write_options()); } bool write_single_coil(uint16_t address, bool value) { this->device_.set_dispatched(); - return this->device_.write_single_coil(address, value); + return this->device_.write_single_coil(address, value, this->device_.write_options()); } bool write_multiple_registers(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_registers(address, values); + return this->device_.write_multiple_registers(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, values); + return this->device_.write_multiple_coils(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, bits); + return this->device_.write_multiple_coils(address, bits, this->device_.write_options()); } - bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + bool queue_pdu(std::span pdu) { return this->queue_pdu(pdu, this->device_.write_options()); } + bool queue_pdu(std::span pdu, modbus::CommandOptions options) { this->device_.set_dispatched(); return this->device_.queue_pdu(pdu, options); } diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index 6f7bf588af7..242e2eea218 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import number +from esphome.components import modbus, number from esphome.components.modbus.helpers import ( MODBUS_WRITE_REGISTER_TYPE, SENSOR_VALUE_TYPE, @@ -23,8 +23,8 @@ from .. import ( add_modbus_base_properties, modbus_calc_properties, modbus_controller_ns, - validate_custom_pdu_item, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STEP, default=1): cv.positive_float, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), validate_min_max, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) await add_modbus_base_properties(var, config, ModbusNumber) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 0e8d5363d74..c964ced987b 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import output +from esphome.components import modbus, output from esphome.components.modbus.helpers import ( SENSOR_VALUE_TYPE, PduBuffer, @@ -18,6 +18,7 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, + validate_writer_item, ) from ..const import ( CONF_CUSTOM_COMMAND, @@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), "holding": cv.All( @@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), reject_odd_holding_write_offset, @@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: byte_offset = modbus_calc_properties(config) # Binary Output @@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_parent(parent)) if write_template: cg.add(var.set_write_template(write_template)) diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index d8319932ab6..6fc8c8331cf 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any import esphome.codegen as cg -from esphome.components import select +from esphome.components import modbus, select from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC @@ -15,6 +15,7 @@ from .. import ( modbus_controller_ns, validate_range_reuse_migration, validate_skip_updates_deprecated, + validate_writer_item, ) from ..const import ( CONF_FORCE_NEW_RANGE, @@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Required(CONF_OPTIONSMAP): ensure_option_map(), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: options_map = config[CONF_OPTIONSMAP] @@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) cg.add(var.set_parent(parent)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if CONF_LAMBDA in config: diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 00b67446a31..2c5b92b810b 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import switch +from esphome.components import modbus, switch from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID @@ -13,9 +13,9 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, - validate_custom_pdu_item, validate_modbus_register, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, } ), @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") assumed_state = config[CONF_ASSUMED_STATE] cg.add(var.set_assumed_state(assumed_state)) if not assumed_state: diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py index 0e53c55b50b..1eafb131664 100644 --- a/tests/component_tests/modbus/test_modbus.py +++ b/tests/component_tests/modbus/test_modbus.py @@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None: def test_client_schema_still_accepts_address_zero() -> None: - # Not rejected for clients today, but not supported either: a client broadcast gets no reply and - # stalls the hub for the full send-wait. + # A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read. schema = modbus.modbus_device_schema(0x01) assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index cab944d825e..fcccae144e0 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv -from esphome.components import modbus_client +from esphome.components import modbus, modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, @@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None: def test_continuous_on_write_pdu_rejected() -> None: """A literal write-code PDU with continuous: true is rejected at config time (reads only).""" - with pytest.raises(cv.Invalid, match="does not apply to a write PDU"): + with pytest.raises(cv.Invalid, match="does not apply to function code"): MODBUS_CLIENT_SEND_SCHEMA( { CONF_ADDRESS: 0x01, @@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None: """ assert modbus_client.MULTI_CONF is True assert modbus_client.MULTI_CONF_NO_DEFAULT is True + + +@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ]) +def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None: + # A read option set true on a static write PDU is refused at validation, naming the key. + config = { + CONF_ADDRESS: 1, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + key: True, + } + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA(config) + + +def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None: + # allow_broadcast_read defaults to False and is accepted on a read PDU to address 0. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]} + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_send_rejects_write_option_on_static_read_pdu() -> None: + # The write-side option is refused on a static read PDU, the mirror of the read-option check. + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True} + ) + + +def test_send_accepts_write_option_on_static_write_pdu() -> None: + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True + + +def test_write_actions_offer_write_option_only() -> None: + # Every write action takes expect_broadcast_write_response and none of the read options. + from esphome.components.modbus_client import ( + _WRITE_MULTIPLE_COILS_SCHEMA, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + _WRITE_SINGLE_COIL_SCHEMA, + _WRITE_SINGLE_REGISTER_SCHEMA, + CONF_START_ADDRESS, + CONF_VALUE, + CONF_VALUES, + ) + + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True} + for schema, extra in ( + (_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}), + (_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}), + (_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}), + (_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}), + ): + config = schema({**base, **extra}) + assert config[write_key] is True + assert modbus.CONF_ALLOW_BROADCAST_READ not in config + with pytest.raises(cv.Invalid): + schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_send_options_follow_the_hub_classification() -> None: + # A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one; + # 0x17 is a read for broadcast purposes, so the reverse holds. + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + read_key = modbus.CONF_ALLOW_BROADCAST_READ + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True} + )[write_key] + with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True} + ) + pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01] + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True} + )[read_key] + with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True} + ) + + +def test_read_write_multiple_offers_allow_broadcast_read_only() -> None: + from esphome.components.modbus_client import ( + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + CONF_READ_ADDRESS, + CONF_VALUES, + CONF_WRITE_ADDRESS, + ) + + config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_READ_ADDRESS: 0x10, + CONF_WRITE_ADDRESS: 0x20, + CONF_VALUES: [1], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + assert CONF_CONTINUOUS not in config + assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config + + +@pytest.mark.parametrize( + "key", + [modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], +) +def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None: + # A broadcast-only option on a literal non-zero address would be silently dropped by the hub. + if key == modbus.CONF_ALLOW_BROADCAST_READ: + pdu = [0x03, 0x00, 0x10, 0x00, 0x01] + else: + pdu = [0x06, 0x00, 0x10, 0x00, 0x01] + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True}) + # A templated address is not decidable at validation and passes through. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True} + ) + assert config[key] is True diff --git a/tests/component_tests/modbus_controller/test_broadcast_address.py b/tests/component_tests/modbus_controller/test_broadcast_address.py new file mode 100644 index 00000000000..01bdacbf863 --- /dev/null +++ b/tests/component_tests/modbus_controller/test_broadcast_address.py @@ -0,0 +1,79 @@ +"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the +device answers it.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus_controller import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _controller(address: int, **extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra}) + + +def test_address_zero_rejected_by_default() -> None: + with pytest.raises(cv.Invalid, match="broadcast address"): + _controller(0) + + +def test_address_zero_accepted_with_allow_broadcast_read() -> None: + config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + assert config[CONF_ADDRESS] == 0 + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_allow_broadcast_read_defaults_false() -> None: + assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False + + +def test_writer_entity_takes_expect_broadcast_write_response() -> None: + # The write-side option lives on the writing platforms, not the controller. + from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID + from esphome.components.modbus_controller.switch import ( + CONFIG_SCHEMA as SWITCH_SCHEMA, + ) + from esphome.const import CONF_NAME + + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = { + CONF_MODBUS_CONTROLLER_ID: "ctl", + CONF_NAME: "Switch", + "register_type": "coil", + CONF_ADDRESS: 0x20, + } + assert SWITCH_SCHEMA(base)[key] is False + assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True}) + + +def test_allow_broadcast_read_requires_address_zero() -> None: + # The option only means something at address 0; elsewhere it would be silently inert. + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + _controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_add_command_options_skips_defaults() -> None: + # The setter is only emitted when an option differs from its C++ default. + import esphome.codegen as cg + from esphome.const import CONF_CONTINUOUS + + var = cg.MockObj("ctl") + emitted: list = [] + original = cg.add + cg.add = emitted.append + try: + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read" + ) + assert emitted == [] + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read" + ) + assert len(emitted) == 1 + assert "set_read_options" in str(emitted[0]) + finally: + cg.add = original diff --git a/tests/component_tests/modbus_controller/test_custom_pdu.py b/tests/component_tests/modbus_controller/test_custom_pdu.py index a3a18da07f4..592f6c12bad 100644 --- a/tests/component_tests/modbus_controller/test_custom_pdu.py +++ b/tests/component_tests/modbus_controller/test_custom_pdu.py @@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there. import pytest from voluptuous import Invalid, MultipleInvalid +from esphome.components import modbus from esphome.components.modbus_controller import ( ModbusItemBaseSchema, validate_custom_pdu_item, @@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None: ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]}) -def _controller_full_config(*, continuous: bool) -> Config: +def _controller_full_config( + *, continuous: bool, allow_broadcast_read: bool = False +) -> Config: """A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the - final-validate to resolve the controller (and its continuous flag) from an item's + final-validate to resolve the controller (and its option flags) from an item's modbus_controller_id.""" ctl_id = ID("ctl", is_declaration=True) config = Config() config["modbus_controller"] = [ - {CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous} + { + CONF_ID: ctl_id, + CONF_ADDRESS: 0 if allow_broadcast_read else 1, + CONF_CONTINUOUS: continuous, + modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read, + } ] config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID])) return config @@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None: CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], } ) + + +def test_broadcastable_custom_pdu_rejected_under_broadcast_controller( + reset_full_config, +) -> None: + """A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast, + never answered, so it is rejected at final validate.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + with pytest.raises(Invalid, match="is a real broadcast at address 0"): + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x41, 0x00, 0x03], + } + ) + + +def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None: + """A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], + } + ) + + +def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None: + """expect_broadcast_write_response on a writer entity whose controller is not at address 0 is + rejected at final validate, where the controller's address is known.""" + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set(_controller_full_config(continuous=False)) + with pytest.raises( + Invalid, match="only applies when the 'ctl' modbus_controller is at address 0" + ): + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + + +def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None: + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 18c04f32d5b..3bdfa094e0e 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { EXPECT_EQ(device.sent_count_, 0); // never transmitted } +// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent, +// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected + EXPECT_EQ(hub.entries(), 1u); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, reply); + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(reply)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from +// another unit id is an unexpected frame and interrupts the transaction as it would for any address. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(0x07, reply); + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a +// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + RetryingDevice device(&hub, BROADCAST_ADDRESS, true); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(BROADCAST_ADDRESS); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read); + + hub.timeout_waiting(); // retry granted: the entry is READY again + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply + EXPECT_EQ(hub.entries(), 1u); +} + +// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast, +// still fire-and-forget) and from a unicast frame (nothing to allow). +TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_EQ(broadcast_device.sent_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + +// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead +// of retiring at transmission, and the reply (from address 0) completes it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); + EXPECT_EQ(hub.entries(), 1u); + + hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0 + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(write)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a +// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in +// flight rather than absorbed, so the queued entry's delivery mode is never changed under it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 1u); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched + + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and +// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the +// send, so the absorbed request still gets its terminal callback. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true})); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed + EXPECT_EQ(hub.entries(), 1u); + EXPECT_FALSE(hub.queued(0).options.continuous); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); + hub.receive_frame_for_test(BROADCAST_ADDRESS, custom); + EXPECT_EQ(device.response_count_, 1); +} + +// A silent device leaves an expected write response to the normal send-wait timeout. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so +// the broadcast guard still refuses it) and from a unicast frame (nothing to expect). +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 0u); + + ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + // The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the // hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 76f7479a5cf..ce2965e449d 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -79,7 +79,8 @@ button: name: "Typed Actions" on_press: - modbus_client.write_single_register: - address: 0x01 + address: !lambda "return 1;" + expect_broadcast_write_response: true start_address: 0x0102 value: !lambda "return 42;" on_response: @@ -93,6 +94,7 @@ button: start_address: 0x10 count: 2 continuous: true + allow_broadcast_read: !lambda "return false;" on_response: then: - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..d6a29d7175b --- /dev/null +++ b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,36 @@ +# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that +# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: Broadcast probe + on_press: + - modbus_client.read_holding_registers: + address: 0 + allow_broadcast_read: true + start_address: 0x10 + count: 1 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);' + - modbus_client.write_single_register: + address: 0 + expect_broadcast_write_response: true + start_address: 0x0102 + value: 42 + on_response: + then: + - logger.log: "broadcast write acked" + - modbus_client.read_write_multiple_registers: + address: 0 + allow_broadcast_read: true + read_address: 0x10 + read_count: 1 + write_address: 0x20 + values: [1] + - modbus_client.send: + address: 0 + expect_broadcast_write_response: true + pdu: [0x41, 0x01] diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index b9a7610cb73..b488e51f3c8 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -6,7 +6,6 @@ modbus_controller: on_online: then: logger.log: "Module Online" - binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..49e89eaa20f --- /dev/null +++ b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,29 @@ +# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a +# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do +# not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +modbus_controller: + - id: modbus_controller_broadcast + address: 0 + allow_broadcast_read: true + modbus_id: modbus_bus + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_sensor + name: Broadcast Read Sensor + register_type: holding + address: 0x0010 + value_type: U_WORD + +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_switch + name: Broadcast Write Switch + register_type: coil + address: 0x20 + expect_broadcast_write_response: true From 457bb3ecc948a250a66497915154723342bb8e24 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 15 Sep 2026 17:33:33 +0100 Subject: [PATCH 129/266] [file] Keep resolved image paths as Path so config-hash normalizes them (#19267) Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 22 +++++----- .../unit_tests/components/file/test_image.py | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 7cef7c754a4..ab769954124 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -42,7 +42,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.cpp_generator import MockObj, MockObjClass from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value: str | ConfigType) -> str: - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) +def local_path(value: Path | ConfigType) -> Path: + # cv.file_ has already resolved the path against the config dir. + return value[CONF_PATH] if isinstance(value, dict) else value -def download_file(url: str, path: Path) -> str: +def download_file(url: str, path: Path) -> Path: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) - return str(path) + # Keep the Path: config-hash normalizes Path values under the data dir, + # which a str would dump verbatim and break the CLI/add-on comparison. + return path def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: @@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" -def download_gh_svg(value: str | ConfigType, source: str) -> str: +def download_gh_svg(value: str | ConfigType, source: str) -> Path: mdi_id = value[CONF_ICON] if isinstance(value, dict) else value url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) -def download_image(value: str | ConfigType) -> str: +def download_image(value: str | ConfigType) -> Path: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value: Any) -> str: +def validate_file_shorthand(value: Any) -> Path: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All( def mdi_schema(source: str) -> cv.All: - def validate_mdi(value: ConfigType) -> str: + def validate_mdi(value: ConfigType) -> Path: return download_gh_svg(value, source) return cv.All( diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py index a9c1684db39..727a4c8c1ef 100644 --- a/tests/unit_tests/components/file/test_image.py +++ b/tests/unit_tests/components/file/test_image.py @@ -5,8 +5,13 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest + +from esphome import yaml_util from esphome.components.file import image as file_image -from esphome.external_files import RemoteFile +from esphome.const import CONF_PATH +from esphome.core import CORE +from esphome.external_files import RemoteFile, url_cache_key from esphome.loader import get_component, get_platform @@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: assert files[1].url == "https://example.com/img.png" +def test_validated_file_values_hash_alike_across_data_dirs( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLI and an add-on data dir dump validated image files identically.""" + url = "https://example.com/img.png" + (setup_core / "img.png").touch() + dumps: list[str] = [] + for data_dir in ( + setup_core / ".esphome", + setup_core.parent / f"{setup_core.name}-data", + ): + monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir)) + with patch("esphome.components.file.image.external_files.download_content"): + config = { + "remote": file_image.validate_file_shorthand(url), + "mdi": file_image.validate_file_shorthand("mdi:home"), + "local": file_image.validate_file_shorthand("img.png"), + "local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}), + } + dumps.append( + yaml_util.dump( + config, + sort_keys=True, + relative_to=CORE.config_dir, + data_dir=CORE.data_dir, + ) + ) + assert dumps[0] == dumps[1] + assert dumps[0].splitlines() == [ + "local: img.png", + "local_schema: img.png", + "mdi: .esphome/image/mdi/home.svg", + f"remote: .esphome/image/{url_cache_key(url)}", + ] + + def test_extractor_matches_validator_path(setup_core: Path) -> None: """The path the validator downloads to equals the extractor's path.""" with patch( From f19403192da7098c0b05acc9dacd20fffac22e29 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 11:47:06 -0500 Subject: [PATCH 130/266] [usb_uart] Add claim_comm_interface option (#18969) --- esphome/components/usb_uart/__init__.py | 53 +++++++++++++++++++++--- esphome/components/usb_uart/usb_uart.cpp | 21 ++++++---- esphome/components/usb_uart/usb_uart.h | 4 ++ tests/components/usb_uart/common.yaml | 1 + 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index edbf75f70f8..5d0f8be1655 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -44,6 +44,7 @@ UART_STOP_BITS_OPTIONS = { } DEFAULT_BAUD_RATE = 9600 +CONF_CLAIM_COMM_INTERFACE = "claim_comm_interface" class Type: @@ -56,6 +57,7 @@ class Type: max_channels: int = 1, baud_rate_required: bool = True, max_baud: int = 1_000_000, + has_comm_interface: bool = False, ) -> None: self.name = name cls = cls or name @@ -65,6 +67,9 @@ class Type: self._max_channels = max_channels self.baud_rate_required = baud_rate_required self.max_baud = max_baud + # True for types that claim the CDC comm (interrupt) interface; only these + # accept the claim_comm_interface option. + self.has_comm_interface = has_comm_interface @property def max_channels(self) -> int: @@ -80,11 +85,21 @@ class Type: uart_types = ( - Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), + Type( + "CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False, has_comm_interface=True + ), Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), - Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type( + "ESP_JTAG", + 0x303A, + 0x1001, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), @@ -95,12 +110,20 @@ uart_types = ( Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), - Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), + Type( + "STM32_VCP", + 0x0483, + 0x5740, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), ) def channel_schema(type_: "Type") -> cv.Schema: - return cv.Schema( + schema = cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( cv.ensure_list( @@ -139,9 +162,26 @@ def channel_schema(type_: "Type") -> cv.Schema: max=type_.max_channels, msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", ), - ) + ), } ) + if type_.has_comm_interface: + # The comm (interrupt) interface pins a host hardware channel per device; + # disable to save one on channel-poor hosts (some devices may need it + # claimed before enabling data flow). + schema = schema.extend( + {cv.Optional(CONF_CLAIM_COMM_INTERFACE, default=True): cv.boolean} + ) + else: + schema = schema.extend( + { + cv.Optional(CONF_CLAIM_COMM_INTERFACE): cv.invalid( + f"'{CONF_CLAIM_COMM_INTERFACE}' is only supported on device types " + f"that claim the CDC comm interface; {type_.name} never claims it" + ) + } + ) + return schema CONFIG_SCHEMA = cv.ensure_list( @@ -172,6 +212,9 @@ async def to_code(config: list[ConfigType]) -> None: for device in config: var = await register_usb_client(device) + # The C++ default is true; only emit the override + if not device.get(CONF_CLAIM_COMM_INTERFACE, True): + cg.add(var.set_claim_comm_interface(False)) for index, channel in enumerate(device[CONF_CHANNELS]): chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) await cg.register_parented(chvar, var) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 60b7fe4e9c4..3113f695f60 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -431,15 +431,20 @@ void USBUartTypeCdcAcm::on_connected() { // they enable data flow on the bulk endpoints. if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { - auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, - channel->cdc_dev_.interrupt_interface_number, 0); - if (err_comm != ESP_OK) { - // Continue anyway: the interface number stays valid for CDC request addressing - ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, - esp_err_to_name(err_comm)); + if (!this->claim_comm_interface_) { + ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", + channel->cdc_dev_.interrupt_interface_number); } else { - ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); - channel->cdc_dev_.interrupt_interface_claimed = true; + auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, + channel->cdc_dev_.interrupt_interface_number, 0); + if (err_comm != ESP_OK) { + // Continue anyway: the interface number stays valid for CDC request addressing + ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, + esp_err_to_name(err_comm)); + } else { + ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); + channel->cdc_dev_.interrupt_interface_claimed = true; + } } } auto err = diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 9d87bf964c0..22563209da3 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -271,12 +271,16 @@ class USBUartComponent : public usb_host::USBClient { class USBUartTypeCdcAcm : public USBUartComponent { public: USBUartTypeCdcAcm(uint16_t vid, uint16_t pid) : USBUartComponent(vid, pid) {} + void set_claim_comm_interface(bool claim) { this->claim_comm_interface_ = claim; } protected: virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + // Each claimed interface pins one host hardware channel per endpoint; skipping + // the comm (interrupt) interface frees one on channel-poor hosts (ESP32-S3: 8). + bool claim_comm_interface_{true}; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 5b23f9d685f..2e41fad1a1b 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -6,6 +6,7 @@ usb_uart: type: cdc_acm vid: 0x1234 pid: 0x5678 + claim_comm_interface: false channels: - id: channel_0_1 - id: uart_1 From 565dc8092c67aa7651086d3e37c7de70b7661d78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:42:52 -0500 Subject: [PATCH 131/266] Update tzdata requirement from >=2026.3 to >=2026.4 (#19328) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bfbf0aa321a..15ee7af7c86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 tzlocal==5.4.4 # from time -tzdata>=2026.3 # from time +tzdata>=2026.4 # from time pyserial==3.5 platformio==6.1.19 esptool==5.4.0 From 57860dc06c39b479e3167bf36361149c3b42cb4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Sep 2026 16:10:46 -0500 Subject: [PATCH 132/266] [core] Add register_simple_action and register_parented_action helpers (#19321) --- AGENTS.md | 15 +++- esphome/automation.py | 127 ++++++++++++++++++++++------ tests/unit_tests/test_automation.py | 125 ++++++++++++++++++++++++++- 3 files changed, 234 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8db3cd3d624..448bf49114f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -431,7 +431,17 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + Register it without writing a builder: + ```python + automation.register_simple_action( + "my_component.do_something", MyAction, schema, synchronous=True + ) + ``` + The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a + no-argument constructor, `register_parented_action` for a class deriving from `Parented`, and + the `@automation.register_action(...)` decorator only when the builder must also set fields. + + Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. * **Conditions:** ```cpp @@ -443,7 +453,8 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. + Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`; + `register_bare_condition`, `register_parented_condition` and the decorator follow the action rules. * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. diff --git a/esphome/automation.py b/esphome/automation.py index 1689d29c42f..3ffda50c812 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -102,6 +102,101 @@ def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schem return CONDITION_REGISTRY.register(name, condition_type, schema) +async def _build_with_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(automation_id, template_arg, parent) + + +async def _build_without_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return cg.new_Pvariable(automation_id, template_arg) + + +async def _build_parented( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(automation_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +def register_simple_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes the object named by ``config[CONF_ID]``. + + Use the ``register_action`` decorator instead when the builder must also set fields. + """ + register_action(name, action_type, schema, synchronous=synchronous)( + _build_with_parent + ) + + +def register_simple_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_simple_action``.""" + register_condition(name, condition_type, schema)(_build_with_parent) + + +def register_bare_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes no arguments.""" + register_action(name, action_type, schema, synchronous=synchronous)( + _build_without_parent + ) + + +def register_bare_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_bare_action``.""" + register_condition(name, condition_type, schema)(_build_without_parent) + + +def register_parented_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action deriving from ``Parented``. + + The object is constructed without arguments and ``set_parent()`` receives the object + named by ``config[CONF_ID]``. + """ + register_action(name, action_type, schema, synchronous=synchronous)(_build_parented) + + +def register_parented_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_parented_action``.""" + register_condition(name, condition_type, schema)(_build_parented) + + Action = cg.esphome_ns.class_("Action") Trigger = cg.esphome_ns.class_("Trigger") ACTION_REGISTRY = Registry() @@ -534,44 +629,20 @@ async def lambda_action_to_code( return new_lambda_pvariable(action_id, lambda_, StatelessLambdaAction, template_arg) -@register_action( +register_simple_action( "component.update", UpdateComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_update_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) -@register_action( +register_simple_action( "component.suspend", SuspendComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_suspend_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) @register_action( diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index a377cf185a8..07ea7533601 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1,7 +1,9 @@ """Tests for esphome.automation module.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, call, patch +from collections.abc import Callable, Generator +from functools import partial +from typing import NamedTuple +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -12,9 +14,18 @@ from esphome.automation import ( TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + register_bare_action, + register_bare_condition, + register_parented_action, + register_parented_condition, + register_simple_action, + register_simple_condition, ) +import esphome.codegen as cg +from esphome.const import CONF_ID +from esphome.core import ID from esphome.cpp_generator import MockObj, RawExpression -from esphome.util import RegistryEntry +from esphome.util import Registry, RegistryEntry def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]: @@ -475,3 +486,111 @@ async def test_build_callback_automations_defaults( mock_build_callback.assert_called_once_with( parent, "add_on_press_callback", [], conf, forwarder=None ) + + +PARENT_ID = ID("my_component") +PARENT_OBJ = MockObj("parent", "->") +NEW_OBJ = MockObj("var", "->") +ACTION_TYPE = cg.esphome_ns.class_("MyAction") +CONDITION_TYPE = cg.esphome_ns.class_("MyCondition") +TEMPLATE_ARG = cg.TemplateArguments() + + +class MockCodegen(NamedTuple): + get_variable: AsyncMock + new_pvariable: MagicMock + register_parented: AsyncMock + + +@pytest.fixture +def mock_cg() -> Generator[MockCodegen]: + """Patch the codegen calls the shared builders make.""" + with ( + patch("esphome.codegen.get_variable", new_callable=AsyncMock) as get_variable, + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch( + "esphome.codegen.register_parented", new_callable=AsyncMock + ) as register_parented, + ): + get_variable.return_value = PARENT_OBJ + new_pvariable.return_value = NEW_OBJ + yield MockCodegen(get_variable, new_pvariable, register_parented) + + +@pytest.fixture +def registries() -> Generator[tuple[Registry, Registry]]: + """Patch both registries so registrations made by a test do not leak.""" + actions = Registry() + conditions = Registry() + with ( + patch("esphome.automation.ACTION_REGISTRY", actions), + patch("esphome.automation.CONDITION_REGISTRY", conditions), + ): + yield actions, conditions + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("register", "is_action", "ctor_parent", "parented"), + [ + (partial(register_simple_action, synchronous=True), True, True, False), + (partial(register_bare_action, synchronous=True), True, False, False), + (partial(register_parented_action, synchronous=True), True, False, True), + (register_simple_condition, False, True, False), + (register_bare_condition, False, False, False), + (register_parented_condition, False, False, True), + ], + ids=[ + "simple_action", + "bare_action", + "parented_action", + "simple_condition", + "bare_condition", + "parented_condition", + ], +) +async def test_shared_builders( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + register: Callable[..., None], + is_action: bool, + ctor_parent: bool, + parented: bool, +) -> None: + """Each helper constructs the object and wires the parent the way its C++ shape needs.""" + actions, conditions = registries + type_id = ACTION_TYPE if is_action else CONDITION_TYPE + register("my.entry", type_id, {}) + entry = (actions if is_action else conditions)["my.entry"] + assert entry.type_id is type_id + config = {CONF_ID: PARENT_ID} if ctor_parent or parented else {} + + result = await entry.fun(config, ID("obj_1"), TEMPLATE_ARG, []) + + assert result is NEW_OBJ + if ctor_parent: + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + mock_cg.new_pvariable.assert_called_once_with( + ID("obj_1"), TEMPLATE_ARG, PARENT_OBJ + ) + else: + mock_cg.get_variable.assert_not_called() + mock_cg.new_pvariable.assert_called_once_with(ID("obj_1"), TEMPLATE_ARG) + if parented: + mock_cg.register_parented.assert_awaited_once_with(NEW_OBJ, PARENT_ID) + else: + mock_cg.register_parented.assert_not_called() + + +@pytest.mark.parametrize("synchronous", [True, False]) +def test_shared_builders_keep_synchronous_flag( + registries: tuple[Registry, Registry], synchronous: bool +) -> None: + """The synchronous flag reaches the registry entry unchanged.""" + actions, _ = registries + register_simple_action("my.simple", ACTION_TYPE, {}, synchronous=synchronous) + register_bare_action("my.bare", ACTION_TYPE, {}, synchronous=synchronous) + register_parented_action("my.parented", ACTION_TYPE, {}, synchronous=synchronous) + assert actions["my.simple"].synchronous is synchronous + assert actions["my.bare"].synchronous is synchronous + assert actions["my.parented"].synchronous is synchronous From 20d199ae2066f1bb723d56b86643fe46608ffea0 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sun, 13 Sep 2026 18:18:52 -0700 Subject: [PATCH 133/266] [pmsa003i] Fix read from uninitialized stack memory (#19053) --- esphome/components/pmsa003i/pmsa003i.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 15f5d3e8793..0b5c72a94d2 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -88,7 +88,11 @@ void PMSA003IComponent::update() { bool PMSA003IComponent::read_data_(PM25AQIData *data) { uint8_t buffer[COUNT_DATA_BYTES]; - this->read_bytes_raw(buffer, COUNT_DATA_BYTES); + const i2c::ErrorCode error = this->read(buffer, COUNT_DATA_BYTES); + if (error != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C error %d", error); + return false; + } // https://github.com/adafruit/Adafruit_PM25AQI From b7acd9c0dc4a390a0369579d46e4ef5e6d3d2e5c Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 13 Sep 2026 18:36:39 -0700 Subject: [PATCH 134/266] [template] Stop water heater republishing when a temperature is unknown (#19013) --- .../water_heater/template_water_heater.cpp | 10 ++++-- ...r_heater_template_unknown_temperature.yaml | 16 +++++++++ .../integration/test_water_heater_template.py | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/water_heater_template_unknown_temperature.yaml diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 092df6fdca3..9d6a3523d28 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -1,6 +1,8 @@ #include "template_water_heater.h" #include "esphome/core/log.h" +#include + namespace esphome::template_ { static const char *const TAG = "template.water_heater"; @@ -45,9 +47,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { void TemplateWaterHeater::loop() { bool changed = false; + // NAN is passed through so a source that has no value yet shows as unknown, but NAN never + // equals NAN, so an already-NAN value must not count as a change or it would republish forever. auto curr_temp = this->current_temperature_f_.call(); if (curr_temp.has_value()) { - if (*curr_temp != this->current_temperature_) { + if (*curr_temp != this->current_temperature_ && + !(std::isnan(*curr_temp) && std::isnan(this->current_temperature_))) { this->current_temperature_ = *curr_temp; changed = true; } @@ -55,7 +60,8 @@ void TemplateWaterHeater::loop() { auto target_temp = this->target_temperature_f_.call(); if (target_temp.has_value()) { - if (*target_temp != this->target_temperature_) { + if (*target_temp != this->target_temperature_ && + !(std::isnan(*target_temp) && std::isnan(this->target_temperature_))) { this->target_temperature_ = *target_temp; changed = true; } diff --git a/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml new file mode 100644 index 00000000000..a70ed25bd7f --- /dev/null +++ b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml @@ -0,0 +1,16 @@ +esphome: + name: wh-template-unknown-test +host: +api: +logger: + +water_heater: + - platform: template + id: unknown_boiler + name: Unknown Boiler + # Both temperatures stay unknown, as they do before an upstream component reports a value. + current_temperature: !lambda "return NAN;" + target_temperature: !lambda "return NAN;" + supported_modes: + - "off" + - eco diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index d63d1d69845..3d7f8851605 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -155,3 +155,36 @@ async def test_water_heater_template( client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) eco_state = await wait_for_state() assert eco_state.mode == WaterHeaterMode.ECO + + +@pytest.mark.asyncio +async def test_water_heater_template_unknown_temperature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a template water heater whose temperature lambdas stay unknown. + + NAN never compares equal to itself, so a lambda that keeps returning NAN must not be + mistaken for a changed value and republish the state on every loop iteration. + """ + async with run_compiled(yaml_config), api_client_connected() as client: + state_count = 0 + + def on_state(state: aioesphomeapi.EntityState) -> None: + nonlocal state_count + if isinstance(state, WaterHeaterState): + state_count += 1 + + entities, _ = await client.list_entities_services() + water_heater_infos = [e for e in entities if isinstance(e, WaterHeaterInfo)] + assert len(water_heater_infos) == 1 + + client.subscribe_states(on_state) + + # Let the device run for a while; only the single initial state may arrive. + await asyncio.sleep(1.0) + assert state_count <= 1, ( + f"Expected at most 1 state publish, got {state_count} - " + "an unknown (NAN) temperature is republishing every loop" + ) From 076dc017ab05b54bb9a5f52fcd266774aec2731e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:32:31 +1200 Subject: [PATCH 135/266] [core] Mark filters, manual_ip and interlock as advanced (#19272) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/ethernet/__init__.py | 4 +- esphome/components/gpio/switch/__init__.py | 8 ++- esphome/components/sensor/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/wifi/__init__.py | 8 ++- .../test_advanced_visibility.py | 53 +++++++++++++++++++ 7 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/config_validation/test_advanced_visibility.py diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1ab6f7103f7..9ef7efc96a3 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -452,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 0454440f142..3e7d345805c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -420,7 +420,9 @@ def _validate(config: ConfigType) -> ConfigType: BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(EthernetComponent), - cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): MANUAL_IP_SCHEMA, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 2e0b0969bc7..766cdc4afb3 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -15,9 +15,13 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)), cv.Optional( - CONF_INTERLOCK_WAIT_TIME, default="0ms" + CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED + ): cv.ensure_list(cv.use_id(switch.Switch)), + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, + default="0ms", + visibility=cv.Visibility.ADVANCED, ): cv.positive_time_period_milliseconds, } ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79d4ce5e0c0..3b632a1847f 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -344,7 +344,9 @@ _SENSOR_SCHEMA = ( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 29399a51b72..5c8d71696f5 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -148,7 +148,9 @@ _TEXT_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index d4b39c029b7..61b687d787e 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -285,7 +285,9 @@ WIFI_NETWORK_BASE = cv.Schema( cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, } ) @@ -484,7 +486,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, diff --git a/tests/component_tests/config_validation/test_advanced_visibility.py b/tests/component_tests/config_validation/test_advanced_visibility.py new file mode 100644 index 00000000000..f7e03743198 --- /dev/null +++ b/tests/component_tests/config_validation/test_advanced_visibility.py @@ -0,0 +1,53 @@ +"""Power-user fields are marked as advanced on the shared schemas. + +``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs +whose defaults suit nearly every user, so a schema-aware editor should keep +them behind its "advanced settings" disclosure rather than on the main form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +def _gpio_switch_schema() -> cv.Schema: + return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA + + +@pytest.mark.parametrize( + ("label", "schema_factory", "fields"), + [ + ("sensor", sensor.sensor_schema, ["filters"]), + ("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]), + ("text_sensor", text_sensor.text_sensor_schema, ["filters"]), + ("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]), + ("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]), + ("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]), + ("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]), + ], +) +def test_power_user_fields_are_advanced( + label: str, schema_factory, fields: list[str] +) -> None: + markers = _markers(schema_factory()) + for field in fields: + assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}" + + +def test_interlock_wait_time_keeps_its_default() -> None: + """Marking the field advanced must not drop its default.""" + markers = _markers(_gpio_switch_schema()) + assert markers["interlock_wait_time"].default() == "0ms" From 8c999d3152c13c622666e328bb98fcf50a949c24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:44:36 -0500 Subject: [PATCH 136/266] [number] Fix the default mode check so mode auto is no longer emitted (#19231) --- esphome/components/number/__init__.py | 14 ++++++---- esphome/components/number/number_traits.h | 2 +- tests/component_tests/number/__init__.py | 0 tests/component_tests/number/config/mode.yaml | 28 +++++++++++++++++++ tests/component_tests/number/test_number.py | 16 +++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/number/__init__.py create mode 100644 tests/component_tests/number/config/mode.yaml create mode 100644 tests/component_tests/number/test_number.py diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ea0c2d77f66..fc0893323be 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -174,6 +174,10 @@ NumberInRangeCondition = number_ns.class_( NumberMode = number_ns.enum("NumberMode") +# Schema default that also matches the C++ initializer in number_traits.h; codegen +# skips the setter when the config equals it. +DEFAULT_MODE = "AUTO" + NUMBER_MODES = { "AUTO": NumberMode.NUMBER_MODE_AUTO, "BOX": NumberMode.NUMBER_MODE_BOX, @@ -216,7 +220,7 @@ _NUMBER_SCHEMA = ( CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED ): validate_unit_of_measurement, cv.Optional( - CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED ): cv.enum(NUMBER_MODES, upper=True), cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED @@ -286,10 +290,10 @@ async def setup_number_core_( cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) - # Only set if non-default to avoid bloating setup() function - # (mode_ is initialized to NUMBER_MODE_AUTO in the header) - if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: - cg.add(var.traits.set_mode(config[CONF_MODE])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_MODE). + # The validated value is the enum key string, not the C++ enum expression. + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.traits.set_mode(mode)) CORE.add_job(_build_number_automations, var, config) diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index f855813c9bf..3c7942b9a36 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -31,7 +31,7 @@ class NumberTraits { float min_value_ = NAN; float max_value_ = NAN; float step_ = NAN; - NumberMode mode_{NUMBER_MODE_AUTO}; + NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py }; } // namespace esphome::number diff --git a/tests/component_tests/number/__init__.py b/tests/component_tests/number/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/number/config/mode.yaml b/tests/component_tests/number/config/mode.yaml new file mode 100644 index 00000000000..b3eae34436f --- /dev/null +++ b/tests/component_tests/number/config/mode.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +number: + - platform: template + id: auto_number + min_value: 0 + max_value: 10 + step: 1 + optimistic: true + - platform: template + id: box_number + min_value: 0 + max_value: 10 + step: 1 + mode: box + optimistic: true + - platform: template + id: explicit_auto_number + min_value: 0 + max_value: 10 + step: 1 + mode: auto + optimistic: true diff --git a/tests/component_tests/number/test_number.py b/tests/component_tests/number/test_number.py new file mode 100644 index 00000000000..b33508602af --- /dev/null +++ b/tests/component_tests/number/test_number.py @@ -0,0 +1,16 @@ +"""Tests for the number component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_mode_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Mode auto is the C++ initializer, so only a non default mode is set.""" + main_cpp = generate_main(component_config_path("mode.yaml")) + + assert "auto_number->traits.set_mode(" not in main_cpp + assert "explicit_auto_number->traits.set_mode(" not in main_cpp + assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp From 6582c618f1469940b1a4e88418b15e3834375b63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:00 -0500 Subject: [PATCH 137/266] [web_server] Skip setters that pass the default port, log and include internal values (#19226) --- esphome/components/web_server/__init__.py | 20 ++++++++--- .../web_server_base/web_server_base.h | 2 +- .../web_server/config/bare.yaml | 12 +++++++ .../web_server/config/custom.yaml | 15 ++++++++ .../web_server/config/defaults.yaml | 15 ++++++++ .../web_server/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/web_server/config/bare.yaml create mode 100644 tests/component_tests/web_server/config/custom.yaml create mode 100644 tests/component_tests/web_server/config/defaults.yaml create mode 100644 tests/component_tests/web_server/test_default_setters.py diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index a50c14a2f72..2459163786d 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -56,6 +56,10 @@ CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" CONF_ALLOWED_ORIGINS = "allowed_origins" +# Schema default that also matches the C++ initializer in web_server_base.h; codegen +# skips the setter when the config equals it. +DEFAULT_PORT = 80 + web_server_ns = cg.esphome_ns.namespace("web_server") WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) @@ -251,7 +255,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(WebServer), - cv.Optional(CONF_PORT, default=80): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True), cv.Optional(CONF_CSS_URL): cv.string, cv.Optional(CONF_CSS_INCLUDE): cv.file_, @@ -379,9 +383,11 @@ async def to_code(config: ConfigType) -> None: version = config[CONF_VERSION] - cg.add(paren.set_port(config[CONF_PORT])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_PORT). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") - cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT]) + cg.add_define("USE_WEBSERVER_PORT", port) cg.add_define("USE_WEBSERVER_VERSION", version) if version >= 2: # Don't compress the index HTML as the data sizes are almost the same. @@ -395,9 +401,11 @@ async def to_code(config: ConfigType) -> None: # Captive portal will still be able to perform OTA updates even when this is set if config.get(CONF_OTA) is False: cg.add_define("USE_WEBSERVER_OTA_DISABLED") - cg.add(var.set_expose_log(config[CONF_LOG])) + # expose_log_ is true in C++; only emit the setter to turn it off. if config[CONF_LOG]: request_log_listener() # Request a log listener slot for web server log streaming + else: + cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: @@ -433,7 +441,9 @@ async def to_code(config: ConfigType) -> None: path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) - cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) + # include_internal_ is false in C++; only emit the setter to turn it on. + if config[CONF_INCLUDE_INTERNAL]: + cg.add(var.set_include_internal(True)) if CONF_LOCAL in config and config[CONF_LOCAL]: cg.add_define("USE_WEBSERVER_LOCAL") if config[CONF_COMPRESSION] == "gzip": diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 94579de70f8..72d3bf75b1c 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -170,7 +170,7 @@ class WebServerBase final { protected: uint8_t initialized_{0}; - uint16_t port_{80}; + uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py AsyncWebServer *server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH diff --git a/tests/component_tests/web_server/config/bare.yaml b/tests/component_tests/web_server/config/bare.yaml new file mode 100644 index 00000000000..dae1c488832 --- /dev/null +++ b/tests/component_tests/web_server/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: diff --git a/tests/component_tests/web_server/config/custom.yaml b/tests/component_tests/web_server/config/custom.yaml new file mode 100644 index 00000000000..2d37d7ae19d --- /dev/null +++ b/tests/component_tests/web_server/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 8080 + log: false + include_internal: true diff --git a/tests/component_tests/web_server/config/defaults.yaml b/tests/component_tests/web_server/config/defaults.yaml new file mode 100644 index 00000000000..3c34da43ac1 --- /dev/null +++ b/tests/component_tests/web_server/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 80 + log: true + include_internal: false diff --git a/tests/component_tests/web_server/test_default_setters.py b/tests/component_tests/web_server/test_default_setters.py new file mode 100644 index 00000000000..2b13ed966b5 --- /dev/null +++ b/tests/component_tests/web_server/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that web_server only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 80, log on and include_internal off already live in the C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_port(" not in main_cpp + assert "set_expose_log(" not in main_cpp + assert "set_include_internal(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_port(8080);" in main_cpp + assert "set_expose_log(false);" in main_cpp + assert "set_include_internal(true);" in main_cpp From eae6af437bae1253cf4dbf85afc14c4a7d62a4fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:56 -0500 Subject: [PATCH 138/266] [output] Skip the power limit setters when they match the defaults (#19225) --- esphome/components/output/__init__.py | 13 +++++--- esphome/components/output/float_output.h | 1 + tests/component_tests/output/__init__.py | 0 .../config/ac_dimmer_min_power_zero.yaml | 13 ++++++++ .../output/config/power_limits.yaml | 18 +++++++++++ tests/component_tests/output/test_output.py | 31 +++++++++++++++++++ tests/components/ac_dimmer/common.yaml | 1 + 7 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/output/__init__.py create mode 100644 tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml create mode 100644 tests/component_tests/output/config/power_limits.yaml create mode 100644 tests/component_tests/output/test_output.py diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 4f6c8943f5e..10d5e5eb593 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -53,12 +53,17 @@ async def setup_output_platform_(obj, config): if CONF_POWER_SUPPLY in config: power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) - if CONF_MAX_POWER in config: + # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when + # the config matches them. The define stays whenever the key is present because + # platforms such as ac_dimmer read the scaling fields directly. + if (max_power := config.get(CONF_MAX_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_max_power(config[CONF_MAX_POWER])) - if CONF_MIN_POWER in config: + if max_power != 1.0: + cg.add(obj.set_max_power(max_power)) + if (min_power := config.get(CONF_MIN_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_min_power(config[CONF_MIN_POWER])) + if min_power != 0.0: + cg.add(obj.set_min_power(min_power)) # Only emit when zero_means_zero is actually enabled. The schema defaults to False # so this key is always present; emitting unconditionally would force # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 673f4235728..57c8c553f65 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput { virtual void write_state(float state) = 0; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING + // Codegen skips the setters for these values; keep in sync with output/__init__.py float max_power_{1.0f}; float min_power_{0.0f}; bool zero_means_zero_{false}; diff --git a/tests/component_tests/output/__init__.py b/tests/component_tests/output/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml new file mode 100644 index 00000000000..84c5eafc5ab --- /dev/null +++ b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ac_dimmer + id: dimmer + gate_pin: GPIO4 + zero_cross_pin: GPIO5 + min_power: 0% diff --git a/tests/component_tests/output/config/power_limits.yaml b/tests/component_tests/output/config/power_limits.yaml new file mode 100644 index 00000000000..682ae9de511 --- /dev/null +++ b/tests/component_tests/output/config/power_limits.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: default_power + pin: GPIO4 + max_power: 100% + min_power: 0% + - platform: ledc + id: custom_power + pin: GPIO5 + max_power: 90% + min_power: 1% diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py new file mode 100644 index 00000000000..172715aef08 --- /dev/null +++ b/tests/component_tests/output/test_output.py @@ -0,0 +1,31 @@ +"""Tests for the output platform codegen.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_default_power_limits_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """max_power 100% and min_power 0% already live in the C++ initializers.""" + main_cpp = generate_main(component_config_path("power_limits.yaml")) + + assert "default_power->set_max_power(" not in main_cpp + assert "default_power->set_min_power(" not in main_cpp + assert "custom_power->set_max_power(0.9f);" in main_cpp + assert "custom_power->set_min_power(0.01f);" in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} + + +def test_default_min_power_keeps_scaling_fields( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%.""" + main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml")) + + assert "dimmer->set_min_power(" not in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index c16e2e834a9..8fa62c0636b 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -4,3 +4,4 @@ output: gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} zero_cross_interrupt_type: ANY + min_power: 0% From 81be397056c7edd6e2e506d0e887fcea8bb07dd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:51:42 -0500 Subject: [PATCH 139/266] [light] Skip the flash transition setter and the empty effect list (#19228) --- esphome/components/light/__init__.py | 14 +++++++-- esphome/components/light/light_state.h | 2 +- .../light/config/transitions.yaml | 29 +++++++++++++++++++ .../light/test_default_setters.py | 19 ++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/light/config/transitions.yaml create mode 100644 tests/component_tests/light/test_default_setters.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dbcc28d64a3..ab9624c3649 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -340,6 +340,10 @@ RESTORE_MODES = { "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, } +# Schema default that also matches the C++ initializer in light_state.h; codegen +# skips the setter when the config equals it. +DEFAULT_FLASH_TRANSITION_LENGTH = "0s" + LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -387,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( CONF_DEFAULT_TRANSITION_LENGTH, default="1s" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_FLASH_TRANSITION_LENGTH, default="0s" + CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } @@ -502,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var): default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) ) is not None: cg.add(light_var.set_default_transition_length(default_transition_length)) + # Skip the setter when the config matches the C++ initializer. if ( flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH) - ) is not None: + ) is not None and flash_transition_length != cv.time_period( + DEFAULT_FLASH_TRANSITION_LENGTH + ): cg.add(light_var.set_flash_transition_length(flash_transition_length)) if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: cg.add(light_var.set_gamma_correct(gamma_correct)) @@ -514,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var): effects = await cg.build_registry_list( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) - cg.add(light_var.add_effects(effects)) + if effects: + cg.add(light_var.add_effects(effects)) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 3a3f8fc368c..eafa161f51e 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -356,7 +356,7 @@ class LightState : public EntityBase, public Component { /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; /// Transition length to use for flash transitions. - uint32_t flash_transition_length_{}; + uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py /// Gamma correction factor for the light. float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT diff --git a/tests/component_tests/light/config/transitions.yaml b/tests/component_tests/light/config/transitions.yaml new file mode 100644 index 00000000000..ecb33b0ea80 --- /dev/null +++ b/tests/component_tests/light/config/transitions.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: out_a + pin: GPIO4 + - platform: ledc + id: out_b + pin: GPIO5 + +light: + - platform: monochromatic + id: plain_light + output: out_a + flash_transition_length: 0s + - platform: monochromatic + id: fancy_light + output: out_b + flash_transition_length: 500ms + effects: + - pulse: + - platform: monochromatic + id: bare_light + output: out_a diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py new file mode 100644 index 00000000000..a4fc24a7cbf --- /dev/null +++ b/tests/component_tests/light/test_default_setters.py @@ -0,0 +1,19 @@ +"""Tests that light codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flash_length_and_empty_effects_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A 0 ms flash transition and an empty effect list match the C++ defaults.""" + main_cpp = generate_main(component_config_path("transitions.yaml")) + + assert "plain_light->set_flash_transition_length(" not in main_cpp + assert "plain_light->add_effects(" not in main_cpp + assert "bare_light->set_flash_transition_length(" not in main_cpp + assert "bare_light->add_effects(" not in main_cpp + assert "fancy_light->set_flash_transition_length(500);" in main_cpp + assert "fancy_light->add_effects({" in main_cpp From 808b7210db14d8610c653605d31eff86a626b29d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:52:57 -0500 Subject: [PATCH 140/266] [wifi] Skip setters that pass the default priority, timeouts, power save and auth mode (#19229) --- esphome/components/wifi/__init__.py | 28 +++++++++---- esphome/components/wifi/wifi_component.h | 4 +- tests/component_tests/wifi/__init__.py | 0 tests/component_tests/wifi/config/bare.yaml | 12 ++++++ tests/component_tests/wifi/config/custom.yaml | 18 +++++++++ .../component_tests/wifi/config/defaults.yaml | 18 +++++++++ .../wifi/test_default_setters.py | 39 +++++++++++++++++++ 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/wifi/__init__.py create mode 100644 tests/component_tests/wifi/config/bare.yaml create mode 100644 tests/component_tests/wifi/config/custom.yaml create mode 100644 tests/component_tests/wifi/config/defaults.yaml create mode 100644 tests/component_tests/wifi/test_default_setters.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 61b687d787e..418e1a49794 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -167,6 +167,9 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_REBOOT_TIMEOUT = "15min" +# Both defaults also match the C++ initializers in wifi_component.h; codegen skips +# the setter when the config equals them. wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") @@ -493,7 +496,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.SplitDefault( CONF_POWER_SAVE_MODE, @@ -603,7 +606,8 @@ def wifi_network(config, ap, static_ip): cg.add(ap.set_channel(config[CONF_CHANNEL])) if static_ip is not None: cg.add(ap.set_manual_ip(manual_ip(static_ip))) - if CONF_PRIORITY in config: + # priority_ is 0 in C++; skip the setter when the config matches it. + if config.get(CONF_PRIORITY, 0) != 0: cg.add(ap.set_priority(config[CONF_PRIORITY])) return ap @@ -652,7 +656,9 @@ async def to_code(config): WiFiAP(), lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) - cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) + # Skip the setter when the config matches the C++ initializer. + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + cg.add(var.set_ap_timeout(ap_timeout)) cg.add_define("USE_WIFI_AP") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which @@ -668,10 +674,18 @@ async def to_code(config): if has_manual_ip: cg.add_define("USE_WIFI_MANUAL_IP") - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) - if CONF_MIN_AUTH_MODE in config: - cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) + # The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum + # auth WPA2; skip the setters when the config matches them. + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": + cg.add(var.set_power_save_mode(power_save_mode)) + if ( + min_auth_mode := config.get(CONF_MIN_AUTH_MODE) + ) is not None and min_auth_mode != "WPA2": + cg.add(var.set_min_auth_mode(min_auth_mode)) fast_connect = config[CONF_FAST_CONNECT] if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 16b62a5bb0e..8bf45814130 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -913,11 +913,11 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; - uint32_t reboot_timeout_{}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP - uint32_t ap_timeout_{}; + uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py #endif // 1-byte enums and integers diff --git a/tests/component_tests/wifi/__init__.py b/tests/component_tests/wifi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/wifi/config/bare.yaml b/tests/component_tests/wifi/config/bare.yaml new file mode 100644 index 00000000000..94e5de47a0f --- /dev/null +++ b/tests/component_tests/wifi/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + ap: + ssid: fallback diff --git a/tests/component_tests/wifi/config/custom.yaml b/tests/component_tests/wifi/config/custom.yaml new file mode 100644 index 00000000000..068479a5404 --- /dev/null +++ b/tests/component_tests/wifi/config/custom.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 5 + ap: + ssid: fallback + ap_timeout: 2min + reboot_timeout: 0s + power_save_mode: light + min_auth_mode: wpa diff --git a/tests/component_tests/wifi/config/defaults.yaml b/tests/component_tests/wifi/config/defaults.yaml new file mode 100644 index 00000000000..1b5e7d7dba9 --- /dev/null +++ b/tests/component_tests/wifi/config/defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 0 + ap: + ssid: fallback + ap_timeout: 90s + reboot_timeout: 15min + power_save_mode: none + min_auth_mode: wpa2 diff --git a/tests/component_tests/wifi/test_default_setters.py b/tests/component_tests/wifi/test_default_setters.py new file mode 100644 index 00000000000..b326f3eaeeb --- /dev/null +++ b/tests/component_tests/wifi/test_default_setters.py @@ -0,0 +1,39 @@ +"""Tests that wifi codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_priority(" not in main_cpp + assert "set_ap_timeout(" not in main_cpp + assert "set_reboot_timeout(" not in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "set_min_auth_mode(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_priority(5);" in main_cpp + assert "set_ap_timeout(120000);" in main_cpp + assert "set_reboot_timeout(0);" in main_cpp + assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp + assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp From f8bda9fbad897d10aadf847ea2fd03fea20cb125 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:53:41 -0500 Subject: [PATCH 141/266] [logger] Skip the hardware UART setter when it matches the default (#19230) --- esphome/components/logger/__init__.py | 13 ++++---- esphome/components/logger/logger.h | 4 +-- tests/component_tests/logger/test_logger.py | 32 +++++++++++++++++++ .../logger/test_logger_libretiny_default.yaml | 8 +++++ .../logger/test_logger_libretiny_uart0.yaml | 9 ++++++ .../logger/test_logger_uart1.yaml | 9 ++++++ 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/logger/test_logger_libretiny_default.yaml create mode 100644 tests/component_tests/logger/test_logger_libretiny_uart0.yaml create mode 100644 tests/component_tests/logger/test_logger_uart1.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 07b8b030840..138db75ad10 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -362,12 +362,13 @@ async def to_code(config: ConfigType) -> None: # pre_setup() switches on uart_ to decide which hardware to initialize # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the # default UART_SELECTION_UART0 and the wrong hardware gets initialized. - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) + # uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the + # setter when the config matches it. + cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0 + if ( + hardware_uart := config.get(CONF_HARDWARE_UART) + ) is not None and hardware_uart != cpp_default_uart: + cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart])) # pre_setup() sets global_logger and must run before any other code # that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV). cg.add(log.pre_setup()) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9c26814f7ec..ae55f4145a9 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -352,10 +352,10 @@ class Logger final : public Component { // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) - UARTSelection uart_{UART_SELECTION_UART0}; + UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py #endif #ifdef USE_LIBRETINY - UARTSelection uart_{UART_SELECTION_DEFAULT}; + UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py #endif #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) bool main_task_recursion_guard_{false}; diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 94a6f7ac7bc..4ce30afb946 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main): f"Component allocation '{alloc.group()}' at position {alloc.start()} " f"appears before logger pre_setup() at position {logger_pre_setup.start()}" ) + + +def test_default_uart_selection_is_not_emitted(generate_main): + """UART0 is the C++ initializer on ESP8266, so the setter is skipped.""" + main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "set_uart_selection(" not in main_cpp + + +def test_custom_uart_selection_is_emitted(generate_main): + """A non default UART still reaches the setter before pre_setup().""" + main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml") + + assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp + + +def test_libretiny_default_uart_selection_is_not_emitted(generate_main): + """DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_default.yaml" + ) + + assert "set_uart_selection(" not in main_cpp + + +def test_libretiny_uart0_is_emitted(generate_main): + """UART0 is not the LibreTiny initializer, so it must still be set.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_uart0.yaml" + ) + + assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp diff --git a/tests/component_tests/logger/test_logger_libretiny_default.yaml b/tests/component_tests/logger/test_logger_libretiny_default.yaml new file mode 100644 index 00000000000..1f11ea4580c --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: diff --git a/tests/component_tests/logger/test_logger_libretiny_uart0.yaml b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml new file mode 100644 index 00000000000..dc25fe99ce2 --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_uart1.yaml b/tests/component_tests/logger/test_logger_uart1.yaml new file mode 100644 index 00000000000..ce45a6ae3fb --- /dev/null +++ b/tests/component_tests/logger/test_logger_uart1.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + hardware_uart: UART1 From a1ad794d036976c3122d735ec7cffa82d8e0fb29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Sep 2026 01:05:27 -0500 Subject: [PATCH 142/266] [esp8266_pwm] Skip the frequency setter when it matches the default (#19224) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 2 +- esphome/components/esp8266_pwm/output.py | 10 ++++++++-- tests/component_tests/esp8266_pwm/__init__.py | 0 .../esp8266_pwm/config/frequency.yaml | 19 +++++++++++++++++++ .../esp8266_pwm/test_esp8266_pwm.py | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp8266_pwm/__init__.py create mode 100644 tests/component_tests/esp8266_pwm/config/frequency.yaml create mode 100644 tests/component_tests/esp8266_pwm/test_esp8266_pwm.py diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index be58a098b6e..79c2e509848 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; - float frequency_{1000.0}; + float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index dd151a3e044..be6e63b154d 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -22,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) +# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen +# skips the setter when the config equals it. +DEFAULT_FREQUENCY = 1000.0 + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -29,7 +33,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): cv.All( pins.internal_gpio_output_pin_schema, valid_pwm_pin ), - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency, } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version( @@ -48,7 +52,9 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). + if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: + cg.add(var.set_frequency(frequency)) @automation.register_action( diff --git a/tests/component_tests/esp8266_pwm/__init__.py b/tests/component_tests/esp8266_pwm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp8266_pwm/config/frequency.yaml b/tests/component_tests/esp8266_pwm/config/frequency.yaml new file mode 100644 index 00000000000..9ffc8af736e --- /dev/null +++ b/tests/component_tests/esp8266_pwm/config/frequency.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +output: + - platform: esp8266_pwm + id: default_frequency + pin: GPIO4 + frequency: 1kHz + - platform: esp8266_pwm + id: custom_frequency + pin: GPIO5 + frequency: 2kHz + - platform: esp8266_pwm + id: schema_default_frequency + pin: GPIO12 diff --git a/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py new file mode 100644 index 00000000000..771e5133459 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py @@ -0,0 +1,16 @@ +"""Tests for the esp8266_pwm output codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_frequency_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The 1 kHz default already lives in the C++ initializer.""" + main_cpp = generate_main(component_config_path("frequency.yaml")) + + assert "default_frequency->set_frequency(" not in main_cpp + assert "schema_default_frequency->set_frequency(" not in main_cpp + assert "custom_frequency->set_frequency(2000.0f);" in main_cpp From 45362dbc5b6ca982f0d1747bd2d2239461de89da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 15 Sep 2026 16:15:06 +0300 Subject: [PATCH 143/266] [bk72xx_ble] Keep wifi power save off while BLE is compiled in (#19317) --- esphome/components/bk72xx_ble/__init__.py | 11 ++++- esphome/components/wifi/__init__.py | 32 ++++++++++++- .../bk72xx_ble/config/test_power_save.yaml | 12 +++++ .../bk72xx_ble/test_power_save.py | 20 ++++++++ .../wifi/test_power_save_off.py | 46 +++++++++++++++++++ .../validate-power-save.bk72xx-ard.yaml | 9 ++++ 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_power_save.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_power_save.py create mode 100644 tests/component_tests/wifi/test_power_save_off.py create mode 100644 tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 74b9cb59548..38cba56c623 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -23,7 +23,7 @@ public ble_api.h. import logging import esphome.codegen as cg -from esphome.components import libretiny +from esphome.components import libretiny, wifi from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, @@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None: # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) + # Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU + # sleep. With the BLE controller running, that sleep never wakes up once the + # station is stopped (adapter restart after failed roams, wifi.disable): the + # device is dead until a power cycle (esphome#18592). Keep power save off + # until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414). + wifi.force_power_save_off( + "with BLE running, the Beken SDK's MCU sleep halts the device once the " + "station is stopped (https://github.com/esphome/esphome/issues/18592)" + ) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 418e1a49794..64eef46f742 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -681,7 +681,16 @@ async def to_code(config): ): cg.add(var.set_reboot_timeout(reboot_timeout)) if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": - cg.add(var.set_power_save_mode(power_save_mode)) + if reasons := CORE.data.get(POWER_SAVE_OFF_REASONS_KEY): + _LOGGER.warning( + "power_save_mode %s is not applied: %s", + power_save_mode, + "; ".join(reasons), + ) + else: + cg.add(var.set_power_save_mode(power_save_mode)) + # From here on force_power_save_off() can no longer take effect + CORE.data[POWER_SAVE_APPLIED_KEY] = True if ( min_auth_mode := config.get(CONF_MIN_AUTH_MODE) ) is not None and min_auth_mode != "WPA2": @@ -843,6 +852,8 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +POWER_SAVE_OFF_REASONS_KEY = "wifi_power_save_off_reasons" +POWER_SAVE_APPLIED_KEY = "wifi_power_save_applied" RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" @@ -875,6 +886,25 @@ def request_wifi_scan_results_lock() -> None: CORE.data[SCAN_RESULTS_LOCK_KEY] = True +def force_power_save_off(reason: str) -> None: + """Keep the station out of WiFi power save regardless of power_save_mode. + + Components whose platform cannot run power save safely call this from their + final validation (FINAL_VALIDATE_SCHEMA), which always runs before any code + generation. Every distinct reason is kept; when the configured mode is not + NONE, wifi's code generation logs them and skips the mode. Calling it once + wifi has generated its code is too late and raises. + """ + if POWER_SAVE_APPLIED_KEY in CORE.data: + raise EsphomeError( + "wifi.force_power_save_off() must be called from final validation, " + "before wifi generates its code" + ) + reasons: list[str] = CORE.data.setdefault(POWER_SAVE_OFF_REASONS_KEY, []) + if reason not in reasons: + reasons.append(reason) + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. diff --git a/tests/component_tests/bk72xx_ble/config/test_power_save.yaml b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml new file mode 100644 index 00000000000..87f599c66e8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml @@ -0,0 +1,12 @@ +esphome: + name: bk-power-save + +bk72xx: + board: cb2s + +wifi: + ssid: test + password: testtest + power_save_mode: high + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_power_save.py b/tests/component_tests/bk72xx_ble/test_power_save.py new file mode 100644 index 00000000000..6973e6e26f8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_power_save.py @@ -0,0 +1,20 @@ +"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not +wake up once the station is stopped while the BLE controller runs.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_power_save_mode_is_not_applied_with_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("test_power_save.yaml")) + + assert "bk72xx_ble::BK72xxBLE" in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "power_save_mode HIGH is not applied" in caplog.text + assert "issues/18592" in caplog.text diff --git a/tests/component_tests/wifi/test_power_save_off.py b/tests/component_tests/wifi/test_power_save_off.py new file mode 100644 index 00000000000..2b4200968a7 --- /dev/null +++ b/tests/component_tests/wifi/test_power_save_off.py @@ -0,0 +1,46 @@ +"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the +station out of power save.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import wifi +from esphome.core import CORE, EsphomeError + + +def test_reasons_accumulate_without_duplicates() -> None: + """Every caller's reason is kept once; a repeated reason is not duplicated.""" + wifi.force_power_save_off("first") + wifi.force_power_save_off("first") + wifi.force_power_save_off("second") + + assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"] + + +def test_forced_off_skips_the_setter_and_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """With a reason recorded, power_save_mode is reported and not applied.""" + wifi.force_power_save_off("the platform cannot sleep") + + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_power_save_mode(" not in main_cpp + assert ( + "power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text + ) + + +def test_call_after_wifi_codegen_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Once wifi has generated its code the hook cannot take effect any more.""" + generate_main(component_config_path("custom.yaml")) + + with pytest.raises(EsphomeError, match="before wifi generates its code"): + wifi.force_power_save_off("too late") diff --git a/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml new file mode 100644 index 00000000000..20b69b6c64b --- /dev/null +++ b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# A wifi power_save_mode other than NONE is forced off with a warning while +# bk72xx_ble is configured (esphome#18592); this config must still validate. +packages: + bk72xx_ble: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + power_save_mode: high From fe0f04b2e434cda731b732e88e59590a65b7f849 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 15 Sep 2026 09:29:03 -0700 Subject: [PATCH 144/266] [modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304) --- esphome/components/modbus/__init__.py | 158 +++++++++-- esphome/components/modbus/modbus.cpp | 26 +- esphome/components/modbus/modbus.h | 37 ++- esphome/components/modbus_client/__init__.py | 56 ++-- .../components/modbus_client/modbus_client.h | 81 ++++-- .../components/modbus_controller/__init__.py | 66 ++++- .../modbus_controller/modbus_controller.cpp | 13 +- .../modbus_controller/modbus_controller.h | 25 +- .../modbus_controller/number/__init__.py | 8 +- .../modbus_controller/output/__init__.py | 9 +- .../modbus_controller/select/__init__.py | 8 +- .../modbus_controller/switch/__init__.py | 8 +- tests/component_tests/modbus/test_modbus.py | 3 +- .../modbus_client/test_modbus_client.py | 146 +++++++++- .../test_broadcast_address.py | 79 ++++++ .../modbus_controller/test_custom_pdu.py | 75 +++++- .../modbus/modbus_client_hub_test.cpp | 255 ++++++++++++++++++ tests/components/modbus_client/common.yaml | 4 +- .../validate-broadcast.esp32-idf.yaml | 36 +++ .../components/modbus_controller/common.yaml | 1 - .../validate-broadcast.esp32-idf.yaml | 29 ++ 21 files changed, 994 insertions(+), 129 deletions(-) create mode 100644 tests/component_tests/modbus_controller/test_broadcast_address.py create mode 100644 tests/components/modbus_client/validate-broadcast.esp32-idf.yaml create mode 100644 tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 76cfdbed706..0a34ed037d5 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, Literal, NamedTuple @@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") CommandOptions = modbus_ns.struct("CommandOptions") MULTI_CONF = True +CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" +CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" @@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 +# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. +_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) + +# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable(). +_NON_BROADCASTABLE_FUNCTION_CODES = frozenset( + {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18} +) + + +def is_function_code_write(function_code: int) -> bool: + """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, + so an exception-flagged code still classifies by its base code (the runtime hub never queues one: + queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" + return function_code & 0x7F in _WRITE_FUNCTION_CODES + + +def is_function_code_broadcastable(function_code: int) -> bool: + """True if the hub accepts the function code at address 0 without allow_broadcast_read.""" + return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES + + class _CommandOption(NamedTuple): """One per-command option forwarded to the hub (modbus::CommandOptions).""" @@ -64,14 +89,47 @@ class _CommandOption(NamedTuple): validator: Any # the static (non-templatable) validator for the key cpp_type: Any # the C++ type the value is generated as default: Any + # Function codes the hub honours the option on; it is stripped from any other. + applies_to: Callable[[int], bool] + requires_broadcast_address: bool = False -# Per-direction command options. Single-sourcing the schema and the setter generation here keeps -# them from drifting; the C++ side must add the matching field per the rules documented on -# CommandOptions (modbus.h). +def _not_write(function_code: int) -> bool: + return not is_function_code_write(function_code) + + +def _not_broadcastable(function_code: int) -> bool: + return not is_function_code_broadcastable(function_code) + + +# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot +# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h). _COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { - "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], - "write": [], + "read": [ + _CommandOption( + CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write + ), + _CommandOption( + CONF_ALLOW_BROADCAST_READ, + "allow_broadcast_read", + cv.boolean, + bool, + False, + _not_broadcastable, + requires_broadcast_address=True, + ), + ], + "write": [ + _CommandOption( + CONF_EXPECT_BROADCAST_WRITE_RESPONSE, + "expect_broadcast_write_response", + cv.boolean, + bool, + False, + is_function_code_broadcastable, + requires_broadcast_address=True, + ), + ], } @@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]: raise ValueError(f"unknown command-options direction {direction!r}") from None -# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 -# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. -_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) +def broadcast_only_option_keys() -> list[str]: + return [ + option.conf_key + for options in _COMMAND_OPTIONS.values() + for option in options + if option.requires_broadcast_address + ] -def is_function_code_write(function_code: int) -> bool: - """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, - so an exception-flagged code still classifies by its base code (the runtime hub never queues one: - queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" - return function_code & 0x7F in _WRITE_FUNCTION_CODES +def reject_broadcast_options_for_unicast( + address_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a broadcast-only option set true on a literal address other than 0.""" + + def validator(config: ConfigType) -> ConfigType: + address = config.get(address_key) + if not isinstance(address, int) or address == BROADCAST_ADDRESS: + return config + for key in broadcast_only_option_keys(): + if config.get(key) is True: + raise cv.Invalid( + f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or " + f"remove the option.", + path=[key], + ) + return config + + return validator + + +def reject_inapplicable_command_options( + pdu_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject an option set true that the hub would strip from a literal PDU's function code.""" + + def validator(config: ConfigType) -> ConfigType: + pdu = config[pdu_key] + if not isinstance(pdu, list): + return config + for direction in _COMMAND_OPTIONS: + for option in _command_options(direction): + if config.get(option.conf_key) is True and not option.applies_to( + pdu[0] + ): + raise cv.Invalid( + f"'{option.conf_key}: true' does not apply to function code " + f"0x{pdu[0]:02X}", + path=[option.conf_key], + ) + return config + + return validator def command_options_schema( - *, direction: Literal["read", "write"], templatable: bool = False + *, + direction: Literal["read", "write"], + templatable: bool = False, + function_code: int | None = None, ) -> dict[cv.Optional, Any]: - """Schema fragment for the per-command options a component forwards to the hub - (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are - direction-specific so a schema never offers an option the hub would strip (e.g. - continuous on a write); the write side has no options yet. For actions (templatable=True the - keys also accept lambdas), register the values with register_templatable_command_options(). + """Schema fragment for the per-command options of one direction; `function_code` (a typed + action's fixed code) leaves out the options that do not apply to it. """ return { cv.Optional(option.conf_key, default=option.default): ( cv.templatable(option.validator) if templatable else option.validator ) for option in _command_options(direction) + if function_code is None or option.applies_to(function_code) } @@ -130,6 +231,25 @@ def command_options_expression( ) +def add_command_options( + var: MockObj, + setter: str, + config: ConfigType, + *, + direction: Literal["read", "write"], +) -> None: + """Emit `var.()` for a config validated with command_options_schema() of the + same direction, skipped when every option is at its C++ default.""" + if all( + config.get(option.conf_key, option.default) == option.default + for option in _command_options(direction) + ): + return + cg.add( + getattr(var, setter)(command_options_expression(config, direction=direction)) + ) + + async def register_templatable_command_options( var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str ) -> None: diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index f428236a821..037901a8733 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); - if (cmd->frame.address() == BROADCAST_ADDRESS) { + if (cmd->fire_and_forget()) { // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above // reports the transmission, and the entry then retires with no terminal callback instead of // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already @@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M return false; } - if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) { - ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); - return false; - } - // Normalize the caller's options in place (the param is a by-value copy) so everything stored or // merged below carries effective options, never the raw request. // continuous is ignored for every mutating code (re-writing a value forever is never intended). @@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); options.continuous = false; } + if (address != BROADCAST_ADDRESS) { + options.allow_broadcast_read = false; + options.expect_broadcast_write_response = false; + } else { + const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]); + if (options.allow_broadcast_read && broadcastable) { + ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]); + options.allow_broadcast_read = false; + } + if (options.expect_broadcast_write_response && !broadcastable) { + ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]); + options.expect_broadcast_write_response = false; + } + if (!broadcastable && !options.allow_broadcast_read) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } + } // A duplicate of a live entry with the same owner is not queued twice; it resolves against that // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a @@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, item.pending); } + item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response; return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 7d7818239d5..1623c099a34 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -111,11 +111,15 @@ enum class FrameState : uint8_t { // Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). // A new field reaches the queue with no plumbing but arrives inert until it defines three rules: // normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in -// retire()/silent_retire(). +// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value. struct CommandOptions { // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. - bool continuous{false}; + bool continuous : 1 {false}; + // Wait for the reply to a read sent to address 0, for a device that answers the broadcast address. + bool allow_broadcast_read : 1 {false}; + bool expect_broadcast_write_response : 1 {false}; }; +static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte"); struct ModbusDeviceCommand { ModbusClientDevice *device; @@ -158,6 +162,10 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + bool fire_and_forget() const { + return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read && + !this->options.expect_broadcast_write_response; + } // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback. void complete_broadcast() { @@ -191,7 +199,8 @@ struct ModbusDeviceCommand { } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED this->state = FrameState::RETIRED; } - this->options = {}; // reset every option + // Only continuous ends with the clear; the delivery flags must survive for a granted retry. + this->options.continuous = false; } // True while the entry is still waiting for a response @@ -534,27 +543,27 @@ class ModbusClientDevice { return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } - bool write_single_register(uint16_t start_address, uint16_t value) { - return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); + bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options); } - bool write_single_coil(uint16_t address, bool value) { - return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); + bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options); } - bool write_multiple_registers(uint16_t start_address, std::span values) { + bool write_multiple_registers(uint16_t start_address, std::span values, CommandOptions options = {}) { // Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's. if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS) - return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values)); - return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. - bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); + bool write_multiple_coils(uint16_t start_address, std::span values, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. - bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); + bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options); } /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception /// (typically a rejected write half) arrives there too via its status - one callback handles both diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index a59eb910664..66ddcd7722d 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -7,7 +7,6 @@ from esphome.components import modbus import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_CONTINUOUS, CONF_COUNT, CONF_ID, CONF_ON_ERROR, @@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema( ) -def _no_continuous_on_write(config: ConfigType) -> ConfigType: - """Reject `continuous: true` on a static write PDU: continuous polling only applies to reads. - Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at - runtime, so a templated pdu or continuous falls through to that backstop.""" - pdu = config[CONF_PDU] - if ( - isinstance(pdu, list) - and config.get(CONF_CONTINUOUS) is True - and modbus.is_function_code_write(pdu[0]) - ): - raise cv.Invalid( - f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code " - f"0x{pdu[0]:02X}); continuous polling only applies to reads", - path=[CONF_CONTINUOUS], - ) - return config - - MODBUS_CLIENT_SEND_SCHEMA = cv.All( _ACTION_BASE_SCHEMA.extend( { @@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All( ) ), **modbus.command_options_schema(direction="read", templatable=True), + **modbus.command_options_schema(direction="write", templatable=True), cv.Optional(CONF_ON_RESPONSE): _handler_schema(), } ), - _no_continuous_on_write, + modbus.reject_inapplicable_command_options(CONF_PDU), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -261,8 +244,7 @@ async def register_client_action( var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf ) # Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the - # matching direction so a write action never generates a read option's setter; the write side - # has no options yet, so this is a no-op there. + # matching direction so a write action never generates a read option's setter. await modbus.register_templatable_command_options( var, config, args, command_direction ) @@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) cg.add(var.set_pdu(template_)) + # The read set is wired by register_client_action() below. + await modbus.register_templatable_command_options(var, config, args, "write") return await register_client_action( var, config, @@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All: } ), _no_address_overflow(CONF_COUNT), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al cv.Required(CONF_VALUES): cv.templatable( cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) ), + **modbus.command_options_schema(direction="write", templatable=True), } ), _no_address_overflow(CONF_VALUES), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) _READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) -_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +_WRITE_SINGLE_REGISTER_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) # A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. -_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +_WRITE_SINGLE_COIL_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), ) ), + # 0x17 counts as a read at address 0, so it takes allow_broadcast_read only. + **modbus.command_options_schema( + direction="read", templatable=True, function_code=0x17 + ), } ), _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 03744239a9b..4c1d11da838 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -85,18 +85,36 @@ template class ClientActionBase : public Action, public m /// builds its static struct; declaring the values here instead of per action means a new read option /// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up. /// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python -/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option -/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members. +/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin. template class ReadCommandOptions { public: // Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips // it for mutating function codes at the door (see modbus::CommandOptions). TEMPLATABLE_VALUE(bool, continuous) + TEMPLATABLE_VALUE(bool, allow_broadcast_read) protected: /// The options for this send, with every templatable value resolved against the action's arguments. modbus::CommandOptions command_options_(const Ts &...x) const { - return {.continuous = this->continuous_.value(x...)}; + return {.continuous = this->continuous_.value(x...), + .allow_broadcast_read = this->allow_broadcast_read_.value(x...)}; + } +}; + +/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys). +template class WriteCommandOptions { + public: + TEMPLATABLE_VALUE(bool, expect_broadcast_write_response) + + protected: + /// Resolves every write option into `options`, so send's merge of both sets stays exhaustive. + void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const { + options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...); + } + modbus::CommandOptions write_command_options_(const Ts &...x) const { + modbus::CommandOptions options{}; + this->apply_write_command_options_(options, x...); + return options; } }; @@ -107,8 +125,11 @@ template class ReadCommandOptions { /// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). /// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check /// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +/// A raw PDU may be a read or a write, so this action carries both option sets. template -class ModbusClientSendAction : public ClientActionBase, public ReadCommandOptions { +class ModbusClientSendAction : public ClientActionBase, + public ReadCommandOptions, + public WriteCommandOptions { public: TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) @@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase, public ReadComman return &this->response_trigger_; } - void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); } + void play(const Ts &...x) override { + modbus::CommandOptions options = this->command_options_(x...); + this->apply_write_command_options_(options, x...); + this->send_or_resolve_(this->pdu_.value(x...), options); + } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -218,7 +243,8 @@ template class ReadBitsAction : public TypedClientActionBase class WriteSingleRegisterAction : public TypedClientActionBase { +template +class WriteSingleRegisterAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(uint16_t, value) @@ -227,7 +253,8 @@ template class WriteSingleRegisterAction : public TypedClientAct void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -240,7 +267,8 @@ template class WriteSingleRegisterAction : public TypedClientAct /// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one /// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. -template class WriteSingleCoilAction : public TypedClientActionBase { +template +class WriteSingleCoilAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(bool, value) @@ -249,7 +277,8 @@ template class WriteSingleCoilAction : public TypedClientActionB void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -264,7 +293,8 @@ template class WriteSingleCoilAction : public TypedClientActionB /// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a /// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static /// list must not allocate on every play(). -template class WriteMultipleRegistersAction : public TypedClientActionBase { +template +class WriteMultipleRegistersAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -288,11 +318,13 @@ template class WriteMultipleRegistersAction : public TypedClient // the empty PDU then resolves via on_not_sent like any refused send. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( - start, std::span(this->values_.data, static_cast(this->len_)))); + start, std::span(this->values_.data, static_cast(this->len_))), + this->write_command_options_(x...)); return; } const std::vector values = this->values_.func(x...); - this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values)), + this->write_command_options_(x...)); } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { @@ -313,7 +345,8 @@ template class WriteMultipleRegistersAction : public TypedClient /// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() /// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than /// a byte - and is packed into a stack buffer on the way to the builder. -template class WriteMultipleCoilsAction : public TypedClientActionBase { +template +class WriteMultipleCoilsAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -334,13 +367,16 @@ template class WriteMultipleCoilsAction : public TypedClientActi const uint16_t start = this->start_address_.value(x...); if (this->count_ >= 0) { const auto count = static_cast(this->count_); - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( - start, - modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + this->send_or_resolve_( + modbus::helpers::create_write_coils_pdu( + start, modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), + count)), + this->write_command_options_(x...)); return; } // The builder packs and bound-checks; an over-long set is rejected and logged there. - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)), + this->write_command_options_(x...)); } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { @@ -359,7 +395,8 @@ template class WriteMultipleCoilsAction : public TypedClientActi /// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in /// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. -template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { +template +class ReadWriteMultipleRegistersAction : public TypedClientActionBase, public ReadCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, read_address) TEMPLATABLE_VALUE(uint16_t, read_count) @@ -385,13 +422,15 @@ template class ReadWriteMultipleRegistersAction : public TypedCl // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, - std::span(this->values_.data, static_cast(this->len_)))); + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_))), + this->command_options_(x...)); return; } const std::vector values = this->values_.func(x...); this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, std::span(values))); + read_start, read_count, write_start, std::span(values)), + this->command_options_(x...)); } // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index f888cc060e3..aa72a08a60b 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType: def _reject_broadcast_address(config: ConfigType) -> ConfigType: - """A modbus_controller polls one device, so its address cannot be the broadcast address (0): - a broadcast is never answered (Modbus 4.1), so no register could ever read back.""" + """Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0.""" + if config[modbus.CONF_ALLOW_BROADCAST_READ]: + if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; " + f"set 'address: 0' or remove the option.", + [modbus.CONF_ALLOW_BROADCAST_READ], + ) + return config modbus.reject_broadcast_address( config.get(CONF_ADDRESS), "a modbus_controller device address", - "Assign the unit address of the device you want to poll.", + "Assign the unit address of the device you want to poll, or set allow_broadcast_read if " + "it answers address 0.", [CONF_ADDRESS], ) return config @@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None: ) +def _reject_broadcastable_custom_pdu(config: ConfigType) -> None: + """A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered.""" + pdu = config.get(CONF_CUSTOM_PDU) + if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if ( + controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS + and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True + ): + raise cv.Invalid( + f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at " + f"address 0 and is never answered, so it can't be polled through the " + f"'{controller[CONF_ID]}' modbus_controller; use a read function code.", + [CONF_CUSTOM_PDU], + ) + + def validate_custom_pdu_item(config: ConfigType) -> None: - """Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor, - text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a - continuously-polling controller.""" + """Final-validate for the platforms that accept custom_pdu.""" migrate_custom_command(config) _reject_continuous_write_custom_pdu(config) + _reject_broadcastable_custom_pdu(config) + + +def _reject_write_option_off_broadcast(config: ConfigType) -> None: + if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the " + f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.", + [modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], + ) + + +def validate_writer_item(config: ConfigType) -> None: + """Final-validate for the writer platforms (number, output, select, switch).""" + if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config: + validate_custom_pdu_item(config) + _reject_write_option_off_broadcast(config) def _final_validate(config: ConfigType) -> None: @@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - cg.add( - var.set_read_options( - modbus.command_options_expression(config, direction="read") - ) - ) + modbus.add_command_options(var, "set_read_options", config, direction="read") await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c7fc10a0bb0..b8d06d3d5a3 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1 bool WriterDevice::send_raw_frame_deprecated(std::span frame) { if (frame.empty()) return false; - return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); + return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_); } void ControllerDevice::set_controller(ModbusController *controller) { @@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span request_pdu) { // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) // A custom polling command sends its PDU to this controller's own address, so only a factory custom // command (a raw frame staged in payload) can carry a different address byte. + // An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback. uint8_t wire_address = this->address_; if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty()) wire_address = this->payload.data()[0]; - if (wire_address == modbus::BROADCAST_ADDRESS) + const bool answered = this->controller_->read_options().allow_broadcast_read && + !modbus::helpers::is_function_code_broadcastable(request_pdu[0]); + if (wire_address == modbus::BROADCAST_ADDRESS && !answered) this->controller_->unqueue_command(this); } @@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) { this->one_shot_command_items_.push_back(make_unique(std::move(command))); // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. auto &item = this->one_shot_command_items_.back(); - // We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling. - if (!item->send()) { + // One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes). + if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) { // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), item->register_address()); @@ -340,7 +343,7 @@ void ModbusController::update() { if (this->can_send()) { for (auto &poll : this->polling_devices_) { ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address()); - // read_options_ carries the controller's continuous flag (the offline probe above sends it too). + // read_options_ carries the controller's read-side flags (the offline probe above sends them too). // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. if (!poll.queue(this->read_options_)) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address()); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 821c500a31e..741d4f6f00d 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice { void notify_online_(std::span request_pdu); - /// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's - /// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0. - bool dispatched_{false}; - bool write_buffer_deprecated_warned_{false}; + /// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag + /// leaves in 2027.3.0. + bool dispatched_ : 1 {false}; + bool write_buffer_deprecated_warned_ : 1 {false}; + modbus::CommandOptions write_options_{}; ModbusController *controller_{nullptr}; }; @@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice { bool dispatched() const { return this->dispatched_; } void set_dispatched() { this->dispatched_ = true; } void clear_dispatched() { this->dispatched_ = false; } + modbus::CommandOptions write_options() const { return this->write_options_; } + void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; } /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); @@ -326,27 +329,29 @@ class WriterEntity { /// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records /// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write. bool dispatched() const { return this->device_.dispatched(); } + void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); } bool write_single_register(uint16_t address, uint16_t value) { this->device_.set_dispatched(); - return this->device_.write_single_register(address, value); + return this->device_.write_single_register(address, value, this->device_.write_options()); } bool write_single_coil(uint16_t address, bool value) { this->device_.set_dispatched(); - return this->device_.write_single_coil(address, value); + return this->device_.write_single_coil(address, value, this->device_.write_options()); } bool write_multiple_registers(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_registers(address, values); + return this->device_.write_multiple_registers(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, values); + return this->device_.write_multiple_coils(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, bits); + return this->device_.write_multiple_coils(address, bits, this->device_.write_options()); } - bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + bool queue_pdu(std::span pdu) { return this->queue_pdu(pdu, this->device_.write_options()); } + bool queue_pdu(std::span pdu, modbus::CommandOptions options) { this->device_.set_dispatched(); return this->device_.queue_pdu(pdu, options); } diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index 6f7bf588af7..242e2eea218 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import number +from esphome.components import modbus, number from esphome.components.modbus.helpers import ( MODBUS_WRITE_REGISTER_TYPE, SENSOR_VALUE_TYPE, @@ -23,8 +23,8 @@ from .. import ( add_modbus_base_properties, modbus_calc_properties, modbus_controller_ns, - validate_custom_pdu_item, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STEP, default=1): cv.positive_float, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), validate_min_max, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) await add_modbus_base_properties(var, config, ModbusNumber) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 0e8d5363d74..c964ced987b 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import output +from esphome.components import modbus, output from esphome.components.modbus.helpers import ( SENSOR_VALUE_TYPE, PduBuffer, @@ -18,6 +18,7 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, + validate_writer_item, ) from ..const import ( CONF_CUSTOM_COMMAND, @@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), "holding": cv.All( @@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), reject_odd_holding_write_offset, @@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: byte_offset = modbus_calc_properties(config) # Binary Output @@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_parent(parent)) if write_template: cg.add(var.set_write_template(write_template)) diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index d8319932ab6..6fc8c8331cf 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any import esphome.codegen as cg -from esphome.components import select +from esphome.components import modbus, select from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC @@ -15,6 +15,7 @@ from .. import ( modbus_controller_ns, validate_range_reuse_migration, validate_skip_updates_deprecated, + validate_writer_item, ) from ..const import ( CONF_FORCE_NEW_RANGE, @@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Required(CONF_OPTIONSMAP): ensure_option_map(), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: options_map = config[CONF_OPTIONSMAP] @@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) cg.add(var.set_parent(parent)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if CONF_LAMBDA in config: diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 00b67446a31..2c5b92b810b 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import switch +from esphome.components import modbus, switch from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID @@ -13,9 +13,9 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, - validate_custom_pdu_item, validate_modbus_register, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, } ), @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") assumed_state = config[CONF_ASSUMED_STATE] cg.add(var.set_assumed_state(assumed_state)) if not assumed_state: diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py index 0e53c55b50b..1eafb131664 100644 --- a/tests/component_tests/modbus/test_modbus.py +++ b/tests/component_tests/modbus/test_modbus.py @@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None: def test_client_schema_still_accepts_address_zero() -> None: - # Not rejected for clients today, but not supported either: a client broadcast gets no reply and - # stalls the hub for the full send-wait. + # A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read. schema = modbus.modbus_device_schema(0x01) assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index cab944d825e..fcccae144e0 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv -from esphome.components import modbus_client +from esphome.components import modbus, modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, @@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None: def test_continuous_on_write_pdu_rejected() -> None: """A literal write-code PDU with continuous: true is rejected at config time (reads only).""" - with pytest.raises(cv.Invalid, match="does not apply to a write PDU"): + with pytest.raises(cv.Invalid, match="does not apply to function code"): MODBUS_CLIENT_SEND_SCHEMA( { CONF_ADDRESS: 0x01, @@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None: """ assert modbus_client.MULTI_CONF is True assert modbus_client.MULTI_CONF_NO_DEFAULT is True + + +@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ]) +def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None: + # A read option set true on a static write PDU is refused at validation, naming the key. + config = { + CONF_ADDRESS: 1, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + key: True, + } + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA(config) + + +def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None: + # allow_broadcast_read defaults to False and is accepted on a read PDU to address 0. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]} + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_send_rejects_write_option_on_static_read_pdu() -> None: + # The write-side option is refused on a static read PDU, the mirror of the read-option check. + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True} + ) + + +def test_send_accepts_write_option_on_static_write_pdu() -> None: + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True + + +def test_write_actions_offer_write_option_only() -> None: + # Every write action takes expect_broadcast_write_response and none of the read options. + from esphome.components.modbus_client import ( + _WRITE_MULTIPLE_COILS_SCHEMA, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + _WRITE_SINGLE_COIL_SCHEMA, + _WRITE_SINGLE_REGISTER_SCHEMA, + CONF_START_ADDRESS, + CONF_VALUE, + CONF_VALUES, + ) + + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True} + for schema, extra in ( + (_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}), + (_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}), + (_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}), + (_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}), + ): + config = schema({**base, **extra}) + assert config[write_key] is True + assert modbus.CONF_ALLOW_BROADCAST_READ not in config + with pytest.raises(cv.Invalid): + schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_send_options_follow_the_hub_classification() -> None: + # A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one; + # 0x17 is a read for broadcast purposes, so the reverse holds. + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + read_key = modbus.CONF_ALLOW_BROADCAST_READ + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True} + )[write_key] + with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True} + ) + pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01] + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True} + )[read_key] + with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True} + ) + + +def test_read_write_multiple_offers_allow_broadcast_read_only() -> None: + from esphome.components.modbus_client import ( + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + CONF_READ_ADDRESS, + CONF_VALUES, + CONF_WRITE_ADDRESS, + ) + + config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_READ_ADDRESS: 0x10, + CONF_WRITE_ADDRESS: 0x20, + CONF_VALUES: [1], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + assert CONF_CONTINUOUS not in config + assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config + + +@pytest.mark.parametrize( + "key", + [modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], +) +def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None: + # A broadcast-only option on a literal non-zero address would be silently dropped by the hub. + if key == modbus.CONF_ALLOW_BROADCAST_READ: + pdu = [0x03, 0x00, 0x10, 0x00, 0x01] + else: + pdu = [0x06, 0x00, 0x10, 0x00, 0x01] + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True}) + # A templated address is not decidable at validation and passes through. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True} + ) + assert config[key] is True diff --git a/tests/component_tests/modbus_controller/test_broadcast_address.py b/tests/component_tests/modbus_controller/test_broadcast_address.py new file mode 100644 index 00000000000..01bdacbf863 --- /dev/null +++ b/tests/component_tests/modbus_controller/test_broadcast_address.py @@ -0,0 +1,79 @@ +"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the +device answers it.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus_controller import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _controller(address: int, **extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra}) + + +def test_address_zero_rejected_by_default() -> None: + with pytest.raises(cv.Invalid, match="broadcast address"): + _controller(0) + + +def test_address_zero_accepted_with_allow_broadcast_read() -> None: + config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + assert config[CONF_ADDRESS] == 0 + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_allow_broadcast_read_defaults_false() -> None: + assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False + + +def test_writer_entity_takes_expect_broadcast_write_response() -> None: + # The write-side option lives on the writing platforms, not the controller. + from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID + from esphome.components.modbus_controller.switch import ( + CONFIG_SCHEMA as SWITCH_SCHEMA, + ) + from esphome.const import CONF_NAME + + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = { + CONF_MODBUS_CONTROLLER_ID: "ctl", + CONF_NAME: "Switch", + "register_type": "coil", + CONF_ADDRESS: 0x20, + } + assert SWITCH_SCHEMA(base)[key] is False + assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True}) + + +def test_allow_broadcast_read_requires_address_zero() -> None: + # The option only means something at address 0; elsewhere it would be silently inert. + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + _controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_add_command_options_skips_defaults() -> None: + # The setter is only emitted when an option differs from its C++ default. + import esphome.codegen as cg + from esphome.const import CONF_CONTINUOUS + + var = cg.MockObj("ctl") + emitted: list = [] + original = cg.add + cg.add = emitted.append + try: + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read" + ) + assert emitted == [] + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read" + ) + assert len(emitted) == 1 + assert "set_read_options" in str(emitted[0]) + finally: + cg.add = original diff --git a/tests/component_tests/modbus_controller/test_custom_pdu.py b/tests/component_tests/modbus_controller/test_custom_pdu.py index a3a18da07f4..592f6c12bad 100644 --- a/tests/component_tests/modbus_controller/test_custom_pdu.py +++ b/tests/component_tests/modbus_controller/test_custom_pdu.py @@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there. import pytest from voluptuous import Invalid, MultipleInvalid +from esphome.components import modbus from esphome.components.modbus_controller import ( ModbusItemBaseSchema, validate_custom_pdu_item, @@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None: ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]}) -def _controller_full_config(*, continuous: bool) -> Config: +def _controller_full_config( + *, continuous: bool, allow_broadcast_read: bool = False +) -> Config: """A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the - final-validate to resolve the controller (and its continuous flag) from an item's + final-validate to resolve the controller (and its option flags) from an item's modbus_controller_id.""" ctl_id = ID("ctl", is_declaration=True) config = Config() config["modbus_controller"] = [ - {CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous} + { + CONF_ID: ctl_id, + CONF_ADDRESS: 0 if allow_broadcast_read else 1, + CONF_CONTINUOUS: continuous, + modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read, + } ] config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID])) return config @@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None: CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], } ) + + +def test_broadcastable_custom_pdu_rejected_under_broadcast_controller( + reset_full_config, +) -> None: + """A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast, + never answered, so it is rejected at final validate.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + with pytest.raises(Invalid, match="is a real broadcast at address 0"): + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x41, 0x00, 0x03], + } + ) + + +def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None: + """A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], + } + ) + + +def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None: + """expect_broadcast_write_response on a writer entity whose controller is not at address 0 is + rejected at final validate, where the controller's address is known.""" + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set(_controller_full_config(continuous=False)) + with pytest.raises( + Invalid, match="only applies when the 'ctl' modbus_controller is at address 0" + ): + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + + +def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None: + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 18c04f32d5b..3bdfa094e0e 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { EXPECT_EQ(device.sent_count_, 0); // never transmitted } +// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent, +// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected + EXPECT_EQ(hub.entries(), 1u); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, reply); + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(reply)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from +// another unit id is an unexpected frame and interrupts the transaction as it would for any address. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(0x07, reply); + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a +// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + RetryingDevice device(&hub, BROADCAST_ADDRESS, true); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(BROADCAST_ADDRESS); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read); + + hub.timeout_waiting(); // retry granted: the entry is READY again + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply + EXPECT_EQ(hub.entries(), 1u); +} + +// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast, +// still fire-and-forget) and from a unicast frame (nothing to allow). +TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_EQ(broadcast_device.sent_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + +// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead +// of retiring at transmission, and the reply (from address 0) completes it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); + EXPECT_EQ(hub.entries(), 1u); + + hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0 + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(write)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a +// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in +// flight rather than absorbed, so the queued entry's delivery mode is never changed under it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 1u); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched + + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and +// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the +// send, so the absorbed request still gets its terminal callback. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true})); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed + EXPECT_EQ(hub.entries(), 1u); + EXPECT_FALSE(hub.queued(0).options.continuous); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); + hub.receive_frame_for_test(BROADCAST_ADDRESS, custom); + EXPECT_EQ(device.response_count_, 1); +} + +// A silent device leaves an expected write response to the normal send-wait timeout. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so +// the broadcast guard still refuses it) and from a unicast frame (nothing to expect). +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 0u); + + ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + // The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the // hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 76f7479a5cf..ce2965e449d 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -79,7 +79,8 @@ button: name: "Typed Actions" on_press: - modbus_client.write_single_register: - address: 0x01 + address: !lambda "return 1;" + expect_broadcast_write_response: true start_address: 0x0102 value: !lambda "return 42;" on_response: @@ -93,6 +94,7 @@ button: start_address: 0x10 count: 2 continuous: true + allow_broadcast_read: !lambda "return false;" on_response: then: - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..d6a29d7175b --- /dev/null +++ b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,36 @@ +# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that +# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: Broadcast probe + on_press: + - modbus_client.read_holding_registers: + address: 0 + allow_broadcast_read: true + start_address: 0x10 + count: 1 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);' + - modbus_client.write_single_register: + address: 0 + expect_broadcast_write_response: true + start_address: 0x0102 + value: 42 + on_response: + then: + - logger.log: "broadcast write acked" + - modbus_client.read_write_multiple_registers: + address: 0 + allow_broadcast_read: true + read_address: 0x10 + read_count: 1 + write_address: 0x20 + values: [1] + - modbus_client.send: + address: 0 + expect_broadcast_write_response: true + pdu: [0x41, 0x01] diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index b9a7610cb73..b488e51f3c8 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -6,7 +6,6 @@ modbus_controller: on_online: then: logger.log: "Module Online" - binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..49e89eaa20f --- /dev/null +++ b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,29 @@ +# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a +# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do +# not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +modbus_controller: + - id: modbus_controller_broadcast + address: 0 + allow_broadcast_read: true + modbus_id: modbus_bus + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_sensor + name: Broadcast Read Sensor + register_type: holding + address: 0x0010 + value_type: U_WORD + +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_switch + name: Broadcast Write Switch + register_type: coil + address: 0x20 + expect_broadcast_write_response: true From 0f500628dd001e0e4c0ec01921c2113925cad178 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 15 Sep 2026 17:33:33 +0100 Subject: [PATCH 145/266] [file] Keep resolved image paths as Path so config-hash normalizes them (#19267) Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 22 +++++----- .../unit_tests/components/file/test_image.py | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 7cef7c754a4..ab769954124 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -42,7 +42,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.cpp_generator import MockObj, MockObjClass from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value: str | ConfigType) -> str: - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) +def local_path(value: Path | ConfigType) -> Path: + # cv.file_ has already resolved the path against the config dir. + return value[CONF_PATH] if isinstance(value, dict) else value -def download_file(url: str, path: Path) -> str: +def download_file(url: str, path: Path) -> Path: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) - return str(path) + # Keep the Path: config-hash normalizes Path values under the data dir, + # which a str would dump verbatim and break the CLI/add-on comparison. + return path def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: @@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" -def download_gh_svg(value: str | ConfigType, source: str) -> str: +def download_gh_svg(value: str | ConfigType, source: str) -> Path: mdi_id = value[CONF_ICON] if isinstance(value, dict) else value url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) -def download_image(value: str | ConfigType) -> str: +def download_image(value: str | ConfigType) -> Path: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value: Any) -> str: +def validate_file_shorthand(value: Any) -> Path: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All( def mdi_schema(source: str) -> cv.All: - def validate_mdi(value: ConfigType) -> str: + def validate_mdi(value: ConfigType) -> Path: return download_gh_svg(value, source) return cv.All( diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py index a9c1684db39..727a4c8c1ef 100644 --- a/tests/unit_tests/components/file/test_image.py +++ b/tests/unit_tests/components/file/test_image.py @@ -5,8 +5,13 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest + +from esphome import yaml_util from esphome.components.file import image as file_image -from esphome.external_files import RemoteFile +from esphome.const import CONF_PATH +from esphome.core import CORE +from esphome.external_files import RemoteFile, url_cache_key from esphome.loader import get_component, get_platform @@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: assert files[1].url == "https://example.com/img.png" +def test_validated_file_values_hash_alike_across_data_dirs( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLI and an add-on data dir dump validated image files identically.""" + url = "https://example.com/img.png" + (setup_core / "img.png").touch() + dumps: list[str] = [] + for data_dir in ( + setup_core / ".esphome", + setup_core.parent / f"{setup_core.name}-data", + ): + monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir)) + with patch("esphome.components.file.image.external_files.download_content"): + config = { + "remote": file_image.validate_file_shorthand(url), + "mdi": file_image.validate_file_shorthand("mdi:home"), + "local": file_image.validate_file_shorthand("img.png"), + "local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}), + } + dumps.append( + yaml_util.dump( + config, + sort_keys=True, + relative_to=CORE.config_dir, + data_dir=CORE.data_dir, + ) + ) + assert dumps[0] == dumps[1] + assert dumps[0].splitlines() == [ + "local: img.png", + "local_schema: img.png", + "mdi: .esphome/image/mdi/home.svg", + f"remote: .esphome/image/{url_cache_key(url)}", + ] + + def test_extractor_matches_validator_path(setup_core: Path) -> None: """The path the validator downloads to equals the extractor's path.""" with patch( From 3f7725a6847b15696dc889b79d032f7a524e02c2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:00:05 +1200 Subject: [PATCH 146/266] Bump version to 2026.9.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 331d2f7984b..a5ed253819f 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b4 +PROJECT_NUMBER = 2026.9.0b5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 56961253557..8a9e9695859 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b4" +__version__ = "2026.9.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ec1d8fa9535ec51a69ac18592bd2e6b66bbf7f06 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 15 Sep 2026 21:06:40 -0400 Subject: [PATCH 147/266] [mdns] Add runtime service enable/disable API (ESP32 only) (#19325) --- esphome/components/mdns/__init__.py | 24 +++++++ esphome/components/mdns/mdns_component.h | 16 +++++ esphome/components/mdns/mdns_esp32.cpp | 69 ++++++++++++++---- esphome/core/defines.h | 3 + tests/component_tests/mdns/__init__.py | 0 .../mdns/test_service_enable_disable.py | 70 +++++++++++++++++++ 6 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/mdns/__init__.py create mode 100644 tests/component_tests/mdns/test_service_enable_disable.py diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index c8020104b37..0fb24fdf1df 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, CONF_ID, + CONF_MDNS, + CONF_OPENTHREAD, CONF_PORT, CONF_PROTOCOL, CONF_SERVICE, @@ -184,6 +186,28 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def request_service_enable_disable() -> bool: + """Request MDNSComponent::set_service_enabled() support. + + ESP32 only, not with OpenThread. Returns True when the + USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. + + Public API for external components. Do not remove. + """ + mdns_config = CORE.config.get(CONF_MDNS) + if ( + mdns_config is None + or mdns_config[CONF_DISABLED] + or not CORE.is_esp32 + or CONF_OPENTHREAD in CORE.config + ): + return False + cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") + # Services must stay stored so a disabled service can be re-registered + enable_mdns_storage() + return True + + @coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config: ConfigType) -> None: if config[CONF_DISABLED] is True: diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 4f97e8cb996..ed06b8e1330 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -63,6 +63,9 @@ struct MDNSService { const MDNSString *proto; TemplatableFn port; FixedVector txt_records; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool enabled{true}; +#endif }; class MDNSComponent final : public Component @@ -112,6 +115,19 @@ class MDNSComponent final : public Component const StaticVector &get_services() const { return this->services_; } #endif +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifndef USE_MDNS_STORE_SERVICES +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" +#endif +#ifdef USE_OPENTHREAD +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#endif + /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). + /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. + /// Returns true if the service is in the requested state afterwards. Blocks briefly on the mDNS task. + bool set_service_enabled(const char *service_type, const char *proto, bool enabled); +#endif + void on_shutdown() override; #ifdef USE_MDNS_DYNAMIC_TXT diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 17000a2bd76..48df61326e2 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ESP32) && defined(USE_MDNS) #include +#include #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -11,6 +12,23 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; +#ifndef USE_OPENTHREAD +static esp_err_t add_service(const MDNSService &service) { + // Stack buffer for up to 16 txt records, heap fallback for more + SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); + for (size_t i = 0; i < service.txt_records.size(); i++) { + const auto &record = service.txt_records[i]; + // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ + // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies + txt_records.get()[i].key = MDNS_STR_ARG(record.key); + txt_records.get()[i].value = MDNS_STR_ARG(record.value); + } + uint16_t port = service.port.value(); + return mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, + txt_records.get(), service.txt_records.size()); +} +#endif + static void register_esp32(MDNSComponent *comp, StaticVector &services) { #ifdef USE_OPENTHREAD // OpenThread handles service registration via SRP client @@ -27,27 +45,50 @@ static void register_esp32(MDNSComponent *comp, StaticVector txt_records(service.txt_records.size()); - for (size_t i = 0; i < service.txt_records.size(); i++) { - const auto &record = service.txt_records[i]; - // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ - // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies - txt_records.get()[i].key = MDNS_STR_ARG(record.key); - txt_records.get()[i].value = MDNS_STR_ARG(record.value); - } - uint16_t port = service.port.value(); - err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, - txt_records.get(), service.txt_records.size()); - + for (auto &service : services) { +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + if (!service.enabled) + continue; +#endif + err = add_service(service); if (err != ESP_OK) { ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err)); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Let a later enable call retry + service.enabled = false; +#endif } } #endif } +#if defined(USE_MDNS_SUPPORTS_ENABLE_DISABLE) && !defined(USE_OPENTHREAD) +bool MDNSComponent::set_service_enabled(const char *service_type, const char *proto, bool enabled) { + // services_ is compiled in setup() + if (!this->is_ready()) { + ESP_LOGW(TAG, "Cannot %s service %s before setup", enabled ? "enable" : "disable", service_type); + return false; + } + for (auto &service : this->services_) { + if (strcmp(MDNS_STR_ARG(service.service_type), service_type) != 0 || + strcmp(MDNS_STR_ARG(service.proto), proto) != 0) { + continue; + } + if (service.enabled == enabled) + return true; + esp_err_t err = enabled ? add_service(service) : mdns_service_remove(service_type, proto); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to %s service %s: %s", enabled ? "enable" : "disable", service_type, esp_err_to_name(err)); + return false; + } + service.enabled = enabled; + return true; + } + ESP_LOGW(TAG, "Service %s not found", service_type); + return false; +} +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD + void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } void MDNSComponent::on_shutdown() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f6010fd7fa0..b36d39bbefa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -479,6 +479,9 @@ #define USE_OPENTHREAD #define USE_ZIGBEE #endif +#ifndef USE_OPENTHREAD +#define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#endif #endif #if defined(USE_ESP32_VARIANT_ESP32S2) diff --git a/tests/component_tests/mdns/__init__.py b/tests/component_tests/mdns/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py new file mode 100644 index 00000000000..eacf722f177 --- /dev/null +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -0,0 +1,70 @@ +"""request_service_enable_disable() only opts in on platforms whose mDNS stack +can add and remove services after setup, and tells the caller so.""" + +import pytest + +from esphome.components import mdns +from esphome.const import CONF_DISABLED, PlatformFramework +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +DEFINE = "USE_MDNS_SUPPORTS_ENABLE_DISABLE" + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def _set_config( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + config: dict, +) -> None: + set_core_config(platform_framework) + CORE.config = config + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO], +) +def test_esp32_adds_define_and_keeps_services_stored( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is True + # Disabled services must stay stored so they can be re-registered later. + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], +) +def test_other_platforms_return_false( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() + + +@pytest.mark.parametrize( + "config", + [ + pytest.param({}, id="no_mdns"), + pytest.param({"mdns": {CONF_DISABLED: True}}, id="mdns_disabled"), + pytest.param( + {"mdns": {CONF_DISABLED: False}, "openthread": {}}, id="openthread" + ), + ], +) +def test_esp32_returns_false_when_services_cannot_be_toggled( + set_core_config: SetCoreConfigCallable, config: dict +) -> None: + _set_config(set_core_config, PlatformFramework.ESP32_IDF, config) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() From d824a32ef1f77c8be80e3d5800c2ca8848585a49 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 20:32:15 -0500 Subject: [PATCH 148/266] [uart_mux] New component to share a UART between a CDC-ACM bridge and local consumers (#19066) Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../cdc_acm_uart/bridge/cdc_acm_uart_bridge.h | 1 + esphome/components/uart_mux/__init__.py | 84 ++++++++++++ esphome/components/uart_mux/uart_mux.cpp | 127 ++++++++++++++++++ esphome/components/uart_mux/uart_mux.h | 109 +++++++++++++++ script/analyze_component_buses.py | 1 + tests/component_tests/uart_mux/__init__.py | 0 tests/component_tests/uart_mux/test_init.py | 42 ++++++ tests/components/uart_mux/common.yaml | 44 ++++++ .../uart_mux/test.esp32-p4-idf.yaml | 2 + .../uart_mux/test.esp32-s2-idf.yaml | 7 + .../uart_mux/test.esp32-s3-idf.yaml | 2 + 12 files changed, 420 insertions(+) create mode 100644 esphome/components/uart_mux/__init__.py create mode 100644 esphome/components/uart_mux/uart_mux.cpp create mode 100644 esphome/components/uart_mux/uart_mux.h create mode 100644 tests/component_tests/uart_mux/__init__.py create mode 100644 tests/component_tests/uart_mux/test_init.py create mode 100644 tests/components/uart_mux/common.yaml create mode 100644 tests/components/uart_mux/test.esp32-p4-idf.yaml create mode 100644 tests/components/uart_mux/test.esp32-s2-idf.yaml create mode 100644 tests/components/uart_mux/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 246a210c7cb..044d0051193 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -589,6 +589,7 @@ esphome/components/uart/* @esphome/core esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd esphome/components/uart/packet_transport/* @clydebarrow +esphome/components/uart_mux/* @kbx81 esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h index 64522c86bd5..405b794653d 100644 --- a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -30,6 +30,7 @@ class CDCACMUARTBridge final : public Component { void set_line_coding(); void set_line_state(bool dtr, bool rts); + uart::IDFUARTComponent *get_uart_parent() const { return this->uart_parent_; } /** * Stop forwarding in both directions and hand the UART back to its configured diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py new file mode 100644 index 00000000000..6c479f0cdd2 --- /dev/null +++ b/esphome/components/uart_mux/__init__.py @@ -0,0 +1,84 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32, uart +from esphome.components.cdc_acm_uart.bridge import CDCACMUARTBridge +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DOMAIN = "uart_mux" +DEPENDENCIES = ["bridge", "uart"] +MULTI_CONF = True + +CONF_BRIDGE_ID = "bridge_id" +CONF_INITIAL_ROUTE = "initial_route" +ROUTE_BRIDGE = "bridge" +ROUTE_LOCAL = "local" + +uart_mux_ns = cg.esphome_ns.namespace("uart_mux") +UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) +SelectLocalAction = uart_mux_ns.class_("SelectLocalAction", automation.Action) +SelectBridgeAction = uart_mux_ns.class_("SelectBridgeAction", automation.Action) +IsLocalCondition = uart_mux_ns.class_("IsLocalCondition", automation.Condition) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UARTMux), + cv.Required(CONF_BRIDGE_ID): cv.use_id(CDCACMUARTBridge), + cv.Optional(CONF_INITIAL_ROUTE, default=ROUTE_BRIDGE): cv.one_of( + ROUTE_BRIDGE, ROUTE_LOCAL, lower=True + ), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + # Two muxes on one bridge would each believe they own the bus. + owned = fv.full_config.get().data.setdefault(DOMAIN, set()) + bridge_id = str(config[CONF_BRIDGE_ID]) + if bridge_id in owned: + raise cv.Invalid( + f"The bridge '{bridge_id}' is already routed by another 'uart_mux'; " + "each bridge supports one mux.", + [CONF_BRIDGE_ID], + ) + owned.add(bridge_id) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + bridge = await cg.get_variable(config[CONF_BRIDGE_ID]) + var = cg.new_Pvariable(config[CONF_ID], bridge) + await cg.register_component(var, config) + if config[CONF_INITIAL_ROUTE] == ROUTE_LOCAL: + cg.add(var.set_start_local(True)) + + +UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(UARTMux)} +) + + +automation.register_simple_action( + "uart_mux.select_local", SelectLocalAction, UART_MUX_ACTION_SCHEMA, synchronous=True +) +automation.register_simple_action( + "uart_mux.select_bridge", + SelectBridgeAction, + UART_MUX_ACTION_SCHEMA, + synchronous=True, +) +automation.register_simple_condition( + "uart_mux.is_local", IsLocalCondition, UART_MUX_ACTION_SCHEMA +) diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp new file mode 100644 index 00000000000..df2ee5ccf59 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -0,0 +1,127 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "uart_mux.h" +#include "esphome/core/log.h" + +#include "driver/uart.h" + +namespace esphome::uart_mux { + +static const char *const TAG = "uart_mux"; + +void UARTMux::setup() { + // A failed UART never assigned its port; nothing behind the mux can work. + if (this->uart_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->settings_ = { + this->uart_->get_baud_rate(), this->uart_->get_rx_full_threshold(), this->uart_->get_rx_timeout(), + this->uart_->get_rx_buffer_size(), this->uart_->get_data_bits(), this->uart_->get_stop_bits(), + this->uart_->get_parity(), + }; + this->apply_settings_(); + + if (this->start_local_) { + this->select_local(); + } else { + // loop() only completes hand-offs; the bridge keeps the UART until an action. + this->disable_loop(); + } +} + +void UARTMux::loop() { + if (!this->bridge_->is_paused()) { + return; + } + // Bytes that arrived during the hand-off belong to neither owner. + this->flush_input_(); + this->route_ = Route::ROUTE_LOCAL; + ESP_LOGD(TAG, "UART routed to local consumers"); + this->disable_loop(); +} + +void UARTMux::dump_config() { + ESP_LOGCONFIG(TAG, + "UART Mux:\n" + " Start local: %s\n" + " Route: %s", + YESNO(this->start_local_), + this->route_ == Route::ROUTE_LOCAL ? LOG_STR_LITERAL("local") + : this->route_ == Route::ROUTE_PENDING_LOCAL ? LOG_STR_LITERAL("pending local") + : LOG_STR_LITERAL("bridge")); +} + +void UARTMux::load_settings(bool dump_config) { + if (!this->load_settings_warned_) { + this->load_settings_warned_ = true; + ESP_LOGW(TAG, "load_settings() ignored; change the framing on the hardware UART instead"); + } + // Undo whatever the caller set on us. Not re-sampled from the live UART, whose + // fields carry the host's line coding while the bridge owns the bus. + this->apply_settings_(); +} + +void UARTMux::apply_settings_() { + this->baud_rate_ = this->settings_.baud_rate; + this->data_bits_ = this->settings_.data_bits; + this->stop_bits_ = this->settings_.stop_bits; + this->parity_ = this->settings_.parity; + this->rx_full_threshold_ = this->settings_.rx_full_threshold; + this->rx_timeout_ = this->settings_.rx_timeout; + this->rx_buffer_size_ = this->settings_.rx_buffer_size; +} + +void UARTMux::select_local() { + if (this->route_ != Route::ROUTE_BRIDGE) { + return; + } + ESP_LOGD(TAG, "Pausing bridge to route UART locally"); + this->bridge_->pause(); + this->route_ = Route::ROUTE_PENDING_LOCAL; + this->enable_loop(); +} + +void UARTMux::select_bridge() { + if (this->route_ == Route::ROUTE_BRIDGE) { + return; + } + // A bridge that failed setup() has no worker tasks; handing it the bus would kill + // the UART in both directions. + if (this->bridge_->is_failed()) { + ESP_LOGW(TAG, "Bridge failed; keeping the UART routed locally"); + return; + } + // While the pause is still pending the bridge's RX task may be inside + // uart_read_bytes() on this port, and nothing local has run, so flush only a + // completed hand-off. + if (this->route_ == Route::ROUTE_LOCAL) { + this->flush_input_(); + } + this->route_ = Route::ROUTE_BRIDGE; + ESP_LOGD(TAG, "UART routed to bridge"); + this->bridge_->resume(); + this->disable_loop(); +} + +void UARTMux::flush_input_() { + // Drain the UART component's one-byte peek cache first: the driver flush does not + // clear it, and draining afterwards could discard a freshly arrived byte instead. + uint8_t discard; + if (this->uart_->available() > 0) { + this->uart_->read_byte(&discard); + } + uart_flush_input(static_cast(this->uart_->get_hw_serial_number())); +} + +void UARTMux::write_array(const uint8_t *data, size_t len) { + if (!this->is_local()) { + ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len); + return; + } + this->uart_->write_array(data, len); +} + +} // namespace esphome::uart_mux +#endif diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h new file mode 100644 index 00000000000..1eb23747142 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.h @@ -0,0 +1,109 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +namespace esphome::uart_mux { + +/// Shares one hardware UART between a CDC-ACM UART bridge and local consumers. Local +/// consumers bind to the mux as their UART; it forwards to the hardware UART only +/// while routed locally and reports the route through is_connected(). Routing is +/// driven by the select_*() actions, typically from tinyusb's on_mount/on_unmount. +class UARTMux final : public uart::UARTComponent, public Component { + public: + explicit UARTMux(cdc_acm_uart::CDCACMUARTBridge *bridge) : uart_(bridge->get_uart_parent()), bridge_(bridge) {} + + void setup() override; + void loop() override; + void dump_config() override; + // Between the hardware UART (BUS) and its consumers (modbus is BUS - 1): the + // mirrored framing must exist before anything reads it from us. + float get_setup_priority() const override { return setup_priority::BUS - 0.5f; } + + /// Route locally at boot instead of leaving the UART with the bridge. + void set_start_local(bool start_local) { this->start_local_ = start_local; } + + /// Pause the bridge and route the UART to local consumers once it has stopped. + void select_local(); + /// Route the UART back to the bridge. + void select_bridge(); + bool is_local() const { return this->route_ == Route::ROUTE_LOCAL; } + + // uart::UARTComponent: forwarded while routed locally, inert otherwise. + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override { return this->is_local() && this->uart_->peek_byte(data); } + bool read_array(uint8_t *data, size_t len) override { return this->is_local() && this->uart_->read_array(data, len); } + size_t available() override { return this->is_local() ? this->uart_->available() : 0; } + uart::UARTFlushResult flush() override { + return this->is_local() ? this->uart_->flush() : uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; + } + bool is_connected() override { return this->is_local(); } + // Ignored: the bridge's tasks block inside the driver, and reinstalling it would + // pull it out from under them. The framing is the hardware UART's to change. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; + + protected: + enum class Route : uint8_t { + ROUTE_BRIDGE, + ROUTE_PENDING_LOCAL, // pause() requested; the bridge may still be on the bus + ROUTE_LOCAL, + }; + + // The hardware UART's settings as configured. Taken once at setup, before the + // bridge can overwrite the live fields with a host's line coding. + struct Settings { + uint32_t baud_rate; + size_t rx_full_threshold; + size_t rx_timeout; + size_t rx_buffer_size; + uint8_t data_bits; + uint8_t stop_bits; + uart::UARTParityOptions parity; + }; + + void check_logger_conflict() override {} + void flush_input_(); + // Publish settings_ through the UARTComponent getters. + void apply_settings_(); + + uart::IDFUARTComponent *uart_; + cdc_acm_uart::CDCACMUARTBridge *bridge_; + Settings settings_{}; + Route route_{Route::ROUTE_BRIDGE}; + bool start_local_{false}; + bool load_settings_warned_{false}; +}; + +template class SelectLocalAction final : public Action { + public: + explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->select_local(); } + + protected: + UARTMux *parent_; +}; + +template class SelectBridgeAction final : public Action { + public: + explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->select_bridge(); } + + protected: + UARTMux *parent_; +}; + +template class IsLocalCondition final : public Condition { + public: + explicit IsLocalCondition(UARTMux *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_local(); } + + protected: + UARTMux *parent_; +}; + +} // namespace esphome::uart_mux +#endif diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index b805d5155a2..8bbb9ed7f9b 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -97,6 +97,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "uart_mux": "Depends on tinyusb which conflicts with usb_host", "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } diff --git a/tests/component_tests/uart_mux/__init__.py b/tests/component_tests/uart_mux/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/uart_mux/test_init.py b/tests/component_tests/uart_mux/test_init.py new file mode 100644 index 00000000000..2221ec56694 --- /dev/null +++ b/tests/component_tests/uart_mux/test_init.py @@ -0,0 +1,42 @@ +"""Tests for the uart_mux component's final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONF_BRIDGE_ID = "bridge_id" + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None: + from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3 + + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S3} + ) + + +def _mux_conf(mux_id: str, bridge_id: str) -> ConfigType: + return {CONF_ID: ID(mux_id), CONF_BRIDGE_ID: ID(bridge_id)} + + +def test_accepts_one_mux_per_bridge(set_core_config: SetCoreConfigCallable) -> None: + _set_esp32_s3(set_core_config) + from esphome.components import uart_mux + + uart_mux._final_validate(_mux_conf("mux_0", "bridge_0")) + uart_mux._final_validate(_mux_conf("mux_1", "bridge_1")) + + +def test_rejects_two_muxes_on_one_bridge( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config) + from esphome.components import uart_mux + + uart_mux._final_validate(_mux_conf("mux_0", "bridge_0")) + with pytest.raises(cv.Invalid, match="already routed by another 'uart_mux'"): + uart_mux._final_validate(_mux_conf("mux_1", "bridge_0")) diff --git a/tests/components/uart_mux/common.yaml b/tests/components/uart_mux/common.yaml new file mode 100644 index 00000000000..3477f78b708 --- /dev/null +++ b/tests/components/uart_mux/common.yaml @@ -0,0 +1,44 @@ +tinyusb: + id: tinyusb_test + on_mount: + - uart_mux.select_bridge: mux_0 + on_unmount: + - uart_mux.select_local: mux_0 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_vendor_id: 0x2345 + +uart: + - id: uart_0 + tx_pin: 14 + rx_pin: 13 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_1 + +bridge: + - platform: cdc_acm_uart + id: bridge_0 + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + +uart_mux: + - id: mux_0 + bridge_id: bridge_0 + initial_route: local + +interval: + - interval: 60s + then: + - if: + condition: + uart_mux.is_local: mux_0 + then: + - lambda: |- + uint8_t byte; + if (id(mux_0).available() && id(mux_0).read_byte(&byte)) { + id(mux_0).write_byte(byte); + } diff --git a/tests/components/uart_mux/test.esp32-p4-idf.yaml b/tests/components/uart_mux/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..ced6f1158eb --- /dev/null +++ b/tests/components/uart_mux/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s2-idf.yaml b/tests/components/uart_mux/test.esp32-s2-idf.yaml new file mode 100644 index 00000000000..5eaa3b38479 --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s2-idf.yaml @@ -0,0 +1,7 @@ +# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares +# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead. +logger: + hardware_uart: UART0 + +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s3-idf.yaml b/tests/components/uart_mux/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..ced6f1158eb --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml From a264093d86242919777fd869aa3fa794c3068edb Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 21:57:06 -0500 Subject: [PATCH 149/266] [uart] Add IDFUARTComponent::flush_input() and use it in uart_mux (#19336) Co-authored-by: Claude Fable 5.1 --- esphome/components/uart/uart_component_esp_idf.h | 6 ++++++ esphome/components/uart_mux/uart_mux.cpp | 16 ++-------------- esphome/components/uart_mux/uart_mux.h | 1 - 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index d9297bfa34a..3b8603f2ac2 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -37,6 +37,12 @@ class IDFUARTComponent final : public UARTComponent, public Component { uint8_t get_hw_serial_number() { return this->uart_num_; } + /// Discard everything received so far: the peek cache and the driver's RX buffer. + void flush_input() { + this->has_peek_ = false; + uart_flush_input(this->uart_num_); + } + /** * Load the UART with the current settings. * @param dump_config (Optional, default `true`): True for displaying new settings or diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp index df2ee5ccf59..953e81d5337 100644 --- a/esphome/components/uart_mux/uart_mux.cpp +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -2,8 +2,6 @@ #include "uart_mux.h" #include "esphome/core/log.h" -#include "driver/uart.h" - namespace esphome::uart_mux { static const char *const TAG = "uart_mux"; @@ -36,7 +34,7 @@ void UARTMux::loop() { return; } // Bytes that arrived during the hand-off belong to neither owner. - this->flush_input_(); + this->uart_->flush_input(); this->route_ = Route::ROUTE_LOCAL; ESP_LOGD(TAG, "UART routed to local consumers"); this->disable_loop(); @@ -97,7 +95,7 @@ void UARTMux::select_bridge() { // uart_read_bytes() on this port, and nothing local has run, so flush only a // completed hand-off. if (this->route_ == Route::ROUTE_LOCAL) { - this->flush_input_(); + this->uart_->flush_input(); } this->route_ = Route::ROUTE_BRIDGE; ESP_LOGD(TAG, "UART routed to bridge"); @@ -105,16 +103,6 @@ void UARTMux::select_bridge() { this->disable_loop(); } -void UARTMux::flush_input_() { - // Drain the UART component's one-byte peek cache first: the driver flush does not - // clear it, and draining afterwards could discard a freshly arrived byte instead. - uint8_t discard; - if (this->uart_->available() > 0) { - this->uart_->read_byte(&discard); - } - uart_flush_input(static_cast(this->uart_->get_hw_serial_number())); -} - void UARTMux::write_array(const uint8_t *data, size_t len) { if (!this->is_local()) { ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len); diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h index 1eb23747142..8e32a166434 100644 --- a/esphome/components/uart_mux/uart_mux.h +++ b/esphome/components/uart_mux/uart_mux.h @@ -66,7 +66,6 @@ class UARTMux final : public uart::UARTComponent, public Component { }; void check_logger_conflict() override {} - void flush_input_(); // Publish settings_ through the UARTComponent getters. void apply_settings_(); From 160bc29c7c1ea27033ac99cdcf9bf9aa109516d1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:01:34 +1200 Subject: [PATCH 150/266] Bump version to 2026.9.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a5ed253819f..a5d6da63332 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b5 +PROJECT_NUMBER = 2026.9.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 8a9e9695859..e6165bf16b0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b5" +__version__ = "2026.9.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From b0996ac4045ee93ad5bd3284e35ec90652ca4cdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:11:57 -0500 Subject: [PATCH 151/266] [template] Skip the switch optimistic and assumed state setters when they match the default (#19232) --- esphome/components/template/switch/__init__.py | 7 +++++-- .../template/switch/template_switch.h | 1 + .../template/config/switch_defaults.yaml | 18 ++++++++++++++++++ .../template/test_template_switch.py | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/template/config/switch_defaults.yaml create mode 100644 tests/component_tests/template/test_template_switch.py diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index 37303abb0d7..0b246869363 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -72,8 +72,11 @@ async def to_code(config): await automation.build_automation( var.get_turn_on_trigger(), [], config[CONF_TURN_ON_ACTION] ) - cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) - cg.add(var.set_assumed_state(config[CONF_ASSUMED_STATE])) + # optimistic_ and assumed_state_ are false in C++; only emit setters to turn them on. + if config[CONF_OPTIMISTIC]: + cg.add(var.set_optimistic(True)) + if config[CONF_ASSUMED_STATE]: + cg.add(var.set_assumed_state(True)) @automation.register_action( diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 1714b4f72b9..3b8b6cde424 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -29,6 +29,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { void write_state(bool state) override; TemplateLambda f_; + // Codegen only emits these setters to turn them on bool optimistic_{false}; bool assumed_state_{false}; Trigger<> turn_on_trigger_; diff --git a/tests/component_tests/template/config/switch_defaults.yaml b/tests/component_tests/template/config/switch_defaults.yaml new file mode 100644 index 00000000000..4387fe07a5b --- /dev/null +++ b/tests/component_tests/template/config/switch_defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +switch: + - platform: template + id: plain_switch + turn_on_action: + - logger.log: "on" + - platform: template + id: enabled_switch + optimistic: true + assumed_state: true diff --git a/tests/component_tests/template/test_template_switch.py b/tests/component_tests/template/test_template_switch.py new file mode 100644 index 00000000000..11c6a9cab87 --- /dev/null +++ b/tests/component_tests/template/test_template_switch.py @@ -0,0 +1,17 @@ +"""Tests for the template switch codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flags_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only true optimistic and assumed_state are set; false is the C++ initializer.""" + main_cpp = generate_main(component_config_path("switch_defaults.yaml")) + + assert "plain_switch->set_optimistic(" not in main_cpp + assert "plain_switch->set_assumed_state(" not in main_cpp + assert "enabled_switch->set_optimistic(true);" in main_cpp + assert "enabled_switch->set_assumed_state(true);" in main_cpp From 1eb445bacbc05c259bcead7a46b4b9c76b777218 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:12:48 -0500 Subject: [PATCH 152/266] [template] Move set_optimistic into the headers (#19278) --- esphome/components/template/cover/template_cover.cpp | 1 - esphome/components/template/cover/template_cover.h | 2 +- esphome/components/template/lock/template_lock.cpp | 1 - esphome/components/template/lock/template_lock.h | 2 +- esphome/components/template/switch/template_switch.cpp | 1 - esphome/components/template/switch/template_switch.h | 2 +- esphome/components/template/valve/template_valve.cpp | 1 - esphome/components/template/valve/template_valve.h | 2 +- 8 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index d5e0967e1e3..93ad887e580 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -53,7 +53,6 @@ void TemplateCover::loop() { if (changed) this->publish_state(); } -void TemplateCover::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index 20c092cda79..cca21046202 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -25,7 +25,7 @@ class TemplateCover final : public cover::Cover, public Component { Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void set_has_stop(bool has_stop); void set_has_position(bool has_position); diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 6e73623ae9b..4a293aab858 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -47,7 +47,6 @@ void TemplateLock::open_latch() { this->prev_trigger_ = &this->open_trigger_; this->open_trigger_.trigger(); } -void TemplateLock::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } float TemplateLock::get_setup_priority() const { return setup_priority::HARDWARE; } void TemplateLock::dump_config() { LOG_LOCK("", "Template Lock", this); diff --git a/esphome/components/template/lock/template_lock.h b/esphome/components/template/lock/template_lock.h index 03e3e86d88e..9b0a1ffe984 100644 --- a/esphome/components/template/lock/template_lock.h +++ b/esphome/components/template/lock/template_lock.h @@ -18,7 +18,7 @@ class TemplateLock final : public lock::Lock, public Component { Trigger<> *get_lock_trigger() { return &this->lock_trigger_; } Trigger<> *get_unlock_trigger() { return &this->unlock_trigger_; } Trigger<> *get_open_trigger() { return &this->open_trigger_; } - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void loop() override; float get_setup_priority() const override; diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 05288b2d4e0..27134fc8b29 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -29,7 +29,6 @@ void TemplateSwitch::write_state(bool state) { if (this->optimistic_) this->publish_state(state); } -void TemplateSwitch::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } bool TemplateSwitch::assumed_state() { return this->assumed_state_; } float TemplateSwitch::get_setup_priority() const { return setup_priority::HARDWARE - 2.0f; } Trigger<> *TemplateSwitch::get_turn_on_trigger() { return &this->turn_on_trigger_; } diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 3b8b6cde424..9af8517f9ac 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -17,7 +17,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { template void set_state_lambda(F &&f) { this->f_.set(std::forward(f)); } Trigger<> *get_turn_on_trigger(); Trigger<> *get_turn_off_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void loop() override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index 3ebeec12856..c9aa161b117 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -47,7 +47,6 @@ void TemplateValve::loop() { this->publish_state(); } -void TemplateValve::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 76c4630aa02..e123f66d7e8 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -23,7 +23,7 @@ class TemplateValve final : public valve::Valve, public Component { Trigger<> *get_stop_trigger(); Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void set_has_stop(bool has_stop); void set_has_position(bool has_position); From 70097ae02fca195ce31c8bd3ec6db78cd8c1b855 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:06 -0500 Subject: [PATCH 153/266] [sds011] Move set_rx_mode_only into the header (#19298) --- esphome/components/sds011/sds011.cpp | 2 -- esphome/components/sds011/sds011.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 1c222e5e803..dfc7857266d 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -106,8 +106,6 @@ void SDS011Component::loop() { } } -void SDS011Component::set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } - void SDS011Component::sds011_write_command_(const uint8_t *command_data) { this->write_byte(SDS011_MSG_HEAD); this->write_byte(SDS011_COMMAND_ID_REQUEST); diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 4f4571ab693..0a896cdc4cd 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -12,7 +12,7 @@ class SDS011Component final : public Component, public uart::UARTDevice { SDS011Component() = default; /// Manually set the rx-only mode. Defaults to false. - void set_rx_mode_only(bool rx_mode_only); + void set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5_sensor) { pm_2_5_sensor_ = pm_2_5_sensor; } void set_pm_10_0_sensor(sensor::Sensor *pm_10_0_sensor) { pm_10_0_sensor_ = pm_10_0_sensor; } From f95a876ef2874642daa3fc1adab30368a7c35b70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:20 -0500 Subject: [PATCH 154/266] [max44009] Move set_mode into the header (#19297) --- esphome/components/max44009/max44009.cpp | 2 -- esphome/components/max44009/max44009.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index 6b8bdc8de5e..731f5840563 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -134,6 +134,4 @@ void MAX44009Sensor::write_(uint8_t reg, uint8_t value) { } } -void MAX44009Sensor::set_mode(MAX44009Mode mode) { this->mode_ = mode; } - } // namespace esphome::max44009 diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index b62aed7a567..5eb1555350a 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -16,7 +16,7 @@ class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, pub void setup() override; void dump_config() override; void update() override; - void set_mode(MAX44009Mode mode); + void set_mode(MAX44009Mode mode) { this->mode_ = mode; } bool set_continuous_mode(); bool set_low_power_mode(); From da3ddee767ff317f61d1dc73763cdef00ad3bced Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:34 -0500 Subject: [PATCH 155/266] [st7789v] Move set_model_str into the header (#19296) --- esphome/components/st7789v/st7789v.cpp | 2 -- esphome/components/st7789v/st7789v.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index b3a60af8c33..2e07e24522c 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -152,8 +152,6 @@ void ST7789V::update() { this->write_display_data(); } -void ST7789V::set_model_str(const char *model_str) { this->model_str_ = model_str; } - void ST7789V::write_display_data() { uint16_t x1 = this->offset_width_; uint16_t x2 = x1 + get_width_internal() - 1; diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 1b7ba318a6c..4011e607c25 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -110,7 +110,7 @@ class ST7789V final : public display::DisplayBuffer, public spi::SPIDevice { public: - void set_model_str(const char *model_str); + void set_model_str(const char *model_str) { this->model_str_ = model_str; } void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } void set_backlight_pin(GPIOPin *backlight_pin) { this->backlight_pin_ = backlight_pin; } From a6fa67b7cac99e36e23d55a39eeed500dcb615ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:49 -0500 Subject: [PATCH 156/266] [bang_bang] Move the single store setters into the header (#19287) --- esphome/components/bang_bang/bang_bang_climate.cpp | 6 ------ esphome/components/bang_bang/bang_bang_climate.h | 8 ++++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index 5dfb1213429..a1104aa1b2a 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -203,16 +203,10 @@ void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &awa this->away_config_ = away_config; } -void BangBangClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } -void BangBangClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } - Trigger<> *BangBangClimate::get_idle_trigger() { return &this->idle_trigger_; } Trigger<> *BangBangClimate::get_cool_trigger() { return &this->cool_trigger_; } Trigger<> *BangBangClimate::get_heat_trigger() { return &this->heat_trigger_; } -void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } -void BangBangClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void BangBangClimate::dump_config() { LOG_CLIMATE("", "Bang Bang Climate", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index d83257f9f34..fff9bf873ff 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -22,10 +22,10 @@ class BangBangClimate final : public climate::Climate, public Component { void setup() override; void dump_config() override; - void set_sensor(sensor::Sensor *sensor); - void set_humidity_sensor(sensor::Sensor *humidity_sensor); - void set_supports_cool(bool supports_cool); - void set_supports_heat(bool supports_heat); + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_normal_config(const BangBangClimateTargetTempConfig &normal_config); void set_away_config(const BangBangClimateTargetTempConfig &away_config); From 2f76ac6362ae257ba1cc0c3c80828549bcd53f8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:05 -0500 Subject: [PATCH 157/266] [thermostat] Move set_default_preset into the header (#19294) --- esphome/components/thermostat/thermostat_climate.cpp | 2 -- esphome/components/thermostat/thermostat_climate.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index e830d359c64..f64673e13fa 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -1304,8 +1304,6 @@ void ThermostatClimate::set_default_preset(const char *custom_preset) { this->default_custom_preset_ = nullptr; } -void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } - void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time) { uint32_t new_duration_ms = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4dc2a74d8e5..b7d46eae227 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -92,7 +92,7 @@ class ThermostatClimate final : public climate::Climate, public Component { void loop() override; void set_default_preset(const char *custom_preset); - void set_default_preset(climate::ClimatePreset preset); + void set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { this->on_boot_restore_from_ = on_boot_restore_from; } From ee6e675d581846ce832b51064b13cf3b4550deb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:22 -0500 Subject: [PATCH 158/266] [esp32_camera] Move the single store setters into the header (#19285) --- .../components/esp32_camera/esp32_camera.cpp | 15 ---------- .../components/esp32_camera/esp32_camera.h | 30 +++++++++---------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 598fe61d464..03fdbc4de7c 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -433,25 +433,10 @@ void ESP32Camera::set_pixel_format(ESP32CameraPixelFormat format) { } } void ESP32Camera::set_jpeg_quality(uint8_t quality) { this->config_.jpeg_quality = quality; } -void ESP32Camera::set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } -void ESP32Camera::set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } -void ESP32Camera::set_contrast(int contrast) { this->contrast_ = contrast; } -void ESP32Camera::set_brightness(int brightness) { this->brightness_ = brightness; } -void ESP32Camera::set_saturation(int saturation) { this->saturation_ = saturation; } -void ESP32Camera::set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* set exposure parameters */ -void ESP32Camera::set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } -void ESP32Camera::set_aec2(bool aec2) { this->aec2_ = aec2; } -void ESP32Camera::set_ae_level(int ae_level) { this->ae_level_ = ae_level; } -void ESP32Camera::set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* set gains parameters */ -void ESP32Camera::set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } -void ESP32Camera::set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } -void ESP32Camera::set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* set white balance */ -void ESP32Camera::set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* set test mode */ -void ESP32Camera::set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* set fps */ void ESP32Camera::set_max_update_interval(uint32_t max_update_interval) { this->max_update_interval_ = max_update_interval; diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 83dab5f77a3..9ff309ad4a0 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -140,25 +140,25 @@ class ESP32Camera final : public camera::Camera { void set_pixel_format(ESP32CameraPixelFormat format); void set_frame_size(ESP32CameraFrameSize size); void set_jpeg_quality(uint8_t quality); - void set_vertical_flip(bool vertical_flip); - void set_horizontal_mirror(bool horizontal_mirror); - void set_contrast(int contrast); - void set_brightness(int brightness); - void set_saturation(int saturation); - void set_special_effect(ESP32SpecialEffect effect); + void set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } + void set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } + void set_contrast(int contrast) { this->contrast_ = contrast; } + void set_brightness(int brightness) { this->brightness_ = brightness; } + void set_saturation(int saturation) { this->saturation_ = saturation; } + void set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* -- exposure */ - void set_aec_mode(ESP32GainControlMode mode); - void set_aec2(bool aec2); - void set_ae_level(int ae_level); - void set_aec_value(uint32_t aec_value); + void set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } + void set_aec2(bool aec2) { this->aec2_ = aec2; } + void set_ae_level(int ae_level) { this->ae_level_ = ae_level; } + void set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* -- gains */ - void set_agc_mode(ESP32GainControlMode mode); - void set_agc_value(uint8_t agc_value); - void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling); + void set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } + void set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } + void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* -- white balance */ - void set_wb_mode(ESP32WhiteBalanceMode mode); + void set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* -- test */ - void set_test_pattern(bool test_pattern); + void set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* -- framerates */ void set_max_update_interval(uint32_t max_update_interval); void set_idle_update_interval(uint32_t idle_update_interval); From cc9f4730b43460ad3782a1b1f7ff1add96992e26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:39 -0500 Subject: [PATCH 159/266] [mqtt_subscribe] Move set_qos into the headers (#19282) --- .../components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp | 1 - .../components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h | 2 +- .../mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp | 1 - .../mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp index 40b5b46e1d2..afb725feb64 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp @@ -25,7 +25,6 @@ void MQTTSubscribeSensor::setup() { } float MQTTSubscribeSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeSensor::dump_config() { LOG_SENSOR("", "MQTT Subscribe", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 739e8456ee8..b0a8a0a78a2 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -18,7 +18,7 @@ class MQTTSubscribeSensor final : public sensor::Sensor, public Component { void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp index edc197671e6..470e08d59ae 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp @@ -15,7 +15,6 @@ void MQTTSubscribeTextSensor::setup() { this->qos_); } float MQTTSubscribeTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeTextSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "MQTT Subscribe Text Sensor", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index 8641825fca9..dc02eb5d187 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -17,7 +17,7 @@ class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Com void setup() override; void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; From 031b9804215f0fae19bbcfd9b6f98773f1ee9b89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:15:07 -0500 Subject: [PATCH 160/266] [web_server] Move the CSS and JS setters into the header (#19286) --- esphome/components/web_server/web_server.cpp | 7 ------- esphome/components/web_server/web_server.h | 8 ++++---- esphome/components/web_server/web_server_v1.cpp | 4 ---- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index ec536910e54..1683492da76 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -336,13 +336,6 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} -#ifdef USE_WEBSERVER_CSS_INCLUDE -void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; } -#endif -#ifdef USE_WEBSERVER_JS_INCLUDE -void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_include; } -#endif - json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 0fbe4ec5515..d60b39278aa 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -204,14 +204,14 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param css_url The url to the web server stylesheet. */ - void set_css_url(const char *css_url); + void set_css_url(const char *css_url) { this->css_url_ = css_url; } /** Set the URL to the script that's embedded in the index page. Defaults to * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ - void set_js_url(const char *js_url); + void set_js_url(const char *js_url) { this->js_url_ = js_url; } #endif #ifdef USE_WEBSERVER_CSS_INCLUDE @@ -219,7 +219,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param css_include Local path to web server script. */ - void set_css_include(const char *css_include); + void set_css_include(const char *css_include) { this->css_include_ = css_include; } #endif #ifdef USE_WEBSERVER_JS_INCLUDE @@ -227,7 +227,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param js_include Local path to web server script. */ - void set_js_include(const char *js_include); + void set_js_include(const char *js_include) { this->js_include_ = js_include; } #endif /** Determine whether internal components should be displayed on the web server. diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 85a4e80541b..08654e353a9 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -69,10 +69,6 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & stream->print(""); } -void WebServer::set_css_url(const char *css_url) { this->css_url_ = css_url; } - -void WebServer::set_js_url(const char *js_url) { this->js_url_ = js_url; } - void WebServer::handle_index_request(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("text/html")); const auto &title = App.get_name(); From 456119ec38e1162bb01292c6e8d8c7e08b7fe67c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:05 -0500 Subject: [PATCH 161/266] [graphical_display_menu] Move set_display and set_font into the header (#19291) --- .../graphical_display_menu/graphical_display_menu.cpp | 4 ---- .../graphical_display_menu/graphical_display_menu.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index f0642d2e8c7..d261c488557 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -57,10 +57,6 @@ void GraphicalDisplayMenu::dump_config() { } } -void GraphicalDisplayMenu::set_display(display::Display *display) { this->display_ = display; } - -void GraphicalDisplayMenu::set_font(display::BaseFont *font) { this->font_ = font; } - void GraphicalDisplayMenu::set_foreground_color(Color foreground_color) { this->foreground_color_ = foreground_color; } void GraphicalDisplayMenu::set_background_color(Color background_color) { this->background_color_ = background_color; } diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ccdf3d304c5..13c0f9d73f8 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -38,8 +38,8 @@ class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponen void setup() override; void dump_config() override; - void set_display(display::Display *display); - void set_font(display::BaseFont *font); + void set_display(display::Display *display) { this->display_ = display; } + void set_font(display::BaseFont *font) { this->font_ = font; } template void set_menu_item_value(V menu_item_value) { this->menu_item_value_ = menu_item_value; } void set_foreground_color(Color foreground_color); void set_background_color(Color background_color); From 7e3a1cf272470b19ab3be0a87f6e3d665118f028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:10 -0500 Subject: [PATCH 162/266] [tsl2561] Move set_is_cs_package into the header (#19289) --- esphome/components/tsl2561/tsl2561.cpp | 1 - esphome/components/tsl2561/tsl2561.h | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 963114b230c..5c53ed607ff 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -146,7 +146,6 @@ void TSL2561Sensor::set_integration_time(TSL2561IntegrationTime integration_time this->integration_time_ = integration_time; } void TSL2561Sensor::set_gain(TSL2561Gain gain) { this->gain_ = gain; } -void TSL2561Sensor::set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 8997d19f53a..8f6251c1342 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -59,7 +59,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param package_cs Is this a CS package. */ - void set_is_cs_package(bool package_cs); + void set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) From 534b4a0f44fa7434abcf3224f13b84783ab2b811 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:18 -0500 Subject: [PATCH 163/266] [template] Move the cover and valve set_has_* setters into the headers (#19283) --- esphome/components/template/cover/template_cover.cpp | 4 ---- esphome/components/template/cover/template_cover.h | 8 ++++---- esphome/components/template/valve/template_valve.cpp | 4 ---- esphome/components/template/valve/template_valve.h | 6 +++--- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 93ad887e580..1efab9c5b86 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -116,10 +116,6 @@ CoverTraits TemplateCover::get_traits() { } Trigger *TemplateCover::get_position_trigger() { return &this->position_trigger_; } Trigger *TemplateCover::get_tilt_trigger() { return &this->tilt_trigger_; } -void TemplateCover::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateCover::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateCover::set_has_position(bool has_position) { this->has_position_ = has_position; } -void TemplateCover::set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } void TemplateCover::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index cca21046202..e69c91bf092 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -27,10 +27,10 @@ class TemplateCover final : public cover::Cover, public Component { Trigger *get_tilt_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_tilt(bool has_tilt); - void set_has_toggle(bool has_toggle); + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateCoverRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index c9aa161b117..f35fdbeaf1c 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -110,10 +110,6 @@ ValveTraits TemplateValve::get_traits() { Trigger *TemplateValve::get_position_trigger() { return &this->position_trigger_; } -void TemplateValve::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateValve::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateValve::set_has_position(bool has_position) { this->has_position_ = has_position; } - void TemplateValve::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index e123f66d7e8..9c39a3624c1 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -25,9 +25,9 @@ class TemplateValve final : public valve::Valve, public Component { Trigger *get_position_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_toggle(bool has_toggle); + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateValveRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; From 1e8f5fa481d3f375d42a980bf9e3f1fd24b95716 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:24 -0500 Subject: [PATCH 164/266] [haier] Move set_send_wifi into the header (#19299) --- esphome/components/haier/haier_base.cpp | 2 -- esphome/components/haier/haier_base.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 48f72dc16b9..87f9331d555 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -190,8 +190,6 @@ void HaierClimateBase::set_supported_presets(climate::ClimatePresetMask presets) this->traits_.add_supported_preset(climate::CLIMATE_PRESET_NONE); } -void HaierClimateBase::set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } - void HaierClimateBase::send_custom_command(const haier_protocol::HaierMessage &message) { this->action_request_ = PendingAction({ActionRequest::SEND_CUSTOM_COMMAND, message}); } diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index db4c1abceb3..18ddbcc1cc5 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -71,7 +71,7 @@ class HaierClimateBase : public esphome::Component, }; bool can_send_message() const { return haier_protocol_.get_outgoing_queue_size() == 0; }; void set_answer_timeout(uint32_t timeout); - void set_send_wifi(bool send_wifi); + void set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } void send_custom_command(const haier_protocol::HaierMessage &message); template void add_status_message_callback(F &&callback) { this->status_message_callback_.add(std::forward(callback)); From d186b4ba9f8dfb6b78b0a77c67467d980f1b7342 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:42 -0500 Subject: [PATCH 165/266] [openthread] Move set_mdns into the header (#19295) --- esphome/components/openthread/openthread.cpp | 2 -- esphome/components/openthread/openthread.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index b98f1091724..ae896fcfeea 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,8 +227,6 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } -void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } - bool OpenThreadComponent::teardown() { switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index f4c6d0962ae..b83ffdb6af6 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -90,7 +90,7 @@ extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguide class OpenThreadSrpComponent final : public Component { public: - void set_mdns(esphome::mdns::MDNSComponent *mdns); + void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } // This has to run after the mdns component or else no services are available to advertise float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; From 37a08e6cb6f1ef57e1f6069e59ba09cd2a958ce7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:47 -0500 Subject: [PATCH 166/266] [lc709203f] Move the single store setters into the header (#19290) --- esphome/components/lc709203f/lc709203f.cpp | 4 ---- esphome/components/lc709203f/lc709203f.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index a5dda6ca437..36e5bce8e3c 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -275,8 +275,4 @@ void Lc709203f::set_pack_size(uint16_t pack_size) { // not cause an error or crash, so I am not doing any additional checking here. } -void Lc709203f::set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } - -void Lc709203f::set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } - } // namespace esphome::lc709203f diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 46f773873af..e9c60e285f5 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -26,8 +26,8 @@ class Lc709203f final : public sensor::Sensor, public PollingComponent, public i void dump_config() override; void set_pack_size(uint16_t pack_size); - void set_thermistor_b_constant(uint16_t b_constant); - void set_pack_voltage(LC709203FBatteryVoltage pack_voltage); + void set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } + void set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) { battery_remaining_sensor_ = battery_remaining_sensor; From fdb86cbd2ea6db0501c5ce8c04d71aed9cd2d8b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:53 -0500 Subject: [PATCH 167/266] [tsl2591] Move the single store setters into the header (#19288) --- esphome/components/tsl2591/tsl2591.cpp | 6 ------ esphome/components/tsl2591/tsl2591.h | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 2a5d6a4ee46..a741b087971 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -200,8 +200,6 @@ void TSL2591Component::set_infrared_sensor(sensor::Sensor *infrared_sensor) { this->infrared_sensor_ = infrared_sensor; } -void TSL2591Component::set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } - void TSL2591Component::set_full_spectrum_sensor(sensor::Sensor *full_spectrum_sensor) { this->full_spectrum_sensor_ = full_spectrum_sensor; } @@ -242,10 +240,6 @@ void TSL2591Component::set_integration_time_and_gain(TSL2591IntegrationTime inte } } -void TSL2591Component::set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } - -void TSL2591Component::set_name(const char *name) { this->name_ = name; } - bool TSL2591Component::is_adc_valid() { uint8_t status; if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_STATUS, &status)) { diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 3fde3404124..1e699329eb3 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -111,13 +111,13 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { * * @param enable Enable or disable power save mode. */ - void set_power_save_mode(bool enable); + void set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } /** Sets the name for this instance of the device. * * @param name The user-friendly name. */ - void set_name(const char *name); + void set_name(const char *name) { this->name_ = name; } /** Sets the device and glass attenuation factors. * @@ -235,7 +235,7 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { /** Used by ESPHome framework. */ void set_infrared_sensor(sensor::Sensor *infrared_sensor); /** Used by ESPHome framework. */ - void set_visible_sensor(sensor::Sensor *visible_sensor); + void set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } /** Used by ESPHome framework. */ void set_calculated_lux_sensor(sensor::Sensor *calculated_lux_sensor); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ From 02435e7455eef3e3866d890d6bf0b308b30f1289 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:17:12 -0500 Subject: [PATCH 168/266] [waveshare_epaper] Move set_full_update_every into the header (#19280) --- esphome/components/waveshare_epaper/waveshare_epaper.cpp | 4 ---- esphome/components/waveshare_epaper/waveshare_epaper.h | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 14ff5ed53cc..93f23424c01 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -2183,8 +2183,6 @@ void GDEW029T5::write_lut_(const uint8_t *lut, const uint8_t size) { this->end_data_(); } -void GDEW029T5::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } - int GDEW029T5::get_width_internal() { return 128; } int GDEW029T5::get_height_internal() { return 296; } void GDEW029T5::dump_config() { @@ -2523,7 +2521,6 @@ void HOT GDEY042T81::display() { ESP_LOGD(TAG, "Set the display back to deep sleep"); this->deep_sleep(); } -void GDEY042T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY042T81::get_width_internal() { return 400; } int GDEY042T81::get_height_internal() { return 300; } uint32_t GDEY042T81::idle_timeout_() { return 5000; } @@ -3156,7 +3153,6 @@ void HOT GDEY0583T81::display() { this->deep_sleep(); } -void GDEY0583T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY0583T81::get_width_internal() { return 648; } int GDEY0583T81::get_height_internal() { return 480; } uint32_t GDEY0583T81::idle_timeout_() { return 5000; } diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.h b/esphome/components/waveshare_epaper/waveshare_epaper.h index fa3737238e6..7e16ce3dc30 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.h +++ b/esphome/components/waveshare_epaper/waveshare_epaper.h @@ -272,7 +272,7 @@ class GDEW029T5 : public WaveshareEPaper { void dump_config() override; void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: void init_display_(); @@ -503,7 +503,7 @@ class GDEY042T81 : public WaveshareEPaper { this->data(0x01); } - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: uint32_t full_update_every_{30}; @@ -695,7 +695,7 @@ class GDEY0583T81 : public WaveshareEPaper { void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: int get_width_internal() override; From 7ed4950c695fa680f0473e55027ee3265bfc1441 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:18:22 -0500 Subject: [PATCH 169/266] [bme280_base][bme680][bmp280_base] Move set_iir_filter into the headers (#19281) --- esphome/components/bme280_base/bme280_base.cpp | 1 - esphome/components/bme280_base/bme280_base.h | 2 +- esphome/components/bme680/bme680.cpp | 1 - esphome/components/bme680/bme680.h | 2 +- esphome/components/bmp280_base/bmp280_base.cpp | 1 - esphome/components/bmp280_base/bmp280_base.h | 2 +- 6 files changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 0f7e42cce3e..11c796352a3 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -341,7 +341,6 @@ void BME280Component::set_pressure_oversampling(BME280Oversampling pressure_over void BME280Component::set_humidity_oversampling(BME280Oversampling humidity_over_sampling) { this->humidity_oversampling_ = humidity_over_sampling; } -void BME280Component::set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BME280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->read_byte(a_register, &data); diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 7fe5f7401da..8b4906b7b77 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -69,7 +69,7 @@ class BME280Component : public PollingComponent { /// Set the oversampling value for the humidity sensor. Default is 16x. void set_humidity_oversampling(BME280Oversampling humidity_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BME280IIRFilter iir_filter); + void set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 164424de096..bac8ed8a5a8 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -503,7 +503,6 @@ void BME680Component::set_pressure_oversampling(BME680Oversampling pressure_over void BME680Component::set_humidity_oversampling(BME680Oversampling humidity_oversampling) { this->humidity_oversampling_ = humidity_oversampling; } -void BME680Component::set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void BME680Component::set_heater(uint16_t heater_temperature, uint16_t heater_duration) { this->heater_temperature_ = heater_temperature; this->heater_duration_ = heater_duration; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index a274578fc18..e401d036595 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -74,7 +74,7 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { /// Set the humidity oversampling value. Defaults to 16X. void set_humidity_oversampling(BME680Oversampling humidity_oversampling); /// Set the IIR Filter value. Defaults to no IIR Filter. - void set_iir_filter(BME680IIRFilter iir_filter); + void set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 1dae5a689e6..34e1d671019 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -254,7 +254,6 @@ void BMP280Component::set_temperature_oversampling(BMP280Oversampling temperatur void BMP280Component::set_pressure_oversampling(BMP280Oversampling pressure_over_sampling) { this->pressure_oversampling_ = pressure_over_sampling; } -void BMP280Component::set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BMP280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->bmp_read_byte(a_register, &data); diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 3bf1edab043..860fff6b4b1 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -59,7 +59,7 @@ class BMP280Component : public PollingComponent { /// Set the oversampling value for the pressure sensor. Default is 16x. void set_pressure_oversampling(BMP280Oversampling pressure_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BMP280IIRFilter iir_filter); + void set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void setup() override; void dump_config() override; From 4fba837d408e03dbab50849c441112a12f2d9d4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:18:32 -0500 Subject: [PATCH 170/266] [adc] Move set_sampling_mode into the header (#19293) --- esphome/components/adc/adc_sensor.h | 2 +- esphome/components/adc/adc_sensor_common.cpp | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 71318987479..46b7e7a2ffd 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -94,7 +94,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v /// - SamplingMode::MIN: Use the lowest sample value /// - SamplingMode::MAX: Use the highest sample value /// @param sampling_mode The desired sampling mode to use for aggregating ADC samples. - void set_sampling_mode(SamplingMode sampling_mode); + void set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } /// Perform a single ADC sampling operation and return the measured value. /// This function handles raw readings, calibration, and averaging as needed. diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 5ca58df10e0..70211000c3d 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -76,6 +76,4 @@ void ADCSensor::set_sample_count(uint8_t sample_count) { } } -void ADCSensor::set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } - } // namespace esphome::adc From d61e0e46952c6fc77286bad0dd19f4d4d0e60340 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:26:51 -0500 Subject: [PATCH 171/266] [tsl2561][tsl2591] Move set_gain into the headers (#19284) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/tsl2561/tsl2561.cpp | 1 - esphome/components/tsl2561/tsl2561.h | 2 +- esphome/components/tsl2591/tsl2591.cpp | 2 -- esphome/components/tsl2591/tsl2591.h | 2 +- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 5c53ed607ff..4e4d403488d 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -145,7 +145,6 @@ float TSL2561Sensor::get_integration_time_ms_() { void TSL2561Sensor::set_integration_time(TSL2561IntegrationTime integration_time) { this->integration_time_ = integration_time; } -void TSL2561Sensor::set_gain(TSL2561Gain gain) { this->gain_ = gain; } bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 8f6251c1342..0800b87c461 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -51,7 +51,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param gain The new gain. */ - void set_gain(TSL2561Gain gain); + void set_gain(TSL2561Gain gain) { this->gain_ = gain; } /** The "CS" package of this sensor has a slightly different formula for * converting the raw values. Use this setting to indicate that this is a CS diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index a741b087971..d147aae88ab 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -216,8 +216,6 @@ void TSL2591Component::set_integration_time(TSL2591IntegrationTime integration_t this->integration_time_ = integration_time; } -void TSL2591Component::set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } - void TSL2591Component::set_device_and_glass_attenuation_factors(float device_factor, float glass_attenuation_factor) { this->device_factor_ = device_factor; this->glass_attenuation_factor_ = glass_attenuation_factor; diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 1e699329eb3..c65fc5f6e54 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -241,7 +241,7 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { /** Used by ESPHome framework. Does NOT actually set the value on the device. */ void set_integration_time(TSL2591IntegrationTime integration_time); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ - void set_gain(TSL2591ComponentGain gain); + void set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } /** Used by ESPHome framework. */ void setup() override; /** Used by ESPHome framework. */ From f0315ea1ecaafce83c1439f29a787f0680328e9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:38:36 -0500 Subject: [PATCH 172/266] [template][modbus_controller] Move set_assumed_state into the headers (#19279) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/modbus_controller/switch/modbus_switch.cpp | 2 -- esphome/components/modbus_controller/switch/modbus_switch.h | 2 +- esphome/components/template/cover/template_cover.cpp | 1 - esphome/components/template/cover/template_cover.h | 2 +- esphome/components/template/switch/template_switch.cpp | 1 - esphome/components/template/switch/template_switch.h | 2 +- esphome/components/template/valve/template_valve.cpp | 1 - esphome/components/template/valve/template_valve.h | 2 +- 8 files changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 7bf45366c0a..f2aae201f33 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -25,8 +25,6 @@ void ModbusSwitch::setup() { } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } -void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } - bool ModbusSwitch::assumed_state() { return this->assumed_state_; } void ModbusSwitch::parse_and_publish(std::span data) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 688a620bac1..b98543532e6 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -31,7 +31,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens void setup() override; void write_state(bool state) override; void dump_config() override; - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_state(bool state) { this->state = state; } void parse_and_publish(std::span data) override; void set_parent(ModbusController *parent) { this->set_controller_(parent); } diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 1efab9c5b86..1bf057da5b5 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -53,7 +53,6 @@ void TemplateCover::loop() { if (changed) this->publish_state(); } -void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } Trigger<> *TemplateCover::get_close_trigger() { return &this->close_trigger_; } diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index e69c91bf092..d3096ba86f9 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -26,7 +26,7 @@ class TemplateCover final : public cover::Cover, public Component { Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } void set_has_position(bool has_position) { this->has_position_ = has_position; } void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 27134fc8b29..edd753d3d2b 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -53,6 +53,5 @@ void TemplateSwitch::dump_config() { LOG_SWITCH("", "Template Switch", this); ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); } -void TemplateSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } } // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 9af8517f9ac..6dc073e4b37 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -18,7 +18,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { Trigger<> *get_turn_on_trigger(); Trigger<> *get_turn_off_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void loop() override; float get_setup_priority() const override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index f35fdbeaf1c..50906876399 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -47,7 +47,6 @@ void TemplateValve::loop() { this->publish_state(); } -void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateValve::get_open_trigger() { return &this->open_trigger_; } diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 9c39a3624c1..504fdb2fbaf 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -24,7 +24,7 @@ class TemplateValve final : public valve::Valve, public Component { Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } void set_has_position(bool has_position) { this->has_position_ = has_position; } void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } From 2ffa57c907585593bc26a8a95b487cc3ab9c2985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:07:27 -0500 Subject: [PATCH 173/266] [esp32_ble_tracker] Initialize ESPBTClient::app_id (#19199) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 1a424a4a8e8..aa470983df9 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -135,7 +135,7 @@ class ESPBTClient : public ESPBTDeviceListener { void set_tracker_state_version(uint8_t *version) { this->tracker_state_version_ = version; } // Memory optimized layout - uint8_t app_id; // App IDs are small integers assigned sequentially + uint8_t app_id{0}; // App IDs are small integers assigned sequentially protected: /// Set state without IDLE handling - use for direct state transitions. From f30a3bc3e13bf96b3de41f72d67a1dab2b7256d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:21 -0500 Subject: [PATCH 174/266] [bluetooth_connection] Use a user provided default constructor for BluetoothConnection (#19200) --- .../components/bluetooth_connection/bluetooth_connection_hub.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 47181e81a73..4c87b876c3a 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -37,6 +37,9 @@ enum class PendingAck : uint8_t { class BluetoothConnection final : public ble_device_base::GattClientListener { public: + // User provided, not "= default": `new(p) BluetoothConnection()` would zero-fill .bss that is already zero. + BluetoothConnection() {} + /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { this->backend_ = backend; From ad20711a22d8c36e12d165549330825281777ae1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:24 -0500 Subject: [PATCH 175/266] [restart] Use a user provided default constructor for RestartSwitch (#19204) --- esphome/components/restart/switch/restart_switch.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index dc9ec8eadcd..03cf03f1662 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) RestartSwitch()` would zero-fill .bss that is already zero. + RestartSwitch() {} + void dump_config() override; protected: From f686fb606beed1dd3233b6eeb57cfede0eb8e471 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:30 -0500 Subject: [PATCH 176/266] [gpio] Use a user provided default constructor for GPIOSwitch (#19203) --- esphome/components/gpio/switch/gpio_switch.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index 7ed0de7c6f0..e7323e6e937 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -9,6 +9,9 @@ namespace esphome::gpio { class GPIOSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) GPIOSwitch()` would zero-fill .bss that is already zero. + GPIOSwitch() {} + void set_pin(GPIOPin *pin) { pin_ = pin; } // ========== INTERNAL METHODS ========== @@ -25,7 +28,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK FixedVector interlock_; uint32_t interlock_wait_time_{0}; From 5198ef8dd8a6b53ba91f5a00bf6c4a6ab25ab974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:43 -0500 Subject: [PATCH 177/266] [binary_sensor] Use a user provided default constructor for DelayedOnFilter (#19205) --- esphome/components/binary_sensor/filter.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 6887de35e1d..bb974fe1322 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -53,6 +53,9 @@ class DelayedOnOffFilter final : public Filter { class DelayedOnFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOnFilter()` would zero-fill .bss that is already zero. + DelayedOnFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 11ce3594c0725fc3eb24776ed70f5336f552e103 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:49 -0500 Subject: [PATCH 178/266] [binary_sensor] Use a user provided default constructor for DelayedOffFilter (#19206) --- esphome/components/binary_sensor/filter.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index bb974fe1322..83f4a9b772f 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -66,6 +66,9 @@ class DelayedOnFilter : public Filter { class DelayedOffFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOffFilter()` would zero-fill .bss that is already zero. + DelayedOffFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 274bfe7aeeabd1976867d6cc972a176f4a871807 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:15:51 -0500 Subject: [PATCH 179/266] [binary_sensor] Use a user provided default constructor (#19111) --- esphome/components/binary_sensor/binary_sensor.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 28c156763a8..a96113b520e 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -32,7 +32,8 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi */ class BinarySensor : public StatefulEntityBase { public: - explicit BinarySensor() = default; + // User provided, not "= default": `new(p) BinarySensor()` would zero-fill .bss that is already zero. + explicit BinarySensor() {} const bool &get_state() const override { return this->state; } void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } From 42aea9efbc0516a028fe9f101a24218e1fb1c692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:44 -0500 Subject: [PATCH 180/266] [ld2450] Use a user provided default constructor for RestartButton (#19183) --- esphome/components/ld2450/button/restart_button.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/button/restart_button.h b/esphome/components/ld2450/button/restart_button.h index 9219011f8ba..87b1a2bbd1e 100644 --- a/esphome/components/ld2450/button/restart_button.h +++ b/esphome/components/ld2450/button/restart_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class RestartButton : public button::Button, public Parented { public: - RestartButton() = default; + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} protected: void press_action() override; From 644f0ededbf657b99d839ecb46460d589cb6446c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:47 -0500 Subject: [PATCH 181/266] [ld2450] Use a user provided default constructor for FactoryResetButton (#19182) --- esphome/components/ld2450/button/factory_reset_button.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/button/factory_reset_button.h b/esphome/components/ld2450/button/factory_reset_button.h index 392fc67ffdd..71dc19a6cd3 100644 --- a/esphome/components/ld2450/button/factory_reset_button.h +++ b/esphome/components/ld2450/button/factory_reset_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class FactoryResetButton : public button::Button, public Parented { public: - FactoryResetButton() = default; + // User provided, not "= default": `new(p) FactoryResetButton()` would zero-fill .bss that is already zero. + FactoryResetButton() {} protected: void press_action() override; From d16a1ec288ed53ef296a1f122a92ecbc8fe17224 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:53 -0500 Subject: [PATCH 182/266] [ld2412] Use a user provided default constructor for DistanceResolutionSelect (#19185) --- esphome/components/ld2412/select/distance_resolution_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index be8dba90b5d..d1bc15dea93 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class DistanceResolutionSelect final : public select::Select, public Parented { public: - DistanceResolutionSelect() = default; + // User provided, not "= default": `new(p) DistanceResolutionSelect()` would zero-fill .bss that is already zero. + DistanceResolutionSelect() {} protected: void control(size_t index) override; From 711d016a5099071884a150069cdb46718ba867ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:58 -0500 Subject: [PATCH 183/266] [ld2412] Use a user provided default constructor for BaudRateSelect (#19184) --- esphome/components/ld2412/select/baud_rate_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 46ec9be1d1e..527b1a1e934 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BaudRateSelect final : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; From e77a8525993cfdaaee6d935ecf5ae2c9a9517e70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:03 -0500 Subject: [PATCH 184/266] [ld2412] Use a user provided default constructor for LightOutControlSelect (#19186) --- esphome/components/ld2412/select/light_out_control_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index c8988fda78e..0867f3b1c2e 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightOutControlSelect final : public select::Select, public Parented { public: - LightOutControlSelect() = default; + // User provided, not "= default": `new(p) LightOutControlSelect()` would zero-fill .bss that is already zero. + LightOutControlSelect() {} protected: void control(size_t index) override; From 7d1c9d648830cfe1eca3be1f7567cb22443a58a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:08 -0500 Subject: [PATCH 185/266] [ld2450] Use a user provided default constructor for BaudRateSelect (#19187) --- esphome/components/ld2450/select/baud_rate_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/select/baud_rate_select.h b/esphome/components/ld2450/select/baud_rate_select.h index cb531181707..af4c477dff2 100644 --- a/esphome/components/ld2450/select/baud_rate_select.h +++ b/esphome/components/ld2450/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BaudRateSelect : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; From 4d648bf872ece7a17a778126f002a98f3e5ef886 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:13 -0500 Subject: [PATCH 186/266] [ld2450] Use a user provided default constructor for BluetoothSwitch (#19191) --- esphome/components/ld2450/switch/bluetooth_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/switch/bluetooth_switch.h b/esphome/components/ld2450/switch/bluetooth_switch.h index 3d48a89b57f..8b118a7b8c1 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.h +++ b/esphome/components/ld2450/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BluetoothSwitch : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; From 794aefb2761265c177ed949095819991282c1814 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:17 -0500 Subject: [PATCH 187/266] [ld2412] Use a user provided default constructor for BluetoothSwitch (#19189) --- esphome/components/ld2412/switch/bluetooth_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 8fd4a86e43c..e753613cdfa 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BluetoothSwitch final : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; From 313d0c1eb3f35f515d326f8ceaec788241fd7c66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:22 -0500 Subject: [PATCH 188/266] [number] Initialize Number::state (#19114) --- esphome/components/number/number.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 579d488cf06..b697e770be0 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -28,7 +28,7 @@ class Number; */ class Number : public EntityBase { public: - float state; + float state{}; void publish_state(float state); From bd47f04479c692b368a6af8799c1725ae2e88008 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:51 -0500 Subject: [PATCH 189/266] [ld2450] Use a user provided default constructor for PresenceTimeoutNumber (#19195) --- esphome/components/ld2450/number/presence_timeout_number.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/number/presence_timeout_number.h b/esphome/components/ld2450/number/presence_timeout_number.h index 09c8afca55b..8c44fa39dc0 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.h +++ b/esphome/components/ld2450/number/presence_timeout_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class PresenceTimeoutNumber : public number::Number, public Parented { public: - PresenceTimeoutNumber() = default; + // User provided, not "= default": `new(p) PresenceTimeoutNumber()` would zero-fill .bss that is already zero. + PresenceTimeoutNumber() {} protected: void control(float value) override; From b0f87615a23756c7cb22bd5e50859acee87c87f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:54 -0500 Subject: [PATCH 190/266] [ld2450] Use a user provided default constructor for ZoneTypeSelect (#19188) --- esphome/components/ld2450/select/zone_type_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/select/zone_type_select.h b/esphome/components/ld2450/select/zone_type_select.h index 566346eb482..cf79c2324dc 100644 --- a/esphome/components/ld2450/select/zone_type_select.h +++ b/esphome/components/ld2450/select/zone_type_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class ZoneTypeSelect : public select::Select, public Parented { public: - ZoneTypeSelect() = default; + // User provided, not "= default": `new(p) ZoneTypeSelect()` would zero-fill .bss that is already zero. + ZoneTypeSelect() {} protected: void control(size_t index) override; From 43e35c4fc26259d984f3f57440d24df8b9f233cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:20:10 -0500 Subject: [PATCH 191/266] [ld2412] Use a user provided default constructor for EngineeringModeSwitch (#19190) --- esphome/components/ld2412/switch/engineering_mode_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index defeb4c76ba..279128ddbc5 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: - EngineeringModeSwitch() = default; + // User provided, not "= default": `new(p) EngineeringModeSwitch()` would zero-fill .bss that is already zero. + EngineeringModeSwitch() {} protected: void write_state(bool state) override; From 2fde80454f170f8aa3389ed8702c3a43c805e3d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:21:30 -0500 Subject: [PATCH 192/266] [ld2412] Use a user provided default constructor for LightThresholdNumber (#19193) --- esphome/components/ld2412/number/light_threshold_number.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index f62d523af38..710b47957c1 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightThresholdNumber final : public number::Number, public Parented { public: - LightThresholdNumber() = default; + // User provided, not "= default": `new(p) LightThresholdNumber()` would zero-fill .bss that is already zero. + LightThresholdNumber() {} protected: void control(float value) override; From 511095d3da567445695364db4305dc53ef5667cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:21:58 -0500 Subject: [PATCH 193/266] [scd4x] Use a user provided default constructor for PerformForcedCalibrationAction (#19176) --- esphome/components/scd4x/automation.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h index 4746c0c879c..e0cc04e2cb7 100644 --- a/esphome/components/scd4x/automation.h +++ b/esphome/components/scd4x/automation.h @@ -9,6 +9,10 @@ namespace esphome::scd4x { template class PerformForcedCalibrationAction final : public Action, public Parented { public: + // User provided, not "= default": `new(p) PerformForcedCalibrationAction()` would zero-fill .bss that is already + // zero. + PerformForcedCalibrationAction() {} + void play(const Ts &...x) override { if (this->value_.has_value()) { this->parent_->perform_forced_calibration(this->value_.value(x...)); From e50428fce596f0f671123060cd85cd9ec075c68b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:22:34 -0500 Subject: [PATCH 194/266] [safe_mode] Use a user provided default constructor for SafeModeComponent (#19163) --- esphome/components/safe_mode/safe_mode.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 0633c92a789..903d9eb79fd 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,6 +17,9 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: + // User provided, not "= default": `new(p) SafeModeComponent()` would zero-fill .bss that is already zero. + SafeModeComponent() {} + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode From 9162dc38cee7d4a0ed08f45d4d2a1bc30278ddcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:23:50 -0500 Subject: [PATCH 195/266] [bluetooth_connection] Use a user provided default constructor for BluedroidGattClient (#19201) --- .../bluetooth_connection/bluetooth_connection_bluedroid.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index f285260e763..a4e9edec233 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -31,6 +31,9 @@ class BluetoothConnection; // void disconnect() cannot overload with an int-returning twin. class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { public: + // User provided, not "= default": `new(p) BluedroidGattClient()` would zero-fill .bss that is already zero. + BluedroidGattClient() {} + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; // Lifecycle of one connection attempt's service search. From 9a76f1be4fc435adf670f04e51a682891988d01f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:21 -0500 Subject: [PATCH 196/266] [core] Give StaticVector a user provided default constructor (#19110) --- esphome/core/helpers.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b1f24b25a3d..cfc92932a9d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -242,8 +242,9 @@ template class StaticVector { size_t count_{0}; public: - // Default constructor - StaticVector() = default; + // User provided, not "= default": otherwise `StaticVector<...> x_{}` members + // value-initialize and memset data_, defeating the comment above. + constexpr StaticVector() noexcept {} // Iterator range constructor template StaticVector(InputIt first, InputIt last) { From 0c1cf6d0a9e6c07e431fcdd1ae13a4c5c5ae29c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:27 -0500 Subject: [PATCH 197/266] [template] Use a user provided default constructor for TemplateBinarySensor (#19128) --- .../components/template/binary_sensor/template_binary_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.h b/esphome/components/template/binary_sensor/template_binary_sensor.h index c78a95e0e36..e1a089b44ca 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.h +++ b/esphome/components/template/binary_sensor/template_binary_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateBinarySensor final : public Component, public binary_sensor::BinarySensor { public: + // User provided, not "= default": `new(p) TemplateBinarySensor()` would zero-fill .bss that is already zero. + TemplateBinarySensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; From 8ffa10f86ced8d6e8d08e8be591e6a594d39a282 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:33 -0500 Subject: [PATCH 198/266] [version] Use a user provided default constructor for VersionTextSensor (#19139) --- esphome/components/version/version_text_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index 96f72ad035b..7ff6ac4d352 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -7,6 +7,8 @@ namespace esphome::version { class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: + // User provided, not "= default": `new(p) VersionTextSensor()` would zero-fill .bss that is already zero. + VersionTextSensor() {} void set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } void set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void setup() override; From 7f61909415fc5e921ecaced7069d4204f339dc9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:42 -0500 Subject: [PATCH 199/266] [binary_sensor] Use a user provided default constructor for SettleFilter (#19135) --- esphome/components/binary_sensor/filter.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 83f4a9b772f..1ec255d63d6 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -149,6 +149,8 @@ class StatelessLambdaFilter : public Filter { class SettleFilter : public Filter { public: + // User provided, not "= default": `new(p) SettleFilter()` would zero-fill .bss that is already zero. + SettleFilter() {} optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 14e640c90390588fe5f4f6e2535a7cd37aa9845e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:48 -0500 Subject: [PATCH 200/266] [internal_temperature] Use a user provided default constructor for InternalTemperatureSensor (#19141) --- esphome/components/internal_temperature/internal_temperature.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 90831cf211d..6a9889ef29c 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -13,6 +13,9 @@ namespace esphome::internal_temperature { class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) InternalTemperatureSensor()` would zero-fill .bss that is already zero. + InternalTemperatureSensor() {} + #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; #endif // USE_ESP32 || (USE_ZEPHYR && USE_NRF52) From ba50e23b81666912688a970036e84d1a969aad44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:58 -0500 Subject: [PATCH 201/266] [uptime] Use a user provided default constructor for UptimeSecondsSensor (#19140) --- esphome/components/uptime/sensor/uptime_seconds_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index b0b12954b28..92d475e62ec 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -7,6 +7,8 @@ namespace esphome::uptime { class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) UptimeSecondsSensor()` would zero-fill .bss that is already zero. + UptimeSecondsSensor() {} void update() override; void dump_config() override; From 25a3f7068e09f9b065da3ad6a63f0797f4825e46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:01 -0500 Subject: [PATCH 202/266] [status] Use a user provided default constructor for StatusBinarySensor (#19138) --- esphome/components/status/status_binary_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 28cf4cd0832..3c25a9e57de 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -7,6 +7,8 @@ namespace esphome::status { class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: + // User provided, not "= default": `new(p) StatusBinarySensor()` would zero-fill .bss that is already zero. + StatusBinarySensor() {} void update() override; void setup() override; From df4176f9e1f1551b01a75ad6689c17042142af61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:08 -0500 Subject: [PATCH 203/266] [uart] Use a user provided default constructor for IDFUARTComponent (#19142) --- esphome/components/uart/uart_component_esp_idf.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 3b8603f2ac2..b591fbe9686 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -18,6 +18,8 @@ namespace esphome::uart { /// peek byte state (has_peek_/peek_byte_) is not synchronized. class IDFUARTComponent final : public UARTComponent, public Component { public: + // User provided, not "= default": `new(p) IDFUARTComponent()` would zero-fill .bss that is already zero. + IDFUARTComponent() {} void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -102,7 +104,7 @@ class IDFUARTComponent final : public UARTComponent, public Component { Framing last_good_framing_{}; bool has_peek_{false}; - uint8_t peek_byte_; + uint8_t peek_byte_{0}; uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). #ifdef USE_UART_WAKE_LOOP_ON_RX From 4e0245da0db6d70e281033d08a922b6c4e5f31b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:13 -0500 Subject: [PATCH 204/266] [gpio] Use a user provided default constructor for GPIOBinarySensor (#19143) --- esphome/components/gpio/binary_sensor/gpio_binary_sensor.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 956443fab55..80636e29a6e 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -47,6 +47,9 @@ class GPIOBinarySensorStore { class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component { public: + // User provided, not "= default": `new(p) GPIOBinarySensor()` would zero-fill .bss that is already zero. + GPIOBinarySensor() {} + // No destructor needed: ESPHome components are created at boot and live forever. // Interrupts are only detached on reboot when memory is cleared anyway. @@ -70,7 +73,7 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon void loop() override; protected: - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; GPIOBinarySensorStore store_; }; From 5b53c63eab74681c65575672754a9650f27fe085 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:18 -0500 Subject: [PATCH 205/266] [http_request] Use a user provided default constructor for HttpRequestIDF (#19175) --- esphome/components/http_request/http_request_idf.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 16a5b6a161c..1c062af81b1 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -30,6 +30,9 @@ class HttpContainerIDF : public HttpContainer { class HttpRequestIDF final : public HttpRequestComponent { public: + // User provided, not "= default": `new(p) HttpRequestIDF()` would zero-fill .bss that is already zero. + HttpRequestIDF() {} + void dump_config() override; void set_buffer_size_rx(uint16_t buffer_size_rx) { this->buffer_size_rx_ = buffer_size_rx; } From ba679d91e413fafb30e518d164938798284fa9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:42 -0500 Subject: [PATCH 206/266] [restart] Use a user provided default constructor for RestartButton (#19168) --- esphome/components/restart/button/restart_button.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/restart/button/restart_button.h b/esphome/components/restart/button/restart_button.h index 974db0cec48..4baac6472c1 100644 --- a/esphome/components/restart/button/restart_button.h +++ b/esphome/components/restart/button/restart_button.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartButton final : public button::Button, public Component { public: + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} + void dump_config() override; protected: From 7eebe18e1a772f75ca2556be3cd655acdbd10be6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:48 -0500 Subject: [PATCH 207/266] [ethernet_info] Use a user provided default constructor for IPAddressEthernetInfo (#19166) --- esphome/components/ethernet_info/ethernet_info_text_sensor.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.h b/esphome/components/ethernet_info/ethernet_info_text_sensor.h index 11002d51bad..c9fcda225f4 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.h +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.h @@ -13,6 +13,9 @@ class IPAddressEthernetInfo final : public Component, public text_sensor::TextSensor, public ethernet::EthernetIPStateListener { public: + // User provided, not "= default": `new(p) IPAddressEthernetInfo()` would zero-fill .bss that is already zero. + IPAddressEthernetInfo() {} + void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } From ac3ef0a02dc09a24315899a0e993eee4c3cde7f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:34:00 -0500 Subject: [PATCH 208/266] [preferences] Use a user provided default constructor for IntervalSyncer (#19164) --- esphome/components/preferences/syncer.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index 8a809672db4..5092c321479 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -9,6 +9,9 @@ namespace esphome::preferences { class IntervalSyncer final : public PollingComponent { public: + // User provided, not "= default": `new(p) IntervalSyncer()` would zero-fill .bss that is already zero. + IntervalSyncer() {} + // Remove before 2027.3.0 ESPDEPRECATED("Use set_update_interval() instead. Removed in 2027.3.0", "2026.9.0") void set_write_interval(uint32_t write_interval) { this->set_update_interval(write_interval); } From 569001d554bdb2d80ae5298b2dadb8982ad65efa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:34:07 -0500 Subject: [PATCH 209/266] [template] Use a user provided default constructor for TemplateSensor (#19125) --- esphome/components/template/sensor/template_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/sensor/template_sensor.h b/esphome/components/template/sensor/template_sensor.h index 825a2b4ffaa..68e22372679 100644 --- a/esphome/components/template/sensor/template_sensor.h +++ b/esphome/components/template/sensor/template_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateSensor()` would zero-fill .bss that is already zero. + TemplateSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; From 4714b77f45b8de4c66c224d67e4230cc56fa550c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:28 -0500 Subject: [PATCH 210/266] [core] Construct App without value initialization (#19109) --- esphome/core/application.h | 2 +- esphome/core/config.py | 5 +++-- tests/unit_tests/core/test_config.py | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a12cdc4ac88..f1cf6fcca02 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -528,7 +528,7 @@ class Application { // 1-byte members (grouped together to minimize padding) uint8_t app_state_{0}; - bool name_add_mac_suffix_; + bool name_add_mac_suffix_{false}; bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; diff --git a/esphome/core/config.py b/esphome/core/config.py index 67a7b5210ee..8a4eb0fc379 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -717,9 +717,10 @@ async def to_code(config: ConfigType) -> None: cg.add_global(cg.RawExpression("using std::min")) cg.add_global(cg.RawExpression("using std::max")) - # Construct App via placement new — see application.cpp for storage details + # Construct App via placement new — see application.cpp for storage details. + # No parens: `Application()` would zero-fill storage that is already zero. cg.add_global(cg.RawStatement("#include ")) - cg.add(cg.RawExpression("new (&App) Application()")) + cg.add(cg.RawExpression("new (&App) Application")) name = config[CONF_NAME] friendly_name = config[CONF_FRIENDLY_NAME] name_add_mac_suffix = config[CONF_NAME_ADD_MAC_SUFFIX] diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 8ab3ad5d153..07cff003cdd 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -175,6 +175,27 @@ async def test_core_area_recorded_at_config_load( assert CORE.area == expected_area +@pytest.mark.asyncio +async def test_app_is_default_initialized( + yaml_file: Callable[[str], Path], +) -> None: + """App is constructed with `new (&App) Application`, no parentheses. + + `Application()` would value-initialize and memset the whole object into + storage that is already zero.""" + result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + raw_expressions = [c.args[0] for c in mock_cg.RawExpression.call_args_list] + assert "new (&App) Application" in raw_expressions + assert "new (&App) Application()" not in raw_expressions + + def test_config_load_without_area_clears_stale_core_area( yaml_file: Callable[[str], Path], ) -> None: From b7064f2bedc3800393605f1f4937b5af017863f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:31 -0500 Subject: [PATCH 211/266] [update] Initialize UpdateInfo::progress (#19121) --- esphome/components/update/update_entity.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index f925d338ff7..96ba6dbd562 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -16,7 +16,7 @@ struct UpdateInfo { std::string firmware_url; std::string md5; bool has_progress{false}; - float progress; + float progress{0}; }; enum UpdateState : uint8_t { From da73140c9ca1336a4a9c9fcf56b702aa52e8df56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:36 -0500 Subject: [PATCH 212/266] [template] Use a user provided default constructor for TemplateTextSensor (#19126) --- esphome/components/template/text_sensor/template_text_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/text_sensor/template_text_sensor.h b/esphome/components/template/text_sensor/template_text_sensor.h index 0538a7ec211..8f03f78be4f 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.h +++ b/esphome/components/template/text_sensor/template_text_sensor.h @@ -9,6 +9,8 @@ namespace esphome::template_ { class TemplateTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateTextSensor()` would zero-fill .bss that is already zero. + TemplateTextSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; From cbb66002635d3fbc5fa549b67e76382838f8ceac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:41 -0500 Subject: [PATCH 213/266] [template] Use a user provided default constructor for TemplateButton (#19124) --- esphome/components/template/button/template_button.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/template/button/template_button.h b/esphome/components/template/button/template_button.h index f64a85eef07..bd07b2258c1 100644 --- a/esphome/components/template/button/template_button.h +++ b/esphome/components/template/button/template_button.h @@ -6,6 +6,9 @@ namespace esphome::template_ { class TemplateButton final : public button::Button { public: + // User provided, not "= default": `new(p) TemplateButton()` would zero-fill .bss that is already zero. + TemplateButton() {} + // Implements the abstract `press_action` but the `on_press` trigger already handles the press. void press_action() override{}; }; From a495fe56f5f3f00d6eb2d8430901115c701811f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:48 -0500 Subject: [PATCH 214/266] [template] Use a user provided default constructor for TemplateSelect (#19130) --- esphome/components/template/select/template_select.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 5da6d732bd4..1cc28a36d37 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -23,6 +23,8 @@ void update_lambda(BaseTemplateSelect *sel_comp, const optional &va template class TemplateSelect : public BaseTemplateSelect { public: + // User provided, not "= default": `new(p) TemplateSelect()` would zero-fill .bss that is already zero. + TemplateSelect() {} template void set_lambda(F &&f) { if constexpr (HAS_LAMBDA) { this->f_.set(std::forward(f)); From 925ddd6c09bcf5133092aa93d27f580c931278da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:00 -0500 Subject: [PATCH 215/266] [template] Use a user provided default constructor for TemplateEvent (#19127) --- esphome/components/template/event/template_event.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/template/event/template_event.h b/esphome/components/template/event/template_event.h index fe83dc9f34b..3d2d9a9efe2 100644 --- a/esphome/components/template/event/template_event.h +++ b/esphome/components/template/event/template_event.h @@ -5,6 +5,10 @@ namespace esphome::template_ { -class TemplateEvent final : public Component, public event::Event {}; +class TemplateEvent final : public Component, public event::Event { + public: + // User provided, not "= default": `new(p) TemplateEvent()` would zero-fill .bss that is already zero. + TemplateEvent() {} +}; } // namespace esphome::template_ From 3180381a26d7fbf4a1423c9727250be85adee184 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:07 -0500 Subject: [PATCH 216/266] [alarm_control_panel] Initialize the state members (#19120) --- .../components/alarm_control_panel/alarm_control_panel.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index e748b8621b9..aced89b7ffc 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -138,11 +138,11 @@ class AlarmControlPanel : public EntityBase { // in order to store last panel state in flash ESPPreferenceObject pref_; // current state - AlarmControlPanelState current_state_; + AlarmControlPanelState current_state_{ACP_STATE_DISARMED}; // the desired (or previous) state - AlarmControlPanelState desired_state_; + AlarmControlPanelState desired_state_{ACP_STATE_DISARMED}; // last time the state was updated - uint32_t last_update_; + uint32_t last_update_{0}; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; // state callback - passes the new state to listeners From 7e750ec611a46377e85c793e21e8eb10792ab54e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:10 -0500 Subject: [PATCH 217/266] [text_sensor] Use a user provided default constructor (#19112) --- esphome/components/text_sensor/text_sensor.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 0e7364bf980..5041ebc4e08 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -29,7 +29,8 @@ class TextSensor : public EntityBase { public: std::string state; - TextSensor() = default; + // User provided, not "= default": `new(p) TextSensor()` would zero-fill .bss that is already zero. + TextSensor() {} ~TextSensor() = default; /// Getter-syntax for .state. From a5931ea20eab43e8dad6ffe733a179dcd516c3c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:21 -0500 Subject: [PATCH 218/266] [fan] Initialize Fan::restore_mode_ (#19118) --- esphome/components/fan/fan.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 106e6e74cd8..7e21971639e 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -183,7 +183,7 @@ class Fan : public EntityBase { LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; - FanRestoreMode restore_mode_; + FanRestoreMode restore_mode_{FanRestoreMode::NO_RESTORE}; private: /// Lazy-allocate preset modes vector (never freed — entity lives forever). From 17c5daa24560607314c7dcd501a5dca4bb8f65e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:33 -0500 Subject: [PATCH 219/266] [ld2412] Drop the unused gate index from GateThresholdNumber (#19107) --- esphome/components/ld2412/number/__init__.py | 4 ++-- esphome/components/ld2412/number/gate_threshold_number.cpp | 2 -- esphome/components/ld2412/number/gate_threshold_number.h | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index 1a81c330adf..f27e241491d 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -109,14 +109,14 @@ async def to_code(config: ConfigType) -> None: for x in range(14): if gate_conf := config.get(f"gate_{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID], x) + n = cg.new_Pvariable(move_config[CONF_ID]) await number.register_number( n, move_config, min_value=0, max_value=100, step=1 ) await cg.register_parented(n, config[CONF_LD2412_ID]) cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID], x) + n = cg.new_Pvariable(still_config[CONF_ID]) await number.register_number( n, still_config, min_value=0, max_value=100, step=1 ) diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index 8d12bad1151..a0a525a8107 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -2,8 +2,6 @@ namespace esphome::ld2412 { -GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} - void GateThresholdNumber::control(float value) { this->publish_state(value); this->parent_->set_gate_threshold(); diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 918b6dfad1a..308da43a34d 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -7,10 +7,10 @@ namespace esphome::ld2412 { class GateThresholdNumber final : public number::Number, public Parented { public: - GateThresholdNumber(uint8_t gate); + // Not "= default": that makes new(p) T() zero-fill the object at every codegen site before the ctor runs. + GateThresholdNumber() {} protected: - uint8_t gate_; void control(float value) override; }; From fd011ec3379382978ea96131ec19e15d457b047c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:19 -0500 Subject: [PATCH 220/266] [esp8266_pwm] Use a user provided default constructor for ESP8266PWM (#19198) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 79c2e509848..87b76a392a6 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -11,6 +11,9 @@ namespace esphome::esp8266_pwm { class ESP8266PWM final : public output::FloatOutput, public Component { public: + // User provided, not "= default": `new(p) ESP8266PWM()` would zero-fill .bss that is already zero. + ESP8266PWM() {} + void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } /// Dynamically update frequency @@ -28,7 +31,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { protected: void write_state(float state) override; - InternalGPIOPin *pin_; + InternalGPIOPin *pin_{nullptr}; float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; From 0f01fa89b8661b828415c50d1be4e1c1a5b544cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:25 -0500 Subject: [PATCH 221/266] [api] Skip setters that pass the default port, reboot timeout and batch delay (#19227) --- esphome/components/api/__init__.py | 24 +++++++++---- esphome/components/api/api_server.h | 6 ++-- tests/component_tests/api/config/bare.yaml | 12 +++++++ tests/component_tests/api/config/custom.yaml | 15 ++++++++ .../component_tests/api/config/defaults.yaml | 15 ++++++++ .../api/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/api/config/bare.yaml create mode 100644 tests/component_tests/api/config/custom.yaml create mode 100644 tests/component_tests/api/config/defaults.yaml create mode 100644 tests/component_tests/api/test_default_setters.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 272b0786905..854bceecfad 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -136,6 +136,12 @@ CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" +# Schema defaults that also match the C++ initializers in api_server.h; codegen +# skips the setter when the config equals them. +DEFAULT_PORT = 6053 +DEFAULT_REBOOT_TIMEOUT = "15min" +DEFAULT_BATCH_DELAY = "100ms" + def _register_provisioning_source(config: ConfigType) -> ConfigType: """Register the API as a provisioning source when encryption is enabled. @@ -292,7 +298,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(APIServer), - cv.Optional(CONF_PORT, default=6053): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, # Removed in 2026.1.0 - kept to provide helpful error message cv.Optional(CONF_PASSWORD): cv.invalid( "The 'password' option has been removed in ESPHome 2026.1.0.\n" @@ -305,14 +311,14 @@ CONFIG_SCHEMA = cv.All( "Or visit https://esphome.io/components/api/#configuration-variables" ), cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.Exclusive( CONF_SERVICES, group_of_exclusion=CONF_ACTIONS ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, - cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All( + cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), @@ -462,9 +468,15 @@ async def to_code(config: ConfigType) -> None: # Request a log listener slot for API log streaming request_log_listener() - cg.add(var.set_port(config[CONF_PORT])) - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) + # Skip the setters when the config matches the C++ initializers (DEFAULT_*). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(var.set_port(port)) + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY): + cg.add(var.set_batch_delay(batch_delay)) if CONF_LISTEN_BACKLOG in config: cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11a..e5a22dcef82 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -314,7 +314,7 @@ class APIServer final : public Component, #endif // 4-byte aligned types - uint32_t reboot_timeout_{300000}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t last_connected_{0}; // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. @@ -351,8 +351,8 @@ class APIServer final : public Component, #endif // Group smaller types together - uint16_t port_{6053}; - uint16_t batch_delay_{100}; + uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py + uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py // Connection limits - these defaults will be overridden by config values // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; diff --git a/tests/component_tests/api/config/bare.yaml b/tests/component_tests/api/config/bare.yaml new file mode 100644 index 00000000000..be5c73f18bc --- /dev/null +++ b/tests/component_tests/api/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: diff --git a/tests/component_tests/api/config/custom.yaml b/tests/component_tests/api/config/custom.yaml new file mode 100644 index 00000000000..cdf4038d5d5 --- /dev/null +++ b/tests/component_tests/api/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: + port: 6054 + reboot_timeout: 0s + batch_delay: 0ms diff --git a/tests/component_tests/api/config/defaults.yaml b/tests/component_tests/api/config/defaults.yaml new file mode 100644 index 00000000000..b20fd9b884c --- /dev/null +++ b/tests/component_tests/api/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: + port: 6053 + reboot_timeout: 15min + batch_delay: 100ms diff --git a/tests/component_tests/api/test_default_setters.py b/tests/component_tests/api/test_default_setters.py new file mode 100644 index 00000000000..32d35cacb7c --- /dev/null +++ b/tests/component_tests/api/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that the api component only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 6053, a 15 min reboot timeout and 100 ms batch delay are C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "api_apiserver_id->set_port(" not in main_cpp + assert "api_apiserver_id->set_reboot_timeout(" not in main_cpp + assert "api_apiserver_id->set_batch_delay(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "api_apiserver_id->set_port(6054);" in main_cpp + assert "api_apiserver_id->set_reboot_timeout(0);" in main_cpp + assert "api_apiserver_id->set_batch_delay(0);" in main_cpp From 0d5683525e0f4b12367ff5a2a09a0b4df7b66786 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:29 -0500 Subject: [PATCH 222/266] [core] Use a user provided default constructor for DelayAction (#19137) --- esphome/core/base_automation.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 276b8aa9728..999b38bd5cf 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -180,7 +180,9 @@ class ProjectUpdateTrigger : public Trigger, public Component { template class DelayAction : public Action { public: - explicit DelayAction() = default; + // User provided, not "= default": `new(p) DelayAction()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr explicit DelayAction() noexcept {} TEMPLATABLE_VALUE(uint32_t, delay) From 0d4c9f3243b0e6e7ae9d026d641ccd0b8ffa9a7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:32 -0500 Subject: [PATCH 223/266] [core] Use a user provided default constructor for Automation (#19197) --- esphome/core/automation.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index ea522a4d2da..5f010521dce 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -608,7 +608,9 @@ template class ActionList { template class Automation { public: /// Default constructor for use with TriggerForwarder (no Trigger object needed). - Automation() = default; + // User provided, not "= default": `new(p) Automation()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr Automation() noexcept {} explicit Automation(Trigger *trigger) { trigger->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } From dd40678ad70be06405070115a7e0855cb66bf278 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:37 -0500 Subject: [PATCH 224/266] [pca9554] Use a user provided default constructor for PCA9554GPIOPin (#19208) --- esphome/components/pca9554/pca9554.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 05e945d1763..cc95f147ac5 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -55,6 +55,9 @@ class PCA9554Component final : public Component, /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. class PCA9554GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCA9554GPIOPin()` would zero-fill .bss that is already zero. + PCA9554GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -69,10 +72,10 @@ class PCA9554GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCA9554Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCA9554Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pca9554 From 237d87880fe4709e00c2ca76182fc184d8729985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:42 -0500 Subject: [PATCH 225/266] [pcf8574] Use a user provided default constructor for PCF8574GPIOPin (#19207) --- esphome/components/pcf8574/pcf8574.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index e8f78bae506..9879d6a47eb 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -51,6 +51,9 @@ class PCF8574Component final : public Component, /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. class PCF8574GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCF8574GPIOPin()` would zero-fill .bss that is already zero. + PCF8574GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -65,10 +68,10 @@ class PCF8574GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCF8574Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCF8574Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pcf8574 From 4c7aee1a77cbe30249a0132f19f0a6e9c3db97a5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:49:56 +1200 Subject: [PATCH 226/266] [improv_ble] Rename from esp32_improv and decouple from ESP32 (#19264) --- CODEOWNERS | 2 +- esphome/component_aliases.py | 1 + .../components/esp32_ble_server/__init__.py | 2 +- .../components/esp32_ble_server/ble_server.h | 2 +- esphome/components/improv_base/__init__.py | 6 +- .../components/improv_base/improv_base.cpp | 2 +- esphome/components/improv_base/improv_base.h | 6 +- .../{esp32_improv => improv_ble}/__init__.py | 90 +++++++++++++------ .../{esp32_improv => improv_ble}/automation.h | 38 ++++---- .../improv_ble_component.cpp} | 69 +++++++------- .../improv_ble_component.h} | 24 ++--- esphome/components/improv_serial/__init__.py | 2 +- .../improv_serial/improv_serial_component.cpp | 2 +- .../improv_serial/improv_serial_component.h | 2 +- esphome/components/wifi/__init__.py | 2 +- esphome/components/wifi/wifi_component.cpp | 44 ++++----- esphome/components/wifi/wifi_component.h | 2 +- esphome/core/defines.h | 7 +- platformio.ini | 2 +- .../esp32_ble_server/config/improv_only.yaml | 2 +- .../esp32_ble_server/test_esp32_ble_server.py | 2 +- tests/component_tests/improv_ble/__init__.py | 0 .../improv_ble/config/automations.yaml | 31 +++++++ .../improv_ble/config/esp32.yaml | 12 +++ .../improv_ble/config/esp8266.yaml | 10 +++ .../improv_ble/config/legacy_key.yaml | 12 +++ .../improv_ble/test_improv_ble.py | 59 ++++++++++++ .../improv_base/rpc_response_builder_test.cpp | 2 +- .../{esp32_improv => improv_ble}/common.yaml | 2 +- .../test.esp32-c3-idf.yaml | 0 .../test.esp32-idf.yaml | 0 .../improv_serial/common-uart0.yaml | 2 +- .../provisioning/test.esp32-idf.yaml | 4 +- 33 files changed, 303 insertions(+), 140 deletions(-) rename esphome/components/{esp32_improv => improv_ble}/__init__.py (63%) rename esphome/components/{esp32_improv => improv_ble}/automation.h (55%) rename esphome/components/{esp32_improv/esp32_improv_component.cpp => improv_ble/improv_ble_component.cpp} (91%) rename esphome/components/{esp32_improv/esp32_improv_component.h => improv_ble/improv_ble_component.h} (86%) create mode 100644 tests/component_tests/improv_ble/__init__.py create mode 100644 tests/component_tests/improv_ble/config/automations.yaml create mode 100644 tests/component_tests/improv_ble/config/esp32.yaml create mode 100644 tests/component_tests/improv_ble/config/esp8266.yaml create mode 100644 tests/component_tests/improv_ble/config/legacy_key.yaml create mode 100644 tests/component_tests/improv_ble/test_improv_ble.py rename tests/components/{esp32_improv => improv_ble}/common.yaml (96%) rename tests/components/{esp32_improv => improv_ble}/test.esp32-c3-idf.yaml (100%) rename tests/components/{esp32_improv => improv_ble}/test.esp32-idf.yaml (100%) diff --git a/CODEOWNERS b/CODEOWNERS index 044d0051193..9b34d523d20 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -182,7 +182,6 @@ esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 esphome/components/esp32_hosted/update/* @swoboda1337 -esphome/components/esp32_improv/* @jesserockz esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core @@ -268,6 +267,7 @@ esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core +esphome/components/improv_ble/* @jesserockz esphome/components/improv_serial/* @esphome/core esphome/components/ina226/* @latonita @Sergio303 esphome/components/ina260/* @mreditor97 diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py index e701bd98d4e..53a34d1e15f 100644 --- a/esphome/component_aliases.py +++ b/esphome/component_aliases.py @@ -6,5 +6,6 @@ See the component-alias section of esphome/loader.py. # alias -> (canonical component, removal version or None) COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "esp32_improv": ("improv_ble", "2027.4.0"), "rp2040": ("rp2", "2027.7.0"), } diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 118ae06e420..924d11db2b1 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -597,7 +597,7 @@ async def to_code(config): cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE])) cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS])) # Only advertise for the server itself when the configuration gives clients something to - # find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays + # find. A server that is auto-loaded purely to host a runtime service (improv_ble) stays # silent until that service asks for advertising. cg.add( var.set_advertising_required( diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 7869c73cc53..e469b60e088 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -40,7 +40,7 @@ class BLEServer final : public Component, public Parented { /** Whether this server needs the device to advertise so clients can find and connect to it. * - * False for a server that only hosts services created at runtime (e.g. esp32_improv), which + * False for a server that only hosts services created at runtime (e.g. improv_ble), which * request advertising themselves for as long as they need it. */ void set_advertising_required(bool required) { this->advertising_required_ = required; } diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index 412d143a486..9b57b6561f9 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -38,9 +38,11 @@ def _process_next_url(url: str) -> str: return url -async def setup_improv_core(var: MockObj, config: ConfigType, component: str) -> None: +async def setup_improv_core(var: MockObj, config: ConfigType) -> None: if next_url := config.get(CONF_NEXT_URL): cg.add(var.set_next_url(_process_next_url(next_url))) - cg.add_define(f"USE_{component.upper()}_NEXT_URL") + # One define for all transports: next_url_ is per object, so a transport + # configured without next_url: calls add_next_url_ and appends nothing. + cg.add_define("USE_IMPROV_NEXT_URL") cg.add_library("improv/Improv", "1.2.7") diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index 1babeb5b5a2..6745f8064b1 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -8,7 +8,7 @@ namespace esphome::improv_base { -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL static const char *const TAG = "improv_base"; static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; diff --git a/esphome/components/improv_base/improv_base.h b/esphome/components/improv_base/improv_base.h index 352bb75d5fc..97801302d4f 100644 --- a/esphome/components/improv_base/improv_base.h +++ b/esphome/components/improv_base/improv_base.h @@ -3,7 +3,7 @@ #include #include "esphome/core/defines.h" -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL #include #endif @@ -11,12 +11,12 @@ namespace esphome::improv_base { class ImprovBase { public: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL void set_next_url(const char *next_url) { this->next_url_ = next_url; } #endif protected: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL /// Format next_url_ into buffer, replacing placeholders. Returns length written. size_t get_formatted_next_url_(char *buffer, size_t buffer_size); /// Append the formatted next_url to the RPC response, warning if it does not fit. diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/improv_ble/__init__.py similarity index 63% rename from esphome/components/esp32_improv/__init__.py rename to esphome/components/improv_ble/__init__.py index 32eb1660142..72ac5866286 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,14 +1,41 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble, improv_base, output -from esphome.components.esp32_ble import BTLoggers +from esphome.components import binary_sensor, improv_base, output import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, CONF_TRIGGER_ID +from esphome.const import ( + CONF_ID, + CONF_ON_START, + CONF_ON_STATE, + CONF_TRIGGER_ID, + PLATFORM_ESP32, +) +from esphome.core import CORE from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_server", "improv_base"] +# The BLE GATT server component that hosts the Improv service, per target +# platform. improv_ble itself is platform neutral; supporting another chip +# means adding its BLE server component here and the matching backend in +# improv_ble_component.cpp. Doubles as the platform gate below, so an +# unsupported chip is rejected in validation rather than at link time. +BLE_SERVER_BACKENDS: dict[str, str] = { + PLATFORM_ESP32: "esp32_ble_server", +} + + +def AUTO_LOAD() -> list[str]: + auto_load = ["improv_base"] + if backend := BLE_SERVER_BACKENDS.get(CORE.target_platform): + auto_load.append(backend) + return auto_load + + CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["wifi", "esp32"] +DEPENDENCIES = ["wifi"] + +# Legacy top-level YAML key that routes here; esphome/loader.py and +# esphome/config.py handle the warning and the key rename. +ALIASES = ["esp32_improv"] +ALIAS_REMOVAL_VERSION = "2027.4.0" CONF_AUTHORIZED_DURATION = "authorized_duration" CONF_AUTHORIZER = "authorizer" @@ -29,29 +56,29 @@ improv_ns = cg.esphome_ns.namespace("improv") Error = improv_ns.enum("Error") State = improv_ns.enum("State") -esp32_improv_ns = cg.esphome_ns.namespace("esp32_improv") -ESP32ImprovComponent = esp32_improv_ns.class_("ESP32ImprovComponent", cg.Component) -ESP32ImprovProvisionedTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisionedTrigger", automation.Trigger.template() +improv_ble_ns = cg.esphome_ns.namespace("improv_ble") +ImprovBLEComponent = improv_ble_ns.class_("ImprovBLEComponent", cg.Component) +ImprovBLEProvisionedTrigger = improv_ble_ns.class_( + "ImprovBLEProvisionedTrigger", automation.Trigger.template() ) -ESP32ImprovProvisioningTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisioningTrigger", automation.Trigger.template() +ImprovBLEProvisioningTrigger = improv_ble_ns.class_( + "ImprovBLEProvisioningTrigger", automation.Trigger.template() ) -ESP32ImprovStartTrigger = esp32_improv_ns.class_( - "ESP32ImprovStartTrigger", automation.Trigger.template() +ImprovBLEStartTrigger = improv_ble_ns.class_( + "ImprovBLEStartTrigger", automation.Trigger.template() ) -ESP32ImprovStateTrigger = esp32_improv_ns.class_( - "ESP32ImprovStateTrigger", automation.Trigger.template() +ImprovBLEStateTrigger = improv_ble_ns.class_( + "ImprovBLEStateTrigger", automation.Trigger.template() ) -ESP32ImprovStoppedTrigger = esp32_improv_ns.class_( - "ESP32ImprovStoppedTrigger", automation.Trigger.template() +ImprovBLEStoppedTrigger = improv_ble_ns.class_( + "ImprovBLEStoppedTrigger", automation.Trigger.template() ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(ESP32ImprovComponent), + cv.GenerateID(): cv.declare_id(ImprovBLEComponent), cv.Required(CONF_AUTHORIZER): cv.Any( cv.none, cv.use_id(binary_sensor.BinarySensor) ), @@ -68,55 +95,60 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisionedTrigger + ImprovBLEProvisionedTrigger ), } ), cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisioningTrigger + ImprovBLEProvisioningTrigger ), } ), cv.Optional(CONF_ON_START): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStartTrigger + ImprovBLEStartTrigger ), } ), cv.Optional(CONF_ON_STATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStateTrigger + ImprovBLEStateTrigger ), } ), cv.Optional(CONF_ON_STOP): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStoppedTrigger + ImprovBLEStoppedTrigger ), } ), } ) .extend(improv_base.IMPROV_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on(list(BLE_SERVER_BACKENDS)), ) async def to_code(config: ConfigType) -> None: + # ESP32 backend setup: the platform gate above means this is the only backend + # that can reach to_code. Make it conditional when a second one is added. + from esphome.components import esp32_ble + # Register the loggers this component needs - esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + esp32_ble.register_bt_logger(esp32_ble.BTLoggers.GATT, esp32_ble.BTLoggers.SMP) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - cg.add_define("USE_IMPROV") + cg.add_define("USE_IMPROV_BLE") - await improv_base.setup_improv_core(var, config, "esp32_improv") + await improv_base.setup_improv_core(var, config) cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION])) cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION])) @@ -155,4 +187,4 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [], conf) use_state_callback = True if use_state_callback: - cg.add_define("USE_ESP32_IMPROV_STATE_CALLBACK") + cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/improv_ble/automation.h similarity index 55% rename from esphome/components/esp32_improv/automation.h rename to esphome/components/improv_ble/automation.h index b3b61f47785..223a1292384 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/improv_ble/automation.h @@ -1,17 +1,17 @@ #pragma once #ifdef USE_ESP32 -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK -#include "esp32_improv_component.h" +#ifdef USE_IMPROV_BLE_STATE_CALLBACK +#include "improv_ble_component.h" #include "esphome/core/automation.h" #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { -class ESP32ImprovProvisionedTrigger final : public Trigger<> { +class ImprovBLEProvisionedTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisionedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONED && !this->parent_->is_failed()) { this->trigger(); @@ -20,12 +20,12 @@ class ESP32ImprovProvisionedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovProvisioningTrigger final : public Trigger<> { +class ImprovBLEProvisioningTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisioningTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONING && !this->parent_->is_failed()) { this->trigger(); @@ -34,12 +34,12 @@ class ESP32ImprovProvisioningTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStartTrigger final : public Trigger<> { +class ImprovBLEStartTrigger final : public Trigger<> { public: - explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStartTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if ((state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION) && !this->parent_->is_failed()) { @@ -49,12 +49,12 @@ class ESP32ImprovStartTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStateTrigger final : public Trigger { public: - explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStateTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (!this->parent_->is_failed()) { this->trigger(state, error); @@ -63,12 +63,12 @@ class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStoppedTrigger final : public Trigger<> { public: - explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStoppedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_STOPPED && !this->parent_->is_failed()) { this->trigger(); @@ -77,10 +77,10 @@ class ESP32ImprovStoppedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp similarity index 91% rename from esphome/components/esp32_improv/esp32_improv_component.cpp rename to esphome/components/improv_ble/improv_ble_component.cpp index 9ec6eb7bab6..bbc1589abf0 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -1,10 +1,7 @@ -#include "esp32_improv_component.h" +#include "improv_ble_component.h" #include -#include "esphome/components/bytebuffer/bytebuffer.h" -#include "esphome/components/esp32_ble/ble.h" -#include "esphome/components/esp32_ble_server/ble_2902.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -15,11 +12,15 @@ #ifdef USE_ESP32 -namespace esphome::esp32_improv { +#include "esphome/components/bytebuffer/bytebuffer.h" +#include "esphome/components/esp32_ble/ble.h" +#include "esphome/components/esp32_ble_server/ble_2902.h" + +namespace esphome::improv_ble { using namespace bytebuffer; -static const char *const TAG = "esp32_improv.component"; +static const char *const TAG = "improv_ble.component"; static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; // command + data length + trailing byte @@ -38,9 +39,9 @@ static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8; static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7 static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7 -ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } +ImprovBLEComponent::ImprovBLEComponent() { global_improv_component = this; } -void ESP32ImprovComponent::setup() { +void ImprovBLEComponent::setup() { #ifdef USE_BINARY_SENSOR if (this->authorizer_ != nullptr) { this->authorizer_->add_on_state_callback([this](bool state) { @@ -66,7 +67,7 @@ void ESP32ImprovComponent::setup() { this->disable_loop(); } -void ESP32ImprovComponent::setup_characteristics() { +void ImprovBLEComponent::setup_characteristics() { this->status_ = this->service_->create_characteristic( improv::STATUS_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); BLEDescriptor *status_descriptor = new BLE2902(); @@ -104,11 +105,11 @@ void ESP32ImprovComponent::setup_characteristics() { this->setup_complete_ = true; } -void ESP32ImprovComponent::loop() { +void ImprovBLEComponent::loop() { if (!global_ble_server->is_running()) { if (this->state_ != improv::STATE_STOPPED) { this->state_ = improv::STATE_STOPPED; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } @@ -200,7 +201,7 @@ void ESP32ImprovComponent::loop() { } } -void ESP32ImprovComponent::set_status_indicator_state_(bool state) { +void ImprovBLEComponent::set_status_indicator_state_(bool state) { #ifdef USE_OUTPUT if (this->status_indicator_ == nullptr) return; @@ -216,7 +217,7 @@ void ESP32ImprovComponent::set_status_indicator_state_(bool state) { } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG -const char *ESP32ImprovComponent::state_to_string_(improv::State state) { +const char *ImprovBLEComponent::state_to_string_(improv::State state) { switch (state) { case improv::STATE_STOPPED: return "STOPPED"; @@ -234,7 +235,7 @@ const char *ESP32ImprovComponent::state_to_string_(improv::State state) { } #endif -bool ESP32ImprovComponent::check_identify_() { +bool ImprovBLEComponent::check_identify_() { uint32_t now = millis(); bool identify = this->identify_start_ != 0 && now - this->identify_start_ <= this->identify_duration_; @@ -246,7 +247,7 @@ bool ESP32ImprovComponent::check_identify_() { return identify; } -void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) { +void ImprovBLEComponent::set_state_(improv::State state, bool update_advertising) { // Skip if state hasn't changed if (this->state_ == state) { return; @@ -274,12 +275,12 @@ void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertisi // Advertise the new state via service data this->advertise_service_data_(); } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } -void ESP32ImprovComponent::set_error_(improv::Error error) { +void ImprovBLEComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } @@ -295,14 +296,14 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { } } -void ESP32ImprovComponent::send_response_(std::span response) { +void ImprovBLEComponent::send_response_(std::span response) { // The BLE characteristic owns its value, so one exact-size copy is required here this->rpc_response_->set_value(std::vector(response.begin(), response.end())); if (this->state_ != improv::STATE_STOPPED) this->rpc_response_->notify(); } -void ESP32ImprovComponent::start() { +void ImprovBLEComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; @@ -320,7 +321,7 @@ void ESP32ImprovComponent::start() { this->enable_loop(); } -void ESP32ImprovComponent::stop() { +void ImprovBLEComponent::stop() { this->should_start_ = false; // Wait before stopping the service to ensure all BLE clients see the state change. // This prevents clients from repeatedly reconnecting and wasting resources by allowing @@ -335,10 +336,10 @@ void ESP32ImprovComponent::stop() { }); } -float ESP32ImprovComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +float ImprovBLEComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } -void ESP32ImprovComponent::dump_config() { - ESP_LOGCONFIG(TAG, "ESP32 Improv:"); +void ImprovBLEComponent::dump_config() { + ESP_LOGCONFIG(TAG, "Improv BLE:"); #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_); #endif @@ -347,7 +348,7 @@ void ESP32ImprovComponent::dump_config() { #endif } -void ESP32ImprovComponent::process_incoming_data_() { +void ImprovBLEComponent::process_incoming_data_() { if (this->incoming_data_.size() < 3) return; uint8_t length = this->incoming_data_[1]; @@ -422,7 +423,7 @@ void ESP32ImprovComponent::process_incoming_data_() { } } -void ESP32ImprovComponent::on_wifi_connect_timeout_() { +void ImprovBLEComponent::on_wifi_connect_timeout_() { this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_state_(improv::STATE_AUTHORIZED); #ifdef USE_BINARY_SENSOR @@ -433,7 +434,7 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } -void ESP32ImprovComponent::check_wifi_connection_() { +void ImprovBLEComponent::check_wifi_connection_() { if (!wifi::global_wifi_component->is_connected()) { return; } @@ -447,7 +448,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { std::array buf; improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS); -#ifdef USE_ESP32_IMPROV_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL // Add next_url if configured (should be first per Improv BLE spec) this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif @@ -480,7 +481,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { this->stop(); } -void ESP32ImprovComponent::advertise_service_data_() { +void ImprovBLEComponent::advertise_service_data_() { uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; service_data[0] = IMPROV_PROTOCOL_ID_1; // PR service_data[1] = IMPROV_PROTOCOL_ID_2; // IM @@ -499,7 +500,7 @@ void ESP32ImprovComponent::advertise_service_data_() { esp32_ble::global_ble->advertising_set_service_data_and_name(std::span(service_data), false); } -void ESP32ImprovComponent::update_advertising_type_() { +void ImprovBLEComponent::update_advertising_type_() { uint32_t now = App.get_loop_component_start_time(); // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data @@ -524,21 +525,21 @@ void ESP32ImprovComponent::update_advertising_type_() { } } -void ESP32ImprovComponent::request_advertising_() { +void ImprovBLEComponent::request_advertising_() { if (this->advertising_requested_) return; this->advertising_requested_ = true; esp32_ble::global_ble->advertising_start(); } -void ESP32ImprovComponent::release_advertising_() { +void ImprovBLEComponent::release_advertising_() { if (!this->advertising_requested_) return; this->advertising_requested_ = false; esp32_ble::global_ble->advertising_stop(); } -improv::State ESP32ImprovComponent::get_initial_state_() const { +improv::State ImprovBLEComponent::get_initial_state_() const { #ifdef USE_BINARY_SENSOR // If we have an authorizer, start in awaiting authorization state return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; @@ -548,8 +549,8 @@ improv::State ESP32ImprovComponent::get_initial_state_() const { #endif } -ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +ImprovBLEComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/improv_ble/improv_ble_component.h similarity index 86% rename from esphome/components/esp32_improv/esp32_improv_component.h rename to esphome/components/improv_ble/improv_ble_component.h index a40d60552a8..2552bed69b5 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -5,12 +5,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" -#include "esphome/components/esp32_ble_server/ble_characteristic.h" -#include "esphome/components/esp32_ble_server/ble_server.h" #include "esphome/components/improv_base/improv_base.h" #include "esphome/components/wifi/wifi_component.h" -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK #include "esphome/core/automation.h" #endif @@ -25,17 +23,23 @@ #include #include +// ESP-IDF is currently the only target platform with a BLE GATT server, so it is +// the only backend this component has. The Python side keeps the platform table +// (BLE_SERVER_BACKENDS in __init__.py); a second backend adds another arm here. #ifdef USE_ESP32 +#include "esphome/components/esp32_ble_server/ble_characteristic.h" +#include "esphome/components/esp32_ble_server/ble_server.h" + #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { using namespace esp32_ble_server; -class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { +class ImprovBLEComponent final : public Component, public improv_base::ImprovBase { public: - ESP32ImprovComponent(); + ImprovBLEComponent(); void dump_config() override; void loop() override; void setup() override; @@ -47,7 +51,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB bool is_active() const { return this->state_ != improv::STATE_STOPPED; } bool should_start() const { return this->should_start_; } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK template void add_on_state_callback(F &&callback) { this->state_callback_.add(std::forward(callback)); } @@ -97,7 +101,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB improv::State state_{improv::STATE_STOPPED}; improv::Error error_state_{improv::ERROR_NONE}; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif @@ -125,8 +129,8 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern ESP32ImprovComponent *global_improv_component; +extern ImprovBLEComponent *global_improv_component; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index a34e2ab7931..0231791e9b3 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -70,7 +70,7 @@ FINAL_VALIDATE_SCHEMA = validate_transport async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await improv_base.setup_improv_core(var, config, "improv_serial") + await improv_base.setup_improv_core(var, config) cg.add_define("USE_IMPROV_SERIAL") if (uart_id := config.get(CONF_UART_ID)) is not None: cg.add(var.set_uart(await cg.get_variable(uart_id))) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ffa7b79d9bf..3827fb6ed45 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -208,7 +208,7 @@ void ImprovSerialComponent::add_webserver_urls_(improv::RpcResponseBuilder &buil void ImprovSerialComponent::send_settings_response_(improv::Command command) { std::array buf; improv::RpcResponseBuilder builder(buf, command); -#ifdef USE_IMPROV_SERIAL_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 68cdd752149..c7d89c76d6b 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -55,7 +55,7 @@ static const uint8_t IMPROV_SERIAL_VERSION = 1; #ifdef USE_WIFI // Wi-Fi connect failure timers: a fresh provision reports at 30 s (stock behavior), while // switching networks on an already-connected device (disconnect + reconnect) can legitimately -// take longer; 90 s matches esp32_improv's default wifi_timeout. +// take longer; 90 s matches improv_ble's default wifi_timeout. static const uint32_t WIFI_CONNECT_TIMEOUT_MS = 30000; static const uint32_t WIFI_SWITCH_TIMEOUT_MS = 90000; #endif diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index a1a3436d470..c22d49e6654 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -355,7 +355,7 @@ def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config full_config = fv.full_config.get() - has_improv = "esp32_improv" in full_config + has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 5ba36143945..125139ad163 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -37,8 +37,8 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_IMPROV -#include "esphome/components/esp32_improv/esp32_improv_component.h" +#ifdef USE_IMPROV_BLE +#include "esphome/components/improv_ble/improv_ble_component.h" #endif #ifdef USE_IMPROV_SERIAL @@ -226,7 +226,7 @@ bool CompactString::operator==(const StringRef &other) const { /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Captive Portal / Improv Mode (AP active, scanning disabled) │ /// ├──────────────────────────────────────────────────────────────────────┤ -/// │ When captive_portal or esp32_improv is active, WiFi scanning is │ +/// │ When captive_portal or improv_ble is active, WiFi scanning is │ /// │ disabled because it disrupts AP clients (radio leaves AP channel │ /// │ to hop through other channels, causing client disconnections). │ /// │ │ @@ -478,9 +478,9 @@ bool WiFiComponent::needs_full_scan_results_() const { } #endif -#ifdef USE_IMPROV +#ifdef USE_IMPROV_BLE // BLE improv also needs results during provisioning - if (esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active()) { + if (improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active()) { return true; } #endif @@ -746,10 +746,10 @@ void WiFiComponent::start() { #endif #endif // USE_WIFI_AP } -#ifdef USE_IMPROV - if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) { +#ifdef USE_IMPROV_BLE + if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } #endif this->wifi_apply_hostname_(); @@ -805,7 +805,7 @@ void WiFiComponent::loop() { break; } // Use longer cooldown when captive portal/improv is active to avoid disrupting user config - bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); + bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -894,12 +894,12 @@ void WiFiComponent::loop() { } #endif // USE_WIFI_AP -#ifdef USE_IMPROV - if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() && - !esp32_improv::global_improv_component->should_start()) { - if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { +#ifdef USE_IMPROV_BLE + if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() && + !improv_ble::global_improv_component->should_start()) { + if (now - this->last_connected_ > improv_ble::global_improv_component->get_wifi_timeout()) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } } @@ -1644,9 +1644,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } -#ifdef USE_IMPROV - if (this->is_esp32_improv_active_()) { - esp32_improv::global_improv_component->stop(); +#ifdef USE_IMPROV_BLE + if (this->is_improv_ble_active_()) { + improv_ble::global_improv_component->stop(); } #endif @@ -1878,7 +1878,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_esp32_improv_active_()) { + } else if (this->is_improv_ble_active_()) { // Improv doesn't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } @@ -1969,7 +1969,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Skip actual adapter restart if captive portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { + if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2228,9 +2228,9 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } -bool WiFiComponent::is_esp32_improv_active_() { -#ifdef USE_IMPROV - return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active(); +bool WiFiComponent::is_improv_ble_active_() { +#ifdef USE_IMPROV_BLE + return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); #else return false; #endif diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index a0983545fbd..67913796499 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -797,7 +797,7 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); - bool is_esp32_improv_active_(); + bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(WiFiAP ¶ms); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b36d39bbefa..b2b5267b112 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,7 +70,6 @@ #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_HOSTED_HTTP_UPDATE -#define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN @@ -83,6 +82,7 @@ #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE +#define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF #define USE_JSON @@ -266,7 +266,7 @@ #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment #define USE_IMPROV_SERIAL -#define USE_IMPROV_SERIAL_NEXT_URL +#define USE_IMPROV_NEXT_URL #define USE_MD5 #define USE_NOISE #define USE_SHA256 @@ -392,8 +392,7 @@ #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C -#define USE_IMPROV -#define USE_ESP32_IMPROV_NEXT_URL +#define USE_IMPROV_BLE #define USE_MICROPHONE #define USE_PSRAM #define USE_SENDSPIN diff --git a/platformio.ini b/platformio.ini index 0e334ac5b4c..722109adec4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,7 +46,7 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea esphome/noise-c@0.1.30 ; noise (api, ota) - improv/Improv@1.2.7 ; improv_serial / esp32_improv + improv/Improv@1.2.7 ; improv_serial / improv_ble kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image diff --git a/tests/component_tests/esp32_ble_server/config/improv_only.yaml b/tests/component_tests/esp32_ble_server/config/improv_only.yaml index 8a5c3ba6383..4239d24b0fe 100644 --- a/tests/component_tests/esp32_ble_server/config/improv_only.yaml +++ b/tests/component_tests/esp32_ble_server/config/improv_only.yaml @@ -9,5 +9,5 @@ wifi: password: password1 # esp32_ble_server is only auto-loaded here, so it has no services of its own. -esp32_improv: +improv_ble: authorizer: none diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 4b7ab79a81c..21a12d9cf2e 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -55,7 +55,7 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: @pytest.mark.parametrize( ("config_file", "required"), [ - # Auto-loaded by esp32_improv only: nothing to find until Improv asks for it + # Auto-loaded by improv_ble only: nothing to find until Improv asks for it ("improv_only.yaml", False), # The configuration defines a service clients are meant to connect to ("own_service.yaml", True), diff --git a/tests/component_tests/improv_ble/__init__.py b/tests/component_tests/improv_ble/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/improv_ble/config/automations.yaml b/tests/component_tests/improv_ble/config/automations.yaml new file mode 100644 index 00000000000..d5d97f5cbf3 --- /dev/null +++ b/tests/component_tests/improv_ble/config/automations.yaml @@ -0,0 +1,31 @@ +esphome: + name: improv-ble-automations +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +binary_sensor: + - platform: gpio + pin: 0 + id: io0_button +output: + - platform: gpio + pin: 2 + id: built_in_led +improv_ble: + authorizer: io0_button + status_indicator: built_in_led + on_provisioned: + - logger.log: provisioned + on_provisioning: + - logger.log: provisioning + on_start: + - logger.log: start + on_state: + - logger.log: state + on_stop: + - logger.log: stop diff --git a/tests/component_tests/improv_ble/config/esp32.yaml b/tests/component_tests/improv_ble/config/esp32.yaml new file mode 100644 index 00000000000..ed55ef358ae --- /dev/null +++ b/tests/component_tests/improv_ble/config/esp32.yaml @@ -0,0 +1,12 @@ +esphome: + name: improv-ble-esp32 +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +improv_ble: + authorizer: none diff --git a/tests/component_tests/improv_ble/config/esp8266.yaml b/tests/component_tests/improv_ble/config/esp8266.yaml new file mode 100644 index 00000000000..d32defd6f33 --- /dev/null +++ b/tests/component_tests/improv_ble/config/esp8266.yaml @@ -0,0 +1,10 @@ +esphome: + name: improv-ble-esp8266 +esp8266: + board: nodemcuv2 +logger: +wifi: + ssid: MySSID + password: password1 +improv_ble: + authorizer: none diff --git a/tests/component_tests/improv_ble/config/legacy_key.yaml b/tests/component_tests/improv_ble/config/legacy_key.yaml new file mode 100644 index 00000000000..9491203ca90 --- /dev/null +++ b/tests/component_tests/improv_ble/config/legacy_key.yaml @@ -0,0 +1,12 @@ +esphome: + name: improv-ble-legacy-key +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +esp32_improv: + authorizer: none diff --git a/tests/component_tests/improv_ble/test_improv_ble.py b/tests/component_tests/improv_ble/test_improv_ble.py new file mode 100644 index 00000000000..02293bdb23b --- /dev/null +++ b/tests/component_tests/improv_ble/test_improv_ble.py @@ -0,0 +1,59 @@ +"""improv_ble is platform neutral; only its BLE server backends are not. + +Covers the platform gate (BLE_SERVER_BACKENDS) and the esp32_improv alias that +keeps pre-rename configurations working. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_esp32_generates_component( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("esp32.yaml")) + assert "improv_ble::ImprovBLEComponent" in main_cpp + + +def test_legacy_key_routes_to_improv_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("legacy_key.yaml")) + assert "improv_ble::ImprovBLEComponent" in main_cpp + assert "'esp32_improv:' top-level key is deprecated" in caplog.text + + +def test_platform_without_ble_server_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + # AUTO_LOAD finds no backend for esp8266 and pulls in improv_base only, so + # the platform gate in CONFIG_SCHEMA is what has to reject the config. + CORE.config_path = component_config_path("esp8266.yaml") + assert read_config({}) is None + assert "only available on" in capsys.readouterr().out + + +def test_automations_emit_renamed_triggers( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("automations.yaml")) + for trigger in ( + "ImprovBLEProvisionedTrigger", + "ImprovBLEProvisioningTrigger", + "ImprovBLEStartTrigger", + "ImprovBLEStateTrigger", + "ImprovBLEStoppedTrigger", + ): + assert f"improv_ble::{trigger}" in main_cpp + assert "set_authorizer" in main_cpp + assert "set_status_indicator" in main_cpp diff --git a/tests/components/improv_base/rpc_response_builder_test.cpp b/tests/components/improv_base/rpc_response_builder_test.cpp index d9d0ad90d88..f7f0eda38c4 100644 --- a/tests/components/improv_base/rpc_response_builder_test.cpp +++ b/tests/components/improv_base/rpc_response_builder_test.cpp @@ -52,7 +52,7 @@ TEST(RpcResponseBuilder, GoldenBytes) { (std::vector{0x04, 0x03, 0x02, 'a', 'b', 0xCC})); } -// esp32_improv calls finish() and build_rpc_response() with no checksum flag, +// improv_ble calls finish() and build_rpc_response() with no checksum flag, // so the two defaults must agree TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) { const std::vector urls = {"https://example.com"}; diff --git a/tests/components/esp32_improv/common.yaml b/tests/components/improv_ble/common.yaml similarity index 96% rename from tests/components/esp32_improv/common.yaml rename to tests/components/improv_ble/common.yaml index 7dc2f7b6c73..7605cd6e657 100644 --- a/tests/components/esp32_improv/common.yaml +++ b/tests/components/improv_ble/common.yaml @@ -12,7 +12,7 @@ output: pin: 2 id: built_in_led -esp32_improv: +improv_ble: authorizer: io0_button authorized_duration: 1min status_indicator: built_in_led diff --git a/tests/components/esp32_improv/test.esp32-c3-idf.yaml b/tests/components/improv_ble/test.esp32-c3-idf.yaml similarity index 100% rename from tests/components/esp32_improv/test.esp32-c3-idf.yaml rename to tests/components/improv_ble/test.esp32-c3-idf.yaml diff --git a/tests/components/esp32_improv/test.esp32-idf.yaml b/tests/components/improv_ble/test.esp32-idf.yaml similarity index 100% rename from tests/components/esp32_improv/test.esp32-idf.yaml rename to tests/components/improv_ble/test.esp32-idf.yaml diff --git a/tests/components/improv_serial/common-uart0.yaml b/tests/components/improv_serial/common-uart0.yaml index 45bf1e5c330..3710cb3bb53 100644 --- a/tests/components/improv_serial/common-uart0.yaml +++ b/tests/components/improv_serial/common-uart0.yaml @@ -5,6 +5,6 @@ wifi: logger: hardware_uart: UART0 -# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_ +# next_url compiles the USE_IMPROV_NEXT_URL branch and add_next_url_ improv_serial: next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}} diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml index baa3aa8f683..4a34539002e 100644 --- a/tests/components/provisioning/test.esp32-idf.yaml +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -1,6 +1,6 @@ # Exercises the provisioning window: api registers as a provisioning source # (encryption enabled, no key), the on_timeout automation, and the wifi (AP + -# captive portal) and esp32_improv cross-component guards. improv_serial is +# captive portal) and improv_ble cross-component guards. improv_serial is # intentionally NOT gated. provisioning: timeout: 1min @@ -26,5 +26,5 @@ binary_sensor: pin: 0 id: io0_button -esp32_improv: +improv_ble: authorizer: io0_button From 3a9bb7e5bc6c4c22bf08f46ebccfb400b4287f0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 08:41:48 -0500 Subject: [PATCH 227/266] [http_request] Keep the update manifest URL as a pointer to the literal (#19211) --- .../update/http_request_update.cpp | 23 ++++++++++++------- .../http_request/update/http_request_update.h | 6 +++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 57dc86d55cf..6a74c00e8e5 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -1,5 +1,7 @@ #include "http_request_update.h" +#include + #include "esphome/core/application.h" #include "esphome/core/version.h" @@ -94,7 +96,7 @@ void HttpRequestUpdate::update_task(void *params) { auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_); if (container != nullptr) container->end(); result->error_str = LOG_STR("Failed to fetch manifest"); @@ -174,21 +176,26 @@ void HttpRequestUpdate::update_task(void *params) { allocator.deallocate(data, content_length); if (!valid) { - ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_); result->error_str = LOG_STR("Failed to parse manifest JSON"); goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } // Merge source_url_ and firmware_url if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { - std::string path = info->firmware_url; - if (path[0] == '/') { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); - info->firmware_url = domain + path; + const char *source = this_update->source_url_; + const size_t source_len = strlen(source); + size_t prefix_len; + if (info->firmware_url[0] == '/') { + // scheme and host, up to the first slash after "https://" + const char *host_end = source_len > 8 ? strchr(source + 8, '/') : nullptr; + prefix_len = host_end != nullptr ? host_end - source : source_len; } else { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); - info->firmware_url = domain + path; + // directory of the manifest, up to and including its last slash + const char *dir_end = strrchr(source, '/'); + prefix_len = dir_end != nullptr ? dir_end - source + 1 : 0; } + info->firmware_url.insert(0, source, prefix_len); } #ifdef ESPHOME_PROJECT_VERSION diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index be9fbf72bfd..05a741b6cd9 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -21,7 +21,7 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo void perform(bool force) override; void check() override { this->update(); } - void set_source_url(const std::string &source_url) { this->source_url_ = source_url; } + void set_source_url(const char *source_url) { this->source_url_ = source_url; } void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; } void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; } @@ -33,13 +33,15 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo protected: HttpRequestComponent *request_parent_; OtaHttpRequestComponent *ota_parent_; - std::string source_url_; static void update_task(void *params); #ifdef USE_ESP32 TaskHandle_t update_task_handle_{nullptr}; #endif uint8_t initial_check_remaining_{0}; + + private: + const char *source_url_{nullptr}; // literal from codegen }; } // namespace esphome::http_request From 544f8ae77175e086eab391cb9d958895506c8788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 09:14:52 -0500 Subject: [PATCH 228/266] [http_request] Take the URL and method as C strings (#19215) --- .../components/http_request/http_request.h | 64 ++++++++++++++----- .../http_request/http_request_arduino.cpp | 19 +++--- .../http_request/http_request_arduino.h | 2 +- .../http_request/http_request_host.cpp | 27 ++++---- .../http_request/http_request_host.h | 2 +- .../http_request/http_request_idf.cpp | 21 +++--- .../http_request/http_request_idf.h | 2 +- 7 files changed, 85 insertions(+), 52 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 4471dffdc2b..71668b8556f 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -331,27 +331,46 @@ class HttpRequestComponent : public Component { void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; } - std::shared_ptr get(const std::string &url) { - return this->start(url, "GET", "", std::vector
{}); - } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + std::shared_ptr get(const char *url) { return this->start(url, "GET", "", std::vector
{}); } + std::shared_ptr get(const char *url, const std::vector
&request_headers) { return this->start(url, "GET", "", request_headers); } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + std::shared_ptr get(const char *url, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->start(url, "GET", "", request_headers, lower_case_collect_headers); } - std::shared_ptr post(const std::string &url, const std::string &body) { + std::shared_ptr post(const char *url, const std::string &body) { return this->start(url, "POST", body, std::vector
{}); } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers) { + return this->start(url, "POST", body, request_headers); + } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + } + + std::shared_ptr get(const std::string &url) { return this->get(url.c_str()); } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + return this->get(url.c_str(), request_headers); + } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->get(url.c_str(), request_headers, lower_case_collect_headers); + } + std::shared_ptr post(const std::string &url, const std::string &body) { + return this->post(url.c_str(), body); + } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers) { - return this->start(url, "POST", body, request_headers); + return this->post(url.c_str(), body, request_headers); } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { - return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + return this->post(url.c_str(), body, request_headers, lower_case_collect_headers); } // Remove before 2027.1.0 @@ -379,11 +398,15 @@ class HttpRequestComponent : public Component { return this->post(url, body, std::vector
(request_headers.begin(), request_headers.end()), collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers) { // Call perform() directly to avoid ambiguity with the deprecated overloads return this->perform(url, method, body, request_headers, {}); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers); + } // Remove before 2027.1.0 ESPDEPRECATED("Pass request_headers as std::vector
instead of std::list. Removed in 2027.1.0.", "2026.7.0") @@ -403,7 +426,7 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, request_headers, lower); + return this->perform(url.c_str(), method.c_str(), body, request_headers, lower); } // Remove before 2027.1.0 @@ -418,7 +441,8 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), lower); + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower); } // Remove before 2027.1.0 @@ -426,19 +450,25 @@ class HttpRequestComponent : public Component { std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, const std::vector &lower_case_collect_headers) { - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower_case_collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->perform(url, method, body, request_headers, lower_case_collect_headers); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers, lower_case_collect_headers); + } protected: - virtual std::shared_ptr perform(const std::string &url, const std::string &method, - const std::string &body, const std::vector
&request_headers, + virtual std::shared_ptr perform(const char *url, const char *method, const std::string &body, + const std::vector
&request_headers, const std::vector &lower_case_collect_headers) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; @@ -499,8 +529,8 @@ template class HttpRequestSendAction final : public Actionparent_->start(this->url_.value(x...), this->method_.value(x...), body, request_headers, - this->lower_case_collect_headers_); + auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, + request_headers, this->lower_case_collect_headers_); auto captured_args = std::make_tuple(x...); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 43ab2e5b53a..0d968222e9e 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -2,6 +2,8 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -22,8 +24,7 @@ static const char *const TAG = "http_request"; static constexpr int ESP8266_SSL_ERR_OOM = -1000; #endif -std::shared_ptr HttpRequestArduino::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestArduino::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -37,7 +38,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur const uint32_t start = millis(); - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; container->set_secure(secure); watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); @@ -70,19 +71,19 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur stream_ptr = std::make_unique(); #endif // USE_HTTP_REQUEST_ESP8266_HTTPS - bool status = container->client_.begin(*stream_ptr, url.c_str()); + bool status = container->client_.begin(*stream_ptr, url); #elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } - bool status = container->client_.begin(url.c_str()); + bool status = container->client_.begin(url); #endif App.feed_wdt(); if (!status) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.collectHeaders(header_keys, index); App.feed_wdt(); - container->status_code = container->client_.sendRequest(method.c_str(), body.c_str()); + container->status_code = container->client_.sendRequest(method, body.c_str()); App.feed_wdt(); if (container->status_code < 0) { #if defined(USE_ESP8266) && defined(USE_HTTP_REQUEST_ESP8266_HTTPS) @@ -139,7 +140,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur } #endif - ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url.c_str(), + ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url, HTTPClient::errorToString(container->status_code).c_str()); this->status_momentary_error("failed", 1000); @@ -147,7 +148,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur return nullptr; } if (!is_success(container->status_code)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 028b9f44a1c..62737f4d0d1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -54,7 +54,7 @@ class HttpRequestArduino final : public HttpRequestComponent { #endif protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; #ifdef USE_ESP8266 diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index cf231e20bdc..a7889702023 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -5,6 +5,8 @@ #include "httplib.h" #include "http_request_host.h" +#include + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -16,8 +18,7 @@ namespace esphome::http_request { static const char *const TAG = "http_request"; -std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -27,10 +28,10 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } std::regex url_regex(R"(^(([^:\/?#]+):)?(//([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)", std::regex::extended); - std::smatch url_match_result; + std::cmatch url_match_result; if (!std::regex_match(url, url_match_result, url_regex) || url_match_result.length() < 7) { - ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url); return nullptr; } auto host = url_match_result[4].str(); @@ -54,7 +55,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } httplib::Client client(scheme_host.c_str()); if (!client.is_valid()) { - ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; } client.set_follow_location(this->follow_redirects_); @@ -64,41 +65,41 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, #endif httplib::Result result; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { result = client.Get(path, h_headers, [&](const char *data, size_t data_length) { ESP_LOGV(TAG, "Got data length: %zu", data_length); container->response_body_.insert(container->response_body_.end(), (const uint8_t *) data, (const uint8_t *) data + data_length); return true; }); - } else if (method == "HEAD") { + } else if (strcmp(method, "HEAD") == 0) { result = client.Head(path, h_headers); - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { result = client.Put(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { result = client.Patch(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { result = client.Post(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } } else { - ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method.c_str(), url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method, url); container->end(); return nullptr; } App.feed_wdt(); if (!result) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url.c_str(), (unsigned) result.error()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url, (unsigned) result.error()); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, auto response = *result; container->status_code = response.status; if (!is_success(response.status)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), response.status); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, response.status); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 9045702f46a..0ae9f2e27b9 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -18,7 +18,7 @@ class HttpContainerHost : public HttpContainer { class HttpRequestHost final : public HttpRequestComponent { public: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 10313be89db..4e5a2c42b57 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -48,8 +50,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { return ESP_OK; } -std::shared_ptr HttpRequestIDF::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestIDF::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -59,15 +60,15 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } esp_http_client_method_t method_idf; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { method_idf = HTTP_METHOD_GET; - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { method_idf = HTTP_METHOD_POST; - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { method_idf = HTTP_METHOD_PUT; - } else if (method == "DELETE") { + } else if (strcmp(method, "DELETE") == 0) { method_idf = HTTP_METHOD_DELETE; - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { method_idf = HTTP_METHOD_PATCH; } else { this->status_momentary_error("failed", ERROR_DURATION_MS); @@ -75,11 +76,11 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c return nullptr; } - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; esp_http_client_config_t config = {}; - config.url = url.c_str(); + config.url = url; config.method = method_idf; config.timeout_ms = this->timeout_; config.disable_auto_redirect = !this->follow_redirects_; @@ -218,7 +219,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } } - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", ERROR_DURATION_MS); return container; } diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 1c062af81b1..f84dc9576bd 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -41,7 +41,7 @@ class HttpRequestIDF final : public HttpRequestComponent { void set_ca_certificate(const char *ca_certificate) { this->ca_certificate_ = ca_certificate; } protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE From e13d4247681cf3e62289b135cd6c5f7db18fa8bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 09:41:14 -0500 Subject: [PATCH 229/266] [speaker] Remove deprecated codec_support_enabled option (#19074) --- .../speaker/media_player/__init__.py | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 90eb19d73df..e1808889f4b 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,7 +1,5 @@ """Speaker Media Player Setup.""" -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import ( @@ -33,9 +31,6 @@ from esphome.const import ( CONF_TASK_STACK_IN_PSRAM, ) -_LOGGER = logging.getLogger(__name__) - - AUTO_LOAD = ["audio"] DEPENDENCIES = ["network"] @@ -44,7 +39,7 @@ DOMAIN = "media_player" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2027.4.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -103,15 +98,6 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Remove before 2026.10.0 - if CONF_CODEC_SUPPORT_ENABLED in config: - _LOGGER.warning( - "'%s' is deprecated and will be removed in 2026.10.0. " - "Codec support is now automatically determined from the pipeline " - "'format' setting. Set format to 'NONE' to enable all codecs.", - CONF_CODEC_SUPPORT_ENABLED, - ) - # Request codecs based on pipeline formats. Codecs needed by local files are # already requested during CONFIG_SCHEMA validation (via audio_files_schema). media_player.request_codecs_for_format_configs( @@ -151,8 +137,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - # Remove before 2026.10.0 - cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), + # Removed in 2026.10.0 - kept to provide helpful error message + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.invalid( + "The 'codec_support_enabled' option has been removed in ESPHome 2026.10.0.\n" + "Codec support is now determined from the pipeline 'format' setting.\n" + "Set 'format: NONE' on the pipeline to enable all codecs." + ), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, From 3804ec423f2ba3cc0a685a0278031221c8065bec Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 13:52:56 -0400 Subject: [PATCH 230/266] [i2s_audio] Resync DMA lockstep in place instead of restarting the speaker task (#19319) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 110 ++++++++++++------ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 39 ++++--- .../i2s_audio/speaker/i2s_audio_speaker.h | 19 ++- .../speaker/i2s_audio_speaker_standard.cpp | 76 ++++++++---- 4 files changed, 160 insertions(+), 84 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index ed5145d4b0e..ec4e459be78 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -48,10 +48,11 @@ static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, Tic auto *speaker = static_cast(user_ctx); size_t bytes_written = 0; esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); - if (err != ESP_OK) { + if (err != ESP_OK || bytes_written != size) { ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_FAIL; } - return err; + return ESP_OK; } void I2SAudioSpeakerSPDIF::setup() { @@ -167,33 +168,44 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } - if (!successful_setup) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } else { - // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. - // This ensures the first data transmitted is valid SPDIF (not raw zeros from - // auto_clear) and prevents phantom DMA events before real audio is available. - // Each preloaded block pushes a 0-real-frame record so that the corresponding - // on_sent events drain in lockstep without crediting any audio frames. + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. Runs with + // the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { + bool ok = true; this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); - if (preload_err != ESP_OK) { - break; // DMA preload buffer full or error - } const uint32_t silence_record = 0; - xQueueSendToBack(this->write_records_queue_, &silence_record, 0); + if ((this->spdif_encoder_->flush_with_silence(0) != ESP_OK) || + (xQueueSendToBack(this->write_records_queue_, &silence_record, 0) != pdTRUE)) { + ok = false; + break; + } } this->spdif_encoder_->set_preload_mode(false); this->spdif_encoder_->reset(); // Clean encoder state for the main loop + return ok; + }; - // Now register the callback and enable the channel + if (successful_setup) { + successful_setup = preload_silence(); + } + + if (successful_setup) { + // Register the callback before enabling so the first transmitted block generates a queued event. xQueueReset(this->i2s_event_queue_); const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - i2s_channel_enable(this->tx_handle_); + successful_setup = i2s_channel_enable(this->tx_handle_) == ESP_OK; + } + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard @@ -210,24 +222,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { uint32_t spdif_pending_frames = 0; int64_t spdif_pending_timestamp = 0; uint32_t spdif_dma_event_count = 0; + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data // to keep the receiver synced. Exits only via break (stream info change, silence timeout, - // lockstep desync, dropped event, or partial-write failure). + // or a failed lockstep resync). while (true) { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion - // event; that desyncs the lockstep queues permanently and the only safe recovery is a full - // task restart. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); - break; - } // User-initiated stop. In SPDIF continuous mode, transition to silence output rather // than tearing the task down. this->spdif_silence_start_ = millis(); @@ -244,6 +252,30 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place. Frames held back by decimation are credited too, since their + // blocks are discarded with the rest of the DMA contents. + this->spdif_encoder_->reset(); + const uint32_t credited_frames = unrecorded_frames + spdif_pending_frames; + const bool resynced = this->resync_lockstep_(credited_frames, preload_silence); + unrecorded_frames = 0; + spdif_pending_frames = 0; + spdif_dma_event_count = 0; + resync_needed = false; + if (credited_frames > 0) { + // Real audio was dropped, so the silence timer's start no longer reflects the stream + this->spdif_silence_start_ = 0; + } + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR completion events, popping a matching record for each. int64_t write_timestamp; bool lockstep_broken = false; @@ -253,8 +285,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // order matches DMA completion order. Empty records queue here means lockstep broke. uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -290,8 +321,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } if (lockstep_broken) { - ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); - break; + resync_needed = true; + continue; } // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write @@ -322,9 +353,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { &blocks_sent, &pcm_consumed); if (err != ESP_OK) { // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; - // resuming would credit the next iteration's bytes against an old block. Bail and - // let loop() restart the task with a clean encoder. - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // resuming would credit the next iteration's bytes against an old block. + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); partial_write_failure = true; break; } @@ -341,7 +371,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } if (!block_committed) { @@ -349,16 +381,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // or emit a full silence block if the encoder is empty. esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); if (err != ESP_OK) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } } // One block committed to DMA; push exactly one record carrying its real-audio frame count. // Failure here means the records queue is full, which violates the lockstep invariant. if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } // Silence-timeout tracking and graceful-stop reset. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 0c1140da0c6..cb82b09f33a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -80,17 +80,6 @@ void I2SAudioSpeakerBase::loop() { } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { ESP_LOGV(TAG, "Stopping"); - // Lockstep-breaking error bits are latched by the task and cleared along with all other bits - // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { - ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { - ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); - } xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -325,16 +314,10 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { - // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` - // and any per-buffer record queue maintained by the task are permanently desynced, so the task - // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP - // (so the task bails immediately, closing the race where loop() could clear the error bit - // before the task observes it). + // Queue is full, so discard the oldest event. The lockstep queues are now desynced; the task resyncs them. int64_t dummy; xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); - xEventGroupSetBitsFromISR(this_speaker->event_group_, - SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, - &need_yield2); + xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT, &need_yield2); } xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); @@ -342,6 +325,24 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s return need_yield1 | need_yield2 | need_yield3; } +void I2SAudioSpeakerBase::drain_lockstep_(uint32_t extra_frames) { + // Stop DMA so no more completion events arrive while the queues are rebuilt + i2s_channel_disable(this->tx_handle_); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT); + + uint32_t frames = extra_frames; + uint32_t record_frames = 0; + while (xQueueReceive(this->write_records_queue_, &record_frames, 0) == pdTRUE) { + frames += record_frames; + } + xQueueReset(this->i2s_event_queue_); + + if (frames > 0) { + ESP_LOGV(TAG, "Crediting %" PRIu32 " dropped frames as played", frames); + this->audio_output_callback_(frames, esp_timer_get_time()); + } +} + void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 5812cc211b2..b443166ea1b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -36,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t { ERR_ESP_NO_MEM = (1 << 19), - ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event - ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested - ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits }; @@ -134,6 +132,21 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} + /// @brief Rebuilds the lockstep queues in place: disables the channel, credits every in-flight real frame as + /// played now, empties both queues, preloads silence through ``preload`` and re-enables the channel. Speaker + /// task only. + /// @param extra_frames Real frames the caller consumed that never reached a write record + /// @param preload Callable returning true once every DMA descriptor holds silence with a matching record + /// @return false if the preload or the channel enable failed; the caller should restart the task + template bool resync_lockstep_(uint32_t extra_frames, F &&preload) { + this->drain_lockstep_(extra_frames); + return preload() && (i2s_channel_enable(this->tx_handle_) == ESP_OK); + } + + /// @brief Disables the channel, credits ``extra_frames`` plus every real frame still recorded as in flight, + /// and empties both lockstep queues. + void drain_lockstep_(uint32_t extra_frames); + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the /// speaker task only. /// @param data Pointer to audio sample data (modified in place) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 17c93763d63..b4b6173458b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -134,27 +134,29 @@ void I2SAudioSpeaker::run_speaker_task() { } } - if (successful_setup) { - // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. - // This guarantees that every on_sent event has a corresponding write record from the start, so - // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer, so every + // on_sent event has a write record from the start. Runs with the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { size_t bytes_loaded = 0; esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, (unsigned) bytes_loaded); - successful_setup = false; - break; + return false; } uint32_t zero_real_frames = 0; if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. ESP_LOGV(TAG, "Failed to push preload write record"); - successful_setup = false; - break; + return false; } } + return true; + }; + + if (successful_setup) { + successful_setup = preload_silence(); } if (successful_setup) { @@ -177,6 +179,9 @@ void I2SAudioSpeaker::run_speaker_task() { // stop to wait until every real-audio buffer has been confirmed played by an ISR event. uint32_t pending_real_buffers = 0; uint32_t last_data_received_time = millis(); + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); @@ -197,8 +202,6 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { - // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion - // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); break; @@ -214,6 +217,22 @@ void I2SAudioSpeaker::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place; the ring buffer keeps accepting audio throughout + const bool resynced = this->resync_lockstep_(unrecorded_frames, preload_silence); + unrecorded_frames = 0; + pending_real_buffers = 0; + resync_needed = false; + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ // entry by construction (preloaded records at startup, plus exactly one record pushed per // iteration alongside exactly one DMA-buffer-sized write). @@ -223,8 +242,7 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { // Should never happen: would indicate the lockstep invariant is broken. - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -240,7 +258,8 @@ void I2SAudioSpeaker::run_speaker_task() { } } if (lockstep_broken) { - break; + resync_needed = true; + continue; } // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring @@ -299,10 +318,12 @@ void I2SAudioSpeaker::run_speaker_task() { size_t bw = 0; i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != output_bytes) { - // A short real-audio write breaks DMA descriptor alignment for every subsequent event; - // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // A short write breaks DMA descriptor alignment for every subsequent event. Drop the chunk rather + // than retry it: it was already narrowed in place. + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) output_bytes); + audio_source->consume(input_bytes); + real_frames_total += frames_to_write; partial_write_failure = true; break; } @@ -316,7 +337,9 @@ void I2SAudioSpeaker::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; @@ -325,19 +348,22 @@ void I2SAudioSpeaker::run_speaker_task() { i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != silence_bytes) { // Same descriptor-alignment hazard as a partial real-audio write. - ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) silence_bytes); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } } // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep - // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + // invariant is broken and every subsequent timestamp would be silently wrong, so rebuild it. if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { - ESP_LOGV(TAG, "Exiting: write records queue full"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } if (real_frames_total > 0) { pending_real_buffers++; From fdf6998a8635454fd96ceb89f7b153629984a0ac Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 15:08:40 -0400 Subject: [PATCH 231/266] [sendspin] Start mDNS service disabled, enable once server is running (#19326) --- esphome/components/mdns/mdns_component.cpp | 4 ++++ esphome/components/sendspin/__init__.py | 8 +++++++- esphome/components/sendspin/sendspin_hub.cpp | 20 +++++++++++++++++++- esphome/components/sendspin/sendspin_hub.h | 14 ++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index fa39e86ed0d..9d1e5853391 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -221,6 +221,10 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Starts disabled; the sendspin hub enables it once its server is running + sendspin_service.enabled = false; +#endif #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index fda4d4f954c..4b65cd1801a 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg -from esphome.components import esp32, network, psram, socket, wifi +from esphome.components import esp32, mdns, network, psram, socket, wifi from esphome.components.const import CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( @@ -11,6 +11,7 @@ from esphome.const import ( CONF_FORMAT, CONF_HEIGHT, CONF_ID, + CONF_MDNS, CONF_MODEL, CONF_NAME, CONF_PROJECT, @@ -285,6 +286,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_SENDSPIN", True) # for MDNS + # Service starts disabled and the hub enables it; always advertised where unsupported + if mdns.request_service_enable_disable(): + mdns_var = await cg.get_variable(CORE.config[CONF_MDNS][CONF_ID]) + cg.add(var.set_mdns(mdns_var)) + data = _get_data() # The color role is not yet wired up in ESPHome; disable it in the library for now. diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 2cb2b909951..3216a696fb5 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,6 +21,10 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +static constexpr uint32_t MDNS_ENABLE_RETRY_MS = 1000; +#endif + #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; @@ -69,7 +73,21 @@ void SendspinHub::setup() { } } -void SendspinHub::loop() { this->client_->loop(); } +void SendspinHub::loop() { + this->client_->loop(); + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // mdns sets up after this hub, so the service is enabled here once mdns is ready. A failed enable retries, + // rate limited so a persistent failure does not flood the log or block on the mdns task every loop pass. + if (!this->mdns_advertised_ && this->mdns_->is_ready()) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->mdns_enable_attempt_ms_ == 0 || now - this->mdns_enable_attempt_ms_ >= MDNS_ENABLE_RETRY_MS) { + this->mdns_enable_attempt_ms_ = now; + this->mdns_advertised_ = this->mdns_->set_service_enabled("_sendspin", "_tcp", true); + } + } +#endif +} void SendspinHub::dump_config() { char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c66c7db3ccb..daaeb2c9a57 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -10,6 +10,10 @@ #include "esphome/core/preferences.h" #include "esphome/core/version.h" +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#include "esphome/components/mdns/mdns_component.h" +#endif + #include #include #include @@ -135,6 +139,10 @@ class SendspinHub final : public Component, void set_model(const char *model) { this->model_ = model; } void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; } +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } +#endif + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -287,6 +295,12 @@ class SendspinHub final : public Component, const char *manufacturer_{"ESPHome"}; const char *model_{nullptr}; // nullptr reports the device name instead const char *firmware_version_{ESPHOME_VERSION}; + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + mdns::MDNSComponent *mdns_{nullptr}; + uint32_t mdns_enable_attempt_ms_{0}; + bool mdns_advertised_{false}; +#endif }; /// @brief Base class for all sendspin subcomponents. From 6f15dc331f660a71fac552a61cf2177777a57577 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:33:41 +0000 Subject: [PATCH 232/266] Bump prek from 0.5.2 to 0.5.3 (#19358) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 010c8243e7e..81208d7cb71 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.7 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -prek==0.5.2 # .github/workflows/ci.yml reads this pin +prek==0.5.3 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py # Unit tests From 845de7b7ddd7defb6cc6ccc715d90d18127bfe70 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 15:38:18 -0400 Subject: [PATCH 233/266] [sendspin] Bump sendspin-cpp to v0.8.0 (#19357) --- esphome/components/sendspin/__init__.py | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 4 ++-- esphome/idf_component.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 4b65cd1801a..49bee109361 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -282,7 +282,7 @@ async def to_code(config: ConfigType) -> None: cg.add(setter(value)) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 3216a696fb5..ca443c18404 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -66,8 +66,8 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start_server()) { - ESP_LOGE(TAG, "Failed to start Sendspin server"); + if (!this->client_->start()) { + ESP_LOGE(TAG, "Failed to start Sendspin client"); this->mark_failed(); return; } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b3cd5ee09b5..95337007b8f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.2 + version: 0.8.0 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From bafa096a1dadd0ec4a0ef718f063910068dbe091 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:19:47 -0500 Subject: [PATCH 234/266] [display] Trim the per pixel cost of draw_pixel_at (#19360) --- esphome/components/display/display.cpp | 5 ++++- esphome/components/display/display.h | 19 +++++++++++++++++++ esphome/components/display/display_buffer.cpp | 5 ++--- esphome/components/display/rect.cpp | 10 ---------- esphome/components/display/rect.h | 10 +++++++++- esphome/components/epaper_spi/epaper_spi.cpp | 2 +- esphome/components/hub75/hub75.cpp | 5 ++--- esphome/components/it8951/it8951.cpp | 4 ++-- esphome/components/mipi_dsi/mipi_dsi.cpp | 2 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 2 +- esphome/components/mipi_spi/mipi_spi.h | 2 +- esphome/components/pixoo/pixoo.cpp | 2 +- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 4 ++-- esphome/components/sdl/sdl_esphome.cpp | 2 +- esphome/components/st7701s/st7701s.cpp | 4 ++-- 15 files changed, 48 insertions(+), 30 deletions(-) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index c2d45dbb600..66fadf12ece 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -3,6 +3,7 @@ #include #include #include "display_color_utils.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -770,10 +771,12 @@ Rect Display::get_clipping() const { void Display::clear_clipping_() { this->clipping_rectangle_.clear(); } +void Display::feed_wdt_pixel_slow_() { App.feed_wdt(); } + bool Display::clip(int x, int y) { if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height()) return false; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; return true; } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index a9ffda422d1..c1389721496 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -758,6 +758,13 @@ class Display : public PollingComponent { bool is_clipping() const { return !this->clipping_rectangle_.empty(); } + /// Whether (x, y) falls outside the active clipping rectangle. Tests the + /// stack top in place: get_clipping() is out of line and returns the Rect + /// by value, which per pixel drawing cannot afford. + bool ESPHOME_ALWAYS_INLINE is_point_clipped(int x, int y) const { + return this->is_clipping() && !this->clipping_rectangle_.back().inside(x, y); + } + /** Check if pixel is within region of display. */ bool clip(int x, int y); @@ -774,6 +781,17 @@ class Display : public PollingComponent { void do_update_(); void clear_clipping_(); + /// Watchdog feed for per pixel loops. App.feed_wdt() is already rate + /// limited, but every call reads the clock; only every 256th pixel makes + /// that call, so the real feeds are unchanged and a pixel costs a counter. + /// At 20 us per pixel on the slowest e-paper path that is about 5 ms + /// between clock reads. + void ESPHOME_ALWAYS_INLINE feed_wdt_per_pixel_() { + if (++this->wdt_pixel_counter_ == 0) + this->feed_wdt_pixel_slow_(); + } + void feed_wdt_pixel_slow_(); + virtual int get_height_internal() = 0; virtual int get_width_internal() = 0; @@ -793,6 +811,7 @@ class Display : public PollingComponent { std::vector on_page_change_triggers_; bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; + uint8_t wdt_pixel_counter_{0}; bool show_test_card_{false}; }; diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index 4c919140494..d564ea67bd5 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -2,7 +2,6 @@ #include -#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::display { @@ -44,7 +43,7 @@ int DisplayBuffer::get_height() { } void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -64,7 +63,7 @@ void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { break; } this->draw_absolute_pixel_internal(x, y, color); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } } // namespace esphome::display diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index a47f7269175..3ecf6d1cf15 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -63,16 +63,6 @@ bool Rect::equal(Rect rect) const { return (rect.x == this->x) && (rect.w == this->w) && (rect.y == this->y) && (rect.h == this->h); } -bool Rect::inside(int16_t test_x, int16_t test_y, bool absolute) const { // NOLINT - if (!this->is_set()) { - return true; - } - if (absolute) { - return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); - } - return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; -} - bool Rect::inside(Rect rect) const { if (!this->is_set() || !rect.is_set()) { return true; diff --git a/esphome/components/display/rect.h b/esphome/components/display/rect.h index f4958fab88c..d65d844b9e6 100644 --- a/esphome/components/display/rect.h +++ b/esphome/components/display/rect.h @@ -26,7 +26,15 @@ class Rect { void shrink(Rect rect); bool inside(Rect rect) const; - bool inside(int16_t test_x, int16_t test_y, bool absolute = true) const; + bool ESPHOME_ALWAYS_INLINE inside(int16_t test_x, int16_t test_y, bool absolute = true) const { + if (!this->is_set()) { + return true; + } + if (absolute) { + return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); + } + return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; + } bool equal(Rect rect) const; void info(const std::string &prefix = "rect info:"); }; diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 3214f932bfb..3b3418d911f 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -299,7 +299,7 @@ bool EPaperBase::initialise(bool partial) { * @return false if the coordinates are out of bounds */ bool EPaperBase::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; if (this->effective_transform_ & SWAP_XY) std::swap(x, y); diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index ba652d427d9..d36928a83af 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -1,5 +1,4 @@ #include "hub75_component.h" -#include "esphome/core/application.h" #include @@ -124,11 +123,11 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]] return; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; driver_->set_pixel(x, y, color.r, color.g, color.b); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 179c2e5f63d..237f1c3c8bd 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -855,7 +855,7 @@ void IT8951Display::apply_transform_(int &x, int &y) const { } bool IT8951Display::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; this->apply_transform_(x, y); if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) @@ -929,7 +929,7 @@ void IT8951Display::fill(Color color) { void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { if (this->buffer_ == nullptr) return; - App.feed_wdt(); + this->feed_wdt_per_pixel_(); if (!this->rotate_coordinates_(x, y)) return; this->write_pixel_native_(static_cast(x), static_cast(y), color); diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 0150cc25442..b6612038b6d 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -259,7 +259,7 @@ bool MipiDsi::check_buffer_() { } void MipiDsi::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index c11044c2882..3f83da7f803 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -259,7 +259,7 @@ bool MipiRgb::check_buffer_() { } void MipiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y) || this->is_failed()) + if (this->is_point_clipped(x, y) || this->is_failed()) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2552451bd7c..550e1998bb5 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -604,7 +604,7 @@ class MipiSpiBuffer // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; if constexpr (not HAS_HARDWARE_ROTATION) { if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 4436b1fb174..aa035be347d 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -120,7 +120,7 @@ void Pixoo::set_pixel_(uint32_t index, Color color) { } void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; const int side = static_cast(this->model_); switch (this->rotation_) { diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index c0afc0607e0..f2f25741f33 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -101,7 +101,7 @@ int RpiDpiRgb::get_height() { } void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -124,7 +124,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void RpiDpiRgb::dump_config() { diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 03fc086021a..a764b74581f 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -164,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * } void Sdl::draw_pixel_at(int x, int y, Color color) { - if (this->texture_ == nullptr || !this->get_clipping().inside(x, y)) + if (this->texture_ == nullptr || this->is_point_clipped(x, y)) return; if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 83f7bc9ce58..47b200c2de6 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -84,7 +84,7 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 } void ST7701S::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -107,7 +107,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void ST7701S::write_command_(uint8_t value) { From 59a6760f5e4196d5b3f15cf481c1882bc2cbef40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:22 -0500 Subject: [PATCH 235/266] [nextion] Remove deprecated get_wave_chan_id() (#19080) --- esphome/components/nextion/nextion_component_base.h | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 5e84291b168..b66c0b9e4e0 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -66,6 +66,7 @@ class NextionComponentBase { #ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } const std::vector &get_wave_buffer() const { return this->wave_buffer_; } size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); } @@ -86,12 +87,6 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } -#ifdef USE_NEXTION_WAVEFORM - // Remove before 2026.10.0 - ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") - uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } - void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } -#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; From 3d4765d05a5aa86f47cd2ac580ca2a197648263c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:28 -0500 Subject: [PATCH 236/266] [template] Remove deprecated bypass_before_arming() (#19075) --- .../alarm_control_panel/template_alarm_control_panel.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 57a99f2830e..5888ce5e29b 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -65,9 +65,6 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } - // Remove before 2026.10.0 - ESPDEPRECATED("bypass_before_arming() is deprecated and will be removed in 2026.10.0", "2026.4.0") - void bypass_before_arming() { this->auto_bypass_sensors_(); } #ifdef USE_BINARY_SENSOR /** Initialize the sensors vector with the specified capacity. From 2f248390b282ca855b427c01584aacd473f0edf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:35 -0500 Subject: [PATCH 237/266] [modbus] Remove disable_crc validation stub (#19078) --- esphome/components/modbus/__init__.py | 16 +--------------- esphome/const.py | 1 - script/ci-custom.py | 2 +- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 0a34ed037d5..fe937587261 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -8,13 +8,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - CONF_CONTINUOUS, - CONF_DISABLE_CRC, - CONF_FLOW_CONTROL_PIN, - CONF_ID, -) +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv @@ -285,10 +279,6 @@ CONFIG_SCHEMA = cv.typed_schema( cv.Optional( CONF_TURNAROUND_TIME, default="600ms" ): cv.positive_time_period_milliseconds, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -297,10 +287,6 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusServer), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/const.py b/esphome/const.py index fd95df41965..5ffbf8c49ac 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -352,7 +352,6 @@ CONF_DIRECTION = "direction" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" CONF_DIRECTION_OUTPUT = "direction_output" CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" -CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" diff --git a/script/ci-custom.py b/script/ci-custom.py index e2b7cd8d376..2c9a64c68be 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -710,7 +710,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1017 +CONST_PY_MAX_CONF = 1016 @lint_content_check(include=["esphome/const.py"]) From 711733f9aff3e2380c2b8efe008257856af89d2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:43 -0500 Subject: [PATCH 238/266] [modbus] Remove deprecated send() (#19079) --- esphome/components/modbus/modbus.h | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 1623c099a34..298cd9f5278 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -252,14 +252,6 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->queue_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); - }; /// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast, /// address 0, gets only on_sent()). False = refused, and no callback of any kind follows. /// Neither means anything reached the wire - on_sent() reports that. From 417b841db76ce15d9c9a6f0d13f13d58fee88b7c Mon Sep 17 00:00:00 2001 From: Dane Powell Date: Wed, 16 Sep 2026 14:53:53 -0700 Subject: [PATCH 239/266] [icnt86] Support ICNT86 touch driver (#18331) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/icnt86/__init__.py | 1 + esphome/components/icnt86/icnt86.cpp | 84 +++++++++++++++++++++ esphome/components/icnt86/icnt86.h | 24 ++++++ esphome/components/icnt86/touchscreen.py | 40 ++++++++++ tests/components/icnt86/common.yaml | 24 ++++++ tests/components/icnt86/test.esp32-idf.yaml | 14 ++++ 7 files changed, 188 insertions(+) create mode 100644 esphome/components/icnt86/__init__.py create mode 100644 esphome/components/icnt86/icnt86.cpp create mode 100644 esphome/components/icnt86/icnt86.h create mode 100644 esphome/components/icnt86/touchscreen.py create mode 100644 tests/components/icnt86/common.yaml create mode 100644 tests/components/icnt86/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9b34d523d20..7f44f8323c3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -265,6 +265,7 @@ esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 +esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core esphome/components/improv_ble/* @jesserockz diff --git a/esphome/components/icnt86/__init__.py b/esphome/components/icnt86/__init__.py new file mode 100644 index 00000000000..07f3b4e31ca --- /dev/null +++ b/esphome/components/icnt86/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@danepowell"] diff --git a/esphome/components/icnt86/icnt86.cpp b/esphome/components/icnt86/icnt86.cpp new file mode 100644 index 00000000000..62a4586ebc3 --- /dev/null +++ b/esphome/components/icnt86/icnt86.cpp @@ -0,0 +1,84 @@ +#include "icnt86.h" +#include "esphome/core/log.h" + +namespace esphome::icnt86 { + +static const char *const TAG = "icnt86"; +static constexpr uint16_t REG_TOUCH_NUM = 0x1001; +static constexpr uint16_t REG_POINT1 = 0x1002; +static constexpr uint8_t MAX_TOUCHES = 5; +static constexpr uint8_t POINT_SIZE = 7; + +void ICNT86Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up icnt86 Touchscreen..."); + + // Register interrupt pin + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Perform reset if necessary + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } +} + +void ICNT86Touchscreen::update_touches() { + uint8_t buf[MAX_TOUCHES * POINT_SIZE] = {0}; + uint8_t mask[1] = {0x00}; + + if (this->read_register16(REG_TOUCH_NUM, buf, 1) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch count"); + return; + } + uint8_t touch_count = buf[0]; + + if (touch_count == 0x00 || touch_count > MAX_TOUCHES) { // No new touch + this->status_clear_warning(); + return; + } + if (this->read_register16(REG_POINT1, buf, touch_count * POINT_SIZE) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch points"); + return; + } + this->write_register16(REG_TOUCH_NUM, mask, 1); + ESP_LOGV(TAG, "Touch count: %d", touch_count); + this->status_clear_warning(); + + for (uint8_t i = 0; i < touch_count; i++) { + uint16_t x = ((uint16_t) buf[2 + 7 * i] << 8) + buf[1 + 7 * i]; + uint16_t y = ((uint16_t) buf[4 + 7 * i] << 8) + buf[3 + 7 * i]; + uint8_t pressure = buf[5 + 7 * i]; + uint8_t touch_id = buf[6 + 7 * i]; + + // A zero-pressure report just means this point is no longer touched; skipping it here leaves is_touched_ + // false (when no other point is active) so send_touches_() reports the release as normal. + if (pressure != 0) { + this->add_raw_touch_position_(touch_id, x, y, pressure); + } + } +} + +void ICNT86Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "icnt86 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/icnt86.h b/esphome/components/icnt86/icnt86.h new file mode 100644 index 00000000000..0d96b015247 --- /dev/null +++ b/esphome/components/icnt86/icnt86.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::icnt86 { + +class ICNT86Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{nullptr}; +}; + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py new file mode 100644 index 00000000000..5d7a7386120 --- /dev/null +++ b/esphome/components/icnt86/touchscreen.py @@ -0,0 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN +from esphome.types import ConfigType + +CODEOWNERS = ["@danepowell"] +DEPENDENCIES = ["i2c"] + +icnt86_ns = cg.esphome_ns.namespace("icnt86") +ICNT86Touchscreen = icnt86_ns.class_( + "ICNT86Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.touchscreen_schema("250ms").extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ICNT86Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ).extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): + cg.add( + var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) + ) + + if reset_pin_config := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) diff --git a/tests/components/icnt86/common.yaml b/tests/components/icnt86/common.yaml new file mode 100644 index 00000000000..1537bb8b762 --- /dev/null +++ b/tests/components/icnt86/common.yaml @@ -0,0 +1,24 @@ +touchscreen: + - platform: icnt86 + i2c_id: i2c_bus + interrupt_pin: ${interrupt_pin_touch} + reset_pin: ${reset_pin_touch} + display: epaper + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] + +display: + - platform: waveshare_epaper + id: epaper + rotation: 90 + cs_pin: ${cs_pin_display} + dc_pin: ${dc_pin_display} + busy_pin: ${busy_pin_display} + reset_pin: ${reset_pin_display} + model: 2.90inv2-r2 + pages: + - id: icnt86_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/icnt86/test.esp32-idf.yaml b/tests/components/icnt86/test.esp32-idf.yaml new file mode 100644 index 00000000000..a0b882292a5 --- /dev/null +++ b/tests/components/icnt86/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + interrupt_pin_touch: GPIO4 + reset_pin_touch: GPIO32 + cs_pin_display: GPIO33 + dc_pin_display: GPIO21 + busy_pin_display: GPIO27 + reset_pin_display: GPIO14 + clk_pin: GPIO25 + mosi_pin: GPIO26 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icnt86: !include common.yaml From cfcaeff27b376fdd63c3c2c9b8abdd3ad75f375b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:57:47 -0500 Subject: [PATCH 240/266] [esp32_hosted] Keep the update manifest URL as a pointer to the literal (#19212) --- .../components/esp32_hosted/update/esp32_hosted_update.cpp | 4 ++-- esphome/components/esp32_hosted/update/esp32_hosted_update.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 4eb5d1745be..d9b375dd20c 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -169,7 +169,7 @@ void Esp32HostedUpdate::dump_config() { ESP_LOGCONFIG(TAG, " Mode: HTTP\n" " Source URL: %s", - this->source_url_.c_str()); + this->source_url_); #else ESP_LOGCONFIG(TAG, " Mode: Embedded\n" @@ -215,7 +215,7 @@ bool Esp32HostedUpdate::fetch_manifest_() { auto container = this->http_request_parent_->get(this->source_url_); if (container == nullptr || container->status_code != 200) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_); this->status_set_error(LOG_STR("Failed to fetch manifest")); return false; } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 4f9d04738dd..c319852bff9 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -25,7 +25,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode setters - void set_source_url(const std::string &url) { this->source_url_ = url; } + void set_source_url(const char *url) { this->source_url_ = url; } void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; } #else // Embedded mode setters @@ -38,7 +38,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode members http_request::HttpRequestComponent *http_request_parent_{nullptr}; - std::string source_url_; + const char *source_url_{nullptr}; // literal from codegen std::string firmware_url_; // HTTP mode helpers From dc300ae5d451c0b6db98924fc9b512af331bf316 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:58:14 -0500 Subject: [PATCH 241/266] [esp8266] Drop the dead NEW_OOM_ABORT flag and its nothrow advice (#19247) --- esphome/components/esp8266/__init__.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 19dbb68f29a..cef0e6ea11d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -363,14 +363,6 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ENABLE_SERIAL1): enable_serial1() - # Arduino 2 has a non-standards conformant new that returns a nullptr instead of failing when - # out of memory and exceptions are disabled. Since Arduino 2.6.0, this flag can be used to make - # new abort instead. Use it so that OOM fails early (on allocation) instead of on dereference of - # a NULL pointer (so the stacktrace makes more sense), and for consistency with Arduino 3, - # which always aborts if exceptions are disabled. - # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` - cg.add_build_flag("-DNEW_OOM_ABORT") - # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. # See throw_stubs.h for details. Must be prepended before , so this From 7386daed5fcb2937e97b3869c59232e66a56a3ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:58:45 -0500 Subject: [PATCH 242/266] [ld2450] Use a user provided default constructor for MultiTargetSwitch (#19192) --- esphome/components/ld2450/switch/multi_target_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/switch/multi_target_switch.h b/esphome/components/ld2450/switch/multi_target_switch.h index 739f308cce9..d711a2d2d29 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.h +++ b/esphome/components/ld2450/switch/multi_target_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class MultiTargetSwitch : public switch_::Switch, public Parented { public: - MultiTargetSwitch() = default; + // User provided, not "= default": `new(p) MultiTargetSwitch()` would zero-fill .bss that is already zero. + MultiTargetSwitch() {} protected: void write_state(bool state) override; From 9288311978465380bca196eae6e890ecd6d112fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 17:19:04 -0500 Subject: [PATCH 243/266] [ota] Skip the web_server plaintext warning when web_server ota is disabled (#19348) --- esphome/components/esphome/ota/__init__.py | 12 +++++++-- tests/component_tests/ota/test_esphome_ota.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f5eb878260c..bcf2a2271cb 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None: CONF_PASSWORD, ) # web_server and prometheus keep the shared listener up; the captive - # portal's copy only exists on the fallback AP and is the recovery path + # portal's copy only exists on the fallback AP and is the recovery path. + # web_server `ota: false` gates /update behind the captive portal on + # every listener + web_server_conf = full_conf.get(CONF_WEB_SERVER) + plaintext_update_reachable = ( + web_server_conf.get(CONF_OTA) is not False + if web_server_conf is not None + else "prometheus" in full_conf + ) if ( - (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + plaintext_update_reachable and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) and any( CONF_ENCRYPTION in conf diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d3092294dc1..235ad902dbc 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -319,6 +319,32 @@ def test_encryption_with_captive_portal_does_not_warn( fv.full_config.reset(token) +@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}]) +def test_encryption_with_web_server_ota_disabled_does_not_warn( + caplog: pytest.LogCaptureFixture, extra: dict[str, Any] +) -> None: + """web_server `ota: false` only serves /update while the captive portal is + active, on every listener, so there is no plaintext endpoint to warn about.""" + full_conf = { + "web_server": {CONF_OTA: False}, + **extra, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + ], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any( + "OTA encryption does not cover" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None: """A static api key makes the device offer encryption and the CLI take it, so the password is dead weight; the config validates with a warning.""" From bb5c06c58a7f2a9d56bcf5c8171caa4fab671110 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:17:56 -0500 Subject: [PATCH 244/266] [image] Drop the unused stride_ member (#19353) --- esphome/components/image/image.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index ccc2f23f200..fd9e92c21d3 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -54,7 +54,6 @@ class Image : public display::BaseImage { const uint8_t *data_start_; Transparency transparency_; size_t bpp_{}; - size_t stride_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif From f8012bc467492feb6cfd9f8e7348c62d452b62c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:22:32 -0500 Subject: [PATCH 245/266] [esp32] Redefine __FILE__ to the basename so assert paths stay out of RAM (#19106) --- esphome/components/esp32/__init__.py | 7 +++++++ .../esp32/config/file_macro_idf_5_0.yaml | 8 +++++++ tests/component_tests/esp32/test_esp32.py | 21 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 tests/component_tests/esp32/config/file_macro_idf_5_0.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d027c9a1c61..de25afa23be 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2609,6 +2609,13 @@ async def to_code(config): # NVS finds stored preferences by key, so preference key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") + # assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and + # IDF's noflash placement puts the flash driver's copies in DRAM. The + # basename keeps the panic output useful at a fraction of the size. + # __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2. + if idf_version() >= cv.Version(5, 1, 0): + cg.add_build_flag("-D__FILE__=__FILE_NAME__") + cg.add_build_flag("-Wno-builtin-macro-redefined") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) diff --git a/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml new file mode 100644 index 00000000000..22ee1e480e6 --- /dev/null +++ b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + version: 5.0.6 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 2dd2a50c83c..777759e8afc 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -658,6 +658,27 @@ def test_platformio_arduino_enables_reproducible_build( assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + ("reproducible_build.yaml", True), + ("reproducible_build_arduino.yaml", True), + ("file_macro_idf_5_0.yaml", False), + ], +) +def test_file_macro_is_basename_only( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """__FILE__ becomes the basename on GCC 12 toolchains; IDF 5.0 (GCC 11) is skipped.""" + generate_main(component_config_path(config_file)) + + assert ("-D__FILE__=__FILE_NAME__" in CORE.build_flags) is expected + assert ("-Wno-builtin-macro-redefined" in CORE.build_flags) is expected + + def test_native_idf_enables_reproducible_build( component_config_path: Callable[[str], Path], ) -> None: From abfe350119e48063540e8569830a7dd8a008fc01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:23:29 -0500 Subject: [PATCH 246/266] [core] Fold the modbus write and bits tests into the shared mesh fixture (#18946) --- .../fixtures/uart_mock_modbus_mesh.yaml | 292 +++++++++++++- ...rt_mock_modbus_server_controller_bits.yaml | 147 ------- ...t_mock_modbus_server_controller_write.yaml | 371 ------------------ tests/integration/test_uart_mock_modbus.py | 140 ++++--- 4 files changed, 340 insertions(+), 610 deletions(-) delete mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml index 69edd614d7a..977cdd359be 100644 --- a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml @@ -17,10 +17,10 @@ uart: baud_rate: 115200 port: /dev/null -# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only -# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second -# server hub. auto_start everywhere: the controller polls at boot, so the -# forwarding must already be live or early requests generate warnings. +# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed registers +# backed by writable globals, addr 5 = the read/write 0x17 target, addr 2/3/6 +# on the second server hub. auto_start everywhere: the controller polls at +# boot, so the forwarding must already be live or early requests generate warnings. # Every test presses Start Scenario, so all merged actions fire in every test. uart_mock: - id: virtual_uart_server @@ -64,6 +64,54 @@ globals: - id: stored_1 type: uint16_t initial_value: "0" + - id: stored_u_word + type: uint16_t + initial_value: "99" + - id: stored_u_word_s + type: uint16_t + initial_value: "4660" + - id: stored_s_word + type: int16_t + initial_value: "-99" + - id: stored_s_word_s + type: int16_t + initial_value: "-2" + - id: stored_u_dword + type: uint32_t + initial_value: "16909060" + - id: stored_s_dword + type: int32_t + initial_value: "-16909060" + - id: stored_u_dword_r + type: uint32_t + initial_value: "67305985" + - id: stored_s_dword_r + type: int32_t + initial_value: "-67305985" + - id: stored_u_qword + type: uint64_t + initial_value: "72623859790382856" + - id: stored_s_qword + type: int64_t + initial_value: "-72623859790382856" + - id: stored_u_qword_r + type: uint64_t + initial_value: "578437695752307201" + - id: stored_s_qword_r + type: int64_t + initial_value: "-578437695752307201" + - id: stored_fp32 + type: float + initial_value: "3.14" + - id: stored_fp32_r + type: float + initial_value: "2.5" + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" modbus: - uart_id: virtual_uart_server @@ -90,6 +138,10 @@ modbus_controller: modbus_id: virtual_modbus_client id: modbus_controller_3 update_interval: 1s + - address: 6 + modbus_id: virtual_modbus_client + id: modbus_controller_6 + update_interval: 1s modbus_server: - address: 1 @@ -97,46 +149,60 @@ modbus_server: registers: - address: 0x01 value_type: U_WORD - read_lambda: return 99; + read_lambda: return id(stored_u_word); + write_lambda: id(stored_u_word) = x; return true; - address: 0x02 value_type: U_WORD_S - read_lambda: return 4660; + read_lambda: return id(stored_u_word_s); + write_lambda: id(stored_u_word_s) = x; return true; - address: 0x03 value_type: S_WORD - read_lambda: return -99; + read_lambda: return id(stored_s_word); + write_lambda: id(stored_s_word) = x; return true; - address: 0x04 value_type: S_WORD_S - read_lambda: return -2; + read_lambda: return id(stored_s_word_s); + write_lambda: id(stored_s_word_s) = x; return true; - address: 0x05 value_type: U_DWORD - read_lambda: return 16909060; + read_lambda: return id(stored_u_dword); + write_lambda: id(stored_u_dword) = x; return true; - address: 0x08 value_type: S_DWORD - read_lambda: return -16909060; + read_lambda: return id(stored_s_dword); + write_lambda: id(stored_s_dword) = x; return true; - address: 0x0B value_type: U_DWORD_R - read_lambda: return 67305985; + read_lambda: return id(stored_u_dword_r); + write_lambda: id(stored_u_dword_r) = x; return true; - address: 0x0E value_type: S_DWORD_R - read_lambda: return -67305985; + read_lambda: return id(stored_s_dword_r); + write_lambda: id(stored_s_dword_r) = x; return true; - address: 0x11 value_type: U_QWORD - read_lambda: return 72623859790382856; + read_lambda: return id(stored_u_qword); + write_lambda: id(stored_u_qword) = x; return true; - address: 0x16 value_type: S_QWORD - read_lambda: return -72623859790382856; + read_lambda: return id(stored_s_qword); + write_lambda: id(stored_s_qword) = x; return true; - address: 0x1B value_type: U_QWORD_R - read_lambda: return 578437695752307201; + read_lambda: return id(stored_u_qword_r); + write_lambda: id(stored_u_qword_r) = x; return true; - address: 0x20 value_type: S_QWORD_R - read_lambda: return -578437695752307201; + read_lambda: return id(stored_s_qword_r); + write_lambda: id(stored_s_qword_r) = x; return true; - address: 0x25 value_type: FP32 - read_lambda: return 3.14; + read_lambda: return id(stored_fp32); + write_lambda: id(stored_fp32) = x; return true; - address: 0x28 value_type: FP32_R - read_lambda: return 3.14; + read_lambda: return id(stored_fp32_r); + write_lambda: id(stored_fp32_r) = x; return true; - address: 5 modbus_id: virtual_modbus_server registers: @@ -165,6 +231,19 @@ modbus_server: - address: 0x01 value_type: U_WORD read_lambda: return 929; + - address: 6 + modbus_id: virtual_modbus_server_2 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; sensor: - platform: modbus_controller @@ -280,6 +359,183 @@ sensor: name: "client_read_1" id: client_read_1 +# The number schema caps min/max at 16777215 (float32 integer precision), so +# the large dword/qword baselines cannot be written back through these numbers. +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + min_value: -16777215 + max_value: 16777215 + step: 0.01 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + min_value: -16777215 + max_value: 16777215 + step: 0.01 + +# The four bits are read both as coils (FC 0x01) and discrete inputs (FC 0x02); +# the server serves both from one shared table, so the two views must agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + button: - platform: template name: "Start Scenario" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml deleted file mode 100644 index cb6fc6f0740..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml +++ /dev/null @@ -1,147 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-bits - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_bit_2 - type: bool - initial_value: "false" - - id: stored_bit_3 - type: bool - initial_value: "true" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 1s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - bits: - - address: 0x00 - read_lambda: return true; - - address: 0x01 - read_lambda: return false; - - address: 0x02 - read_lambda: return id(stored_bit_2); - write_lambda: id(stored_bit_2) = x; return true; - - address: 0x03 - read_lambda: return id(stored_bit_3); - write_lambda: id(stored_bit_3) = x; return true; - -# The same four bits are read both as coils (FC 0x01) and as discrete inputs -# (FC 0x02): the server serves both from one shared bit table, so the two -# views must always agree. -binary_sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_0" - address: 0x00 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_1" - address: 0x01 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_3" - address: 0x03 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_0" - address: 0x00 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_1" - address: 0x01 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_2" - address: 0x02 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_3" - address: 0x03 - register_type: discrete_input - -# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the -# multiple-coils write (FC 0x0F) so both server write paths are exercised. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_3" - address: 0x03 - register_type: coil - use_write_multiple: true - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml deleted file mode 100644 index 5ade49bd48c..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ /dev/null @@ -1,371 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-write - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_u_word - type: uint16_t - initial_value: "11" - - id: stored_u_word_s - type: uint16_t - initial_value: "4660" - - id: stored_s_word - type: int16_t - initial_value: "-11" - - id: stored_s_word_s - type: int16_t - initial_value: "-2" - - id: stored_u_dword - type: uint32_t - initial_value: "1001" - - id: stored_s_dword - type: int32_t - initial_value: "-1001" - - id: stored_u_dword_r - type: uint32_t - initial_value: "3003" - - id: stored_s_dword_r - type: int32_t - initial_value: "-3003" - - id: stored_u_qword - type: uint64_t - initial_value: "5005" - - id: stored_s_qword - type: int64_t - initial_value: "-5005" - - id: stored_u_qword_r - type: uint64_t - initial_value: "7007" - - id: stored_s_qword_r - type: int64_t - initial_value: "-7007" - - id: stored_fp32 - type: float - initial_value: "1.5" - - id: stored_fp32_r - type: float - initial_value: "2.5" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 2s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return id(stored_u_word); - write_lambda: id(stored_u_word) = x; return true; - - address: 0x02 - value_type: U_WORD_S - read_lambda: return id(stored_u_word_s); - write_lambda: id(stored_u_word_s) = x; return true; - - address: 0x03 - value_type: S_WORD - read_lambda: return id(stored_s_word); - write_lambda: id(stored_s_word) = x; return true; - - address: 0x04 - value_type: S_WORD_S - read_lambda: return id(stored_s_word_s); - write_lambda: id(stored_s_word_s) = x; return true; - - address: 0x05 - value_type: U_DWORD - read_lambda: return id(stored_u_dword); - write_lambda: id(stored_u_dword) = x; return true; - - address: 0x08 - value_type: S_DWORD - read_lambda: return id(stored_s_dword); - write_lambda: id(stored_s_dword) = x; return true; - - address: 0x0B - value_type: U_DWORD_R - read_lambda: return id(stored_u_dword_r); - write_lambda: id(stored_u_dword_r) = x; return true; - - address: 0x0E - value_type: S_DWORD_R - read_lambda: return id(stored_s_dword_r); - write_lambda: id(stored_s_dword_r) = x; return true; - - address: 0x11 - value_type: U_QWORD - read_lambda: return id(stored_u_qword); - write_lambda: id(stored_u_qword) = x; return true; - - address: 0x16 - value_type: S_QWORD - read_lambda: return id(stored_s_qword); - write_lambda: id(stored_s_qword) = x; return true; - - address: 0x1B - value_type: U_QWORD_R - read_lambda: return id(stored_u_qword_r); - write_lambda: id(stored_u_qword_r) = x; return true; - - address: 0x20 - value_type: S_QWORD_R - read_lambda: return id(stored_s_qword_r); - write_lambda: id(stored_s_qword_r) = x; return true; - - address: 0x25 - value_type: FP32 - read_lambda: return id(stored_fp32); - write_lambda: id(stored_fp32) = x; return true; - - address: 0x28 - value_type: FP32_R - read_lambda: return id(stored_fp32_r); - write_lambda: id(stored_fp32_r) = x; return true; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 65535 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - min_value: 0 - max_value: 65535 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - min_value: -16777215 - max_value: 16777215 - step: 0.01 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - min_value: -16777215 - max_value: 16777215 - step: 0.01 - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 232e1fb6543..36aa9a9668c 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -19,23 +19,40 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState import pytest -from .state_utils import SensorTracker, find_entity, wait_for_state +from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state from .types import APIClientConnectedFactory, RunCompiledFunction -@dataclass -class RegisterTestCase: - """Test parameters for a single modbus register write/read round-trip.""" +def _swap16(value: int) -> int: + """Byte-swapped view of a 16-bit register as the raw U_WORD wire value.""" + return ((value & 0xFF) << 8) | (value >> 8) - initial_value: object - write_number_name: str - write_value: float - post_write_value: object + +# Raw U_WORD view of reg_u_word_s's initial 0x1234 +MESH_RAW_U_WORD_S = _swap16(4660) + +# Initial values of the mesh fixture's address 1 registers; the +# server_controller test reads them and the write test uses them as baseline. +MESH_INITIAL_VALUES: dict[str, object] = { + "reg_u_word": 99, + "reg_u_word_s": 4660, + "reg_s_word": -99, + "reg_s_word_s": -2, + "reg_u_dword": 16909060, + "reg_s_dword": -16909060, + "reg_u_dword_r": pytest.approx(67305985), + "reg_s_dword_r": pytest.approx(-67305985), + "reg_u_qword": pytest.approx(72623859790382856), + "reg_s_qword": pytest.approx(-72623859790382856), + "reg_u_qword_r": pytest.approx(578437695752307201), + "reg_s_qword_r": pytest.approx(-578437695752307201), + "reg_fp32": pytest.approx(3.14), + "reg_fp32_r": pytest.approx(2.5), +} # --------------------------------------------------------------------------- @@ -310,23 +327,7 @@ async def test_uart_mock_modbus_server_controller( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - expected_values = { - "reg_u_word": 99, - "reg_u_word_s": 4660, - "reg_u_word_s_raw": 13330, - "reg_s_word": -99, - "reg_s_word_s": -2, - "reg_u_dword": 16909060, - "reg_s_dword": -16909060, - "reg_u_dword_r": pytest.approx(67305985), - "reg_s_dword_r": pytest.approx(-67305985), - "reg_u_qword": pytest.approx(72623859790382856), - "reg_s_qword": pytest.approx(-72623859790382856), - "reg_u_qword_r": pytest.approx(578437695752307201), - "reg_s_qword_r": pytest.approx(-578437695752307201), - "reg_fp32": pytest.approx(3.14), - "reg_fp32_r": pytest.approx(3.14), - } + expected_values = MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} tracker = SensorTracker(list(expected_values.keys())) futures = tracker.expect_all(expected_values) @@ -334,14 +335,12 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_write( yaml_config: str, @@ -357,51 +356,47 @@ async def test_uart_mock_modbus_server_controller_write( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - register_test_cases: dict[str, RegisterTestCase] = { - "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), - "reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185), - "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), - "reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257), - "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), - "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), - "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), - "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), - "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), - "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), - "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), - "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), - "reg_fp32": RegisterTestCase( - pytest.approx(1.5, abs=0.01), - "write_fp32", - 3.14, - pytest.approx(3.14, abs=0.01), - ), - "reg_fp32_r": RegisterTestCase( - pytest.approx(2.5, abs=0.01), - "write_fp32_r", - 6.28, - pytest.approx(6.28, abs=0.01), - ), + # Per read-back sensor: the number entity to write through and the value; + # floats read back within tolerance, everything else exactly + register_writes: dict[str, tuple[str, int | float]] = { + "reg_u_word": ("write_u_word", 42), + "reg_u_word_s": ("write_u_word_s", 17185), + "reg_s_word": ("write_s_word", -42), + "reg_s_word_s": ("write_s_word_s", -257), + "reg_u_dword": ("write_u_dword", 2002), + "reg_s_dword": ("write_s_dword", -2002), + "reg_u_dword_r": ("write_u_dword_r", 4004), + "reg_s_dword_r": ("write_s_dword_r", -4004), + "reg_u_qword": ("write_u_qword", 6006), + "reg_s_qword": ("write_s_qword", -6006), + "reg_u_qword_r": ("write_u_qword_r", 8008), + "reg_s_qword_r": ("write_s_qword_r", -8008), + "reg_fp32": ("write_fp32", 6.28), + "reg_fp32_r": ("write_fp32_r", 9.42), } - tracker = SensorTracker(list(register_test_cases.keys())) + tracker = SensorTracker([*register_writes, "reg_u_word_s_raw"]) + # The raw U_WORD view of 0x02 pins the byte swap on the write path: the + # round trip through write_u_word_s applies the swap an even number of + # times, so only the raw sensor can catch a symmetrically dropped swap. # Phase 1: expect initial baseline values initial_futures = tracker.expect_all( - {name: case.initial_value for name, case in register_test_cases.items()} + MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} ) # Phase 2: expect post-write values (registered now so on_state can match them) written_futures = tracker.expect_all( - {name: case.post_write_value for name, case in register_test_cases.items()} + { + name: pytest.approx(value, abs=0.01) if isinstance(value, float) else value + for name, (_, value) in register_writes.items() + } + | {"reg_u_word_s_raw": _swap16(register_writes["reg_u_word_s"][1])} ) async with ( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the baseline can already be in the - # states the device sends on connect; matching it there saves waiting for - # the next poll entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -410,19 +405,22 @@ async def test_uart_mock_modbus_server_controller_write( # connection is working before issuing writes await tracker.await_all(initial_futures, timeout=4.0) - # Issue write commands for all register types - for case in register_test_cases.values(): - entity = find_entity(entities, case.write_number_name, NumberInfo) - assert entity is not None, ( - f"{case.write_number_name} number entity not found" - ) - client.number_command(entity.key, case.write_value) + # Issue write commands for all register types; exact object_id match, + # since several write_* names are prefixes of a sibling + numbers = { + e.object_id.lower(): e for e in entities if isinstance(e, NumberInfo) + } + for number_name, value in register_writes.values(): + entity = numbers.get(number_name) + assert entity is not None, f"{number_name} number entity not found" + client.number_command(entity.key, value) # Wait for sensors to reflect the written values (round-trip write+read) await tracker.await_all(written_futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_bits( yaml_config: str, @@ -468,8 +466,6 @@ async def test_uart_mock_modbus_server_controller_bits( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot and binary sensors drop repeats, so the - # baseline can arrive only in the states the device sends on connect entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -480,8 +476,7 @@ async def test_uart_mock_modbus_server_controller_bits( # Flip both writable bits: 0x02 false -> true, 0x03 true -> false for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): - entity = find_entity(entities, switch_name, SwitchInfo) - assert entity is not None, f"{switch_name} switch entity not found" + entity = require_entity(entities, switch_name, SwitchInfo) client.switch_command(entity.key, value) # Wait for both read views to reflect the written values @@ -508,9 +503,6 @@ async def test_uart_mock_modbus_server_controller_multiple( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) From 3fe87688d92d7272ecd969aa24b9ac7c42e2d841 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:31:27 -0500 Subject: [PATCH 247/266] [esp32] Enable octal flash when the flash mode is opi (#19218) --- esphome/components/esp32/__init__.py | 9 ++++++++ .../esp32/config/flash_mode_opi_s3.yaml | 8 +++++++ tests/component_tests/esp32/test_esp32.py | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_opi_s3.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index de25afa23be..0c5b9c5df6a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1519,6 +1519,13 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_MINIMUM_CHIP_REVISION], ) ) + if config[CONF_VARIANT] != VARIANT_ESP32S3 and config.get(CONF_FLASH_MODE) == "opi": + errs.append( + cv.Invalid( + f"'{CONF_FLASH_MODE}: opi' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FLASH_MODE], + ) + ) if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_SRAM1_AS_IRAM]: errs.append( cv.Invalid( @@ -2732,6 +2739,8 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True ) + # the opi mode choice only exists once octal flash is enabled + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_OCT_FLASH", flash_mode == "opi") if flash_frequency := config.get(CONF_FLASH_FREQUENCY): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True diff --git a/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml new file mode 100644 index 00000000000..82262f63493 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 777759e8afc..d5d0acfb2ad 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -252,6 +252,16 @@ def test_esp32_rejects_unsupported_cli_toolchain( r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", id="nvs_encryption_key_id_out_of_range", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "flash_mode": "opi", + "framework": {"type": "esp-idf"}, + }, + r"'flash_mode: opi' is only supported on ESP32S3 @ data\['flash_mode'\]", + id="flash_mode_opi_only_on_s3", + ), ], ) def test_esp32_configuration_errors( @@ -704,10 +714,22 @@ def test_flash_mode_sets_sdkconfig_and_pio_option( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is False assert CORE.platformio_options.get("board_build.flash_mode") == "qio" assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" +def test_flash_mode_opi_enables_octal_flash( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode: opi needs the octal flash switch or ESP-IDF ignores the mode.""" + generate_main(component_config_path("flash_mode_opi_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_OPI") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is True + + def test_flash_mode_unset_leaves_defaults( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From ffb1ad288f29faed92e8591c38724ed5efba721e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:32:13 -0500 Subject: [PATCH 248/266] [remote_receiver] Wake the main loop when the RMT callback stores a frame (#19102) --- .../remote_receiver/remote_receiver_rmt.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 4eebbbb16f1..e4ffd7e1105 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -1,5 +1,6 @@ #include "remote_receiver.h" #include "esphome/core/log.h" +#include "esphome/core/wake.h" #ifdef USE_ESP32 #include @@ -14,25 +15,32 @@ static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; - rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + store->buffer_write); + const uint32_t buffer_write = store->buffer_write; + rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + buffer_write); uint32_t event_size = sizeof(rmt_rx_done_event_data_t); - uint32_t next_write = store->buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); + uint32_t next_write = buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); if (next_write + event_size + store->receive_size > store->buffer_size) { next_write = 0; } if (store->buffer_read - next_write < event_size + store->receive_size) { - next_write = store->buffer_write; + next_write = buffer_write; store->overflow = true; } if (event->num_symbols <= store->filter_symbols) { - next_write = store->buffer_write; + next_write = buffer_write; } store->error = rmt_receive(channel, (uint8_t *) store->buffer + next_write + event_size, store->receive_size, &store->config); event_buffer->num_symbols = event->num_symbols; event_buffer->received_symbols = event->received_symbols; + const bool stored = next_write != buffer_write; store->buffer_write = next_write; - return false; + // a stored frame is decoded, and a failed re-arm reported, on the next loop pass instead of + // waiting out the loop interval; filtered noise and dropped frames leave nothing to read + BaseType_t task_woken = pdFALSE; + if (stored || store->error != ESP_OK) + wake_loop_isrsafe(&task_woken); + return task_woken != pdFALSE; } void RemoteReceiverComponent::setup() { From b001514cf30f616d698f2839ffd3a005272d6b23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:33:52 -0500 Subject: [PATCH 249/266] [remote_base] Accept protocols registered by external components (#19332) --- esphome/components/remote_base/__init__.py | 14 ++++-- .../receiver_with_external_protocol.yaml | 36 +++++++++++++++ .../fake_protocol/__init__.py | 39 ++++++++++++++++ .../remote_receiver/test_slot_counts.py | 46 ++++++++++++++++++- 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml create mode 100644 tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 27b6eb9fc82..bf8707ff1e8 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -169,6 +169,12 @@ def request_protocol(name: str) -> None: cg.add_define(protocol_define(name)) +def _request_protocol_if_in_tree(name: str) -> None: + """Registry names from external components have no source file here and need no define.""" + if _protocol_stem(name) in _PROTOCOL_STEMS: + request_protocol(name) + + # Only the protocol sources a configuration uses are compiled FILTER_SOURCE_FILES = filter_source_files_from_defines( {f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS} @@ -182,7 +188,7 @@ def register_binary_sensor( def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable: async def new_func(var: MockObj, config: ConfigType) -> None: - request_protocol(name) + _request_protocol_if_in_tree(name) await coroutine(func)(var, config) return registerer(new_func) @@ -200,7 +206,7 @@ def register_trigger(name, type, data_type): def decorator(func): async def new_func(config): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(config[CONF_TRIGGER_ID]) await coroutine(func)(var, config) await automation.build_automation(var, [(data_type, "x")], config) @@ -218,7 +224,7 @@ def register_dumper(name, type, schema=None): def decorator(func): async def new_func(config, dumper_id): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(dumper_id) await coroutine(func)(var, config) return var @@ -259,7 +265,7 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(action_id, template_arg) await register_transmittable(var, config) if CONF_REPEAT in config: diff --git a/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml new file mode 100644 index 00000000000..e094e5bd52e --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml @@ -0,0 +1,36 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +external_components: + - source: + type: local + path: ../external_components + +fake_protocol: + +remote_receiver: + - id: rcvr + pin: GPIO4 + dump: + - fake + - nec + on_fake: + then: + - remote_transmitter.transmit_fake: + on_nec: + then: + - logger.log: nec + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +binary_sensor: + - platform: remote_receiver + name: Fake Input + fake: diff --git a/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py new file mode 100644 index 00000000000..971497aa794 --- /dev/null +++ b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py @@ -0,0 +1,39 @@ +"""External component registering a protocol that has no source file in remote_base.""" + +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.types import ConfigType + +DEPENDENCIES = ["remote_base"] + +ns = cg.esphome_ns.namespace("fake_protocol") +FakeData = ns.struct("FakeData") +FakeBinarySensor = ns.class_( + "FakeBinarySensor", remote_base.RemoteReceiverBinarySensorBase +) +FakeTrigger = ns.class_("FakeTrigger", remote_base.RemoteReceiverTrigger) +FakeAction = ns.class_("FakeAction", remote_base.RemoteTransmitterActionBase) +FakeDumper = ns.class_("FakeDumper", remote_base.RemoteReceiverDumperBase) + +CONFIG_SCHEMA = cv.Schema({}) + + +@remote_base.register_binary_sensor("fake", FakeBinarySensor, {}) +def fake_binary_sensor(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_trigger("fake", FakeTrigger, FakeData) +def fake_trigger(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_dumper("fake", FakeDumper) +def fake_dumper(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_action("fake", FakeAction, {}) +async def fake_action(var: cg.MockObj, config: ConfigType, args: list) -> None: + pass diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py index 4d69e6d923b..ee79e9a06d5 100644 --- a/tests/component_tests/remote_receiver/test_slot_counts.py +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -1,13 +1,16 @@ """Listener and dumper StaticVector sizes come from codegen slot counts.""" -from collections.abc import Callable +from collections.abc import Callable, Generator from pathlib import Path +import sys import pytest +from esphome import loader from esphome.automation import ACTION_REGISTRY from esphome.components import remote_base import esphome.config_validation as cv +from esphome.core import CORE from ..helpers import get_define_value @@ -74,6 +77,47 @@ def test_every_registry_name_maps_to_a_protocol_source() -> None: assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name +@pytest.fixture +def restore_protocol_registries() -> Generator[None]: + """Loading an external protocol component adds to module-level registries; undo that. + + The loader caches the component too, so drop it or a second load would skip the + decorators and leave the restored registries without the external names. + """ + registries = ( + remote_base.BINARY_SENSOR_REGISTRY, + remote_base.TRIGGER_REGISTRY, + remote_base.DUMPER_REGISTRY, + ACTION_REGISTRY, + ) + saved = [dict(registry) for registry in registries] + yield + for registry, entries in zip(registries, saved, strict=True): + registry.clear() + registry.update(entries) + loader._COMPONENT_CACHE.pop("fake_protocol", None) + sys.modules.pop("esphome.components.fake_protocol", None) + + +@pytest.mark.usefixtures("restore_protocol_registries") +def test_external_protocols_register_without_a_remote_base_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An external protocol goes through all four decorators without a source file here, so no define is emitted.""" + main_cpp = generate_main( + component_config_path("receiver_with_external_protocol.yaml") + ) + defines = {define.name for define in CORE.defines} + assert "USE_REMOTE_PROTOCOL_NEC" in defines + assert "USE_REMOTE_PROTOCOL_FAKE" not in defines + for cls in ("FakeBinarySensor", "FakeTrigger", "FakeDumper", "FakeAction"): + assert f"fake_protocol::{cls}" in main_cpp, cls + # fake and nec dumpers; on_fake and on_nec triggers plus the fake binary sensor + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2" + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "3" + + def test_request_protocol_rejects_unknown_names() -> None: """A misspelled protocol would otherwise surface only as a link error.""" with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"): From 8bcb5004da8af416f11028bf4609f2c78ca104e5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 19:35:07 -0400 Subject: [PATCH 250/266] [sendspin] Add a switch platform to enable and disable the client (#19361) --- CODEOWNERS | 1 + .../media_player/sendspin_media_player.cpp | 4 ++ .../media_source/sendspin_media_source.cpp | 17 ++++- esphome/components/sendspin/sendspin_hub.cpp | 67 +++++++++++++------ esphome/components/sendspin/sendspin_hub.h | 28 ++++++-- .../components/sendspin/switch/__init__.py | 31 +++++++++ .../sendspin/switch/sendspin_switch.cpp | 26 +++++++ .../sendspin/switch/sendspin_switch.h | 24 +++++++ esphome/core/defines.h | 1 + tests/components/sendspin/common-switch.yaml | 6 ++ .../sendspin/test-switch.esp32-idf.yaml | 2 + 11 files changed, 179 insertions(+), 28 deletions(-) create mode 100644 esphome/components/sendspin/switch/__init__.py create mode 100644 esphome/components/sendspin/switch/sendspin_switch.cpp create mode 100644 esphome/components/sendspin/switch/sendspin_switch.h create mode 100644 tests/components/sendspin/common-switch.yaml create mode 100644 tests/components/sendspin/test-switch.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 7f44f8323c3..aba498c3652 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -478,6 +478,7 @@ esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt +esphome/components/sendspin/switch/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index fe0bda6f421..59ead1bb539 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -97,6 +97,10 @@ void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { // Ignore any commands sent before the media player is setup return; } + if (!this->parent_->is_client_running()) { + ESP_LOGW(TAG, "Cannot control media player: Sendspin is disabled"); + return; + } auto volume = call.get_volume(); if (volume.has_value()) { diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index 88ff234e831..c3fb1fe1cbd 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -45,6 +45,8 @@ bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri. // THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) bool SendspinMediaSource::play_uri(const std::string &uri) { + // The queued request has been delivered, whatever the outcome, so the next stream start may request again + this->pending_start_ = false; if (!this->is_ready() || this->is_failed() || !this->has_listener()) { return false; } @@ -54,6 +56,11 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { return false; } + if (!this->parent_->is_client_running()) { + ESP_LOGE(TAG, "Cannot play '%s': Sendspin is disabled", uri.c_str()); + return false; + } + if (!uri.starts_with(URI_PREFIX)) { ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); return false; @@ -74,7 +81,6 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { } // Tell the orchestrator we're now playing so it routes audio output from us - this->pending_start_ = false; this->set_state_(media_source::MediaSourceState::PLAYING); return true; @@ -82,6 +88,15 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { // THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + if (!this->parent_->is_client_running()) { + if (command == media_source::MediaSourceCommand::STOP) { + // Nothing is playing, so the orchestrator gets its pipeline back straight away + this->on_stream_end(); + } else { + ESP_LOGW(TAG, "Cannot handle command: Sendspin is disabled"); + } + return; + } switch (command) { case media_source::MediaSourceCommand::STOP: { if (!this->pending_start_) { diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index ca443c18404..58ec57c7681 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,10 +21,6 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; -#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE -static constexpr uint32_t MDNS_ENABLE_RETRY_MS = 1000; -#endif - #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; @@ -66,26 +62,24 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start()) { - ESP_LOGE(TAG, "Failed to start Sendspin client"); - this->mark_failed(); - return; - } +#ifndef USE_SENDSPIN_SWITCH + this->enabled_ = true; +#endif } void SendspinHub::loop() { + if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && + !this->status_has_error()) { + if (!this->enabled_.value()) { + this->client_->stop(); + } else if (!this->client_->start()) { + this->status_set_error(LOG_STR("Failed to start Sendspin client")); + } + } this->client_->loop(); #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE - // mdns sets up after this hub, so the service is enabled here once mdns is ready. A failed enable retries, - // rate limited so a persistent failure does not flood the log or block on the mdns task every loop pass. - if (!this->mdns_advertised_ && this->mdns_->is_ready()) { - const uint32_t now = App.get_loop_component_start_time(); - if (this->mdns_enable_attempt_ms_ == 0 || now - this->mdns_enable_attempt_ms_ >= MDNS_ENABLE_RETRY_MS) { - this->mdns_enable_attempt_ms_ = now; - this->mdns_advertised_ = this->mdns_->set_service_enabled("_sendspin", "_tcp", true); - } - } + this->update_mdns_service_(); #endif } @@ -114,25 +108,54 @@ void SendspinHub::dump_config() { #endif } +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::set_enabled(bool enabled) { + if (this->status_has_error()) { + ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", + enabled ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + return; + } + this->enabled_ = enabled; +} + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +// THREAD CONTEXT: Main loop +void SendspinHub::update_mdns_service_() { + // Synced from loop() because mdns sets up after this hub and only builds its service list then. + if (!this->mdns_->is_ready()) { + return; + } + bool advertise = this->client_->is_started(); + if (advertise == this->mdns_advertised_) { + return; + } + // One attempt per change + this->mdns_advertised_ = advertise; + if (!this->mdns_->set_service_enabled("_sendspin", "_tcp", advertise)) { + ESP_LOGE(TAG, "Failed to %s mDNS service", advertise ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + } +} +#endif + // --- Delegating methods --- // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::connect_to_server(const std::string &url) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->connect_to(url); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->disconnect(reason); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->update_state(state); } } @@ -251,7 +274,7 @@ void SendspinHub::artwork_frame_done(uint8_t slot) { // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { - if (this->is_ready()) { + if (this->is_client_running()) { sendspin::ClientCommandControllerObject obj = { .command = command, .volume = volume, diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index daaeb2c9a57..b00fdc436eb 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -97,7 +97,7 @@ class SendspinHub final : public Component, /// @brief Connects the underlying client to the given Sendspin server. /// - /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// No-op if the hub's client is not running (see is_client_running()). /// Must be called from the main loop thread. /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). void connect_to_server(const std::string &url); @@ -105,7 +105,7 @@ class SendspinHub final : public Component, /// @brief Disconnects the underlying client from the current server. /// /// Sends a `client/goodbye` message with the given reason before closing the connection. - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param reason Reason reported to the server: /// - `ANOTHER_SERVER`: client is switching to another server. /// - `SHUTDOWN`: client is shutting down. @@ -115,7 +115,7 @@ class SendspinHub final : public Component, /// @brief Updates the client's reported playback state on the server. /// - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param state New client state: /// - `SYNCHRONIZED`: client is synchronized and playing from the server. /// - `ERROR`: client encountered a playback error. @@ -130,6 +130,17 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or + /// stop. + /// + /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks + /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has + /// called this once. Must be called from the main loop thread. + void set_enabled(bool enabled); + + /// @brief Returns whether the Sendspin client is running. + bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the @@ -212,6 +223,11 @@ class SendspinHub final : public Component, /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_client_id_into_buffer(std::span buf); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. + void update_mdns_service_(); +#endif + // --- SendspinClientListener overrides --- void on_group_update(const sendspin::GroupUpdateObject &group) override; @@ -290,6 +306,9 @@ class SendspinHub final : public Component, bool task_stack_in_psram_{false}; + // Requested client state, applied from loop(). Empty until the switch restores its state. + std::optional enabled_; + // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. const char *manufacturer_{"ESPHome"}; @@ -298,8 +317,7 @@ class SendspinHub final : public Component, #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE mdns::MDNSComponent *mdns_{nullptr}; - uint32_t mdns_enable_attempt_ms_{0}; - bool mdns_advertised_{false}; + bool mdns_advertised_{false}; // Last state requested from mdns #endif }; diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py new file mode 100644 index 00000000000..63f5f7ad28b --- /dev/null +++ b/esphome/components/sendspin/switch/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) + +CONFIG_SCHEMA = cv.All( + switch.switch_schema( + SendspinSwitch, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, +) + + +async def to_code(config: ConfigType) -> None: + var = await switch.new_switch(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + cg.add_define("USE_SENDSPIN_SWITCH", True) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp new file mode 100644 index 00000000000..0bf029d4c77 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -0,0 +1,26 @@ +#include "sendspin_switch.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.switch"; + +void SendspinSwitch::setup() { + // The hub waits for this request, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinSwitch::write_state(bool state) { + this->parent_->set_enabled(state); + this->publish_state(state); +} + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h new file mode 100644 index 00000000000..253d952b220 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::sendspin_ { + +/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). +class SendspinSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b2b5267b112..bc1418c6e2b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -400,6 +400,7 @@ #define USE_SENDSPIN_CONTROLLER #define USE_SENDSPIN_METADATA #define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_SWITCH #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml new file mode 100644 index 00000000000..d332cb0dde0 --- /dev/null +++ b/tests/components/sendspin/common-switch.yaml @@ -0,0 +1,6 @@ +packages: + sendspin: !include common.yaml + +switch: + - platform: sendspin + name: "Sendspin Enabled" diff --git a/tests/components/sendspin/test-switch.esp32-idf.yaml b/tests/components/sendspin/test-switch.esp32-idf.yaml new file mode 100644 index 00000000000..d32c14c054a --- /dev/null +++ b/tests/components/sendspin/test-switch.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-switch.yaml From 1a04359454be71e184b21b6227660b9b476145f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 19:04:14 -0500 Subject: [PATCH 251/266] [switch] Use Switch::control() instead of hand written turn_on/turn_off branches (#19364) --- esphome/components/api/api_connection.cpp | 7 +----- .../components/copy/switch/copy_switch.cpp | 8 +------ .../components/gpio/switch/gpio_switch.cpp | 12 ++-------- esphome/components/ld6002b/ld6002b.cpp | 20 ++++------------ .../switch/modbus_switch.cpp | 6 +---- .../output/switch/output_switch.cpp | 10 +------- esphome/components/sprinkler/sprinkler.cpp | 24 ++++--------------- esphome/components/switch/switch.cpp | 1 - .../template/switch/template_switch.cpp | 6 +---- 9 files changed, 15 insertions(+), 79 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 749eaeb3929..3064ff09b18 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -720,12 +720,7 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * } void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) { ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch) - - if (msg.state) { - a_switch->turn_on(); - } else { - a_switch->turn_off(); - } + a_switch->control(msg.state); } #endif diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 91b76f11c0a..555f0030a5b 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -13,12 +13,6 @@ void CopySwitch::setup() { void CopySwitch::dump_config() { LOG_SWITCH("", "Copy Switch", this); } -void CopySwitch::write_state(bool state) { - if (state) { - source_->turn_on(); - } else { - source_->turn_off(); - } -} +void CopySwitch::write_state(bool state) { this->source_->control(state); } } // namespace esphome::copy diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index d432655a2a4..d231b3d77a5 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -13,18 +13,10 @@ void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); // write state before setup - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); } void GPIOSwitch::dump_config() { LOG_SWITCH("", "GPIO Switch", this); diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 73fc7df3311..aa34ad9d392 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -301,14 +301,10 @@ void LD6002BComponent::setup() { target_display_controlled = true; // Nothing reports this switch back, so its restored state is the only state // there is. Restoring through the switch keeps its inversion in the path: - // the restored value is logical, and turn_on()/turn_off() are what turn it + // the restored value is logical, and driving the switch is what turns it // into the raw command, the published state and the stream flag. const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); - if (state) { - this->target_display_switch_->turn_on(); - } else { - this->target_display_switch_->turn_off(); - } + this->target_display_switch_->control(state); } #endif if (!target_display_controlled) { @@ -328,11 +324,7 @@ void LD6002BComponent::setup() { // The switch owns the stream, so it is also what applies the restored state: // driving it rather than the module keeps the entity's inversion in the path. const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->point_cloud_switch_->turn_on(); - } else { - this->point_cloud_switch_->turn_off(); - } + this->point_cloud_switch_->control(state); } #endif if (!point_cloud_controlled) { @@ -375,11 +367,7 @@ void LD6002BComponent::setup() { // Driving the switch applies its inversion; it also marks the restored value // as reported, so the work mode fallback runs on that until the query lands. const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->low_power_switch_->turn_on(); - } else { - this->low_power_switch_->turn_off(); - } + this->low_power_switch_->control(state); } #else bool want_low_power = false; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index f2aae201f33..855a7b28c30 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -16,11 +16,7 @@ void ModbusSwitch::setup() { optional initial_state = Switch::get_initial_state_with_restore_mode(); if (initial_state.has_value()) { // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index 7cee2a86398..325514ddf40 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -6,15 +6,7 @@ namespace esphome::output { static const char *const TAG = "output.switch"; void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); } -void OutputSwitch::setup() { - bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } -} +void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); } void OutputSwitch::write_state(bool state) { if (state) { this->output_->turn_on(); diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9fd0d9208bb..cdec1581266 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -546,11 +546,7 @@ void Sprinkler::set_auto_advance(const bool auto_advance) { if (this->auto_adv_sw_->state == auto_advance) { return; } - if (auto_advance) { - this->auto_adv_sw_->turn_on(); - } else { - this->auto_adv_sw_->turn_off(); - } + this->auto_adv_sw_->control(auto_advance); } void Sprinkler::set_repeat(optional repeat) { @@ -573,11 +569,7 @@ void Sprinkler::set_queue_enable(bool queue_enable) { if (this->queue_enable_sw_->state == queue_enable) { return; } - if (queue_enable) { - this->queue_enable_sw_->turn_on(); - } else { - this->queue_enable_sw_->turn_off(); - } + this->queue_enable_sw_->control(queue_enable); } void Sprinkler::set_reverse(const bool reverse) { @@ -587,11 +579,7 @@ void Sprinkler::set_reverse(const bool reverse) { if (this->reverse_sw_->state == reverse) { return; } - if (reverse) { - this->reverse_sw_->turn_on(); - } else { - this->reverse_sw_->turn_off(); - } + this->reverse_sw_->control(reverse); } void Sprinkler::set_standby(const bool standby) { @@ -601,11 +589,7 @@ void Sprinkler::set_standby(const bool standby) { if (this->standby_sw_->state == standby) { return; } - if (standby) { - this->standby_sw_->turn_on(); - } else { - this->standby_sw_->turn_off(); - } + this->standby_sw_->control(standby); } uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 8413c7b4936..57e4f222bce 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -10,7 +10,6 @@ static const char *const TAG = "switch"; Switch::Switch() : state(false) {} void Switch::control(bool target_state) { - ESP_LOGV(TAG, "'%s' Control: %s", this->get_name().c_str(), ONOFF(target_state)); if (target_state) { this->turn_on(); } else { diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index edd753d3d2b..729db370531 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -42,11 +42,7 @@ void TemplateSwitch::setup() { if (initial_state.has_value()) { ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value())); // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void TemplateSwitch::dump_config() { From 34158e17b885bcc749fac6003b2be665a3a2765f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 21:42:29 -0400 Subject: [PATCH 252/266] [mixer] Raise ducking decibel_reduction maximum to 255 (#19347) --- esphome/components/mixer/speaker/__init__.py | 2 +- tests/components/mixer/common.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index a3746c019a0..26619f35a76 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -155,7 +155,7 @@ async def to_code(config: ConfigType) -> None: { cv.GenerateID(): cv.use_id(SourceSpeaker), cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=51) + cv.int_range(min=0, max=255) ), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index 55e96df4c27..489475c794f 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -3,7 +3,7 @@ esphome: then: - mixer_speaker.apply_ducking: id: source_speaker_1_id - decibel_reduction: 10 + decibel_reduction: 255 duration: 1s speaker: From 4be83021904a0f028124d500c76bba8bad1bd412 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:36:08 +1200 Subject: [PATCH 253/266] [ci] Move skills to .agents/skills with .claude and .github symlinks (#19369) --- {.claude => .agents}/skills/pr-workflow/SKILL.md | 0 .claude/skills | 1 + .github/skills | 1 + script/ci-custom.py | 3 +++ 4 files changed, 5 insertions(+) rename {.claude => .agents}/skills/pr-workflow/SKILL.md (100%) create mode 120000 .claude/skills create mode 120000 .github/skills diff --git a/.claude/skills/pr-workflow/SKILL.md b/.agents/skills/pr-workflow/SKILL.md similarity index 100% rename from .claude/skills/pr-workflow/SKILL.md rename to .agents/skills/pr-workflow/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/skills b/.github/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.github/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/script/ci-custom.py b/script/ci-custom.py index 2c9a64c68be..286dda85b9e 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -247,6 +247,9 @@ def lint_ext_check(fname): "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md", + # Symlinks to the shared .agents/skills directory + ".claude/skills", + ".github/skills", # Symlink to the real wifi scan_list.h so the test stub cannot drift "tests/integration/fixtures/external_components/wifi/scan_list.h", ] From cf0a87de28cd74b2171964c32c113099ed2544a3 Mon Sep 17 00:00:00 2001 From: rexmoriarty Date: Thu, 17 Sep 2026 04:47:40 -0500 Subject: [PATCH 254/266] [mixer] Don't discard a start request while reaping a stopped task (#19368) --- esphome/components/mixer/speaker/mixer_speaker.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 7d33b6c49f8..41b7123269a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -385,7 +385,8 @@ void MixerSpeaker::loop() { // Retries on a subsequent loop if the task is still running on the other core if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); - xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); + // Keep a start request that arrived while the task was stopping, otherwise it is lost for good + xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START); this->all_stopped_since_ms_ = 0; } From 8cd22eaceae171c9c01e297424e2532759af3ad9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 17 Sep 2026 08:23:55 -0400 Subject: [PATCH 255/266] [audio] Bump esp-audio-libs to v4.0.1 (#19372) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 14a08188949..b882aaa6b75 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -339,7 +339,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0") + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 95337007b8f..d12a27221b9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: - version: 4.0.0 + version: 4.0.1 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 7e3d4a48d156f100b0bcb9bd45a801a321f45568 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 07:56:29 -0500 Subject: [PATCH 256/266] [modbus_controller] Remove deprecated helper shims (#19076) --- .../modbus_controller/modbus_controller.h | 74 +------------------ 1 file changed, 2 insertions(+), 72 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 741d4f6f00d..d21b3194358 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -30,53 +30,10 @@ using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; #pragma GCC diagnostic pop -// Remove before 2026.10.0 — these helpers have moved to modbus::helpers -ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") -inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } - -ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_read_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_write_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); } - -ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::byte_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::word_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::dword_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::qword_from_hex_str(value, pos); -} - -template -ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0") -T get_data(const std::vector &data, size_t buffer_offset) { - return modbus::helpers::get_data(data, buffer_offset); -} - -// Span overloads of the deprecated helpers below: read lambdas receive their payload as a +// Span overloads of the former modbus_controller helpers: read lambdas receive their payload as a // std::span (previously a const std::vector &), and a span does not convert to // a vector, so existing lambdas calling these by name need an overload that accepts one. These carry -// this release's deprecation window, since the span forms only exist from it. +// the 2026.8.0 deprecation window, since the span forms only exist from it. // payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers // type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here // would only make that call ambiguous. @@ -99,33 +56,6 @@ inline bool coil_from_vector(int coil, std::span data) { return modbus::helpers::bit_from_packed(coil, data); } -template -ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") -N mask_and_shift_by_rightbit(N data, uint32_t mask) { - return modbus::helpers::mask_and_shift_by_rightbit(data, mask); -} - -ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - modbus::helpers::number_to_payload(data, value, value_type); -} - -ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") -inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { - return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) - .value_or(0); -} - -ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline std::vector float_to_payload(float value, SensorValueType value_type) { - std::vector data; - modbus::helpers::float_to_payload(data, value, value_type); - return data; -} - -class ModbusController; - /// How an item relates to the register range built just before it (same register type, address order). /// The numeric order doubles as the comparator tiebreak for items at the same address (see /// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and From 942322738a4d2d15ecee759e31ac90d1ff447fb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:14:57 -0500 Subject: [PATCH 257/266] [remote_receiver] [remote_transmitter] Keep the RMT setup error message as a pointer to the literal (#19210) --- .../remote_receiver/remote_receiver.h | 4 +- .../remote_receiver/remote_receiver_rmt.cpp | 39 ++++++------------- .../remote_transmitter/remote_transmitter.h | 4 +- .../remote_transmitter_rmt.cpp | 35 ++++++----------- 4 files changed, 27 insertions(+), 55 deletions(-) diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index e59a8b25573..997863f032b 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -83,14 +83,14 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, protected: #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void decode_rmt_(rmt_symbol_word_t *item, size_t item_count); + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); rmt_channel_handle_t channel_{NULL}; uint32_t filter_symbols_{0}; uint32_t receive_symbols_{0}; bool with_dma_{false}; uint32_t carrier_frequency_{0}; uint8_t carrier_duty_percent_{100}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; #endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index e4ffd7e1105..64392aa7eeb 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -43,6 +43,11 @@ static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_r return task_woken != pdFALSE; } +void RemoteReceiverComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); +} + void RemoteReceiverComponent::setup() { rmt_rx_channel_config_t channel; memset(&channel, 0, sizeof(channel)); @@ -55,13 +60,8 @@ void RemoteReceiverComponent::setup() { channel.flags.with_dma = this->with_dma_; esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_rx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_rx_channel")); return; } if (this->pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -71,9 +71,7 @@ void RemoteReceiverComponent::setup() { } error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } @@ -85,9 +83,7 @@ void RemoteReceiverComponent::setup() { carrier.flags.polarity_active_low = this->pin_->is_inverted(); error = rmt_apply_carrier(this->channel_, &carrier); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } @@ -97,9 +93,7 @@ void RemoteReceiverComponent::setup() { callbacks.on_recv_done = rmt_callback; error = rmt_rx_register_event_callbacks(this->channel_, &callbacks, &this->store_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_rx_register_event_callbacks"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_rx_register_event_callbacks")); return; } @@ -122,9 +116,7 @@ void RemoteReceiverComponent::setup() { error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size, &this->store_.config); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_receive"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_receive")); return; } } @@ -148,18 +140,11 @@ void RemoteReceiverComponent::dump_config() { (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteReceiverComponent::loop() { if (this->store_.error != ESP_OK) { - ESP_LOGE(TAG, "Receive error"); - this->error_code_ = this->store_.error; - this->error_string_ = "in rmt_callback"; - this->mark_failed(); + this->fail_(this->store_.error, LOG_STR("in rmt_callback")); } if (this->store_.overflow) { ESP_LOGW(TAG, "Buffer overflow"); diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 4db4e80a60e..99e1ce9504d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -141,6 +141,8 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); void configure_rmt_(); void wait_for_rmt_(); @@ -156,8 +158,6 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa bool eot_level_{false}; rmt_channel_handle_t channel_{NULL}; rmt_encoder_handle_t encoder_{NULL}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; bool inverted_{false}; bool non_blocking_{false}; #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 3c9a12d472f..6d27be8d472 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -51,6 +51,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size } #endif +void RemoteTransmitterComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); +} + void RemoteTransmitterComponent::setup() { this->inverted_ = this->pin_->is_inverted(); this->configure_rmt_(); @@ -67,11 +72,6 @@ void RemoteTransmitterComponent::dump_config() { if (this->current_carrier_frequency_ != 0 && this->carrier_duty_percent_ != 100) { ESP_LOGCONFIG(TAG, " Carrier Duty: %u%%", this->carrier_duty_percent_); } - - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteTransmitterComponent::digital_write(bool value) { @@ -129,13 +129,8 @@ void RemoteTransmitterComponent::configure_rmt_() { #endif error = rmt_new_tx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_tx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_tx_channel")); return; } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) @@ -159,9 +154,7 @@ void RemoteTransmitterComponent::configure_rmt_() { encoder.min_chunk_size = 1; error = rmt_new_simple_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_simple_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_simple_encoder")); return; } #else @@ -169,18 +162,14 @@ void RemoteTransmitterComponent::configure_rmt_() { memset(&encoder, 0, sizeof(encoder)); error = rmt_new_copy_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_copy_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_copy_encoder")); return; } #endif error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } this->digital_write(open_drain || this->inverted_); @@ -199,9 +188,7 @@ void RemoteTransmitterComponent::configure_rmt_() { error = rmt_apply_carrier(this->channel_, &carrier); } if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } From a7559547b27f2f3179071ab782d990fe1583d9ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:15:13 -0500 Subject: [PATCH 258/266] [remote_receiver] Treat buffer size as bytes on the pulse ring targets (#19101) --- .../components/remote_receiver/__init__.py | 15 ++++---- .../remote_receiver/remote_receiver.cpp | 15 ++++---- .../remote_receiver/remote_receiver.h | 2 +- .../config/receiver_bk72xx.yaml | 9 +++++ .../config/receiver_esp32_c61.yaml | 12 ++++++ .../config/receiver_ln882x.yaml | 9 +++++ .../remote_receiver/config/receiver_rp2.yaml | 9 +++++ .../config/receiver_rtl87xx.yaml | 9 +++++ .../remote_receiver/test_buffer_size.py | 38 ++++++++++++++----- 9 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_ln882x.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_rp2.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 6eaecf7ab00..866e108131e 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -112,20 +112,21 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.Required(CONF_PIN): cv.All(pins.internal_gpio_input_pin_schema), cv.Optional(CONF_DUMP, default=[]): remote_base.validate_dumpers, cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance, + # pulse ring targets hold one 4 byte entry per pulse; 4000b keeps their 1000 pulses cv.SplitDefault( CONF_BUFFER_SIZE, esp32=cv.UNDEFINED, # the pulse ring needs a size; only RMT targets size themselves in setup() **{ - f"esp32_{variant.removeprefix('ESP32').lower()}": "1000b" + f"esp32_{variant.removeprefix('ESP32').lower()}": "4000b" for variant in esp32_rmt.VARIANTS_NO_RMT }, - esp8266="1000b", - bk72xx="1000b", - ln882x="1000b", - rtl87xx="1000b", - rp2="1000b", - ): cv.All(cv.validate_bytes, cv.int_range(min=64)), + esp8266="4000b", + bk72xx="4000b", + ln882x="4000b", + rtl87xx="4000b", + rp2="4000b", + ): cv.All(cv.validate_bytes, cv.int_range(min=64, max=65535)), cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=4294967295)), diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index bbcb7ae765b..b3e4649096b 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -14,7 +14,7 @@ static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_ int32_t multiplier = ((int32_t) level << 1) - 1; uint32_t buffer_write = arg->buffer_write; arg->buffer[buffer_write++] = (int32_t) delta * multiplier; - if (buffer_write >= arg->buffer_size) { + if (buffer_write >= arg->buffer_entries) { buffer_write = 0; } @@ -65,8 +65,9 @@ void RemoteReceiverComponent::setup() { this->store_.idle_us = this->idle_us_; this->store_.filter_us = this->filter_us_; this->store_.pin = this->pin_->to_isr(); - this->store_.buffer = new int32_t[this->buffer_size_]; - this->store_.buffer_size = this->buffer_size_; + // rounded up so a size that is not a multiple of four never holds less than requested + this->store_.buffer_entries = (this->buffer_size_ + sizeof(int32_t) - 1) / sizeof(int32_t); + this->store_.buffer = new int32_t[this->store_.buffer_entries]; this->store_.prev_micros = micros(); this->store_.commit_micros = this->store_.prev_micros; this->store_.prev_level = this->pin_->digital_read(); @@ -79,11 +80,11 @@ void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG( TAG, "Remote Receiver:\n" - " Buffer Size: %" PRIu32 "\n" + " Buffer Size: %" PRIu32 " bytes (%" PRIu32 " pulses)\n" " Tolerance: %" PRIu32 "%s\n" " Filter out pulses shorter than: %" PRIu32 " us\n" " Signal is done after %" PRIu32 " us of no changes", - this->buffer_size_, this->tolerance_, + this->buffer_size_, this->store_.buffer_entries, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); @@ -119,7 +120,7 @@ void RemoteReceiverComponent::loop() { while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) { reserve_size++; temp_read++; - if (temp_read >= s.buffer_size) { + if (temp_read >= s.buffer_entries) { temp_read = 0; } } @@ -129,7 +130,7 @@ void RemoteReceiverComponent::loop() { // read the buffer for (uint32_t i = 0; i < reserve_size + 1; i++) { this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]); - if (s.buffer_read >= s.buffer_size) { + if (s.buffer_read >= s.buffer_entries) { s.buffer_read = 0; } } diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 997863f032b..6f93979b183 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -30,7 +30,7 @@ struct RemoteReceiverComponentStore { uint32_t buffer_read{0}; volatile uint32_t commit_micros{0}; volatile uint32_t prev_micros{0}; - uint32_t buffer_size{1000}; + uint32_t buffer_entries{0}; uint32_t filter_us{10}; uint32_t idle_us{10000}; ISRInternalGPIOPin pin; diff --git a/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml new file mode 100644 index 00000000000..c9c95ed05c3 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +bk72xx: + board: generic-bk7252 + +remote_receiver: + - id: rcvr + pin: P6 diff --git a/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml new file mode 100644 index 00000000000..e8930d4e17e --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32-c61-devkitc1 + variant: esp32c61 + framework: + type: esp-idf + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml new file mode 100644 index 00000000000..8767b546e62 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +ln882x: + board: generic-ln882h + +remote_receiver: + - id: rcvr + pin: PA4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rp2.yaml b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml new file mode 100644 index 00000000000..cfc66786ba2 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rp2: + board: rpipicow + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml new file mode 100644 index 00000000000..113bece34c5 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +remote_receiver: + - id: rcvr + pin: PA12 diff --git a/tests/component_tests/remote_receiver/test_buffer_size.py b/tests/component_tests/remote_receiver/test_buffer_size.py index 9bfd12d9f55..cc4ea49ccb8 100644 --- a/tests/component_tests/remote_receiver/test_buffer_size.py +++ b/tests/component_tests/remote_receiver/test_buffer_size.py @@ -1,8 +1,16 @@ -"""buffer_size reaches the receiver when set, and always on the pulse ring targets.""" +"""buffer_size is bytes on the pulse ring targets and only reaches RMT targets when set.""" from collections.abc import Callable from pathlib import Path +import pytest + +from esphome.components import remote_receiver +from esphome.components.esp8266 import gpio as esp8266_gpio # noqa: F401 registers the pin schema +from esphome.config_validation import Invalid +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + def test_explicit_buffer_size_is_passed_through( generate_main: Callable[[str | Path], str], @@ -12,17 +20,29 @@ def test_explicit_buffer_size_is_passed_through( assert "rcvr->set_buffer_size(2000);" in main_cpp -def test_pulse_ring_target_keeps_a_default( +@pytest.mark.parametrize( + "target", ["esp8266", "rp2", "bk72xx", "rtl87xx", "ln882x", "esp32_c2", "esp32_c61"] +) +def test_pulse_ring_default_holds_1000_pulses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], + target: str, ) -> None: - main_cpp = generate_main(component_config_path("receiver_esp8266.yaml")) - assert "rcvr->set_buffer_size(1000);" in main_cpp + main_cpp = generate_main(component_config_path(f"receiver_{target}.yaml")) + assert "rcvr->set_buffer_size(4000);" in main_cpp -def test_esp32_variant_without_rmt_keeps_a_default( - generate_main: Callable[[str | Path], str], - component_config_path: Callable[[str], Path], +@pytest.mark.parametrize( + ("value", "expected"), + [("32b", None), ("64b", 64), ("65b", 65), ("65535b", 65535), ("65536b", None)], +) +def test_buffer_size_range( + set_core_config: SetCoreConfigCallable, value: str, expected: int | None ) -> None: - main_cpp = generate_main(component_config_path("receiver_esp32_c2.yaml")) - assert "rcvr->set_buffer_size(1000);" in main_cpp + set_core_config(PlatformFramework.ESP8266_ARDUINO) + config = {"pin": "GPIO4", "buffer_size": value} + if expected is None: + with pytest.raises(Invalid): + remote_receiver.CONFIG_SCHEMA(config) + else: + assert remote_receiver.CONFIG_SCHEMA(config)["buffer_size"] == expected From 2a8d6b69cd0dcbe497e1ed4b8576a3a0710cb4a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:16:41 -0500 Subject: [PATCH 259/266] [rp2] Bump arduino-pico framework to 6.1.0 (#19261) --- .../bluetooth_connection_rp2.cpp | 2 +- esphome/components/rp2/__init__.py | 12 ++++++------ esphome/components/rp2/boards.py | 18 ++++++++++++++++++ .../components/rp2040_ble/btstack_memory.cpp | 2 +- esphome/core/defines.h | 2 +- platformio.ini | 4 ++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 16a89dcfdd1..eec2c8c3186 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -626,7 +626,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { // explicit kick the MTU would only be exchanged on the first GATT query, // which never happens on a V3_WITH_CACHE connection. // Both registration calls above return void (BTstack 075a078, arduino-pico - // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // 6.1.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by // the connect timeout in loop(). gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); } diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index dae7df26c32..a1bbf6a3d66 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -197,20 +197,20 @@ def _parse_platform_version(value: Any) -> str: # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 0) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain -# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut -RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" +# develop-branch commit carrying the arduino-pico 6.1.0 update and the board +# JSON files it adds; replace with a release tag when one is cut +RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1" def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { - "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(6, 0, 0), None), + "dev": (cv.Version(6, 1, 0), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 1, 0), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 4b2f9769b01..a9ce11c33dc 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1135,6 +1135,18 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "soldered_nula_node_rp2040": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -2127,6 +2139,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_node_rp2040": { + "name": "Soldered Electronics NULA Node", + "mcu": "rp2040", + "max_pin": 29, + "wifi": True, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp index 8af57924a2b..699555f623f 100644 --- a/esphome/components/rp2040_ble/btstack_memory.cpp +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -20,7 +20,7 @@ namespace esphome::rp2040_ble { namespace { -// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// Pinned against arduino-pico 6.1.0's prebuilt archives: a framework bump (or // a changed ENABLE_* macro) shifting the struct layout must fail the build // here, not overrun the pool blocks at runtime. Sizes differ per core // architecture (measured from each archive's own storage symbols). GCC only: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bc1418c6e2b..fb76f90bcf3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -537,7 +537,7 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 0) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C diff --git a/platformio.ini b/platformio.ini index 722109adec4..37504384cbc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -203,11 +203,11 @@ extra_scripts = extends = common:arduino board_build.filesystem_size = 0.5m -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0 +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1 platform_packages = ; The framework-arduinopico package is no longer published to the PlatformIO ; registry, so install the framework straight from the GitHub release - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip framework = arduino lib_deps = From 508c24c5e3c6394a3bda02aa4711b3225bc5f57e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:17:11 -0500 Subject: [PATCH 260/266] [esp32][rp2] Print the previous boot crash report before the logger reads it (#19351) --- esphome/components/esp32/crash_handler.cpp | 14 +++++++++----- esphome/components/esp32/crash_handler.h | 7 +------ esphome/components/esp32/hal.cpp | 8 -------- esphome/components/rp2/crash_handler.cpp | 22 +++++++++++++++++----- esphome/components/rp2/crash_handler.h | 3 ++- esphome/core/application.h | 4 ++-- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 6f65243aaa0..b72a2777c7a 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash"; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); -void crash_handler_read_and_clear() { +// Validate the NOINIT record. Runs on every has_data() call; re-running is +// harmless and the magic is left alone so the record survives an OTA +// rollback reboot, crash_handler_clear() drops it once an API client has it. +static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data @@ -194,11 +197,12 @@ void crash_handler_read_and_clear() { s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count; #endif } - // Don't clear magic here — crash data must survive OTA rollback reboots. - // Magic is cleared by crash_handler_clear() after an API client receives the data. } -bool crash_handler_has_data() { return s_crash_data_valid; } +bool crash_handler_has_data() { + read_crash_data(); + return s_crash_data_valid; +} void crash_handler_clear() { // Only clear the magic so data doesn't survive the next reboot. @@ -426,7 +430,7 @@ static void log_foreign_addresses() { // crashes again during boot, and allowing the CLI's process_stacktrace to match // and decode each address individually. void crash_handler_log() { - if (!s_crash_data_valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index c5e7d145ece..314be80314c 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,11 +4,6 @@ namespace esphome::esp32 { -/// Read and validate crash data from NOINIT memory. -/// Does not clear the magic marker — call crash_handler_clear() after -/// the data has been delivered to an API client so it survives OTA rollback reboots. -void crash_handler_read_and_clear(); - /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); @@ -16,7 +11,7 @@ void crash_handler_log(); /// Call after the data has been delivered to an API client. void crash_handler_clear(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::esp32 diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp index f6199d557f3..199cb89f516 100644 --- a/esphome/components/esp32/hal.cpp +++ b/esphome/components/esp32/hal.cpp @@ -1,9 +1,6 @@ #ifdef USE_ESP32 -// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set -// before crash_handler.h's #ifdef-guarded namespace block is parsed. #include "esphome/core/defines.h" -#include "crash_handler.h" #include "esphome/core/hal.h" #include @@ -45,11 +42,6 @@ void arch_restart() { } void arch_init() { -#ifdef USE_ESP32_CRASH_HANDLER - // Read crash data from previous boot before anything else - esp32::crash_handler_read_and_clear(); -#endif - // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index a0fea216371..9bcdc8bee4e 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -55,8 +55,7 @@ namespace esphome::rp2 { static const char *const TAG = "rp2.crash"; -// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). -// The valid field is explicitly cleared in crash_handler_read_and_clear() instead. +// Filled from the watchdog scratch registers on the first read. static struct CrashData { bool valid; uint32_t pc; @@ -64,11 +63,24 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -bool crash_handler_has_data() { return s_crash_data.valid; } +// Logger::pre_setup() logs the record before App.pre_setup() reaches +// arch_init(), so the first caller reads it and later calls are no-ops. +// The read clears the scratch registers, so it must not run twice, and +// arch_init() keeps its call so the read precedes watchdog_enable(), which +// overwrites scratch[4]. +static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_handler_has_data() { + crash_handler_read_and_clear(); + return s_crash_data.valid; +} void crash_handler_read_and_clear() { + if (s_crash_data_read) + return; + s_crash_data_read = true; s_crash_data.valid = false; uint32_t magic = watchdog_hw->scratch[0]; if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { @@ -97,7 +109,7 @@ void crash_handler_read_and_clear() { // the device crashes again during boot, and allowing the CLI's process_stacktrace // to match and decode each address individually. void crash_handler_log() { - if (!s_crash_data.valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/rp2/crash_handler.h b/esphome/components/rp2/crash_handler.h index 8c43d9fd3b0..3aec80b63b2 100644 --- a/esphome/components/rp2/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -9,12 +9,13 @@ namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. +/// Only the first call reads; later calls are no-ops. void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::rp2 diff --git a/esphome/core/application.h b/esphome/core/application.h index f1cf6fcca02..8ed4c09096a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu class Application { public: #ifdef ESPHOME_NAME_ADD_MAC_SUFFIX - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC. void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); @@ -87,7 +87,7 @@ class Application { this->friendly_name_ = StringRef(friendly_name, friendly_name_len); } #else - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash. void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) { arch_init(); From 05bba3fdc262a01b5e5932f39e0a7cd52d696d4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:17:28 -0500 Subject: [PATCH 261/266] [image] Blend grayscale alpha with Color::gradient (#19356) --- esphome/components/image/image.cpp | 12 +++++------- esphome/core/color.cpp | 15 +++++---------- esphome/core/color.h | 12 +++++++++--- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 9b603683abc..bfe311be284 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -48,14 +48,12 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color continue; // skip drawing } break; - case TRANSPARENCY_ALPHA_CHANNEL: { - auto on = (float) gray / 255.0f; - auto off = 1.0f - on; - // blend color_on and color_off - color = Color(color_on.r * on + color_off.r * off, color_on.g * on + color_off.g * off, - color_on.b * on + color_off.b * off, 0xFF); + case TRANSPARENCY_ALPHA_CHANNEL: + // gray is the alpha: blend from color_off to color_on, drawn opaque + color = Color(Color::blend_channel(color_off.r, color_on.r, gray), + Color::blend_channel(color_off.g, color_on.g, gray), + Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); break; - } default: break; } diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index edbc7714720..ba8a594340f 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -6,18 +6,13 @@ namespace esphome { constinit const Color Color::BLACK(0, 0, 0, 0); constinit const Color Color::WHITE(255, 255, 255, 255); -Color Color::gradient(const Color &to_color, uint8_t amnt) { - uint8_t inv = 255 - amnt; - Color new_color; - new_color.r = (uint16_t(this->r) * inv + uint16_t(to_color.r) * amnt) / 255; - new_color.g = (uint16_t(this->g) * inv + uint16_t(to_color.g) * amnt) / 255; - new_color.b = (uint16_t(this->b) * inv + uint16_t(to_color.b) * amnt) / 255; - new_color.w = (uint16_t(this->w) * inv + uint16_t(to_color.w) * amnt) / 255; - return new_color; +Color Color::gradient(const Color &to_color, uint8_t amnt) const { + return Color(blend_channel(this->r, to_color.r, amnt), blend_channel(this->g, to_color.g, amnt), + blend_channel(this->b, to_color.b, amnt), blend_channel(this->w, to_color.w, amnt)); } -Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); } +Color Color::fade_to_white(uint8_t amnt) const { return this->gradient(Color::WHITE, amnt); } -Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); } +Color Color::fade_to_black(uint8_t amnt) const { return this->gradient(Color::BLACK, amnt); } } // namespace esphome diff --git a/esphome/core/color.h b/esphome/core/color.h index 442470623df..c7fd522e1a5 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -174,9 +174,15 @@ struct Color { uint8_t((uint16_t(b) * 255U / max_rgb)), w); } - Color gradient(const Color &to_color, uint8_t amnt); - Color fade_to_white(uint8_t amnt); - Color fade_to_black(uint8_t amnt); + /// One channel of gradient(): from at amnt 0 to to at amnt 255. Inline so a + /// per pixel loop can blend without a call; gradient() itself stays out of + /// line so the light effects and fade_to_*() share one copy. + static inline uint8_t blend_channel(uint8_t from, uint8_t to, uint8_t amnt) ESPHOME_ALWAYS_INLINE { + return (uint16_t(from) * (255 - amnt) + uint16_t(to) * amnt) / 255; + } + Color gradient(const Color &to_color, uint8_t amnt) const; + Color fade_to_white(uint8_t amnt) const; + Color fade_to_black(uint8_t amnt) const; Color lighten(uint8_t delta) { return *this + delta; } Color darken(uint8_t delta) { return *this - delta; } From 40934484c6d589c5ae29a980f00aaf9a95ae59cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:34:36 -0500 Subject: [PATCH 262/266] [output] Use BinaryOutput::set_state() instead of hand written turn_on/turn_off branches (#19366) --- esphome/components/binary/light/binary_light_output.h | 6 +----- esphome/components/improv_ble/improv_ble_component.cpp | 6 +----- esphome/components/mcp4461/output/mcp4461_output.cpp | 8 -------- esphome/components/mcp4461/output/mcp4461_output.h | 3 --- esphome/components/output/switch/output_switch.cpp | 6 +----- 5 files changed, 3 insertions(+), 26 deletions(-) diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index 32707e8b0c8..b8de7932cd6 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -17,11 +17,7 @@ class BinaryLightOutput final : public light::LightOutput { void write_state(light::LightState *state) override { bool binary; state->current_values_as_binary(&binary); - if (binary) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(binary); } protected: diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index bbc1589abf0..0a20beb33c8 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -208,11 +208,7 @@ void ImprovBLEComponent::set_status_indicator_state_(bool state) { if (this->status_indicator_state_ == state) return; this->status_indicator_state_ = state; - if (state) { - this->status_indicator_->turn_on(); - } else { - this->status_indicator_->turn_off(); - } + this->status_indicator_->set_state(state); #endif } diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 5c373ddc7d2..d38eed4d096 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -38,14 +38,6 @@ float Mcp4461Wiper::update_state() { return this->state_; } -void Mcp4461Wiper::set_state(bool state) { - if (state) { - this->turn_on(); - } else { - this->turn_off(); - } -} - void Mcp4461Wiper::turn_on() { this->parent_->enable_wiper_(this->wiper_); } void Mcp4461Wiper::turn_off() { this->parent_->disable_wiper_(this->wiper_); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index c8d1ef1ec51..1052369a744 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -13,9 +13,6 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parentedcontrol(this->get_initial_state_with_restore_mode().value_or(false)); } void OutputSwitch::write_state(bool state) { - if (state) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(state); this->publish_state(state); } From 28b2a689a722313715664936fd7fcc3424f7e29f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:02 -0500 Subject: [PATCH 263/266] [web_server] Reduce flash used by the JSON and request helpers (#19303) --- esphome/components/json/json_util.cpp | 2 + esphome/components/json/json_util.h | 3 + esphome/components/web_server/web_server.cpp | 68 +++++++++++-------- esphome/components/web_server/web_server.h | 2 +- .../web_server_idf/web_server_idf.h | 4 +- 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 984134b95f9..1b1eefe59b3 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -66,6 +66,8 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } +JsonBuilder::JsonBuilder() = default; + SerializationBuffer<> JsonBuilder::serialize() { // =========================================================================================== // CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9f51d9927b8..130e1503321 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -168,6 +168,9 @@ inline JsonDocument parse_json(const std::string &data) { /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: + // Out of line: inlining the JsonDocument constructor duplicates it at every call site + JsonBuilder(); + JsonObject root() { if (!root_created_) { root_ = doc_.to(); diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1683492da76..8b0dfe166fb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -66,9 +66,12 @@ static const char *const TAG = "web_server"; // GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only) // POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only) static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) { + // Every path returns this one object so it is built in place; fields are only set once the URL is known valid + UrlMatch match{}; + // URL must start with '/' and have content after it if (url_len < 2 || url_ptr[0] != '/') - return UrlMatch{}; + return match; const char *p = url_ptr + 1; const char *end = url_ptr + url_len; @@ -90,15 +93,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Must have domain with trailing slash if (!s2) - return UrlMatch{}; - - UrlMatch match{}; - match.domain = make_ref(s1, s2); - match.valid = true; - - if (only_domain || s2 >= end) return match; + if (only_domain || s2 >= end) { + match.domain = make_ref(s1, s2); + match.valid = true; + return match; + } + // Parse remaining segments only when needed const char *s3 = next_segment(s2); const char *s4 = s3 ? next_segment(s3) : nullptr; @@ -109,7 +111,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Reject empty segments if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty())) - return UrlMatch{}; + return match; // Interpret based on segment count if (!s3) { @@ -121,28 +123,31 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, if (is_post) { match.id = seg2; match.method = seg3; - return match; - } + } else { #ifdef USE_DEVICES - match.device_name = seg2; - match.id = seg3; + match.device_name = seg2; + match.id = seg3; #else - return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES + return match; // 3-segment GET not supported without USE_DEVICES #endif + } } else { // 3 segments after domain: /{domain}/{device}/{entity}/{action} #ifdef USE_DEVICES if (!is_post) { - return UrlMatch{}; // 4-segment GET not supported (action requires POST) + return match; // 4-segment GET not supported (action requires POST) } match.device_name = seg2; match.id = seg3; match.method = seg4; #else - return UrlMatch{}; // Not supported without USE_DEVICES + // Not supported without USE_DEVICES + return match; #endif } + match.domain = make_ref(s1, s2); + match.valid = true; return match; } @@ -336,6 +341,9 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +// Kept out of the callers so the 64 bit division is emitted once +__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } + json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); @@ -343,7 +351,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); - root[ESPHOME_F("comment")] = comment_buffer; + root[ESPHOME_F("comment")] = static_cast(comment_buffer); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else @@ -351,7 +359,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { #endif root[ESPHOME_F("log")] = this->expose_log_; root[ESPHOME_F("lang")] = "en"; - root[ESPHOME_F("uptime")] = static_cast(millis_64() / 1000); + root[ESPHOME_F("uptime")] = uptime_seconds(); return builder.serialize(); } @@ -382,7 +390,7 @@ void WebServer::setup() { if (this->events_.empty()) return; char buf[32]; - auto uptime = static_cast(millis_64() / 1000); + auto uptime = uptime_seconds(); size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); @@ -467,7 +475,10 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const const size_t scheme_sep = origin.find("://"); if (scheme_sep != std::string::npos) { const std::string host = get_request_header(request, "Host"); - if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + // Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here + const size_t authority = scheme_sep + 3; + if (!host.empty() && origin.size() - authority == host.size() && + memcmp(origin.data() + authority, host.data(), host.size()) == 0) return true; } @@ -534,7 +545,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion // Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name} // Uses names (not object_id) to avoid UTF-8 collision issues -static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) { +static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) { const StringRef &name = obj->get_name(); size_t prefix_len = strlen(prefix); size_t name_len = name.size(); @@ -569,7 +580,7 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("id")] = id_buf; + root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { root[ESPHOME_F("domain")] = prefix; @@ -594,14 +605,13 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Keep as separate function even though only used once: reduces code size by ~48 bytes // by allowing compiler to share code between template instantiations (bool, float, etc.) template -static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, - JsonDetail start_config) { +static void set_json_value(JsonObject root, EntityBase *obj, const char *prefix, T value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); root[ESPHOME_F("value")] = value; } template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, +static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const char *prefix, S state, T value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; @@ -1230,7 +1240,7 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso // Format: YYYY-MM-DD (max 10 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day); - set_json_icon_state_value(root, obj, "date", value, value, start_config); + set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1290,7 +1300,7 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso // Format: HH:MM:SS (8 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "time", value, value, start_config); + set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1351,7 +1361,7 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity * char value[24]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "datetime", value, value, start_config); + set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -2295,7 +2305,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version, start_config); + obj->update_info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; root[ESPHOME_F("title")] = obj->update_info.title; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d60b39278aa..7aa4ac24a3b 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -593,7 +593,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand web_server_base::WebServerBase *base_; #ifdef USE_ESP32 - AsyncEventSource events_{"/events", this}; + AsyncEventSource events_{StringRef::from_lit("/events"), this}; #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 6469b4c5648..743d296d73a 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -322,7 +322,7 @@ class AsyncEventSource : public AsyncWebHandler { using connect_handler_t = std::function; public: - AsyncEventSource(std::string url, esphome::web_server::WebServer *ws) : url_(std::move(url)), web_server_(ws) {} + AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} ~AsyncEventSource() override; // NOLINTNEXTLINE(readability-identifier-naming) @@ -352,7 +352,7 @@ class AsyncEventSource : public AsyncWebHandler { // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); - std::string url_; + StringRef url_; // Must outlive this object (string literal) // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. From 271f85185d5138ed48ef0d113ab7676ae7e30e68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:15 -0500 Subject: [PATCH 264/266] [sensor] Remove deprecated raw_state member (#19077) --- .../number/modbus_number.cpp | 1 - .../sensor/modbus_sensor.cpp | 1 - esphome/components/sensor/sensor.cpp | 10 +- esphome/components/sensor/sensor.h | 24 ++-- .../fixtures/sensor_raw_state.yaml | 53 +++++++++ .../fixtures/sensor_raw_state_no_filter.yaml | 31 +++++ tests/integration/test_sensor_raw_state.py | 108 ++++++++++++++++++ 7 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 tests/integration/fixtures/sensor_raw_state.yaml create mode 100644 tests/integration/fixtures/sensor_raw_state_no_filter.yaml create mode 100644 tests/integration/test_sensor_raw_state.py diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index aff05cd517a..223aa12bec2 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -23,7 +23,6 @@ void ModbusNumber::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Number new state : %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index b2bc2b5fd04..2035f2220a3 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -22,7 +22,6 @@ void ModbusSensor::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Sensor new state: %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 59e011932b1..bee5d7c6d33 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -40,10 +40,7 @@ const LogString *state_class_to_string(StateClass state_class) { return StateClassStrings::get_log_str(static_cast(state_class), 0); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -Sensor::Sensor() : state(NAN), raw_state(NAN) {} -#pragma GCC diagnostic pop +Sensor::Sensor() : state(NAN) {} int8_t Sensor::get_accuracy_decimals() { if (this->sensor_flags_.has_accuracy_override) @@ -66,11 +63,8 @@ StateClass Sensor::get_state_class() { } void Sensor::publish_state(float state) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->raw_state = state; -#pragma GCC diagnostic pop #ifdef USE_SENSOR_FILTER + this->raw_state_ = state; this->raw_callback_.call(state); #endif diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index f4ea4af9851..20288fa88e0 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -96,18 +96,20 @@ class Sensor : public EntityBase { /// Getter-syntax for .state. float get_state() const { return this->state; } - /// Getter-syntax for .raw_state + /// Get the last state received by publish_state(), before any filters were applied. float get_raw_state() const { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return this->raw_state; -#pragma GCC diagnostic pop +#ifdef USE_SENSOR_FILTER + return this->raw_state_; +#else + return this->state; // No filters compiled in, raw == filtered +#endif } /** Publish a new state to the front-end. * - * First, the new state will be assigned to the raw_value. Then it's passed through all filters - * until it finally lands in the .value member variable and a callback is issued. + * The value is passed through the filter chain (when filters are compiled in) before landing in + * the `state` member and triggering the state callback. The pre-filter value is available via + * get_raw_state(). * * @param state The state as a floating point number. */ @@ -137,17 +139,11 @@ class Sensor : public EntityBase { */ float state; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.10.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.10.0", "2026.4.0") - float raw_state; -#pragma GCC diagnostic pop - void internal_send_state_to_frontend(float state); protected: #ifdef USE_SENSOR_FILTER + float raw_state_{NAN}; ///< The last state passed to publish_state(), before filters. LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. diff --git a/tests/integration/fixtures/sensor_raw_state.yaml b/tests/integration/fixtures/sensor_raw_state.yaml new file mode 100644 index 00000000000..9c19032028f --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state.yaml @@ -0,0 +1,53 @@ +esphome: + name: test-sensor-raw-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Filters are compiled in for this config (USE_SENSOR_FILTER), so raw storage exists +sensor: + # No filters on this sensor: get_raw_state() must equal state + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + accuracy_decimals: 1 + + # Filtered sensor: get_raw_state() must be the pre-filter value + - platform: template + name: "With Filter Sensor" + id: with_filter_sensor + accuracy_decimals: 1 + filters: + - multiply: 2.0 + +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - sensor.template.publish: + id: no_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "NO_FILTER: state=%.1f raw_state=%.1f" + args: + - id(no_filter_sensor).state + - id(no_filter_sensor).get_raw_state() + + - platform: template + name: "Test With Filter Button" + id: test_with_filter_button + on_press: + - sensor.template.publish: + id: with_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "WITH_FILTER: state=%.1f raw_state=%.1f" + args: + - id(with_filter_sensor).state + - id(with_filter_sensor).get_raw_state() diff --git a/tests/integration/fixtures/sensor_raw_state_no_filter.yaml b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml new file mode 100644 index 00000000000..fec912691f2 --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml @@ -0,0 +1,31 @@ +esphome: + name: test-sensor-raw-state-no-filter + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# No sensor in this config has filters, so USE_SENSOR_FILTER is not defined and +# get_raw_state() falls back to state +sensor: + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + accuracy_decimals: 1 + +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - sensor.template.publish: + id: no_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "NO_FILTER: state=%.1f raw_state=%.1f" + args: + - id(no_filter_sensor).state + - id(no_filter_sensor).get_raw_state() diff --git a/tests/integration/test_sensor_raw_state.py b/tests/integration/test_sensor_raw_state.py new file mode 100644 index 00000000000..a178ebf7d4c --- /dev/null +++ b/tests/integration/test_sensor_raw_state.py @@ -0,0 +1,108 @@ +"""Integration tests for Sensor::get_raw_state(). + +Raw state storage only exists when filters are compiled in (USE_SENSOR_FILTER). +Without it, get_raw_state() returns state, so both build configurations are covered: +one fixture with a filtered sensor and one with no filters at all. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import APIClient, EntityInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +NO_FILTER_PATTERN = re.compile(r"NO_FILTER: state=([\d.]+) raw_state=([\d.]+)") +WITH_FILTER_PATTERN = re.compile(r"WITH_FILTER: state=([\d.]+) raw_state=([\d.]+)") + + +async def _press_and_read( + client: APIClient, + entities: list[EntityInfo], + button_object_id: str, + future: asyncio.Future[tuple[float, float]], + label: str, +) -> tuple[float, float]: + button = next( + (e for e in entities if button_object_id in e.object_id.lower()), None + ) + assert button is not None, f"{button_object_id} not found" + client.button_command(button.key) + try: + return await asyncio.wait_for(future, timeout=5.0) + except TimeoutError: + pytest.fail(f"Timeout waiting for {label} log message") + + +@pytest.mark.asyncio +async def test_sensor_raw_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With filters compiled in, raw state is stored separately from state.""" + loop = asyncio.get_running_loop() + no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + with_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + + def check_output(line: str) -> None: + if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): + no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + if not with_filter_future.done() and ( + match := WITH_FILTER_PATTERN.search(line) + ): + with_filter_future.set_result( + (float(match.group(1)), float(match.group(2))) + ) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + state, raw_state = await _press_and_read( + client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + ) + assert state == 21.5 + assert raw_state == 21.5 + + state, raw_state = await _press_and_read( + client, + entities, + "test_with_filter_button", + with_filter_future, + "WITH_FILTER", + ) + assert state == 43.0 + assert raw_state == 21.5 + + +@pytest.mark.asyncio +async def test_sensor_raw_state_no_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Without filters compiled in, get_raw_state() returns state.""" + loop = asyncio.get_running_loop() + no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + + def check_output(line: str) -> None: + if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): + no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + state, raw_state = await _press_and_read( + client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + ) + assert state == 21.5 + assert raw_state == 21.5 From 7d17efc15f1c41792af68f495077c7277f995e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:46 -0500 Subject: [PATCH 265/266] [espidf] Emit json2 size data so the link edge is not blocked (#18848) --- esphome/build_gen/espidf.py | 13 +- esphome/espidf/size_summary.py | 101 ++++++-- esphome/espidf/toolchain.py | 15 +- tests/unit_tests/build_gen/test_espidf.py | 12 + tests/unit_tests/test_espidf_toolchain.py | 37 +++ tests/unit_tests/test_size_summary.py | 298 +++++++++++++++++----- 6 files changed, 388 insertions(+), 88 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 2ef89cf595b..7689fc93b0e 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -90,9 +90,10 @@ def get_project_cmakelists( """ idf_target = variant_to_idf_target(get_esp32_variant()) - # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and - # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get - # --format=raw because the legacy mode doesn't support it. + # esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng; + # 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks + # total_size, hence the ELF fallback in espidf/size_summary.py; both + # go away together when 1.x support is dropped. size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" # Project-wide compile options: -D defines and -W warning flags (skip @@ -211,10 +212,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) project({CORE.name}) -# Emit raw JSON size data for ESPHome to read post-build. +# Emit per-memory-type JSON size data for ESPHome to read post-build. +# json2 stays small; raw dumps every symbol (~2s on a large map) and +# this command runs inside the link edge, blocking everything downstream. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2 -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 2be3634c693..d98363dd67e 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -9,16 +9,19 @@ byte-identical to PlatformIO's output: Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes) The format matches ``script/ci_memory_impact_extract.py`` so CI memory -analysis works unchanged on native ESP-IDF builds. RAM total is the -DRAM region size from the linker map; Flash total is taken from +analysis works unchanged on native ESP-IDF builds. RAM usage comes from +the DRAM (or unified DIRAM) region of the linker map. Flash used is the +exact image size matching the ``Total image size`` line: json2 +``total_size`` when present, otherwise derived from the ELF (see +``_image_size_from_elf``). Flash total is taken from ``partitions.csv`` using PlatformIO's rule (first app partition whose subtype is ``factory`` or ``ota_0``; see ``platform-espressif32/builder/main.py::_update_max_upload_size``). Structured size data is produced at link time by a CMake POST_BUILD custom command (see ``build_gen/espidf.py``) which writes -``esp_idf_size.json`` next to the ELF. We read that file here rather -than re-running ``esp_idf_size`` from Python. +``esp_idf_size.json`` (``--format=json2``, a per-memory-type summary) +next to the ELF; we read that rather than re-running ``esp_idf_size``. """ from __future__ import annotations @@ -27,6 +30,7 @@ import csv import json import logging from pathlib import Path +import struct from esphome.build_helpers.size_summary import print_size_line @@ -69,11 +73,43 @@ def _find_app_partition_size(partitions_csv: Path) -> int: raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}") -def print_summary(size_json: Path, partitions_csv: Path | None) -> None: +def _image_size_from_elf(elf: Path) -> int: + """Sum the allocated PROGBITS section sizes from an ELF32 file. + + Matches ``esp_idf_size.ng.memorymap._get_image_size`` byte for byte; + esptool's ``ELFFile`` filters sections differently and would not. + Raises ``ValueError`` for anything but a well-formed ELF32 LE file. + """ + with elf.open("rb") as f: + header = f.read(52) # ELF32 header + if len(header) < 52 or header[:6] != b"\x7fELF\x01\x01": + raise ValueError(f"{elf} is not a 32-bit little-endian ELF") + (e_shoff,) = struct.unpack_from(" None: """Print PlatformIO-shaped RAM and Flash one-liners. Failures are non-fatal: the build has already succeeded, we just couldn't - summarize. Logs the cause at debug level. + summarize. Anomalies (missing region, unreadable ELF) warn; expected + optional inputs (no size json, no partitions.csv) log at debug. """ if not size_json.is_file(): _LOGGER.debug("Skipping size summary: %s not found", size_json) @@ -83,20 +119,49 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Skipping size summary: %s", e) return - - memory_types = data.get("memory_types", {}) - ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {} - ram_used = ram_region.get("used") - ram_total = ram_region.get("size") - if ram_total and ram_used is not None: - print_size_line("RAM", ram_used, ram_total) - - image_size = data.get("image_size") - if image_size is None or partitions_csv is None: + if not isinstance(data, dict): + _LOGGER.warning("Skipping size summary: unexpected json shape in %s", size_json) return + + layout = data.get("layout") + regions = { + entry.get("name"): entry + for entry in (layout if isinstance(layout, list) else []) + if isinstance(entry, dict) + } + # Every chip has a DRAM or DIRAM region, so a warning here usually + # means the esp_idf_size json schema changed + ram_region = regions.get("DRAM") or regions.get("DIRAM") + if ram_region is None: + _LOGGER.warning("Skipping RAM summary: no DRAM/DIRAM region in %s", size_json) + elif ( + isinstance(ram_total := ram_region.get("total"), int) + and ram_total > 0 + and isinstance(ram_used := ram_region.get("used"), int) + ): + print_size_line("RAM", ram_used, ram_total) + else: + _LOGGER.warning( + "Skipping RAM summary: unusable region %s in %s", ram_region, size_json + ) + + # esp-idf-size >= 2.1 (IDF >= 6.0) reports the exact image size in + # json2; older 1.x omits it, so derive the same figure from the ELF. + flash_used = data.get("total_size") + if not (isinstance(flash_used, int) and flash_used > 0): + _LOGGER.debug("No total_size in %s, deriving from %s", size_json, firmware_elf) + try: + flash_used = _image_size_from_elf(firmware_elf) + except (OSError, ValueError) as e: + # The ELF must be present and well formed after a successful build + _LOGGER.warning("Skipping Flash summary: %s", e) + return try: app_size = _find_app_partition_size(partitions_csv) - except ValueError as e: + except (OSError, ValueError) as e: _LOGGER.debug("Skipping Flash summary: %s", e) return - print_size_line("Flash", image_size, app_size) + if app_size <= 0: + _LOGGER.debug("Skipping Flash summary: app partition size is 0") + return + print_size_line("Flash", flash_used, app_size) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 986f9dfb8bd..f695bdb7ab4 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -542,7 +542,7 @@ def run_compile(config, verbose: bool) -> int: if rc == 0: size_json = CORE.relative_build_path("build", "esp_idf_size.json") partitions = CORE.relative_build_path("partitions.csv") - print_summary(size_json, partitions if partitions.is_file() else None) + print_summary(size_json, partitions, get_built_elf_path()) return rc @@ -579,6 +579,16 @@ def get_ota_firmware_path() -> Path: return build_dir / "firmware.ota.bin" +def get_built_elf_path() -> Path: + """Path to the ELF idf.py writes directly, ``/.elf``. + + Exists as soon as the build finishes, unlike the ``firmware.elf`` + copy that ``create_elf_copy`` makes later. + """ + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.elf" + + def get_elf_path() -> Path: """Get the path to the firmware ELF file. @@ -706,8 +716,7 @@ def create_elf_copy() -> bool: "download ELF" link requests the literal filename ``firmware.elf`` (PlatformIO convention), so copy it to that name. """ - build_dir = CORE.relative_build_path("build") - src_elf = build_dir / f"{CORE.name}.elf" + src_elf = get_built_elf_path() dst_elf = get_elf_path() if not src_elf.is_file(): diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 079f10ddb91..2848d7202df 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -163,6 +163,18 @@ def test_has_discovered_components_after_configure(tmp_path: Path) -> None: assert has_discovered_components() +def test_get_project_cmakelists_size_command_uses_json2() -> None: + """The POST_BUILD size command uses the cheap json2 format, with --ng + only on the 1.x tool bundled with IDF < 6.""" + content = _render() + assert "-m esp_idf_size --ng --format=json2" in content + + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 0, 0) + content = _render() + assert "--ng" not in content + assert "--format=json2" in content + + def test_get_project_cmakelists_uses_supplied_builtin_components() -> None: """A cached list replaces project_description.json and is still filtered by EXCLUDE_COMPONENTS.""" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 9deb27d83cb..bb2aab17a24 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -638,6 +638,43 @@ def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: mock_run.assert_called_once_with("build", "size", jobs=1) +def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None: + """print_summary receives the size json, partitions.csv, and the built + ELF from get_built_elf_path, which must stay in lockstep with the + project() name in the generated CMakeLists.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary") as mock_summary, + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + mock_summary.assert_called_once_with( + CORE.relative_build_path("build", "esp_idf_size.json"), + CORE.relative_build_path("partitions.csv"), + CORE.relative_build_path("build", f"{CORE.name}.elf"), + ) + + +def test_create_elf_copy(setup_core: Path) -> None: + """The built .elf is copied to the firmware.elf dashboard name.""" + _setup_build(setup_core) + src = toolchain.get_built_elf_path() + src.parent.mkdir(parents=True, exist_ok=True) + src.write_bytes(b"elf") + assert toolchain.create_elf_copy() is True + assert toolchain.get_elf_path().read_bytes() == b"elf" + + +def test_create_elf_copy_missing_source(setup_core: Path) -> None: + """A missing built ELF is a warning and False, not a crash.""" + _setup_build(setup_core) + assert toolchain.create_elf_copy() is False + + def test_run_compile_without_compile_process_limit(setup_core: Path) -> None: """When no compile_process_limit is set, no job limit is passed to idf.py.""" _setup_build(setup_core) diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index 0c0852a191e..245184f2d03 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -4,6 +4,8 @@ from __future__ import annotations import json from pathlib import Path +import struct +from unittest.mock import patch import pytest @@ -17,64 +19,106 @@ def _write_size_json(tmp_path: Path, data: dict) -> Path: return out +def _write_partitions(tmp_path: Path) -> Path: + """Drop a partitions.csv with a 0x1C0000 (1835008 byte) app slot.""" + out = tmp_path / "partitions.csv" + out.write_text( + "# name, type, subtype, offset, size, flags\n" + "app0, app, ota_0, 0x10000, 0x1C0000,\n" + ) + return out + + +def _elf_bytes(sections: list[tuple[int, int, int]], shentsize: int = 40) -> bytes: + """Build a minimal ELF32 LE whose section headers carry the given + (sh_type, sh_flags, sh_size) triples.""" + out = bytearray(52) + out[0:4] = b"\x7fELF" + out[4] = out[5] = 1 # 32-bit, little-endian + struct.pack_into(" dict: - """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + """Synthetic json2 for the original ESP32 (split IRAM/DRAM), in the + esp-idf-size >= 2.1 shape that carries ``total_size``.""" return { - "image_size": 827455, - "memory_types": { - "DRAM": { - "size": 180736, + "version": "1.1", + "total_size": 827455, + "layout": [ + { + "name": "DRAM", + "total": 180736, "used": 47332, - "sections": { - ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, - ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + "free": 133404, + "parts": { + ".bss": {"size": 30616}, + ".data": {"size": 16716}, }, }, - "IRAM": { - "size": 131072, + { + "name": "IRAM", + "total": 131072, "used": 80351, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 79323}, - ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + "free": 50721, + "parts": { + ".text": {"size": 79323}, + ".vectors": {"size": 1028}, }, }, - }, + ], } def _s3_size_data() -> dict: - """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + """Synthetic json2 for ESP32-S3 (unified DIRAM), in the esp-idf-size 1.x + shape without ``total_size``.""" return { - "image_size": 724215, - "memory_types": { - "DIRAM": { - "size": 341760, + "version": "1.1", + "layout": [ + { + "name": "DIRAM", + "total": 341760, "used": 104999, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 58051}, - ".dram0.bss": {"abbrev_name": ".bss", "size": 27088}, - ".dram0.data": {"abbrev_name": ".data", "size": 19708}, - ".noinit": {"abbrev_name": ".noinit", "size": 152}, + "free": 236761, + "parts": { + ".text": {"size": 58051}, + ".bss": {"size": 27088}, + ".data": {"size": 19708}, + ".noinit": {"size": 152}, }, }, - "IRAM": { - "size": 16384, + { + "name": "IRAM", + "total": 16384, "used": 16384, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 15356}, - ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + "free": 0, + "parts": { + ".text": {"size": 15356}, + ".vectors": {"size": 1028}, }, }, - }, + ], } +def _print_summary_ram_only(tmp_path: Path, size_json: Path) -> None: + """Call print_summary with no partitions.csv or ELF on disk.""" + print_summary(size_json, tmp_path / "partitions.csv", tmp_path / "firmware.elf") + + def test_print_summary_esp32_uses_dram( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged.""" + """Original ESP32: RAM = DRAM.used / DRAM.total.""" size_json = _write_size_json(tmp_path, _esp32_size_data()) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "RAM:" in out assert "used 47332 bytes from 180736 bytes" in out @@ -83,63 +127,193 @@ def test_print_summary_esp32_uses_dram( def test_print_summary_s3_falls_back_to_diram( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage.""" + """ESP32-S3 with no DRAM entry falls back to DIRAM and reports raw region usage.""" size_json = _write_size_json(tmp_path, _s3_size_data()) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "used 104999 bytes from 341760 bytes" in out def test_print_summary_skips_when_diram_total_collapses( - tmp_path: Path, capsys: pytest.CaptureFixture[str] + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A zero-size region drops the RAM line rather than divide by zero.""" size_json = _write_size_json( tmp_path, { - "memory_types": { - "DIRAM": { - "size": 0, - "used": 0, - "sections": {}, - }, - }, + "version": "1.1", + "layout": [{"name": "DIRAM", "total": 0, "used": 0}], }, ) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "RAM:" not in out + assert "unusable region" in caplog.text def test_print_summary_handles_missing_json( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """Missing size json is non-fatal and prints nothing.""" - print_summary(tmp_path / "does_not_exist.json", partitions_csv=None) + _print_summary_ram_only(tmp_path, tmp_path / "does_not_exist.json") assert capsys.readouterr().out == "" -def test_print_summary_handles_no_memory_types( - tmp_path: Path, capsys: pytest.CaptureFixture[str] +def test_print_summary_handles_no_layout( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: - """A size json without ``memory_types`` still doesn't crash.""" - size_json = _write_size_json(tmp_path, {"image_size": 0}) - print_summary(size_json, partitions_csv=None) + """A size json without ``layout`` warns so schema drift is visible.""" + size_json = _write_size_json(tmp_path, {"version": "1.1"}) + _print_summary_ram_only(tmp_path, size_json) assert capsys.readouterr().out == "" - - -def test_print_summary_flash_line( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - """A partition table with an app row yields the Flash line in the exact - padded shape script/ci_memory_impact_extract.py greps.""" - size_json = _write_size_json(tmp_path, _esp32_size_data()) - partitions = tmp_path / "partitions.csv" - partitions.write_text( - "# name, type, subtype, offset, size, flags\n" - "app0, app, ota_0, 0x10000, 0x1C0000,\n" + assert any( + r.levelname == "WARNING" and "no DRAM/DIRAM region" in r.message + for r in caplog.records ) - print_summary(size_json, partitions) + + +def test_print_summary_flash_line_prefers_total_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """With ``total_size`` in the json, that figure wins without reading the + ELF, in the exact shape script/ci_memory_impact_extract.py greps.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = _write_partitions(tmp_path) + print_summary(size_json, partitions, tmp_path / "firmware.elf") out = capsys.readouterr().out assert "Flash: " in out assert "(used 827455 bytes from 1835008 bytes)" in out + + +def test_print_summary_flash_line_derives_from_elf( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A 1.x json without ``total_size`` sums the ELF's loadable PROGBITS + sections; NOBITS and non-alloc sections are excluded.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + partitions = _write_partitions(tmp_path) + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_bytes( + _elf_bytes( + [ + (1, 0x6, 700000), # PROGBITS, alloc+exec: counted + (1, 0x2, 24215), # PROGBITS, alloc: counted + (8, 0x2, 50000), # NOBITS (.bss): excluded + (1, 0x0, 12345), # PROGBITS, no alloc (.debug_*): excluded + ] + ) + ) + print_summary(size_json, partitions, firmware_elf) + out = capsys.readouterr().out + assert "(used 724215 bytes from 1835008 bytes)" in out + + +@pytest.mark.parametrize( + "data", + [ + pytest.param([1, 2], id="top_level_list"), + pytest.param({"version": "1.1", "layout": None}, id="layout_null"), + pytest.param({"version": "1.1", "layout": 7}, id="layout_scalar"), + ], +) +def test_print_summary_handles_unexpected_shapes( + data: object, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A foreign-schema size json degrades to a warning, never a traceback.""" + size_json = _write_size_json(tmp_path, data) + _print_summary_ram_only(tmp_path, size_json) + assert capsys.readouterr().out == "" + + +def test_print_summary_skips_flash_on_zero_app_partition( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero-size app partition skips the Flash line rather than printing + a from-0-bytes figure CI would record.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = tmp_path / "partitions.csv" + partitions.write_text( + "# name, type, subtype, offset, size, flags\napp0, app, ota_0, 0x10000, 0x0,\n" + ) + print_summary(size_json, partitions, tmp_path / "firmware.elf") + out = capsys.readouterr().out + assert "Flash:" not in out + + +def test_print_summary_skips_flash_on_unreadable_partitions( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An unreadable partitions.csv is non-fatal (chmod tricks don't work + for root in CI containers, so simulate the OSError instead).""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = _write_partitions(tmp_path) + with patch( + "esphome.espidf.size_summary._find_app_partition_size", + side_effect=PermissionError("denied"), + ): + print_summary(size_json, partitions, tmp_path / "firmware.elf") + assert "Flash:" not in capsys.readouterr().out + + +def test_print_summary_flash_falls_back_on_bad_total_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero or non-int total_size falls back to the ELF instead of + printing a used-0-bytes line CI would read as a real measurement.""" + data = _s3_size_data() + data["total_size"] = 0 + size_json = _write_size_json(tmp_path, data) + partitions = _write_partitions(tmp_path) + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_bytes(_elf_bytes([(1, 0x2, 4096)])) + print_summary(size_json, partitions, firmware_elf) + out = capsys.readouterr().out + assert "(used 4096 bytes from 1835008 bytes)" in out + + +_GOOD_ELF = _elf_bytes([(1, 0x2, 1024)]) + + +@pytest.mark.parametrize( + ("elf_bytes", "with_partitions"), + [ + pytest.param(None, True, id="missing_elf"), + pytest.param(b"junk", True, id="not_an_elf"), + pytest.param( + _elf_bytes([(1, 0x2, 1024)], shentsize=0), True, id="bad_shentsize" + ), + pytest.param(_GOOD_ELF[:60], True, id="truncated_table"), + pytest.param(_elf_bytes([]), True, id="no_sections"), + pytest.param(_elf_bytes([(8, 0x2, 50000)]), True, id="no_progbits"), + pytest.param(_GOOD_ELF, False, id="missing_partitions"), + ], +) +def test_print_summary_skips_flash_on_bad_input( + elf_bytes: bytes | None, + with_partitions: bool, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """An unusable ELF or missing partitions.csv skips the Flash line, not the RAM line.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + firmware_elf = tmp_path / "firmware.elf" + if elf_bytes is not None: + firmware_elf.write_bytes(elf_bytes) + if with_partitions: + _write_partitions(tmp_path) + print_summary(size_json, tmp_path / "partitions.csv", firmware_elf) + out = capsys.readouterr().out + assert "RAM:" in out + assert "Flash:" not in out + # ELF problems warn (anomaly after a successful build); a missing + # partitions.csv stays at debug + warned = any( + r.levelname == "WARNING" and "Skipping Flash summary" in r.message + for r in caplog.records + ) + assert warned == with_partitions From 8ac3dba11ac3669961ad833b324c1c1142463d38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:40:30 -0500 Subject: [PATCH 266/266] [esp32] Add a flash chip option that drops the unused flash vendor drivers (#19217) --- esphome/components/esp32/__init__.py | 42 +++++++++++ esphome/core/application.cpp | 34 ++++++++- .../esp32/config/flash_chip_gd.yaml | 9 +++ .../esp32/config/flash_chip_generic.yaml | 9 +++ .../esp32/config/flash_chip_mxic_opi_s3.yaml | 10 +++ tests/component_tests/esp32/test_esp32.py | 73 +++++++++++++++++++ tests/components/esp32/test.esp32-idf.yaml | 1 + tests/components/esp32/test.esp32-s3-idf.yaml | 1 + 8 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/esp32/config/flash_chip_gd.yaml create mode 100644 tests/component_tests/esp32/config/flash_chip_generic.yaml create mode 100644 tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0c5b9c5df6a..748be9ae4cf 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -111,6 +111,7 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_FLASH_CHIP = "flash_chip" CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_NVS_ENCRYPTION = "nvs_encryption" @@ -464,6 +465,20 @@ ESP32_CHIP_REVISIONS = { "3.1": "CONFIG_ESP32_REV_MIN_3_1", } +# Flash vendor drivers ESP-IDF can link; each costs IRAM plus a 124 B table in DRAM +# and only the one matching the flash ID is ever used +ESP32_FLASH_CHIPS = { + "gd": "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", + "issi": "CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP", + "mxic": "CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP", + "winbond": "CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP", + "boya": "CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP", + "th": "CONFIG_SPI_FLASH_SUPPORT_TH_CHIP", + "mxic_opi": "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", +} +FLASH_CHIP_GENERIC = "generic" +FLASH_CHIP_OPI = "mxic_opi" # the octal driver, ESP32-S3 only + # Socket limit configuration for ESP-IDF # ESP-IDF CONFIG_LWIP_MAX_SOCKETS has range 1-253, default 10 DEFAULT_MAX_SOCKETS = 10 # ESP-IDF default @@ -1533,6 +1548,25 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_SRAM1_AS_IRAM], ) ) + if (flash_chip := advanced.get(CONF_FLASH_CHIP)) is not None: + opi = flash_chip == FLASH_CHIP_OPI + if opi and config[CONF_VARIANT] != VARIANT_ESP32S3: + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) + elif opi != (config.get(CONF_FLASH_MODE) == "opi"): + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' requires '{CONF_FLASH_MODE}: opi'" + if opi + else f"'{CONF_FLASH_CHIP}: {flash_chip}' does not match " + f"'{CONF_FLASH_MODE}: opi'; octal flash uses {FLASH_CHIP_OPI}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) if ( config[CONF_VARIANT] != VARIANT_ESP32P4 and config.get(CONF_ENGINEERING_SAMPLE) is not None @@ -1971,6 +2005,9 @@ FRAMEWORK_SCHEMA = cv.Schema( *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, + cv.Optional(CONF_FLASH_CHIP): cv.one_of( + FLASH_CHIP_GENERIC, *ESP32_FLASH_CHIPS, lower=True + ), # DHCP server is needed for WiFi AP mode. When WiFi component is used, # it will handle disabling DHCP server when AP is not configured. # Default to false (disabled) when WiFi is not used. @@ -2765,6 +2802,11 @@ async def to_code(config): add_idf_sdkconfig_option(flag, rev == min_rev) cg.add_define("USE_ESP32_MIN_CHIP_REVISION_SET") + # Keep only the flash vendor driver the board needs; the boot log names it + if (flash_chip := conf[CONF_ADVANCED].get(CONF_FLASH_CHIP)) is not None: + for chip, flag in ESP32_FLASH_CHIPS.items(): + add_idf_sdkconfig_option(flag, chip == flash_chip) + # Use SRAM1 region as IRAM on ESP32 (original) variant # This provides an additional 40KB of IRAM by using SRAM1 memory that was previously # reserved for bootloader DRAM. Requires a bootloader from ESP-IDF v5.1 or later. diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 38d3503c2c3..50d1c619595 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -11,6 +11,19 @@ #include #include #include +#include +#if __has_include() +#include // ESP-IDF 6 +#include +#else +#include +#include +#endif +// Vendor flash drivers linked next to the generic one; sdkconfig defines each as 1 or not at all +#define ESPHOME_FLASH_VENDOR_DRIVERS \ + (CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP + CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP + CONFIG_SPI_FLASH_SUPPORT_GD_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP + CONFIG_SPI_FLASH_SUPPORT_TH_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP) #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -157,8 +170,25 @@ void Application::process_dump_config_() { esp_chip_info(&chip_info); ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100, chip_info.revision % 100, chip_info.cores); -#if defined(USE_ESP32_VARIANT_ESP32) && (!defined(USE_ESP32_MIN_CHIP_REVISION_SET) || !defined(USE_ESP32_SRAM1_AS_IRAM)) - static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; + [[maybe_unused]] static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 0 + { + // Only the driver in use earns its IRAM; with several linked at least one is idle + const spi_flash_chip_t *flash_driver = esp_flash_default_chip->chip_drv; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 1 + constexpr bool idle_driver = true; +#else + const bool idle_driver = flash_driver == &esp_flash_chip_generic; +#endif + if (idle_driver) { + const char *value = flash_driver->name; +#ifdef CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP + if (flash_driver == &esp_flash_chip_mxic_opi) + value = "mxic_opi"; +#endif + ESP_LOGW(TAG, "Set flash_chip: %s %s to save IRAM", value, ESP32_ADVANCED_PATH); + } + } #endif #if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET) { diff --git a/tests/component_tests/esp32/config/flash_chip_gd.yaml b/tests/component_tests/esp32/config/flash_chip_gd.yaml new file mode 100644 index 00000000000..6d564135c00 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_gd.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: gd diff --git a/tests/component_tests/esp32/config/flash_chip_generic.yaml b/tests/component_tests/esp32/config/flash_chip_generic.yaml new file mode 100644 index 00000000000..8c7bcf61664 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_generic.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: generic diff --git a/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml new file mode 100644 index 00000000000..1531e749f20 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf + advanced: + flash_chip: mxic_opi diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index d5d0acfb2ad..a42d244ac8f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,6 +10,7 @@ from typing import Any import pytest from esphome.components.esp32 import ( + ESP32_FLASH_CHIPS, KEY_FATFS_REQUIRED, KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, KEY_MBEDTLS_TLS_SERVER_REQUIRED, @@ -252,6 +253,41 @@ def test_esp32_rejects_unsupported_cli_toolchain( r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", id="nvs_encryption_key_id_out_of_range", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "mxic_opi"}, + }, + }, + r"'flash_chip: mxic_opi' is only supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_mxic_opi_only_on_s3", + ), + pytest.param( + { + "variant": "esp32s3", + "flash_mode": "opi", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "gd"}, + }, + }, + r"'flash_chip: gd' does not match 'flash_mode: opi'; octal flash uses mxic_opi @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_must_match_opi_mode", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "mxic_opi"}, + }, + }, + r"'flash_chip: mxic_opi' requires 'flash_mode: opi' @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_mxic_opi_requires_opi_mode", + ), pytest.param( { "variant": "esp32", @@ -719,6 +755,43 @@ def test_flash_mode_sets_sdkconfig_and_pio_option( assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" +@pytest.mark.parametrize( + ("config_file", "enabled"), + [ + pytest.param("flash_chip_gd.yaml", "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", id="gd"), + pytest.param("flash_chip_generic.yaml", None, id="generic"), + pytest.param( + "flash_chip_mxic_opi_s3.yaml", + "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", + id="mxic_opi_s3", + ), + ], +) +def test_flash_chip_keeps_one_vendor_driver( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + enabled: str | None, +) -> None: + """flash_chip enables only the chosen vendor driver.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + vendors = { + k: v for k, v in sdkconfig.items() if k.startswith("CONFIG_SPI_FLASH_SUPPORT_") + } + assert vendors == {flag: flag == enabled for flag in ESP32_FLASH_CHIPS.values()} + + +def test_flash_chip_unset_keeps_idf_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_chip every vendor driver stays at its ESP-IDF default.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_SPI_FLASH_SUPPORT_") for key in sdkconfig) + + def test_flash_mode_opi_enables_octal_flash( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 7f31fe59c6f..5c7cb1d61b8 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -22,6 +22,7 @@ esp32: disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true + flash_chip: gd watchdog_timeout: 7s wifi: diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index b9a3b804a8d..5bdf94e8e1d 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -9,6 +9,7 @@ esp32: type: esp-idf advanced: execute_from_psram: true + flash_chip: gd disable_libc_locks_in_iram: true # Test default RAM optimization enabled disable_debug_stubs: true disable_ocd_aware: true