From 6929bdb4151f6a970b12a357d202d98596ecd115 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:01:30 -0400 Subject: [PATCH 1/3] [remote_transmitter] Remove delays and use RMT instead (#11505) --- .../remote_transmitter/remote_transmitter.h | 26 +++- .../remote_transmitter_esp32.cpp | 145 +++++++++++++++++- 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index aa1f54911d..b5d8e8d83f 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -12,6 +12,25 @@ namespace esphome { namespace remote_transmitter { +#ifdef USE_ESP32 +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +// IDF version 5.5.1 and above is required because of a bug in +// the RMT encoder: https://github.com/espressif/esp-idf/issues/17244 +typedef union { // NOLINT(modernize-use-using) + struct { + uint16_t duration : 15; + uint16_t level : 1; + }; + uint16_t val; +} rmt_symbol_half_t; + +struct RemoteTransmitterComponentStore { + uint32_t times{0}; + uint32_t index{0}; +}; +#endif +#endif + class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, public Component #ifdef USE_ESP32 @@ -56,9 +75,14 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, #ifdef USE_ESP32 void configure_rmt_(); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) + RemoteTransmitterComponentStore store_{}; + std::vector rmt_temp_; +#else + std::vector rmt_temp_; +#endif uint32_t current_carrier_frequency_{38000}; bool initialized_{false}; - std::vector rmt_temp_; bool with_dma_{false}; bool eot_level_{false}; rmt_channel_handle_t channel_{NULL}; diff --git a/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp b/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp index 119aa81e7e..27bbf3c210 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp @@ -10,6 +10,46 @@ namespace remote_transmitter { static const char *const TAG = "remote_transmitter"; +// Maximum RMT symbol duration (15-bit field) +static constexpr uint32_t RMT_SYMBOL_DURATION_MAX = 0x7FFF; + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size_t written, size_t free, + rmt_symbol_word_t *symbols, bool *done, void *arg) { + auto *store = static_cast(arg); + const auto *encoded = static_cast(data); + size_t length = size / sizeof(rmt_symbol_half_t); + size_t count = 0; + + // copy symbols + for (size_t i = 0; i < free; i++) { + uint16_t sym_0 = encoded[store->index++].val; + if (store->index >= length) { + store->index = 0; + store->times--; + if (store->times == 0) { + *done = true; + symbols[count++].val = sym_0; + return count; + } + } + uint16_t sym_1 = encoded[store->index++].val; + if (store->index >= length) { + store->index = 0; + store->times--; + if (store->times == 0) { + *done = true; + symbols[count++].val = sym_0 | (sym_1 << 16); + return count; + } + } + symbols[count++].val = sym_0 | (sym_1 << 16); + } + *done = false; + return count; +} +#endif + void RemoteTransmitterComponent::setup() { this->inverted_ = this->pin_->is_inverted(); this->configure_rmt_(); @@ -34,6 +74,17 @@ void RemoteTransmitterComponent::dump_config() { } void RemoteTransmitterComponent::digital_write(bool value) { +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) + rmt_symbol_half_t symbol = { + .duration = 1, + .level = value, + }; + rmt_transmit_config_t config; + memset(&config, 0, sizeof(config)); + config.flags.eot_level = value; + this->store_.times = 1; + this->store_.index = 0; +#else rmt_symbol_word_t symbol = { .duration0 = 1, .level0 = value, @@ -42,8 +93,8 @@ void RemoteTransmitterComponent::digital_write(bool value) { }; rmt_transmit_config_t config; memset(&config, 0, sizeof(config)); - config.loop_count = 0; config.flags.eot_level = value; +#endif esp_err_t error = rmt_transmit(this->channel_, this->encoder_, &symbol, sizeof(symbol), &config); if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); @@ -90,6 +141,20 @@ void RemoteTransmitterComponent::configure_rmt_() { gpio_pullup_dis(gpio_num_t(this->pin_->get_pin())); } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) + rmt_simple_encoder_config_t encoder; + memset(&encoder, 0, sizeof(encoder)); + encoder.callback = encoder_callback; + encoder.arg = &this->store_; + 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(); + return; + } +#else rmt_copy_encoder_config_t encoder; memset(&encoder, 0, sizeof(encoder)); error = rmt_new_copy_encoder(&encoder, &this->encoder_); @@ -99,6 +164,7 @@ void RemoteTransmitterComponent::configure_rmt_() { this->mark_failed(); return; } +#endif error = rmt_enable(this->channel_); if (error != ESP_OK) { @@ -130,6 +196,79 @@ void RemoteTransmitterComponent::configure_rmt_() { } } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { + if (this->is_failed()) { + return; + } + + if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { + this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); + this->configure_rmt_(); + } + + this->rmt_temp_.clear(); + this->rmt_temp_.reserve(this->temp_.get_data().size() + 1); + + // encode any delay at the start of the buffer to simplify the encoder callback + // this will be skipped the first time around + send_wait = this->from_microseconds_(static_cast(send_wait)); + while (send_wait > 0) { + int32_t duration = std::min(send_wait, uint32_t(RMT_SYMBOL_DURATION_MAX)); + this->rmt_temp_.push_back({ + .duration = static_cast(duration), + .level = static_cast(this->eot_level_), + }); + send_wait -= duration; + } + + // encode data + size_t offset = this->rmt_temp_.size(); + for (int32_t value : this->temp_.get_data()) { + bool level = value >= 0; + if (!level) { + value = -value; + } + value = this->from_microseconds_(static_cast(value)); + while (value > 0) { + int32_t duration = std::min(value, int32_t(RMT_SYMBOL_DURATION_MAX)); + this->rmt_temp_.push_back({ + .duration = static_cast(duration), + .level = static_cast(level ^ this->inverted_), + }); + value -= duration; + } + } + + if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.size() <= offset) { + ESP_LOGE(TAG, "Empty data"); + return; + } + + this->transmit_trigger_->trigger(); + + rmt_transmit_config_t config; + memset(&config, 0, sizeof(config)); + config.flags.eot_level = this->eot_level_; + this->store_.times = send_times; + this->store_.index = offset; + esp_err_t error = rmt_transmit(this->channel_, this->encoder_, this->rmt_temp_.data(), + this->rmt_temp_.size() * sizeof(rmt_symbol_half_t), &config); + if (error != ESP_OK) { + ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); + this->status_set_warning(); + } else { + this->status_clear_warning(); + } + error = rmt_tx_wait_all_done(this->channel_, -1); + if (error != ESP_OK) { + ESP_LOGW(TAG, "rmt_tx_wait_all_done failed: %s", esp_err_to_name(error)); + this->status_set_warning(); + } + + this->complete_trigger_->trigger(); +} +#else void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { if (this->is_failed()) return; @@ -151,7 +290,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen val = this->from_microseconds_(static_cast(val)); do { - int32_t item = std::min(val, int32_t(32767)); + int32_t item = std::min(val, int32_t(RMT_SYMBOL_DURATION_MAX)); val -= item; if (rmt_i % 2 == 0) { @@ -180,7 +319,6 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen for (uint32_t i = 0; i < send_times; i++) { rmt_transmit_config_t config; memset(&config, 0, sizeof(config)); - config.loop_count = 0; config.flags.eot_level = this->eot_level_; esp_err_t error = rmt_transmit(this->channel_, this->encoder_, this->rmt_temp_.data(), this->rmt_temp_.size() * sizeof(rmt_symbol_word_t), &config); @@ -200,6 +338,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } this->complete_trigger_->trigger(); } +#endif } // namespace remote_transmitter } // namespace esphome From 7f06e0bbca36e151644a16d3b52500695f3484db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Oct 2025 13:32:18 -0700 Subject: [PATCH 2/3] [template] Store initial option as index in template select --- .../components/template/select/__init__.py | 3 +- .../template/select/template_select.cpp | 30 +++++++++---------- .../template/select/template_select.h | 4 +-- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/esphome/components/template/select/__init__.py b/esphome/components/template/select/__init__.py index 3282092d63..b998a1c2c7 100644 --- a/esphome/components/template/select/__init__.py +++ b/esphome/components/template/select/__init__.py @@ -74,7 +74,8 @@ async def to_code(config): else: cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) - cg.add(var.set_initial_option(config[CONF_INITIAL_OPTION])) + initial_option_index = config[CONF_OPTIONS].index(config[CONF_INITIAL_OPTION]) + cg.add(var.set_initial_option_index(initial_option_index)) if CONF_RESTORE_VALUE in config: cg.add(var.set_restore_value(config[CONF_RESTORE_VALUE])) diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index 95b0ee0d2b..a07215e77e 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -10,26 +10,21 @@ void TemplateSelect::setup() { if (this->f_.has_value()) return; - std::string value; - if (!this->restore_value_) { - value = this->initial_option_; - ESP_LOGD(TAG, "State from initial: %s", value.c_str()); - } else { - size_t index; + size_t index = this->initial_option_index_; + if (this->restore_value_) { this->pref_ = global_preferences->make_preference(this->get_preference_hash()); - if (!this->pref_.load(&index)) { - value = this->initial_option_; - ESP_LOGD(TAG, "State from initial (could not load stored index): %s", value.c_str()); - } else if (!this->has_index(index)) { - value = this->initial_option_; - ESP_LOGD(TAG, "State from initial (restored index %d out of bounds): %s", index, value.c_str()); + size_t restored_index; + if (this->pref_.load(&restored_index) && this->has_index(restored_index)) { + index = restored_index; + ESP_LOGD(TAG, "State from restore: %s", this->at(index).value().c_str()); } else { - value = this->at(index).value(); - ESP_LOGD(TAG, "State from restore: %s", value.c_str()); + ESP_LOGD(TAG, "State from initial (could not load or invalid stored index): %s", this->at(index).value().c_str()); } + } else { + ESP_LOGD(TAG, "State from initial: %s", this->at(index).value().c_str()); } - this->publish_state(value); + this->publish_state(this->at(index).value()); } void TemplateSelect::update() { @@ -65,11 +60,14 @@ void TemplateSelect::dump_config() { LOG_UPDATE_INTERVAL(this); if (this->f_.has_value()) return; + auto initial_option = this->at(this->initial_option_index_); ESP_LOGCONFIG(TAG, " Optimistic: %s\n" " Initial Option: %s\n" " Restore Value: %s", - YESNO(this->optimistic_), this->initial_option_.c_str(), YESNO(this->restore_value_)); + YESNO(this->optimistic_), + initial_option.has_value() ? initial_option.value().c_str() : LOG_STR_LITERAL("unknown"), + YESNO(this->restore_value_)); } } // namespace template_ diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 2f00765c3d..d46ce38314 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -19,13 +19,13 @@ class TemplateSelect : public select::Select, public PollingComponent { Trigger *get_set_trigger() const { return this->set_trigger_; } void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_initial_option(const std::string &initial_option) { this->initial_option_ = initial_option; } + void set_initial_option_index(size_t initial_option_index) { this->initial_option_index_ = initial_option_index; } void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } protected: void control(const std::string &value) override; bool optimistic_ = false; - std::string initial_option_; + size_t initial_option_index_{0}; bool restore_value_ = false; Trigger *set_trigger_ = new Trigger(); optional()>> f_; From 7efa1f7641062bc0f0592793d08cf7f16a3396b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Oct 2025 13:39:06 -0700 Subject: [PATCH 3/3] test --- .../host_mode_empty_string_options.yaml | 11 ++++++++ .../test_host_mode_empty_string_options.py | 28 +++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/integration/fixtures/host_mode_empty_string_options.yaml b/tests/integration/fixtures/host_mode_empty_string_options.yaml index ab8e6cd005..a170511c46 100644 --- a/tests/integration/fixtures/host_mode_empty_string_options.yaml +++ b/tests/integration/fixtures/host_mode_empty_string_options.yaml @@ -41,6 +41,17 @@ select: - "" # Empty string at the end initial_option: "Choice X" + - platform: template + name: "Select Initial Option Test" + id: select_initial_option_test + optimistic: true + options: + - "First" + - "Second" + - "Third" + - "Fourth" + initial_option: "Third" # Test non-default initial option + # Add a sensor to ensure we have other entities in the list sensor: - platform: template diff --git a/tests/integration/test_host_mode_empty_string_options.py b/tests/integration/test_host_mode_empty_string_options.py index 242db2d40f..1316e43a9f 100644 --- a/tests/integration/test_host_mode_empty_string_options.py +++ b/tests/integration/test_host_mode_empty_string_options.py @@ -36,8 +36,8 @@ async def test_host_mode_empty_string_options( # Find our select entities select_entities = [e for e in entity_info if isinstance(e, SelectInfo)] - assert len(select_entities) == 3, ( - f"Expected 3 select entities, got {len(select_entities)}" + assert len(select_entities) == 4, ( + f"Expected 4 select entities, got {len(select_entities)}" ) # Verify each select entity by name and check their options @@ -71,6 +71,15 @@ async def test_host_mode_empty_string_options( assert empty_last.options[2] == "Choice Z" assert empty_last.options[3] == "" # Empty string at end + # Check "Select Initial Option Test" - verify non-default initial option + assert "Select Initial Option Test" in selects_by_name + initial_option_test = selects_by_name["Select Initial Option Test"] + assert len(initial_option_test.options) == 4 + assert initial_option_test.options[0] == "First" + assert initial_option_test.options[1] == "Second" + assert initial_option_test.options[2] == "Third" + assert initial_option_test.options[3] == "Fourth" + # If we got here without protobuf decoding errors, the fix is working # The bug would have caused "Invalid protobuf message" errors with trailing bytes @@ -78,7 +87,12 @@ async def test_host_mode_empty_string_options( # This ensures empty strings work properly in state messages too states: dict[int, EntityState] = {} states_received_future: asyncio.Future[None] = loop.create_future() - expected_select_keys = {empty_first.key, empty_middle.key, empty_last.key} + expected_select_keys = { + empty_first.key, + empty_middle.key, + empty_last.key, + initial_option_test.key, + } received_select_keys = set() def on_state(state: EntityState) -> None: @@ -109,6 +123,14 @@ async def test_host_mode_empty_string_options( assert empty_first.key in states assert empty_middle.key in states assert empty_last.key in states + assert initial_option_test.key in states + + # Verify the initial option is set correctly to "Third" (not the default "First") + initial_state = states[initial_option_test.key] + assert initial_state.state == "Third", ( + f"Expected initial state 'Third' but got '{initial_state.state}' - " + f"initial_option_index optimization may not be working correctly" + ) # The main test is that we got here without protobuf errors # The select entities with empty string options were properly encoded