diff --git a/docker/Dockerfile b/docker/Dockerfile index 348a503bc8..8ebdd1e49b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -23,7 +23,7 @@ RUN if command -v apk > /dev/null; then \ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 -RUN pip install --no-cache-dir -U pip uv==0.6.14 +RUN pip install --no-cache-dir -U pip uv==0.10.1 COPY requirements.txt / diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index e51689fd07..14ccbd2248 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -219,35 +219,8 @@ void APIConnection::loop() { this->process_batch_(); } - switch (this->active_iterator_) { - case ActiveIterator::LIST_ENTITIES: - if (this->iterator_storage_.list_entities.completed()) { - this->destroy_active_iterator_(); - if (this->flags_.state_subscription) { - this->begin_iterator_(ActiveIterator::INITIAL_STATE); - } - } else { - this->process_iterator_batch_(this->iterator_storage_.list_entities); - } - break; - case ActiveIterator::INITIAL_STATE: - if (this->iterator_storage_.initial_state.completed()) { - this->destroy_active_iterator_(); - // Process any remaining batched messages immediately - if (!this->deferred_batch_.empty()) { - this->process_batch_(); - } - // Now that everything is sent, enable immediate sending for future state changes - this->flags_.should_try_send_immediately = true; - // Release excess memory from buffers that grew during initial sync - this->deferred_batch_.release_buffer(); - this->helper_->release_buffers(); - } else { - this->process_iterator_batch_(this->iterator_storage_.initial_state); - } - break; - case ActiveIterator::NONE: - break; + if (this->active_iterator_ != ActiveIterator::NONE) { + this->process_active_iterator_(); } if (this->flags_.sent_ping) { @@ -283,6 +256,49 @@ void APIConnection::loop() { #endif } +void APIConnection::process_active_iterator_() { + // Caller ensures active_iterator_ != NONE + if (this->active_iterator_ == ActiveIterator::LIST_ENTITIES) { + if (this->iterator_storage_.list_entities.completed()) { + this->destroy_active_iterator_(); + if (this->flags_.state_subscription) { + this->begin_iterator_(ActiveIterator::INITIAL_STATE); + } + } else { + this->process_iterator_batch_(this->iterator_storage_.list_entities); + } + } else { // INITIAL_STATE + if (this->iterator_storage_.initial_state.completed()) { + this->destroy_active_iterator_(); + // Process any remaining batched messages immediately + if (!this->deferred_batch_.empty()) { + this->process_batch_(); + } + // Now that everything is sent, enable immediate sending for future state changes + this->flags_.should_try_send_immediately = true; + // Release excess memory from buffers that grew during initial sync + this->deferred_batch_.release_buffer(); + this->helper_->release_buffers(); + } else { + this->process_iterator_batch_(this->iterator_storage_.initial_state); + } + } +} + +void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { + size_t initial_size = this->deferred_batch_.size(); + size_t max_batch = this->get_max_batch_size_(); + while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { + iterator.advance(); + } + + // If the batch is full, process it immediately + // Note: iterator.advance() already calls schedule_batch_() via schedule_message_() + if (this->deferred_batch_.size() >= max_batch) { + this->process_batch_(); + } +} + bool APIConnection::send_disconnect_response_() { // remote initiated disconnect_client // don't close yet, we still need to send the disconnect response diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index abcb162865..a16d681760 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -15,6 +15,10 @@ #include #include +namespace esphome { +class ComponentIterator; +} // namespace esphome + namespace esphome::api { // Keepalive timeout in milliseconds @@ -366,20 +370,13 @@ class APIConnection final : public APIServerConnectionBase { return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY; } - // Helper method to process multiple entities from an iterator in a batch - template void process_iterator_batch_(Iterator &iterator) { - size_t initial_size = this->deferred_batch_.size(); - size_t max_batch = this->get_max_batch_size_(); - while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { - iterator.advance(); - } + // Process active iterator (list_entities/initial_state) during connection setup. + // Extracted from loop() — only runs during initial handshake, NONE in steady state. + void __attribute__((noinline)) process_active_iterator_(); - // If the batch is full, process it immediately - // Note: iterator.advance() already calls schedule_batch_() via schedule_message_() - if (this->deferred_batch_.size() >= max_batch) { - this->process_batch_(); - } - } + // Helper method to process multiple entities from an iterator in a batch. + // Takes ComponentIterator base class reference to avoid duplicate template instantiations. + void process_iterator_batch_(ComponentIterator &iterator); #ifdef USE_BINARY_SENSOR static uint16_t try_send_binary_sensor_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size); diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index bef36dd015..90769f9a81 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -94,7 +94,6 @@ class ListEntitiesIterator : public ComponentIterator { bool on_update(update::UpdateEntity *entity) override; #endif bool on_end() override; - bool completed() { return this->state_ == IteratorState::NONE; } protected: APIConnection *client_; diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 3c9f33835a..6f8577ca7b 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -88,7 +88,6 @@ class InitialStateIterator : public ComponentIterator { #ifdef USE_UPDATE bool on_update(update::UpdateEntity *entity) override; #endif - bool completed() { return this->state_ == IteratorState::NONE; } protected: APIConnection *client_; diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d3c343b9db..a680a78951 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1436,14 +1436,6 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) - # Set the uv cache inside the data dir so "Clean All" clears it. - # Avoids persistent corrupted cache from mid-stream download failures. - os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache")) - - # Set the uv cache inside the data dir so "Clean All" clears it. - # Avoids persistent corrupted cache from mid-stream download failures. - os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache")) - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 87b5e2b738..acbe9d88fc 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -369,42 +369,9 @@ bool ESP32BLE::ble_dismantle_() { } void ESP32BLE::loop() { - switch (this->state_) { - case BLE_COMPONENT_STATE_OFF: - case BLE_COMPONENT_STATE_DISABLED: - return; - case BLE_COMPONENT_STATE_DISABLE: { - ESP_LOGD(TAG, "Disabling"); - -#ifdef ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT - for (auto *ble_event_handler : this->ble_status_event_handlers_) { - ble_event_handler->ble_before_disabled_event_handler(); - } -#endif - - if (!ble_dismantle_()) { - ESP_LOGE(TAG, "Could not be dismantled"); - this->mark_failed(); - return; - } - this->state_ = BLE_COMPONENT_STATE_DISABLED; - return; - } - case BLE_COMPONENT_STATE_ENABLE: { - ESP_LOGD(TAG, "Enabling"); - this->state_ = BLE_COMPONENT_STATE_OFF; - - if (!ble_setup_()) { - ESP_LOGE(TAG, "Could not be set up"); - this->mark_failed(); - return; - } - - this->state_ = BLE_COMPONENT_STATE_ACTIVE; - return; - } - case BLE_COMPONENT_STATE_ACTIVE: - break; + if (this->state_ != BLE_COMPONENT_STATE_ACTIVE) { + this->loop_handle_state_transition_not_active_(); + return; } BLEEvent *ble_event = this->ble_events_.pop(); @@ -520,6 +487,37 @@ void ESP32BLE::loop() { } } +void ESP32BLE::loop_handle_state_transition_not_active_() { + // Caller ensures state_ != ACTIVE + if (this->state_ == BLE_COMPONENT_STATE_DISABLE) { + ESP_LOGD(TAG, "Disabling"); + +#ifdef ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT + for (auto *ble_event_handler : this->ble_status_event_handlers_) { + ble_event_handler->ble_before_disabled_event_handler(); + } +#endif + + if (!ble_dismantle_()) { + ESP_LOGE(TAG, "Could not be dismantled"); + this->mark_failed(); + return; + } + this->state_ = BLE_COMPONENT_STATE_DISABLED; + } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { + ESP_LOGD(TAG, "Enabling"); + this->state_ = BLE_COMPONENT_STATE_OFF; + + if (!ble_setup_()) { + ESP_LOGE(TAG, "Could not be set up"); + this->mark_failed(); + return; + } + + this->state_ = BLE_COMPONENT_STATE_ACTIVE; + } +} + // Helper function to load new event data based on type void load_ble_event(BLEEvent *event, esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { event->load_gap_event(e, p); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 1999c870f8..f1ab81b6dc 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -155,6 +155,10 @@ class ESP32BLE : public Component { #endif static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); + // Handle DISABLE and ENABLE transitions when not in the ACTIVE state. + // Other non-ACTIVE states (e.g. OFF, DISABLED) are currently treated as no-ops. + void __attribute__((noinline)) loop_handle_state_transition_not_active_(); + bool ble_setup_(); bool ble_dismantle_(); bool ble_pre_setup_(); diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index f696cfff1c..32f8f16ec1 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -45,9 +45,28 @@ class MDNSComponent : public Component { void setup() override; void dump_config() override; -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_ARDUINO) - void loop() override; -#endif + // Polling interval for MDNS.update() on platforms that require it (ESP8266, RP2040). + // + // On these platforms, MDNS.update() calls _process(true) which only manages timer-driven + // state machines (probe/announce timeouts and service query cache TTLs). Incoming mDNS + // packets are handled independently via the lwIP onRx UDP callback and are NOT affected + // by how often update() is called. + // + // The shortest internal timer is the 250ms probe interval (RFC 6762 Section 8.1). + // Announcement intervals are 1000ms and cache TTL checks are on the order of seconds + // to minutes. A 50ms polling interval provides sufficient resolution for all timers + // while completely removing mDNS from the per-iteration loop list. + // + // In steady state (after the ~8 second boot probe/announce phase completes), update() + // checks timers that are set to never expire, making every call pure overhead. + // + // Tasmota uses a 50ms main loop cycle with mDNS working correctly, confirming this + // interval is safe in production. + // + // By using set_interval() instead of overriding loop(), the component is excluded from + // the main loop list via has_overridden_loop(), eliminating all per-iteration overhead + // including virtual dispatch. + static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index dcbe5ebd52..295a408cbd 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -36,9 +36,14 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); } - -void MDNSComponent::loop() { MDNS.update(); } +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp8266); + // Schedule MDNS.update() via set_interval() instead of overriding loop(). + // This removes the component from the per-iteration loop list entirely, + // eliminating virtual dispatch overhead on every main loop cycle. + // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); +} void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index e4a9b60cdb..05d991c1fa 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -35,9 +35,14 @@ static void register_rp2040(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_rp2040); } - -void MDNSComponent::loop() { MDNS.update(); } +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_rp2040); + // Schedule MDNS.update() via set_interval() instead of overriding loop(). + // This removes the component from the per-iteration loop list entirely, + // eliminating virtual dispatch overhead on every main loop cycle. + // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); +} void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 5a64f37da4..6e86405b74 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -4,28 +4,72 @@ #include "esphome/core/log.h" #include "esphome/core/progmem.h" -namespace esphome { -namespace rtttl { +namespace esphome::rtttl { static const char *const TAG = "rtttl"; -static const uint32_t DOUBLE_NOTE_GAP_MS = 10; - // These values can also be found as constants in the Tone library (Tone.h) static const uint16_t NOTES[] = {0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, 523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988, 1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976, 2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951}; -static const uint16_t I2S_SPEED = 1000; +#if defined(USE_OUTPUT) || defined(USE_SPEAKER) +static const uint32_t DOUBLE_NOTE_GAP_MS = 10; +#endif // USE_OUTPUT || USE_SPEAKER -#undef HALF_PI -static const double HALF_PI = 1.5707963267948966192313216916398; +#ifdef USE_SPEAKER +static const size_t SAMPLE_BUFFER_SIZE = 2048; + +struct SpeakerSample { + int8_t left{0}; + int8_t right{0}; +}; inline double deg2rad(double degrees) { static const double PI_ON_180 = 4.0 * atan(1.0) / 180.0; return degrees * PI_ON_180; } +#endif // USE_SPEAKER + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +// RTTTL state strings indexed by State enum (0-4): STOPPED, INIT, STARTING, RUNNING, STOPPING, plus UNKNOWN fallback +PROGMEM_STRING_TABLE(RtttlStateStrings, "State::STOPPED", "State::INIT", "State::STARTING", "State::RUNNING", + "State::STOPPING", "UNKNOWN"); + +static const LogString *state_to_string(State state) { + return RtttlStateStrings::get_log_str(static_cast(state), RtttlStateStrings::LAST_INDEX); +} +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + +static uint8_t note_index_from_char(char note) { + switch (note) { + case 'c': + return 1; + // 'c#': 2 + case 'd': + return 3; + // 'd#': 4 + case 'e': + return 5; + case 'f': + return 6; + // 'f#': 7 + case 'g': + return 8; + // 'g#': 9 + case 'a': + return 10; + // 'a#': 11 + // Support both 'b' (English notation for B natural) and 'h' (German notation for B natural) + case 'b': + case 'h': + return 12; + case 'p': + default: + return 0; + } +} void Rtttl::dump_config() { ESP_LOGCONFIG(TAG, @@ -34,161 +78,34 @@ void Rtttl::dump_config() { this->gain_); } -void Rtttl::play(std::string rtttl) { - if (this->state_ != State::STATE_STOPPED && this->state_ != State::STATE_STOPPING) { - size_t pos = this->rtttl_.find(':'); - size_t len = (pos != std::string::npos) ? pos : this->rtttl_.length(); - ESP_LOGW(TAG, "Already playing: %.*s", (int) len, this->rtttl_.c_str()); - return; - } - - this->rtttl_ = std::move(rtttl); - - this->default_duration_ = 4; - this->default_octave_ = 6; - this->note_duration_ = 0; - - int bpm = 63; - uint8_t num; - - // Get name - this->position_ = this->rtttl_.find(':'); - - // it's somewhat documented to be up to 10 characters but let's be a bit flexible here - if (this->position_ == std::string::npos || this->position_ > 15) { - ESP_LOGE(TAG, "Unable to determine name; missing ':'"); - return; - } - - ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); - - // get default duration - this->position_ = this->rtttl_.find("d=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing 'd='"); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num > 0) - this->default_duration_ = num; - - // get default octave - this->position_ = this->rtttl_.find("o=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing 'o="); - return; - } - this->position_ += 2; - num = get_integer_(); - if (num >= 3 && num <= 7) - this->default_octave_ = num; - - // get BPM - this->position_ = this->rtttl_.find("b=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing b="); - return; - } - this->position_ += 2; - num = get_integer_(); - if (num != 0) - bpm = num; - - this->position_ = this->rtttl_.find(':', this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing second ':'"); - return; - } - this->position_++; - - // BPM usually expresses the number of quarter notes per minute - this->wholenote_ = 60 * 1000L * 4 / bpm; // this is the time for whole note (in milliseconds) - - this->output_freq_ = 0; - this->last_note_ = millis(); - this->note_duration_ = 1; - -#ifdef USE_SPEAKER - if (this->speaker_ != nullptr) { - this->set_state_(State::STATE_INIT); - this->samples_sent_ = 0; - this->samples_count_ = 0; - } -#endif -#ifdef USE_OUTPUT - if (this->output_ != nullptr) { - this->set_state_(State::STATE_RUNNING); - } -#endif -} - -void Rtttl::stop() { -#ifdef USE_OUTPUT - if (this->output_ != nullptr) { - this->output_->set_level(0.0); - this->set_state_(STATE_STOPPED); - } -#endif -#ifdef USE_SPEAKER - if (this->speaker_ != nullptr) { - if (this->speaker_->is_running()) { - this->speaker_->stop(); - } - this->set_state_(STATE_STOPPING); - } -#endif - this->position_ = this->rtttl_.length(); - this->note_duration_ = 0; -} - -void Rtttl::finish_() { - ESP_LOGV(TAG, "Rtttl::finish_()"); -#ifdef USE_OUTPUT - if (this->output_ != nullptr) { - this->output_->set_level(0.0); - this->set_state_(State::STATE_STOPPED); - } -#endif -#ifdef USE_SPEAKER - if (this->speaker_ != nullptr) { - SpeakerSample sample[2]; - sample[0].left = 0; - sample[0].right = 0; - sample[1].left = 0; - sample[1].right = 0; - this->speaker_->play((uint8_t *) (&sample), 8); - this->speaker_->finish(); - this->set_state_(State::STATE_STOPPING); - } -#endif - // Ensure no more notes are played in case finish_() is called for an error. - this->position_ = this->rtttl_.length(); - this->note_duration_ = 0; -} - void Rtttl::loop() { - if (this->state_ == State::STATE_STOPPED) { + if (this->state_ == State::STOPPED) { this->disable_loop(); return; } +#ifdef USE_OUTPUT + if (this->output_ != nullptr && millis() - this->last_note_ < this->note_duration_) { + return; + } +#endif // USE_OUTPUT + #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { - if (this->state_ == State::STATE_STOPPING) { + if (this->state_ == State::STOPPING) { if (this->speaker_->is_stopped()) { - this->set_state_(State::STATE_STOPPED); + this->set_state_(State::STOPPED); } else { return; } - } else if (this->state_ == State::STATE_INIT) { + } else if (this->state_ == State::INIT) { if (this->speaker_->is_stopped()) { this->speaker_->start(); - this->set_state_(State::STATE_STARTING); + this->set_state_(State::STARTING); } - } else if (this->state_ == State::STATE_STARTING) { + } else if (this->state_ == State::STARTING) { if (this->speaker_->is_running()) { - this->set_state_(State::STATE_RUNNING); + this->set_state_(State::RUNNING); } } if (!this->speaker_->is_running()) { @@ -230,19 +147,17 @@ void Rtttl::loop() { } } } -#endif -#ifdef USE_OUTPUT - if (this->output_ != nullptr && millis() - this->last_note_ < this->note_duration_) - return; -#endif +#endif // USE_SPEAKER + if (this->position_ >= this->rtttl_.length()) { this->finish_(); return; } // align to note: most rtttl's out there does not add and space after the ',' separator but just in case... - while (this->rtttl_[this->position_] == ',' || this->rtttl_[this->position_] == ' ') + while (this->rtttl_[this->position_] == ',' || this->rtttl_[this->position_] == ' ') { this->position_++; + } // first, get note duration, if available uint8_t num = this->get_integer_(); @@ -254,35 +169,8 @@ void Rtttl::loop() { this->wholenote_ / this->default_duration_; // we will need to check if we are a dotted note after } - uint8_t note; + uint8_t note = note_index_from_char(this->rtttl_[this->position_]); - switch (this->rtttl_[this->position_]) { - case 'c': - note = 1; - break; - case 'd': - note = 3; - break; - case 'e': - note = 5; - break; - case 'f': - note = 6; - break; - case 'g': - note = 8; - break; - case 'a': - note = 10; - break; - case 'h': - case 'b': - note = 12; - break; - case 'p': - default: - note = 0; - } this->position_++; // now, get optional '#' sharp @@ -292,7 +180,7 @@ void Rtttl::loop() { } // now, get scale - uint8_t scale = get_integer_(); + uint8_t scale = this->get_integer_(); if (scale == 0) { scale = this->default_octave_; } @@ -345,7 +233,8 @@ void Rtttl::loop() { this->output_->set_level(0.0); } } -#endif +#endif // USE_OUTPUT + #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { this->samples_sent_ = 0; @@ -370,20 +259,152 @@ void Rtttl::loop() { } // Convert from frequency in Hz to high and low samples in fixed point } -#endif +#endif // USE_SPEAKER this->last_note_ = millis(); } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE -// RTTTL state strings indexed by State enum (0-4): STOPPED, INIT, STARTING, RUNNING, STOPPING, plus UNKNOWN fallback -PROGMEM_STRING_TABLE(RtttlStateStrings, "STATE_STOPPED", "STATE_INIT", "STATE_STARTING", "STATE_RUNNING", - "STATE_STOPPING", "UNKNOWN"); +void Rtttl::play(std::string rtttl) { + if (this->state_ != State::STOPPED && this->state_ != State::STOPPING) { + size_t pos = this->rtttl_.find(':'); + size_t len = (pos != std::string::npos) ? pos : this->rtttl_.length(); + ESP_LOGW(TAG, "Already playing: %.*s", (int) len, this->rtttl_.c_str()); + return; + } -static const LogString *state_to_string(State state) { - return RtttlStateStrings::get_log_str(static_cast(state), RtttlStateStrings::LAST_INDEX); + this->rtttl_ = std::move(rtttl); + + this->default_duration_ = 4; + this->default_octave_ = 6; + this->note_duration_ = 0; + + int bpm = 63; + uint8_t num; + + // Get name + this->position_ = this->rtttl_.find(':'); + + // it's somewhat documented to be up to 10 characters but let's be a bit flexible here + if (this->position_ == std::string::npos || this->position_ > 15) { + ESP_LOGE(TAG, "Unable to determine name; missing ':'"); + return; + } + + ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); + + // get default duration + this->position_ = this->rtttl_.find("d=", this->position_); + if (this->position_ == std::string::npos) { + ESP_LOGE(TAG, "Missing 'd='"); + return; + } + this->position_ += 2; + num = this->get_integer_(); + if (num > 0) { + this->default_duration_ = num; + } + + // get default octave + this->position_ = this->rtttl_.find("o=", this->position_); + if (this->position_ == std::string::npos) { + ESP_LOGE(TAG, "Missing 'o="); + return; + } + this->position_ += 2; + num = this->get_integer_(); + if (num >= 3 && num <= 7) { + this->default_octave_ = num; + } + + // get BPM + this->position_ = this->rtttl_.find("b=", this->position_); + if (this->position_ == std::string::npos) { + ESP_LOGE(TAG, "Missing b="); + return; + } + this->position_ += 2; + num = this->get_integer_(); + if (num != 0) { + bpm = num; + } + + this->position_ = this->rtttl_.find(':', this->position_); + if (this->position_ == std::string::npos) { + ESP_LOGE(TAG, "Missing second ':'"); + return; + } + this->position_++; + + // BPM usually expresses the number of quarter notes per minute + this->wholenote_ = 60 * 1000L * 4 / bpm; // this is the time for whole note (in milliseconds) + + this->output_freq_ = 0; + this->last_note_ = millis(); + this->note_duration_ = 1; + +#ifdef USE_OUTPUT + if (this->output_ != nullptr) { + this->set_state_(State::RUNNING); + } +#endif // USE_OUTPUT + +#ifdef USE_SPEAKER + if (this->speaker_ != nullptr) { + this->set_state_(State::INIT); + this->samples_sent_ = 0; + this->samples_count_ = 0; + } +#endif // USE_SPEAKER +} + +void Rtttl::stop() { +#ifdef USE_OUTPUT + if (this->output_ != nullptr) { + this->output_->set_level(0.0); + this->set_state_(State::STOPPED); + } +#endif // USE_OUTPUT + +#ifdef USE_SPEAKER + if (this->speaker_ != nullptr) { + if (this->speaker_->is_running()) { + this->speaker_->stop(); + } + this->set_state_(State::STOPPING); + } +#endif // USE_SPEAKER + + this->position_ = this->rtttl_.length(); + this->note_duration_ = 0; +} + +void Rtttl::finish_() { + ESP_LOGV(TAG, "Rtttl::finish_()"); + +#ifdef USE_OUTPUT + if (this->output_ != nullptr) { + this->output_->set_level(0.0); + this->set_state_(State::STOPPED); + } +#endif // USE_OUTPUT + +#ifdef USE_SPEAKER + if (this->speaker_ != nullptr) { + SpeakerSample sample[2]; + sample[0].left = 0; + sample[0].right = 0; + sample[1].left = 0; + sample[1].right = 0; + this->speaker_->play((uint8_t *) (&sample), 8); + this->speaker_->finish(); + this->set_state_(State::STOPPING); + } +#endif // USE_SPEAKER + + // Ensure no more notes are played in case finish_() is called for an error. + this->position_ = this->rtttl_.length(); + this->note_duration_ = 0; } -#endif void Rtttl::set_state_(State state) { State old_state = this->state_; @@ -391,15 +412,14 @@ void Rtttl::set_state_(State state) { ESP_LOGV(TAG, "State changed from %s to %s", LOG_STR_ARG(state_to_string(old_state)), LOG_STR_ARG(state_to_string(state))); - // Clear loop_done when transitioning from STOPPED to any other state - if (state == State::STATE_STOPPED) { + // Clear loop_done when transitioning from `State::STOPPED` to any other state + if (state == State::STOPPED) { this->disable_loop(); this->on_finished_playback_callback_.call(); ESP_LOGD(TAG, "Playback finished"); - } else if (old_state == State::STATE_STOPPED) { + } else if (old_state == State::STOPPED) { this->enable_loop(); } } -} // namespace rtttl -} // namespace esphome +} // namespace esphome::rtttl diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 1e924a897c..6f5df07766 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -5,48 +5,41 @@ #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" -#endif +#endif // USE_OUTPUT #ifdef USE_SPEAKER #include "esphome/components/speaker/speaker.h" -#endif +#endif // USE_SPEAKER -namespace esphome { -namespace rtttl { +namespace esphome::rtttl { -enum State : uint8_t { - STATE_STOPPED = 0, - STATE_INIT, - STATE_STARTING, - STATE_RUNNING, - STATE_STOPPING, +enum class State : uint8_t { + STOPPED = 0, + INIT, + STARTING, + RUNNING, + STOPPING, }; -#ifdef USE_SPEAKER -static const size_t SAMPLE_BUFFER_SIZE = 2048; - -struct SpeakerSample { - int8_t left{0}; - int8_t right{0}; -}; -#endif - class Rtttl : public Component { public: #ifdef USE_OUTPUT void set_output(output::FloatOutput *output) { this->output_ = output; } -#endif +#endif // USE_OUTPUT + #ifdef USE_SPEAKER void set_speaker(speaker::Speaker *speaker) { this->speaker_ = speaker; } -#endif - float get_gain() { return gain_; } - void set_gain(float gain) { this->gain_ = clamp(gain, 0.0f, 1.0f); } +#endif // USE_SPEAKER + + void dump_config() override; + void loop() override; void play(std::string rtttl); void stop(); - void dump_config() override; - bool is_playing() { return this->state_ != State::STATE_STOPPED; } - void loop() override; + float get_gain() { return this->gain_; } + void set_gain(float gain) { this->gain_ = clamp(gain, 0.0f, 1.0f); } + + bool is_playing() { return this->state_ != State::STOPPED; } void add_on_finished_playback_callback(std::function callback) { this->on_finished_playback_callback_.add(std::move(callback)); @@ -90,12 +83,12 @@ class Rtttl : public Component { /// The gain of the output. float gain_{0.6f}; /// The current state of the RTTTL player. - State state_{State::STATE_STOPPED}; + State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. output::FloatOutput *output_; -#endif +#endif // USE_OUTPUT #ifdef USE_SPEAKER /// The speaker to write the sound to. @@ -110,8 +103,7 @@ class Rtttl : public Component { int samples_count_{0}; /// The number of samples for the gap between notes. int samples_gap_{0}; - -#endif +#endif // USE_SPEAKER /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; @@ -145,5 +137,4 @@ class FinishedPlaybackTrigger : public Trigger<> { } }; -} // namespace rtttl -} // namespace esphome +} // namespace esphome::rtttl diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 0daabc0282..7b5435185d 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -204,36 +204,40 @@ void Application::loop() { this->last_loop_ = last_op_end_time; if (this->dump_config_at_ < this->components_.size()) { - if (this->dump_config_at_ == 0) { - char build_time_str[Application::BUILD_TIME_STR_SIZE]; - this->get_build_time_string(build_time_str); - ESP_LOGI(TAG, "ESPHome version " ESPHOME_VERSION " compiled on %s", build_time_str); + this->process_dump_config_(); + } +} + +void Application::process_dump_config_() { + if (this->dump_config_at_ == 0) { + char build_time_str[Application::BUILD_TIME_STR_SIZE]; + this->get_build_time_string(build_time_str); + ESP_LOGI(TAG, "ESPHome version " ESPHOME_VERSION " compiled on %s", build_time_str); #ifdef ESPHOME_PROJECT_NAME - ESP_LOGI(TAG, "Project " ESPHOME_PROJECT_NAME " version " ESPHOME_PROJECT_VERSION); + ESP_LOGI(TAG, "Project " ESPHOME_PROJECT_NAME " version " ESPHOME_PROJECT_VERSION); #endif #ifdef USE_ESP32 - esp_chip_info_t chip_info; - 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); + esp_chip_info_t chip_info; + 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) - // Suggest optimization for chips that don't need the PSRAM cache workaround - if (chip_info.revision >= 300) { + // Suggest optimization for chips that don't need the PSRAM cache workaround + if (chip_info.revision >= 300) { #ifdef USE_PSRAM - ESP_LOGW(TAG, "Set minimum_chip_revision: \"%d.%d\" to save ~10KB IRAM", chip_info.revision / 100, - chip_info.revision % 100); + ESP_LOGW(TAG, "Set minimum_chip_revision: \"%d.%d\" to save ~10KB IRAM", chip_info.revision / 100, + chip_info.revision % 100); #else - ESP_LOGW(TAG, "Set minimum_chip_revision: \"%d.%d\" to reduce binary size", chip_info.revision / 100, - chip_info.revision % 100); -#endif - } -#endif + ESP_LOGW(TAG, "Set minimum_chip_revision: \"%d.%d\" to reduce binary size", chip_info.revision / 100, + chip_info.revision % 100); #endif } - - this->components_[this->dump_config_at_]->call_dump_config(); - this->dump_config_at_++; +#endif +#endif } + + this->components_[this->dump_config_at_]->call_dump_config(); + this->dump_config_at_++; } void IRAM_ATTR HOT Application::feed_wdt(uint32_t time) { diff --git a/esphome/core/application.h b/esphome/core/application.h index 592bf809f1..8478100a56 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -519,6 +519,11 @@ class Application { void before_loop_tasks_(uint32_t loop_start_time); void after_loop_tasks_(); + /// Process dump_config output one component per loop iteration. + /// Extracted from loop() to keep cold startup/reconnect logging out of the hot path. + /// Caller must ensure dump_config_at_ < components_.size(). + void __attribute__((noinline)) process_dump_config_(); + void feed_wdt_arch_(); /// Perform a delay while also monitoring socket file descriptors for readiness diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index e13d81a8e4..6c03b74a17 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -26,6 +26,7 @@ class ComponentIterator { public: void begin(bool include_internal = false); void advance(); + bool completed() const { return this->state_ == IteratorState::NONE; } virtual bool on_begin(); #ifdef USE_BINARY_SENSOR virtual bool on_binary_sensor(binary_sensor::BinarySensor *binary_sensor) = 0; diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 97ac28b623..4194c3aa9e 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -107,6 +107,24 @@ static void validate_static_string(const char *name) { // iterating over them from the loop task is fine; but iterating from any other context requires the lock to be held to // avoid the main thread modifying the list while it is being accessed. +// Calculate random offset for interval timers +// Extracted from set_timer_common_ to reduce code size - float math + random_float() +// only needed for intervals, not timeouts +uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { + return static_cast(std::min(delay / 2, MAX_INTERVAL_DELAY) * random_float()); +} + +// Check if a retry was already cancelled in items_ or to_add_ +// Extracted from set_timer_common_ to reduce code size - retry path is cold and deprecated +// Remove before 2026.8.0 along with all retry code +bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, + uint32_t hash_or_id) { + return has_cancelled_timeout_in_container_locked_(this->items_, component, name_type, static_name, hash_or_id, + /* match_retry= */ true) || + has_cancelled_timeout_in_container_locked_(this->to_add_, component, name_type, static_name, hash_or_id, + /* match_retry= */ true); +} + // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, @@ -130,84 +148,66 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item auto item = this->get_item_from_pool_locked_(); item->component = component; - switch (name_type) { - case NameType::STATIC_STRING: - item->set_static_name(static_name); - break; - case NameType::HASHED_STRING: - item->set_hashed_name(hash_or_id); - break; - case NameType::NUMERIC_ID: - item->set_numeric_id(hash_or_id); - break; - case NameType::NUMERIC_ID_INTERNAL: - item->set_internal_id(hash_or_id); - break; - } + item->set_name(name_type, static_name, hash_or_id); item->type = type; item->callback = std::move(func); // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use this->set_item_removed_(item.get(), false); item->is_retry = is_retry; + // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. + // Using a pointer lets both paths share the cancel + push_back epilogue. + auto *target = &this->to_add_; + #ifndef ESPHOME_THREAD_SINGLE // Special handling for defer() (delay = 0, type = TIMEOUT) // Single-core platforms don't need thread-safe defer handling if (delay == 0 && type == SchedulerItem::TIMEOUT) { // Put in defer queue for guaranteed FIFO execution - if (!skip_cancel) { - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); - } - this->defer_queue_.push_back(std::move(item)); - return; - } + target = &this->defer_queue_; + } else #endif /* not ESPHOME_THREAD_SINGLE */ - - // Type-specific setup - if (type == SchedulerItem::INTERVAL) { - item->interval = delay; - // first execution happens immediately after a random smallish offset - // Calculate random offset (0 to min(interval/2, 5s)) - uint32_t offset = (uint32_t) (std::min(delay / 2, MAX_INTERVAL_DELAY) * random_float()); - item->set_next_execution(now + offset); + { + // Type-specific setup + if (type == SchedulerItem::INTERVAL) { + item->interval = delay; + // first execution happens immediately after a random smallish offset + uint32_t offset = this->calculate_interval_offset_(delay); + item->set_next_execution(now + offset); #ifdef ESPHOME_LOG_HAS_VERBOSE - SchedulerNameLog name_log; - ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", - name_log.format(name_type, static_name, hash_or_id), delay, offset); + SchedulerNameLog name_log; + ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", + name_log.format(name_type, static_name, hash_or_id), delay, offset); #endif - } else { - item->interval = 0; - item->set_next_execution(now + delay); - } + } else { + item->interval = 0; + item->set_next_execution(now + delay); + } #ifdef ESPHOME_DEBUG_SCHEDULER - this->debug_log_timer_(item.get(), name_type, static_name, hash_or_id, type, delay, now); + this->debug_log_timer_(item.get(), name_type, static_name, hash_or_id, type, delay, now); #endif /* ESPHOME_DEBUG_SCHEDULER */ - // For retries, check if there's a cancelled timeout first - // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name - if (is_retry && (name_type != NameType::STATIC_STRING || static_name != nullptr) && type == SchedulerItem::TIMEOUT && - (has_cancelled_timeout_in_container_locked_(this->items_, component, name_type, static_name, hash_or_id, - /* match_retry= */ true) || - has_cancelled_timeout_in_container_locked_(this->to_add_, component, name_type, static_name, hash_or_id, - /* match_retry= */ true))) { - // Skip scheduling - the retry was cancelled + // For retries, check if there's a cancelled timeout first + // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name + if (is_retry && (name_type != NameType::STATIC_STRING || static_name != nullptr) && + type == SchedulerItem::TIMEOUT && + this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) { + // Skip scheduling - the retry was cancelled #ifdef ESPHOME_DEBUG_SCHEDULER - SchedulerNameLog skip_name_log; - ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", - skip_name_log.format(name_type, static_name, hash_or_id)); + SchedulerNameLog skip_name_log; + ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", + skip_name_log.format(name_type, static_name, hash_or_id)); #endif - return; + return; + } } - // If name is provided, do atomic cancel-and-add (unless skip_cancel is true) - // Cancel existing items + // Common epilogue: atomic cancel-and-add (unless skip_cancel is true) if (!skip_cancel) { this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); } - // Add new item directly to to_add_ - // since we have the lock held - this->to_add_.push_back(std::move(item)); + target->push_back(std::move(item)); } void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, std::function func) { diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ede729d164..394178a831 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -219,28 +219,15 @@ class Scheduler { // Helper to get the name type NameType get_name_type() const { return name_type_; } - // Helper to set a static string name (no allocation) - void set_static_name(const char *name) { - name_.static_name = name; - name_type_ = NameType::STATIC_STRING; - } - - // Helper to set a hashed string name (hash computed from std::string) - void set_hashed_name(uint32_t hash) { - name_.hash_or_id = hash; - name_type_ = NameType::HASHED_STRING; - } - - // Helper to set a numeric ID name - void set_numeric_id(uint32_t id) { - name_.hash_or_id = id; - name_type_ = NameType::NUMERIC_ID; - } - - // Helper to set an internal numeric ID (separate namespace from NUMERIC_ID) - void set_internal_id(uint32_t id) { - name_.hash_or_id = id; - name_type_ = NameType::NUMERIC_ID_INTERNAL; + // Set name storage: for STATIC_STRING stores the pointer, for all other types stores hash_or_id. + // Both union members occupy the same offset, so only one store is needed. + void set_name(NameType type, const char *static_name, uint32_t hash_or_id) { + if (type == NameType::STATIC_STRING) { + name_.static_name = static_name; + } else { + name_.hash_or_id = hash_or_id; + } + name_type_ = type; } static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); @@ -355,6 +342,17 @@ class Scheduler { // Helper to perform full cleanup when too many items are cancelled void full_cleanup_removed_items_(); + // Helper to calculate random offset for interval timers - extracted to reduce code size of set_timer_common_ + // IMPORTANT: Must not be inlined - called only for intervals, keeping it out of the hot path saves flash. + uint32_t __attribute__((noinline)) calculate_interval_offset_(uint32_t delay); + + // Helper to check if a retry was already cancelled - extracted to reduce code size of set_timer_common_ + // Remove before 2026.8.0 along with all retry code. + // IMPORTANT: Must not be inlined - retry path is cold and deprecated. + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + bool __attribute__((noinline)) + is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); + #ifdef ESPHOME_DEBUG_SCHEDULER // Helper for debug logging in set_timer_common_ - extracted to reduce code size void debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name, uint32_t hash_or_id, diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index e66f9a2c97..d42f89d029 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -133,6 +133,8 @@ def run_platformio_cli(*args, **kwargs) -> str | int: ) # Suppress Python syntax warnings from third-party scripts during compilation os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") + # Increase uv retry count to handle transient network errors (default is 3) + os.environ.setdefault("UV_HTTP_RETRIES", "10") cmd = ["platformio"] + list(args) if not CORE.verbose: diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index d67787b3d5..bc054f5aee 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -6,8 +6,8 @@ esp32: type: esp-idf components: - espressif/mdns^1.8.2 - - name: espressif/esp_hosted - ref: 2.7.0 + - name: espressif/button + ref: 4.1.5 advanced: enable_idf_experimental_features: yes disable_debug_stubs: true