From 78c8447d1e2a367afa45080679909b704bbe1a11 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:47:42 -0400 Subject: [PATCH 01/22] [esp32_hall] Remove esp32_hall (#9117) --- esphome/components/esp32_hall/__init__.py | 0 esphome/components/esp32_hall/esp32_hall.cpp | 25 ------------------- esphome/components/esp32_hall/esp32_hall.h | 23 ----------------- esphome/components/esp32_hall/sensor.py | 24 ------------------ .../components/esp32_hall/test.esp32-ard.yaml | 3 --- 5 files changed, 75 deletions(-) delete mode 100644 esphome/components/esp32_hall/__init__.py delete mode 100644 esphome/components/esp32_hall/esp32_hall.cpp delete mode 100644 esphome/components/esp32_hall/esp32_hall.h delete mode 100644 esphome/components/esp32_hall/sensor.py delete mode 100644 tests/components/esp32_hall/test.esp32-ard.yaml diff --git a/esphome/components/esp32_hall/__init__.py b/esphome/components/esp32_hall/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/esphome/components/esp32_hall/esp32_hall.cpp b/esphome/components/esp32_hall/esp32_hall.cpp deleted file mode 100644 index 762497aedc..0000000000 --- a/esphome/components/esp32_hall/esp32_hall.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#ifdef USE_ESP32 -#include "esp32_hall.h" -#include "esphome/core/log.h" -#include "esphome/core/hal.h" -#include - -namespace esphome { -namespace esp32_hall { - -static const char *const TAG = "esp32_hall"; - -void ESP32HallSensor::update() { - adc1_config_width(ADC_WIDTH_BIT_12); - int value_int = hall_sensor_read(); - float value = (value_int / 4095.0f) * 10000.0f; - ESP_LOGD(TAG, "'%s': Got reading %.0f µT", this->name_.c_str(), value); - this->publish_state(value); -} -std::string ESP32HallSensor::unique_id() { return get_mac_address() + "-hall"; } -void ESP32HallSensor::dump_config() { LOG_SENSOR("", "ESP32 Hall Sensor", this); } - -} // namespace esp32_hall -} // namespace esphome - -#endif diff --git a/esphome/components/esp32_hall/esp32_hall.h b/esphome/components/esp32_hall/esp32_hall.h deleted file mode 100644 index 8db50c4667..0000000000 --- a/esphome/components/esp32_hall/esp32_hall.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" - -#ifdef USE_ESP32 - -namespace esphome { -namespace esp32_hall { - -class ESP32HallSensor : public sensor::Sensor, public PollingComponent { - public: - void dump_config() override; - - void update() override; - - std::string unique_id() override; -}; - -} // namespace esp32_hall -} // namespace esphome - -#endif diff --git a/esphome/components/esp32_hall/sensor.py b/esphome/components/esp32_hall/sensor.py deleted file mode 100644 index e7953d4b3d..0000000000 --- a/esphome/components/esp32_hall/sensor.py +++ /dev/null @@ -1,24 +0,0 @@ -import esphome.codegen as cg -from esphome.components import sensor -import esphome.config_validation as cv -from esphome.const import ICON_MAGNET, STATE_CLASS_MEASUREMENT, UNIT_MICROTESLA - -DEPENDENCIES = ["esp32"] - -esp32_hall_ns = cg.esphome_ns.namespace("esp32_hall") -ESP32HallSensor = esp32_hall_ns.class_( - "ESP32HallSensor", sensor.Sensor, cg.PollingComponent -) - -CONFIG_SCHEMA = sensor.sensor_schema( - ESP32HallSensor, - unit_of_measurement=UNIT_MICROTESLA, - icon=ICON_MAGNET, - accuracy_decimals=1, - state_class=STATE_CLASS_MEASUREMENT, -).extend(cv.polling_component_schema("60s")) - - -async def to_code(config): - var = await sensor.new_sensor(config) - await cg.register_component(var, config) diff --git a/tests/components/esp32_hall/test.esp32-ard.yaml b/tests/components/esp32_hall/test.esp32-ard.yaml deleted file mode 100644 index f8429f5aa0..0000000000 --- a/tests/components/esp32_hall/test.esp32-ard.yaml +++ /dev/null @@ -1,3 +0,0 @@ -sensor: - - platform: esp32_hall - name: ESP32 Hall Sensor From bf161f1eaa84060e29b3a275712e7599b33ebe77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Jun 2025 20:04:45 +0200 Subject: [PATCH 02/22] Resolve esphome::optional vs std::optional ambiguity in code generation (#9119) --- esphome/cpp_types.py | 4 +++- tests/component_tests/text/test_text.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index dab993f87f..a0dd62cb4e 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -29,7 +29,9 @@ Component = esphome_ns.class_("Component") ComponentPtr = Component.operator("ptr") PollingComponent = esphome_ns.class_("PollingComponent", Component) Application = esphome_ns.class_("Application") -optional = esphome_ns.class_("optional") +# Create optional with explicit namespace to avoid ambiguity with std::optional +# The generated code will use esphome::optional instead of just optional +optional = global_ns.namespace("esphome").class_("optional") arduino_json_ns = global_ns.namespace("ArduinoJson") JsonObject = arduino_json_ns.class_("JsonObject") JsonObjectConst = arduino_json_ns.class_("JsonObjectConst") diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 51fcb3d382..75f1c4b88b 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -66,5 +66,5 @@ def test_text_config_lamda_is_set(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert "it_4->set_template([=]() -> optional {" in main_cpp + assert "it_4->set_template([=]() -> esphome::optional {" in main_cpp assert 'return std::string{"Hello"};' in main_cpp From cb8b0ec62e7fcfff078dfdef90d46a6007e21bdf Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 17 Jun 2025 13:05:06 -0500 Subject: [PATCH 03/22] Add intent progress event to voice assistant enum (#9103) --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 1 + 3 files changed, 4 insertions(+) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 843b72795a..b23652a982 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1643,6 +1643,7 @@ enum VoiceAssistantEvent { VOICE_ASSISTANT_STT_VAD_END = 12; VOICE_ASSISTANT_TTS_STREAM_START = 98; VOICE_ASSISTANT_TTS_STREAM_END = 99; + VOICE_ASSISTANT_INTENT_PROGRESS = 100; } message VoiceAssistantEventData { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 415409f880..bde1824d71 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -516,6 +516,8 @@ template<> const char *proto_enum_to_string(enums::V return "VOICE_ASSISTANT_TTS_STREAM_START"; case enums::VOICE_ASSISTANT_TTS_STREAM_END: return "VOICE_ASSISTANT_TTS_STREAM_END"; + case enums::VOICE_ASSISTANT_INTENT_PROGRESS: + return "VOICE_ASSISTANT_INTENT_PROGRESS"; default: return "UNKNOWN"; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 14a1f3f353..9d270bcdc1 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -208,6 +208,7 @@ enum VoiceAssistantEvent : uint32_t { VOICE_ASSISTANT_STT_VAD_END = 12, VOICE_ASSISTANT_TTS_STREAM_START = 98, VOICE_ASSISTANT_TTS_STREAM_END = 99, + VOICE_ASSISTANT_INTENT_PROGRESS = 100, }; enum VoiceAssistantTimerEvent : uint32_t { VOICE_ASSISTANT_TIMER_STARTED = 0, From 7ed095e635ef54d69af35bc6a4f5894e366ead11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Jun 2025 20:07:45 +0200 Subject: [PATCH 04/22] Optimize LightState memory layout (#9113) --- esphome/components/light/light_state.h | 30 +++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index acba986f24..b93823feac 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -17,7 +17,7 @@ namespace light { class LightOutput; -enum LightRestoreMode { +enum LightRestoreMode : uint8_t { LIGHT_RESTORE_DEFAULT_OFF, LIGHT_RESTORE_DEFAULT_ON, LIGHT_ALWAYS_OFF, @@ -212,12 +212,18 @@ class LightState : public EntityBase, public Component { /// Store the output to allow effects to have more access. LightOutput *output_; - /// Value for storing the index of the currently active effect. 0 if no effect is active - uint32_t active_effect_index_{}; /// The currently active transformer for this light (transition/flash). std::unique_ptr transformer_{nullptr}; - /// Whether the light value should be written in the next cycle. - bool next_write_{true}; + /// List of effects for this light. + std::vector effects_; + /// Value for storing the index of the currently active effect. 0 if no effect is active + uint32_t active_effect_index_{}; + /// 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_{}; + /// Gamma correction factor for the light. + float gamma_correct_{}; /// Object used to store the persisted values of the light. ESPPreferenceObject rtc_; @@ -236,19 +242,13 @@ class LightState : public EntityBase, public Component { */ CallbackManager target_state_reached_callback_{}; - /// 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_{}; - /// Gamma correction factor for the light. - float gamma_correct_{}; - /// Restore mode of the light. - LightRestoreMode restore_mode_; /// Initial state of the light. optional initial_state_{}; - /// List of effects for this light. - std::vector effects_; + /// Restore mode of the light. + LightRestoreMode restore_mode_; + /// Whether the light value should be written in the next cycle. + bool next_write_{true}; // for effects, true if a transformer (transition) is active. bool is_transformer_active_ = false; }; From 47e7988c8e73254802d22257d71d174d8721d6c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Jun 2025 20:14:03 +0200 Subject: [PATCH 05/22] Reduce Switch component memory usage by 8 bytes per instance (#9112) --- esphome/components/switch/switch.h | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index e8018ed36f..b999296564 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -21,7 +21,7 @@ const int RESTORE_MODE_PERSISTENT_MASK = 0x02; const int RESTORE_MODE_INVERTED_MASK = 0x04; const int RESTORE_MODE_DISABLED_MASK = 0x08; -enum SwitchRestoreMode { +enum SwitchRestoreMode : uint8_t { SWITCH_ALWAYS_OFF = !RESTORE_MODE_ON_MASK, SWITCH_ALWAYS_ON = RESTORE_MODE_ON_MASK, SWITCH_RESTORE_DEFAULT_OFF = RESTORE_MODE_PERSISTENT_MASK, @@ -49,12 +49,12 @@ class Switch : public EntityBase, public EntityBase_DeviceClass { */ void publish_state(bool state); - /// The current reported state of the binary sensor. - bool state; - /// Indicates whether or not state is to be retrieved from flash and how SwitchRestoreMode restore_mode{SWITCH_RESTORE_DEFAULT_OFF}; + /// The current reported state of the binary sensor. + bool state; + /** Turn this switch on. This is called by the front-end. * * For implementing switches, please override write_state. @@ -123,10 +123,16 @@ class Switch : public EntityBase, public EntityBase_DeviceClass { */ virtual void write_state(bool state) = 0; - CallbackManager state_callback_{}; - bool inverted_{false}; - Deduplicator publish_dedup_; + // Pointer first (4 bytes) ESPPreferenceObject rtc_; + + // CallbackManager (12 bytes on 32-bit - contains vector) + CallbackManager state_callback_{}; + + // Small types grouped together + Deduplicator publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_) + bool inverted_{false}; // 1 byte + // Total: 3 bytes, 1 byte padding }; #define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj)) From 43ab63455b2cf70fede3f60114fe574da9f05a4f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Jun 2025 16:42:36 -0400 Subject: [PATCH 06/22] Pin libretiny to 1.9.1 (#9118) --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 77938424f9..a3c990066a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -200,7 +200,7 @@ build_unflags = ; This are common settings for the LibreTiny (all variants) using Arduino. [common:libretiny-arduino] extends = common:arduino -platform = libretiny +platform = libretiny@1.9.1 framework = arduino lib_deps = droscy/esp_wireguard@0.4.2 ; wireguard From 0bf613bd34a52df5f6002477dc3f7ddedcb2f0f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 00:08:22 +0200 Subject: [PATCH 07/22] Bump ruff from 0.11.13 to 0.12.0 (#9120) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 2c2549c64b..8b42b9347c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.7 flake8==7.2.0 # also change in .pre-commit-config.yaml when updating -ruff==0.11.13 # also change in .pre-commit-config.yaml when updating +ruff==0.12.0 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From 7cc0008837f557afa3ad337f797c8f0954b2af07 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 16 Jun 2025 02:36:49 -0500 Subject: [PATCH 08/22] [i2s_audio] Add ``dump_config`` methods, shorten log messages (#9099) --- esphome/components/i2s_audio/i2s_audio.cpp | 2 +- .../microphone/i2s_audio_microphone.cpp | 51 +++++++++++-------- .../microphone/i2s_audio_microphone.h | 1 + .../i2s_audio/speaker/i2s_audio_speaker.cpp | 38 ++++++++++---- .../i2s_audio/speaker/i2s_audio_speaker.h | 1 + 5 files changed, 61 insertions(+), 32 deletions(-) diff --git a/esphome/components/i2s_audio/i2s_audio.cpp b/esphome/components/i2s_audio/i2s_audio.cpp index 2de3f1d9f8..0f2995b4bd 100644 --- a/esphome/components/i2s_audio/i2s_audio.cpp +++ b/esphome/components/i2s_audio/i2s_audio.cpp @@ -18,7 +18,7 @@ void I2SAudioComponent::setup() { static i2s_port_t next_port_num = I2S_NUM_0; if (next_port_num >= I2S_NUM_MAX) { - ESP_LOGE(TAG, "Too many I2S Audio components"); + ESP_LOGE(TAG, "Too many components"); this->mark_failed(); return; } diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 52d0ae34fb..2cd004ffaa 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -45,7 +45,7 @@ void I2SAudioMicrophone::setup() { #if SOC_I2S_SUPPORTS_ADC if (this->adc_) { if (this->parent_->get_port() != I2S_NUM_0) { - ESP_LOGE(TAG, "Internal ADC only works on I2S0!"); + ESP_LOGE(TAG, "Internal ADC only works on I2S0"); this->mark_failed(); return; } @@ -55,7 +55,7 @@ void I2SAudioMicrophone::setup() { { if (this->pdm_) { if (this->parent_->get_port() != I2S_NUM_0) { - ESP_LOGE(TAG, "PDM only works on I2S0!"); + ESP_LOGE(TAG, "PDM only works on I2S0"); this->mark_failed(); return; } @@ -64,14 +64,14 @@ void I2SAudioMicrophone::setup() { this->active_listeners_semaphore_ = xSemaphoreCreateCounting(MAX_LISTENERS, MAX_LISTENERS); if (this->active_listeners_semaphore_ == nullptr) { - ESP_LOGE(TAG, "Failed to create semaphore"); + ESP_LOGE(TAG, "Creating semaphore failed"); this->mark_failed(); return; } this->event_group_ = xEventGroupCreate(); if (this->event_group_ == nullptr) { - ESP_LOGE(TAG, "Failed to create event group"); + ESP_LOGE(TAG, "Creating event group failed"); this->mark_failed(); return; } @@ -79,6 +79,15 @@ void I2SAudioMicrophone::setup() { this->configure_stream_settings_(); } +void I2SAudioMicrophone::dump_config() { + ESP_LOGCONFIG(TAG, + "Microphone:\n" + " Pin: %d\n" + " PDM: %s\n" + " DC offset correction: %s", + static_cast(this->din_pin_), YESNO(this->pdm_), YESNO(this->correct_dc_offset_)); +} + void I2SAudioMicrophone::configure_stream_settings_() { uint8_t channel_count = 1; #ifdef USE_I2S_LEGACY @@ -151,7 +160,7 @@ bool I2SAudioMicrophone::start_driver_() { config.mode = (i2s_mode_t) (config.mode | I2S_MODE_ADC_BUILT_IN); err = i2s_driver_install(this->parent_->get_port(), &config, 0, nullptr); if (err != ESP_OK) { - ESP_LOGE(TAG, "Error installing I2S driver: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Error installing driver: %s", esp_err_to_name(err)); return false; } @@ -174,7 +183,7 @@ bool I2SAudioMicrophone::start_driver_() { err = i2s_driver_install(this->parent_->get_port(), &config, 0, nullptr); if (err != ESP_OK) { - ESP_LOGE(TAG, "Error installing I2S driver: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Error installing driver: %s", esp_err_to_name(err)); return false; } @@ -183,7 +192,7 @@ bool I2SAudioMicrophone::start_driver_() { err = i2s_set_pin(this->parent_->get_port(), &pin_config); if (err != ESP_OK) { - ESP_LOGE(TAG, "Error setting I2S pin: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Error setting pin: %s", esp_err_to_name(err)); return false; } } @@ -198,7 +207,7 @@ bool I2SAudioMicrophone::start_driver_() { /* Allocate a new RX channel and get the handle of this channel */ err = i2s_new_channel(&chan_cfg, NULL, &this->rx_handle_); if (err != ESP_OK) { - ESP_LOGE(TAG, "Error creating new I2S channel: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Error creating channel: %s", esp_err_to_name(err)); return false; } @@ -270,14 +279,14 @@ bool I2SAudioMicrophone::start_driver_() { err = i2s_channel_init_std_mode(this->rx_handle_, &std_cfg); } if (err != ESP_OK) { - ESP_LOGE(TAG, "Error initializing I2S channel: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Error initializing channel: %s", esp_err_to_name(err)); return false; } /* Before reading data, start the RX channel first */ i2s_channel_enable(this->rx_handle_); if (err != ESP_OK) { - ESP_LOGE(TAG, "Error enabling I2S Microphone: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Enabling failed: %s", esp_err_to_name(err)); return false; } #endif @@ -304,29 +313,29 @@ void I2SAudioMicrophone::stop_driver_() { if (this->adc_) { err = i2s_adc_disable(this->parent_->get_port()); if (err != ESP_OK) { - ESP_LOGW(TAG, "Error disabling ADC - it may not have started: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Error disabling ADC: %s", esp_err_to_name(err)); } } #endif err = i2s_stop(this->parent_->get_port()); if (err != ESP_OK) { - ESP_LOGW(TAG, "Error stopping I2S microphone - it may not have started: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Error stopping: %s", esp_err_to_name(err)); } err = i2s_driver_uninstall(this->parent_->get_port()); if (err != ESP_OK) { - ESP_LOGW(TAG, "Error uninstalling I2S driver - it may not have started: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Error uninstalling driver: %s", esp_err_to_name(err)); } #else if (this->rx_handle_ != nullptr) { /* Have to stop the channel before deleting it */ err = i2s_channel_disable(this->rx_handle_); if (err != ESP_OK) { - ESP_LOGW(TAG, "Error stopping I2S microphone - it may not have started: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Error stopping: %s", esp_err_to_name(err)); } /* If the handle is not needed any more, delete it to release the channel resources */ err = i2s_del_channel(this->rx_handle_); if (err != ESP_OK) { - ESP_LOGW(TAG, "Error deleting I2S channel - it may not have started: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Error deleting channel: %s", esp_err_to_name(err)); } this->rx_handle_ = nullptr; } @@ -403,7 +412,7 @@ size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_w // Ignore ESP_ERR_TIMEOUT if ticks_to_wait = 0, as it will read the data on the next call if (!this->status_has_warning()) { // Avoid spamming the logs with the error message if its repeated - ESP_LOGW(TAG, "Error reading from I2S microphone: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); } this->status_set_warning(); return 0; @@ -431,19 +440,19 @@ void I2SAudioMicrophone::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & MicrophoneEventGroupBits::TASK_STARTING) { - ESP_LOGD(TAG, "Task started, attempting to allocate buffer"); + ESP_LOGV(TAG, "Task started, attempting to allocate buffer"); xEventGroupClearBits(this->event_group_, MicrophoneEventGroupBits::TASK_STARTING); } if (event_group_bits & MicrophoneEventGroupBits::TASK_RUNNING) { - ESP_LOGD(TAG, "Task is running and reading data"); + ESP_LOGV(TAG, "Task is running and reading data"); xEventGroupClearBits(this->event_group_, MicrophoneEventGroupBits::TASK_RUNNING); this->state_ = microphone::STATE_RUNNING; } if ((event_group_bits & MicrophoneEventGroupBits::TASK_STOPPED)) { - ESP_LOGD(TAG, "Task finished, freeing resources and uninstalling I2S driver"); + ESP_LOGV(TAG, "Task finished, freeing resources and uninstalling driver"); vTaskDelete(this->task_handle_); this->task_handle_ = nullptr; @@ -473,7 +482,7 @@ void I2SAudioMicrophone::loop() { } if (!this->start_driver_()) { - this->status_momentary_error("I2S driver failed to start, unloading it and attempting again in 1 second", 1000); + this->status_momentary_error("Driver failed to start; retrying in 1 second", 1000); this->stop_driver_(); // Stop/frees whatever possibly started break; } @@ -483,7 +492,7 @@ void I2SAudioMicrophone::loop() { &this->task_handle_); if (this->task_handle_ == nullptr) { - this->status_momentary_error("Task failed to start, attempting again in 1 second", 1000); + this->status_momentary_error("Task failed to start, retrying in 1 second", 1000); this->stop_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } } diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index c35f88f8ee..4c384ba963 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -18,6 +18,7 @@ namespace i2s_audio { class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, public Component { public: void setup() override; + void dump_config() override; void start() override; void stop() override; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index f4c761ecc0..41da8a4642 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -110,29 +110,48 @@ void I2SAudioSpeaker::setup() { } } +void I2SAudioSpeaker::dump_config() { + ESP_LOGCONFIG(TAG, + "Speaker:\n" + " Pin: %d\n" + " Buffer duration: %" PRIu32, + static_cast(this->dout_pin_), this->buffer_duration_ms_); + if (this->timeout_.has_value()) { + ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); + } +#ifdef USE_I2S_LEGACY +#if SOC_I2S_SUPPORTS_DAC + ESP_LOGCONFIG(TAG, " Internal DAC mode: %d", static_cast(this->internal_dac_mode_)); +#endif + ESP_LOGCONFIG(TAG, " Communication format: %d", static_cast(this->i2s_comm_fmt_)); +#else + ESP_LOGCONFIG(TAG, " Communication format: %s", this->i2s_comm_fmt_.c_str()); +#endif +} + void I2SAudioSpeaker::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::STATE_STARTING) { - ESP_LOGD(TAG, "Starting Speaker"); + ESP_LOGD(TAG, "Starting"); this->state_ = speaker::STATE_STARTING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::STATE_STARTING); } if (event_group_bits & SpeakerEventGroupBits::STATE_RUNNING) { - ESP_LOGD(TAG, "Started Speaker"); + ESP_LOGD(TAG, "Started"); this->state_ = speaker::STATE_RUNNING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::STATE_RUNNING); this->status_clear_warning(); this->status_clear_error(); } if (event_group_bits & SpeakerEventGroupBits::STATE_STOPPING) { - ESP_LOGD(TAG, "Stopping Speaker"); + ESP_LOGD(TAG, "Stopping"); this->state_ = speaker::STATE_STOPPING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::STATE_STOPPING); } if (event_group_bits & SpeakerEventGroupBits::STATE_STOPPED) { if (!this->task_created_) { - ESP_LOGD(TAG, "Stopped Speaker"); + ESP_LOGD(TAG, "Stopped"); this->state_ = speaker::STATE_STOPPED; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); this->speaker_task_handle_ = nullptr; @@ -140,20 +159,19 @@ void I2SAudioSpeaker::loop() { } if (event_group_bits & SpeakerEventGroupBits::ERR_TASK_FAILED_TO_START) { - this->status_set_error("Failed to start speaker task"); + this->status_set_error("Failed to start task"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_TASK_FAILED_TO_START); } if (event_group_bits & SpeakerEventGroupBits::ALL_ERR_ESP_BITS) { uint32_t error_bits = event_group_bits & SpeakerEventGroupBits::ALL_ERR_ESP_BITS; - ESP_LOGW(TAG, "Error writing to I2S: %s", esp_err_to_name(err_bit_to_esp_err(error_bits))); + ESP_LOGW(TAG, "Writing failed: %s", esp_err_to_name(err_bit_to_esp_err(error_bits))); this->status_set_warning(); } if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NOT_SUPPORTED) { - this->status_set_error("Failed to adjust I2S bus to match the incoming audio"); - ESP_LOGE(TAG, - "Incompatible audio format: sample rate = %" PRIu32 ", channels = %" PRIu8 ", bits per sample = %" PRIu8, + this->status_set_error("Failed to adjust bus to match incoming audio"); + ESP_LOGE(TAG, "Incompatible audio format: sample rate = %" PRIu32 ", channels = %u, bits per sample = %u", this->audio_stream_info_.get_sample_rate(), this->audio_stream_info_.get_channels(), this->audio_stream_info_.get_bits_per_sample()); } @@ -202,7 +220,7 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) { size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { if (this->is_failed()) { - ESP_LOGE(TAG, "Cannot play audio, speaker failed to setup"); + ESP_LOGE(TAG, "Setup failed; cannot play audio"); return 0; } if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index b5e4b94bc4..eb2a0ae756 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -24,6 +24,7 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } void setup() override; + void dump_config() override; void loop() override; void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } From 62abfbec9e8d97aed6eb8cd1c06afe286c06d3a5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 17 Jun 2025 01:59:07 +0100 Subject: [PATCH 09/22] [i2s_audio] Bugfix: crashes when unlocking i2s bus multiple times (#9100) --- .../i2s_audio/microphone/i2s_audio_microphone.cpp | 12 +++++++++--- .../i2s_audio/microphone/i2s_audio_microphone.h | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 2cd004ffaa..0477e0682d 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -136,6 +136,7 @@ bool I2SAudioMicrophone::start_driver_() { if (!this->parent_->try_lock()) { return false; // Waiting for another i2s to return lock } + this->locked_driver_ = true; esp_err_t err; #ifdef USE_I2S_LEGACY @@ -340,7 +341,10 @@ void I2SAudioMicrophone::stop_driver_() { this->rx_handle_ = nullptr; } #endif - this->parent_->unlock(); + if (this->locked_driver_) { + this->parent_->unlock(); + this->locked_driver_ = false; + } } void I2SAudioMicrophone::mic_task(void *params) { @@ -482,7 +486,8 @@ void I2SAudioMicrophone::loop() { } if (!this->start_driver_()) { - this->status_momentary_error("Driver failed to start; retrying in 1 second", 1000); + ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); + this->status_momentary_error("driver_fail", 1000); this->stop_driver_(); // Stop/frees whatever possibly started break; } @@ -492,7 +497,8 @@ void I2SAudioMicrophone::loop() { &this->task_handle_); if (this->task_handle_ == nullptr) { - this->status_momentary_error("Task failed to start, retrying in 1 second", 1000); + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task_fail", 1000); this->stop_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } } diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 4c384ba963..5f66f2e962 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -81,6 +81,7 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub bool pdm_{false}; bool correct_dc_offset_; + bool locked_driver_{false}; int32_t dc_offset_{0}; }; From fd83628c4985dec2d81f711826f3eb9c927152ef Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 17 Jun 2025 12:30:09 +1000 Subject: [PATCH 10/22] [spi] Cater for non-word-aligned buffers on esp8266 (#9108) --- esphome/components/spi/spi_arduino.cpp | 31 ++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index f7fe523a33..432f7cf2cd 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -3,7 +3,6 @@ namespace esphome { namespace spi { - #ifdef USE_ARDUINO static const char *const TAG = "spi-esp-arduino"; @@ -38,17 +37,31 @@ class SPIDelegateHw : public SPIDelegate { void write16(uint16_t data) override { this->channel_->transfer16(data); } -#ifdef USE_RP2040 void write_array(const uint8_t *ptr, size_t length) override { - // avoid overwriting the supplied buffer - uint8_t *rxbuf = new uint8_t[length]; // NOLINT(cppcoreguidelines-owning-memory) - memcpy(rxbuf, ptr, length); - this->channel_->transfer((void *) rxbuf, length); - delete[] rxbuf; // NOLINT(cppcoreguidelines-owning-memory) - } + if (length == 1) { + this->channel_->transfer(*ptr); + return; + } +#ifdef USE_RP2040 + // avoid overwriting the supplied buffer. Use vector for automatic deallocation + auto rxbuf = std::vector(length); + memcpy(rxbuf.data(), ptr, length); + this->channel_->transfer((void *) rxbuf.data(), 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); + } #else - void write_array(const uint8_t *ptr, size_t length) override { this->channel_->writeBytes(ptr, length); } + this->channel_->writeBytes(ptr, length); #endif + } void read_array(uint8_t *ptr, size_t length) override { this->channel_->transfer(ptr, length); } From 4bc9646e8f47a86cadf66294121c02b74611e655 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Jun 2025 20:07:45 +0200 Subject: [PATCH 11/22] Optimize LightState memory layout (#9113) --- esphome/components/light/light_state.h | 30 +++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index acba986f24..b93823feac 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -17,7 +17,7 @@ namespace light { class LightOutput; -enum LightRestoreMode { +enum LightRestoreMode : uint8_t { LIGHT_RESTORE_DEFAULT_OFF, LIGHT_RESTORE_DEFAULT_ON, LIGHT_ALWAYS_OFF, @@ -212,12 +212,18 @@ class LightState : public EntityBase, public Component { /// Store the output to allow effects to have more access. LightOutput *output_; - /// Value for storing the index of the currently active effect. 0 if no effect is active - uint32_t active_effect_index_{}; /// The currently active transformer for this light (transition/flash). std::unique_ptr transformer_{nullptr}; - /// Whether the light value should be written in the next cycle. - bool next_write_{true}; + /// List of effects for this light. + std::vector effects_; + /// Value for storing the index of the currently active effect. 0 if no effect is active + uint32_t active_effect_index_{}; + /// 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_{}; + /// Gamma correction factor for the light. + float gamma_correct_{}; /// Object used to store the persisted values of the light. ESPPreferenceObject rtc_; @@ -236,19 +242,13 @@ class LightState : public EntityBase, public Component { */ CallbackManager target_state_reached_callback_{}; - /// 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_{}; - /// Gamma correction factor for the light. - float gamma_correct_{}; - /// Restore mode of the light. - LightRestoreMode restore_mode_; /// Initial state of the light. optional initial_state_{}; - /// List of effects for this light. - std::vector effects_; + /// Restore mode of the light. + LightRestoreMode restore_mode_; + /// Whether the light value should be written in the next cycle. + bool next_write_{true}; // for effects, true if a transformer (transition) is active. bool is_transformer_active_ = false; }; From 89267b9e06515e66d2ca56c82595a8db82d5e945 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Jun 2025 20:14:03 +0200 Subject: [PATCH 12/22] Reduce Switch component memory usage by 8 bytes per instance (#9112) --- esphome/components/switch/switch.h | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index e8018ed36f..b999296564 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -21,7 +21,7 @@ const int RESTORE_MODE_PERSISTENT_MASK = 0x02; const int RESTORE_MODE_INVERTED_MASK = 0x04; const int RESTORE_MODE_DISABLED_MASK = 0x08; -enum SwitchRestoreMode { +enum SwitchRestoreMode : uint8_t { SWITCH_ALWAYS_OFF = !RESTORE_MODE_ON_MASK, SWITCH_ALWAYS_ON = RESTORE_MODE_ON_MASK, SWITCH_RESTORE_DEFAULT_OFF = RESTORE_MODE_PERSISTENT_MASK, @@ -49,12 +49,12 @@ class Switch : public EntityBase, public EntityBase_DeviceClass { */ void publish_state(bool state); - /// The current reported state of the binary sensor. - bool state; - /// Indicates whether or not state is to be retrieved from flash and how SwitchRestoreMode restore_mode{SWITCH_RESTORE_DEFAULT_OFF}; + /// The current reported state of the binary sensor. + bool state; + /** Turn this switch on. This is called by the front-end. * * For implementing switches, please override write_state. @@ -123,10 +123,16 @@ class Switch : public EntityBase, public EntityBase_DeviceClass { */ virtual void write_state(bool state) = 0; - CallbackManager state_callback_{}; - bool inverted_{false}; - Deduplicator publish_dedup_; + // Pointer first (4 bytes) ESPPreferenceObject rtc_; + + // CallbackManager (12 bytes on 32-bit - contains vector) + CallbackManager state_callback_{}; + + // Small types grouped together + Deduplicator publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_) + bool inverted_{false}; // 1 byte + // Total: 3 bytes, 1 byte padding }; #define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj)) From 5269523ca1f73accd53a2a642c685fa2e6a5f42f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:17:56 +1200 Subject: [PATCH 13/22] Bump version to 2025.6.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d362e8f1cf..ce36945821 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 = 2025.6.0b2 +PROJECT_NUMBER = 2025.6.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 889e040ad3..28dad16ef6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1,6 +1,6 @@ """Constants used by esphome.""" -__version__ = "2025.6.0b2" +__version__ = "2025.6.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 3bc5db4fd7673e64172c56a5fa11e62f0d8d8c25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Jun 2025 00:54:45 +0200 Subject: [PATCH 14/22] Bump ruff in pre-commit to 0.12.0 (#9121) --- .pre-commit-config.yaml | 2 +- esphome/components/bme680/sensor.py | 2 +- pyproject.toml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d55c00eea7..634c474571 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.10 + rev: v0.12.0 hooks: # Run the linter. - id: ruff diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index abdf6d3969..f41aefcec3 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -12,8 +12,8 @@ from esphome.const import ( CONF_OVERSAMPLING, CONF_PRESSURE, CONF_TEMPERATURE, - DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ATMOSPHERIC_PRESSURE, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, ICON_GAS_CYLINDER, STATE_CLASS_MEASUREMENT, diff --git a/pyproject.toml b/pyproject.toml index 3bec607150..97b0df9eff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,10 +120,12 @@ select = [ ignore = [ "E501", # line too long + "PLC0415", # `import` should be at the top-level of a file "PLR0911", # Too many return statements ({returns} > {max_returns}) "PLR0912", # Too many branches ({branches} > {max_branches}) "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLW1641", # Object does not implement `__hash__` method "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "UP038", # https://github.com/astral-sh/ruff/issues/7871 https://github.com/astral-sh/ruff/pull/16681 From 849c858495abb762eed8c20a94cb840d610901b5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:16:24 +1200 Subject: [PATCH 15/22] Bump version to 2025.6.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ce36945821..d1e950c548 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 = 2025.6.0b3 +PROJECT_NUMBER = 2025.6.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 28dad16ef6..95a5dbe218 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1,6 +1,6 @@ """Constants used by esphome.""" -__version__ = "2025.6.0b3" +__version__ = "2025.6.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From da5cf995491f03b889126365723e7b6d2b168fe6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 17 Jun 2025 13:05:06 -0500 Subject: [PATCH 16/22] Add intent progress event to voice assistant enum (#9103) --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 1 + 3 files changed, 4 insertions(+) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 843b72795a..b23652a982 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1643,6 +1643,7 @@ enum VoiceAssistantEvent { VOICE_ASSISTANT_STT_VAD_END = 12; VOICE_ASSISTANT_TTS_STREAM_START = 98; VOICE_ASSISTANT_TTS_STREAM_END = 99; + VOICE_ASSISTANT_INTENT_PROGRESS = 100; } message VoiceAssistantEventData { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 415409f880..bde1824d71 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -516,6 +516,8 @@ template<> const char *proto_enum_to_string(enums::V return "VOICE_ASSISTANT_TTS_STREAM_START"; case enums::VOICE_ASSISTANT_TTS_STREAM_END: return "VOICE_ASSISTANT_TTS_STREAM_END"; + case enums::VOICE_ASSISTANT_INTENT_PROGRESS: + return "VOICE_ASSISTANT_INTENT_PROGRESS"; default: return "UNKNOWN"; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 14a1f3f353..9d270bcdc1 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -208,6 +208,7 @@ enum VoiceAssistantEvent : uint32_t { VOICE_ASSISTANT_STT_VAD_END = 12, VOICE_ASSISTANT_TTS_STREAM_START = 98, VOICE_ASSISTANT_TTS_STREAM_END = 99, + VOICE_ASSISTANT_INTENT_PROGRESS = 100, }; enum VoiceAssistantTimerEvent : uint32_t { VOICE_ASSISTANT_TIMER_STARTED = 0, From 53496a1ecdd1a94926b956beed45fdc5b0374991 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Jun 2025 00:15:26 -0400 Subject: [PATCH 17/22] [heatpumpir] Bump HeatpumpIR to 1.0.35 (#9123) --- esphome/components/heatpumpir/climate.py | 2 +- platformio.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index c0eb8db4b3..9e5a2bf45c 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -125,6 +125,6 @@ async def to_code(config): cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_library("tonia/HeatpumpIR", "1.0.32") + cg.add_library("tonia/HeatpumpIR", "1.0.35") if CORE.is_libretiny: CORE.add_platformio_option("lib_ignore", "IRremoteESP8266") diff --git a/platformio.ini b/platformio.ini index a3c990066a..4aa947ddc7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -72,7 +72,7 @@ lib_deps = glmnet/Dsmr@0.7 ; dsmr rweather/Crypto@0.4.0 ; dsmr dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.32 ; heatpumpir + tonia/HeatpumpIR@1.0.35 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO From fd3c22945bef91723252002b23d239926e30e30a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Jun 2025 00:18:23 -0400 Subject: [PATCH 18/22] [i2s_audio] Bump esphome/ESP32-audioI2S to 2.3.0 (#9124) --- esphome/components/i2s_audio/media_player/__init__.py | 2 +- platformio.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/i2s_audio/media_player/__init__.py b/esphome/components/i2s_audio/media_player/__init__.py index f7ef134803..8797d13e7c 100644 --- a/esphome/components/i2s_audio/media_player/__init__.py +++ b/esphome/components/i2s_audio/media_player/__init__.py @@ -116,5 +116,5 @@ async def to_code(config): cg.add_library("WiFiClientSecure", None) cg.add_library("HTTPClient", None) - cg.add_library("esphome/ESP32-audioI2S", "2.2.0") + cg.add_library("esphome/ESP32-audioI2S", "2.3.0") cg.add_build_flag("-DAUDIO_NO_SD_FS") diff --git a/platformio.ini b/platformio.ini index 4aa947ddc7..58e0b77c07 100644 --- a/platformio.ini +++ b/platformio.ini @@ -135,7 +135,7 @@ lib_deps = HTTPClient ; http_request,nextion (Arduino built-in) ESPmDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) - esphome/ESP32-audioI2S@2.2.0 ; i2s_audio + esphome/ESP32-audioI2S@2.3.0 ; i2s_audio droscy/esp_wireguard@0.4.2 ; wireguard esphome/esp-audio-libs@1.1.4 ; audio From 2e534ce41ec8bd6da77746f0517302fb58201a8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Jun 2025 11:49:25 +0200 Subject: [PATCH 19/22] Reduce CPU overhead by allowing components to disable their loop() (#9089) --- esphome/components/anova/anova.cpp | 6 +- esphome/components/bedjet/bedjet_hub.cpp | 6 +- .../bedjet/climate/bedjet_climate.cpp | 6 +- .../ble_client/sensor/ble_rssi_sensor.cpp | 6 +- .../ble_client/sensor/ble_sensor.cpp | 6 +- .../text_sensor/ble_text_sensor.cpp | 6 +- .../captive_portal/captive_portal.cpp | 9 +- .../captive_portal/captive_portal.h | 5 +- .../esp32_ble_client/ble_client_base.cpp | 17 +- .../esp32_ble_client/ble_client_base.h | 2 + .../esp32_improv/esp32_improv_component.cpp | 3 + .../components/online_image/online_image.cpp | 4 + esphome/components/preferences/syncer.h | 2 + esphome/components/rtttl/rtttl.cpp | 9 +- esphome/components/safe_mode/safe_mode.cpp | 2 + esphome/components/sntp/sntp_component.cpp | 6 + esphome/components/tlc5971/tlc5971.cpp | 5 +- esphome/core/application.cpp | 69 +++++++- esphome/core/application.h | 28 ++++ esphome/core/component.cpp | 40 +++-- esphome/core/component.h | 21 +++ tests/integration/conftest.py | 26 ++- .../loop_test_component/__init__.py | 78 +++++++++ .../loop_test_component.cpp | 43 +++++ .../loop_test_component/loop_test_component.h | 58 +++++++ .../fixtures/loop_disable_enable.yaml | 48 ++++++ tests/integration/test_loop_disable_enable.py | 150 ++++++++++++++++++ tests/integration/types.py | 14 +- 28 files changed, 646 insertions(+), 29 deletions(-) create mode 100644 tests/integration/fixtures/external_components/loop_test_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/loop_test_component/loop_test_component.cpp create mode 100644 tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h create mode 100644 tests/integration/fixtures/loop_disable_enable.yaml create mode 100644 tests/integration/test_loop_disable_enable.py diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index ebf6c1d037..d0e8f6827f 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -17,7 +17,11 @@ void Anova::setup() { this->current_request_ = 0; } -void Anova::loop() {} +void Anova::loop() { + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE callbacks so loop isn't needed + this->disable_loop(); +} void Anova::control(const ClimateCall &call) { if (call.get_mode().has_value()) { diff --git a/esphome/components/bedjet/bedjet_hub.cpp b/esphome/components/bedjet/bedjet_hub.cpp index 7ebed2e78d..007ca1ca7d 100644 --- a/esphome/components/bedjet/bedjet_hub.cpp +++ b/esphome/components/bedjet/bedjet_hub.cpp @@ -480,7 +480,11 @@ void BedJetHub::set_clock(uint8_t hour, uint8_t minute) { /* Internal */ -void BedJetHub::loop() {} +void BedJetHub::loop() { + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE callbacks so loop isn't needed + this->disable_loop(); +} void BedJetHub::update() { this->dispatch_status_(); } void BedJetHub::dump_config() { diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp index 854129f816..f22d312b5a 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.cpp +++ b/esphome/components/bedjet/climate/bedjet_climate.cpp @@ -83,7 +83,11 @@ void BedJetClimate::reset_state_() { this->publish_state(); } -void BedJetClimate::loop() {} +void BedJetClimate::loop() { + // This component is controlled via the parent BedJetHub + // Empty loop not needed, disable to save CPU cycles + this->disable_loop(); +} void BedJetClimate::control(const ClimateCall &call) { ESP_LOGD(TAG, "Received BedJetClimate::control"); diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp index 81d244ce6d..663c52ac10 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp @@ -11,7 +11,11 @@ namespace ble_client { static const char *const TAG = "ble_rssi_sensor"; -void BLEClientRSSISensor::loop() {} +void BLEClientRSSISensor::loop() { + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE GAP callbacks so loop isn't needed + this->disable_loop(); +} void BLEClientRSSISensor::dump_config() { LOG_SENSOR("", "BLE Client RSSI Sensor", this); diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index f91b07fee2..d0ccfe1f2e 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -11,7 +11,11 @@ namespace ble_client { static const char *const TAG = "ble_sensor"; -void BLESensor::loop() {} +void BLESensor::loop() { + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE callbacks so loop isn't needed + this->disable_loop(); +} void BLESensor::dump_config() { LOG_SENSOR("", "BLE Sensor", this); diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index 5083e235c6..e7da297fa0 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -14,7 +14,11 @@ static const char *const TAG = "ble_text_sensor"; static const std::string EMPTY = ""; -void BLETextSensor::loop() {} +void BLETextSensor::loop() { + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE callbacks so loop isn't needed + this->disable_loop(); +} void BLETextSensor::dump_config() { LOG_TEXT_SENSOR("", "BLE Text Sensor", this); diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 31e6c51f0f..2c1ce17fb3 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -37,7 +37,12 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { request->redirect("/?save"); } -void CaptivePortal::setup() {} +void CaptivePortal::setup() { +#ifndef USE_ARDUINO + // No DNS server needed for non-Arduino frameworks + this->disable_loop(); +#endif +} void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { @@ -50,6 +55,8 @@ void CaptivePortal::start() { this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); this->dns_server_->start(53, "*", ip); + // Re-enable loop() when DNS server is started + this->enable_loop(); #endif this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *req) { diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 24d1295e6a..94db7fef50 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -21,8 +21,11 @@ class CaptivePortal : public AsyncWebHandler, public Component { void dump_config() override; #ifdef USE_ARDUINO void loop() override { - if (this->dns_server_ != nullptr) + if (this->dns_server_ != nullptr) { this->dns_server_->processNextRequest(); + } else { + this->disable_loop(); + } } #endif float get_setup_priority() const override; diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 4e61fb287c..8ae1eb1bac 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -22,6 +22,16 @@ void BLEClientBase::setup() { this->connection_index_ = connection_index++; } +void BLEClientBase::set_state(espbt::ClientState st) { + ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st); + ESPBTClient::set_state(st); + + if (st == espbt::ClientState::READY_TO_CONNECT) { + // Enable loop when we need to connect + this->enable_loop(); + } +} + void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { this->set_state(espbt::ClientState::INIT); @@ -37,9 +47,14 @@ void BLEClientBase::loop() { } // READY_TO_CONNECT means we have discovered the device // and the scanner has been stopped by the tracker. - if (this->state_ == espbt::ClientState::READY_TO_CONNECT) { + else if (this->state_ == espbt::ClientState::READY_TO_CONNECT) { this->connect(); } + // If its idle, we can disable the loop as set_state + // will enable it again when we need to connect. + else if (this->state_ == espbt::ClientState::IDLE) { + this->disable_loop(); + } } float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 89ac04e38c..814a9664d9 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -93,6 +93,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { bool check_addr(esp_bd_addr_t &addr) { return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0; } + void set_state(espbt::ClientState st) override; + protected: int gattc_if_; esp_bd_addr_t remote_bda_; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 9d84d38968..d41094fda1 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -168,6 +168,8 @@ void ESP32ImprovComponent::loop() { case improv::STATE_PROVISIONED: { this->incoming_data_.clear(); this->set_status_indicator_state_(false); + // Provisioning complete, no further loop execution needed + this->disable_loop(); break; } } @@ -254,6 +256,7 @@ void ESP32ImprovComponent::start() { ESP_LOGD(TAG, "Setting Improv to start"); this->should_start_ = true; + this->enable_loop(); } void ESP32ImprovComponent::stop() { diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 8030bd0095..3f1d58fb45 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -178,18 +178,21 @@ void OnlineImage::update() { if (this->format_ == ImageFormat::BMP) { ESP_LOGD(TAG, "Allocating BMP decoder"); this->decoder_ = make_unique(this); + this->enable_loop(); } #endif // USE_ONLINE_IMAGE_BMP_SUPPORT #ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT if (this->format_ == ImageFormat::JPEG) { ESP_LOGD(TAG, "Allocating JPEG decoder"); this->decoder_ = esphome::make_unique(this); + this->enable_loop(); } #endif // USE_ONLINE_IMAGE_JPEG_SUPPORT #ifdef USE_ONLINE_IMAGE_PNG_SUPPORT if (this->format_ == ImageFormat::PNG) { ESP_LOGD(TAG, "Allocating PNG decoder"); this->decoder_ = make_unique(this); + this->enable_loop(); } #endif // USE_ONLINE_IMAGE_PNG_SUPPORT @@ -212,6 +215,7 @@ void OnlineImage::update() { void OnlineImage::loop() { if (!this->decoder_) { // Not decoding at the moment => nothing to do. + this->disable_loop(); return; } if (!this->downloader_ || this->decoder_->is_finished()) { diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index 8976a1fe15..b6b422d4ba 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -12,6 +12,8 @@ class IntervalSyncer : public Component { void setup() override { if (this->write_interval_ != 0) { set_interval(this->write_interval_, []() { global_preferences->sync(); }); + // When using interval-based syncing, we don't need the loop + this->disable_loop(); } } void loop() override { diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index e24816fd83..2c4a0f917f 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -142,8 +142,10 @@ void Rtttl::stop() { } void Rtttl::loop() { - if (this->note_duration_ == 0 || this->state_ == State::STATE_STOPPED) + if (this->note_duration_ == 0 || this->state_ == State::STATE_STOPPED) { + this->disable_loop(); return; + } #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { @@ -391,6 +393,11 @@ void Rtttl::set_state_(State state) { this->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 (old_state == State::STATE_STOPPED && state != State::STATE_STOPPED) { + this->enable_loop(); + } } } // namespace rtttl diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 89c9242357..5a62604269 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -42,6 +42,8 @@ void SafeModeComponent::loop() { ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter"); this->clean_rtc(); this->boot_successful_ = true; + // Disable loop since we no longer need to check + this->disable_loop(); } } diff --git a/esphome/components/sntp/sntp_component.cpp b/esphome/components/sntp/sntp_component.cpp index f9a9981c52..c7642d0637 100644 --- a/esphome/components/sntp/sntp_component.cpp +++ b/esphome/components/sntp/sntp_component.cpp @@ -67,6 +67,12 @@ void SNTPComponent::loop() { time.minute, time.second); this->time_sync_callback_.call(); this->has_time_ = true; + +#ifdef USE_ESP_IDF + // On ESP-IDF, time sync is permanent and update() doesn't force resync + // Time is now synchronized, no need to check anymore + this->disable_loop(); +#endif } } // namespace sntp diff --git a/esphome/components/tlc5971/tlc5971.cpp b/esphome/components/tlc5971/tlc5971.cpp index ebcc3af361..05ff0a0080 100644 --- a/esphome/components/tlc5971/tlc5971.cpp +++ b/esphome/components/tlc5971/tlc5971.cpp @@ -24,8 +24,10 @@ void TLC5971::dump_config() { } void TLC5971::loop() { - if (!this->update_) + if (!this->update_) { + this->disable_loop(); return; + } uint32_t command; @@ -93,6 +95,7 @@ void TLC5971::set_channel_value(uint16_t channel, uint16_t value) { return; if (this->pwm_amounts_[channel] != value) { this->update_ = true; + this->enable_loop(); } this->pwm_amounts_[channel] = value; } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 4ed96f7300..58df49f0f2 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -97,7 +97,13 @@ void Application::loop() { // Feed WDT with time this->feed_wdt(last_op_end_time); - for (Component *component : this->looping_components_) { + // Mark that we're in the loop for safe reentrant modifications + this->in_loop_ = true; + + for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; + this->current_loop_index_++) { + Component *component = this->looping_components_[this->current_loop_index_]; + // Update the cached time before each component runs this->loop_component_start_time_ = last_op_end_time; @@ -112,6 +118,8 @@ void Application::loop() { this->app_state_ |= new_app_state; this->feed_wdt(last_op_end_time); } + + this->in_loop_ = false; this->app_state_ = new_app_state; // Use the last component's end time instead of calling millis() again @@ -235,9 +243,66 @@ void Application::teardown_components(uint32_t timeout_ms) { } void Application::calculate_looping_components_() { + // First add all active components for (auto *obj : this->components_) { - if (obj->has_overridden_loop()) + if (obj->has_overridden_loop() && + (obj->get_component_state() & COMPONENT_STATE_MASK) != COMPONENT_STATE_LOOP_DONE) { this->looping_components_.push_back(obj); + } + } + + this->looping_components_active_end_ = this->looping_components_.size(); + + // Then add all inactive (LOOP_DONE) components + // This handles components that called disable_loop() during setup, before this method runs + for (auto *obj : this->components_) { + if (obj->has_overridden_loop() && + (obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { + this->looping_components_.push_back(obj); + } + } +} + +void Application::disable_component_loop_(Component *component) { + // This method must be reentrant - components can disable themselves during their own loop() call + // Linear search to find component in active section + // Most configs have 10-30 looping components (30 is on the high end) + // O(n) is acceptable here as we optimize for memory, not complexity + for (uint16_t i = 0; i < this->looping_components_active_end_; i++) { + if (this->looping_components_[i] == component) { + // Move last active component to this position + this->looping_components_active_end_--; + if (i != this->looping_components_active_end_) { + std::swap(this->looping_components_[i], this->looping_components_[this->looping_components_active_end_]); + + // If we're currently iterating and just swapped the current position + if (this->in_loop_ && i == this->current_loop_index_) { + // Decrement so we'll process the swapped component next + this->current_loop_index_--; + } + } + return; + } + } +} + +void Application::enable_component_loop_(Component *component) { + // This method must be reentrant - components can re-enable themselves during their own loop() call + // Single pass through all components to find and move if needed + // With typical 10-30 components, O(n) is faster than maintaining a map + const uint16_t size = this->looping_components_.size(); + for (uint16_t i = 0; i < size; i++) { + if (this->looping_components_[i] == component) { + if (i < this->looping_components_active_end_) { + return; // Already active + } + // Found in inactive section - move to active + if (i != this->looping_components_active_end_) { + std::swap(this->looping_components_[i], this->looping_components_[this->looping_components_active_end_]); + } + this->looping_components_active_end_++; + return; + } } } diff --git a/esphome/core/application.h b/esphome/core/application.h index f04ea05d8e..ea298638d2 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -572,13 +572,41 @@ class Application { void calculate_looping_components_(); + // These methods are called by Component::disable_loop() and Component::enable_loop() + // Components should not call these directly - use this->disable_loop() or this->enable_loop() + // to ensure component state is properly updated along with the loop partition + void disable_component_loop_(Component *component); + void enable_component_loop_(Component *component); + void feed_wdt_arch_(); /// Perform a delay while also monitoring socket file descriptors for readiness void yield_with_select_(uint32_t delay_ms); std::vector components_{}; + + // Partitioned vector design for looping components + // ================================================= + // Components are partitioned into [active | inactive] sections: + // + // looping_components_: [A, B, C, D | E, F] + // ^ + // looping_components_active_end_ (4) + // + // - Components A,B,C,D are active and will be called in loop() + // - Components E,F are inactive (disabled/failed) and won't be called + // - No flag checking needed during iteration - just loop 0 to active_end_ + // - When a component is disabled, it's swapped with the last active component + // and active_end_ is decremented + // - When a component is enabled, it's swapped with the first inactive component + // and active_end_ is incremented + // - This eliminates branch mispredictions from flag checking in the hot loop std::vector looping_components_{}; + uint16_t looping_components_active_end_{0}; + + // For safe reentrant modifications during iteration + uint16_t current_loop_index_{0}; + bool in_loop_{false}; #ifdef USE_BINARY_SENSOR std::vector binary_sensors_{}; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 0a4606074a..3117f49ac1 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -30,17 +30,18 @@ const float LATE = -100.0f; } // namespace setup_priority -// Component state uses bits 0-1 (4 states) -const uint8_t COMPONENT_STATE_MASK = 0x03; +// Component state uses bits 0-2 (8 states, 5 used) +const uint8_t COMPONENT_STATE_MASK = 0x07; const uint8_t COMPONENT_STATE_CONSTRUCTION = 0x00; const uint8_t COMPONENT_STATE_SETUP = 0x01; const uint8_t COMPONENT_STATE_LOOP = 0x02; const uint8_t COMPONENT_STATE_FAILED = 0x03; -// Status LED uses bits 2-3 -const uint8_t STATUS_LED_MASK = 0x0C; +const uint8_t COMPONENT_STATE_LOOP_DONE = 0x04; +// Status LED uses bits 3-4 +const uint8_t STATUS_LED_MASK = 0x18; const uint8_t STATUS_LED_OK = 0x00; -const uint8_t STATUS_LED_WARNING = 0x04; // Bit 2 -const uint8_t STATUS_LED_ERROR = 0x08; // Bit 3 +const uint8_t STATUS_LED_WARNING = 0x08; // Bit 3 +const uint8_t STATUS_LED_ERROR = 0x10; // Bit 4 const uint16_t WARN_IF_BLOCKING_OVER_MS = 50U; ///< Initial blocking time allowed without warning const uint16_t WARN_IF_BLOCKING_INCREMENT_MS = 10U; ///< How long the blocking time must be larger to warn again @@ -113,6 +114,9 @@ void Component::call() { case COMPONENT_STATE_FAILED: // NOLINT(bugprone-branch-clone) // State failed: Do nothing break; + case COMPONENT_STATE_LOOP_DONE: // NOLINT(bugprone-branch-clone) + // State loop done: Do nothing, component has finished its work + break; default: break; } @@ -136,14 +140,30 @@ bool Component::should_warn_of_blocking(uint32_t blocking_time) { return false; } void Component::mark_failed() { - ESP_LOGE(TAG, "Component %s was marked as failed.", this->get_component_source()); + ESP_LOGE(TAG, "Component %s was marked as failed", this->get_component_source()); this->component_state_ &= ~COMPONENT_STATE_MASK; this->component_state_ |= COMPONENT_STATE_FAILED; this->status_set_error(); + // Also remove from loop since failed components shouldn't loop + App.disable_component_loop_(this); +} +void Component::disable_loop() { + ESP_LOGD(TAG, "%s loop disabled", this->get_component_source()); + this->component_state_ &= ~COMPONENT_STATE_MASK; + this->component_state_ |= COMPONENT_STATE_LOOP_DONE; + App.disable_component_loop_(this); +} +void Component::enable_loop() { + if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { + ESP_LOGD(TAG, "%s loop enabled", this->get_component_source()); + this->component_state_ &= ~COMPONENT_STATE_MASK; + this->component_state_ |= COMPONENT_STATE_LOOP; + App.enable_component_loop_(this); + } } void Component::reset_to_construction_state() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) { - ESP_LOGI(TAG, "Component %s is being reset to construction state.", this->get_component_source()); + ESP_LOGI(TAG, "Component %s is being reset to construction state", this->get_component_source()); this->component_state_ &= ~COMPONENT_STATE_MASK; this->component_state_ |= COMPONENT_STATE_CONSTRUCTION; // Clear error status when resetting @@ -276,8 +296,8 @@ uint32_t WarnIfComponentBlockingGuard::finish() { } if (should_warn) { const char *src = component_ == nullptr ? "" : component_->get_component_source(); - ESP_LOGW(TAG, "Component %s took a long time for an operation (%" PRIu32 " ms).", src, blocking_time); - ESP_LOGW(TAG, "Components should block for at most 30 ms."); + ESP_LOGW(TAG, "Component %s took a long time for an operation (%" PRIu32 " ms)", src, blocking_time); + ESP_LOGW(TAG, "Components should block for at most 30 ms"); } return curr_time; diff --git a/esphome/core/component.h b/esphome/core/component.h index f77d40ae35..a37d64086a 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -58,6 +58,7 @@ extern const uint8_t COMPONENT_STATE_CONSTRUCTION; extern const uint8_t COMPONENT_STATE_SETUP; extern const uint8_t COMPONENT_STATE_LOOP; extern const uint8_t COMPONENT_STATE_FAILED; +extern const uint8_t COMPONENT_STATE_LOOP_DONE; extern const uint8_t STATUS_LED_MASK; extern const uint8_t STATUS_LED_OK; extern const uint8_t STATUS_LED_WARNING; @@ -150,6 +151,26 @@ class Component { this->mark_failed(); } + /** Disable this component's loop. The loop() method will no longer be called. + * + * This is useful for components that only need to run for a certain period of time + * or when inactive, saving CPU cycles. + * + * @note Components should call this->disable_loop() on themselves, not on other components. + * This ensures the component's state is properly updated along with the loop partition. + */ + void disable_loop(); + + /** Enable this component's loop. The loop() method will be called normally. + * + * This is useful for components that transition between active and inactive states + * and need to re-enable their loop() method when becoming active again. + * + * @note Components should call this->enable_loop() on themselves, not on other components. + * This ensures the component's state is properly updated along with the loop partition. + */ + void enable_loop(); + bool is_failed() const; bool is_ready() const; diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 90377300a6..525e3541b3 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Generator +from collections.abc import AsyncGenerator, Callable, Generator from contextlib import AbstractAsyncContextManager, asynccontextmanager import logging import os @@ -46,6 +46,7 @@ if platform.system() == "Windows": "Integration tests are not supported on Windows", allow_module_level=True ) + import pty # not available on Windows @@ -362,7 +363,10 @@ async def api_client_connected( async def _read_stream_lines( - stream: asyncio.StreamReader, lines: list[str], output_stream: TextIO + stream: asyncio.StreamReader, + lines: list[str], + output_stream: TextIO, + line_callback: Callable[[str], None] | None = None, ) -> None: """Read lines from a stream, append to list, and echo to output stream.""" log_parser = LogParser() @@ -380,6 +384,9 @@ async def _read_stream_lines( file=output_stream, flush=True, ) + # Call the callback if provided + if line_callback: + line_callback(decoded_line.rstrip()) @asynccontextmanager @@ -388,6 +395,7 @@ async def run_binary_and_wait_for_port( host: str, port: int, timeout: float = PORT_WAIT_TIMEOUT, + line_callback: Callable[[str], None] | None = None, ) -> AsyncGenerator[None]: """Run a binary, wait for it to open a port, and clean up on exit.""" # Create a pseudo-terminal to make the binary think it's running interactively @@ -435,7 +443,9 @@ async def run_binary_and_wait_for_port( # Read from output stream output_tasks = [ asyncio.create_task( - _read_stream_lines(output_reader, stdout_lines, sys.stdout) + _read_stream_lines( + output_reader, stdout_lines, sys.stdout, line_callback + ) ) ] @@ -515,6 +525,7 @@ async def run_compiled_context( compile_esphome: CompileFunction, port: int, port_socket: socket.socket | None = None, + line_callback: Callable[[str], None] | None = None, ) -> AsyncGenerator[None]: """Context manager to write, compile and run an ESPHome configuration.""" # Write the YAML config @@ -528,7 +539,9 @@ async def run_compiled_context( port_socket.close() # Run the binary and wait for the API server to start - async with run_binary_and_wait_for_port(binary_path, LOCALHOST, port): + async with run_binary_and_wait_for_port( + binary_path, LOCALHOST, port, line_callback=line_callback + ): yield @@ -542,7 +555,9 @@ async def run_compiled( port, port_socket = reserved_tcp_port def _run_compiled( - yaml_content: str, filename: str | None = None + yaml_content: str, + filename: str | None = None, + line_callback: Callable[[str], None] | None = None, ) -> AbstractAsyncContextManager[asyncio.subprocess.Process]: return run_compiled_context( yaml_content, @@ -551,6 +566,7 @@ async def run_compiled( compile_esphome, port, port_socket, + line_callback=line_callback, ) yield _run_compiled diff --git a/tests/integration/fixtures/external_components/loop_test_component/__init__.py b/tests/integration/fixtures/external_components/loop_test_component/__init__.py new file mode 100644 index 0000000000..c5eda67d1e --- /dev/null +++ b/tests/integration/fixtures/external_components/loop_test_component/__init__.py @@ -0,0 +1,78 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_COMPONENTS, CONF_ID, CONF_NAME + +CODEOWNERS = ["@esphome/tests"] + +loop_test_component_ns = cg.esphome_ns.namespace("loop_test_component") +LoopTestComponent = loop_test_component_ns.class_("LoopTestComponent", cg.Component) + +CONF_DISABLE_AFTER = "disable_after" +CONF_TEST_REDUNDANT_OPERATIONS = "test_redundant_operations" + +COMPONENT_CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LoopTestComponent), + cv.Required(CONF_NAME): cv.string, + cv.Optional(CONF_DISABLE_AFTER, default=0): cv.int_, + cv.Optional(CONF_TEST_REDUNDANT_OPERATIONS, default=False): cv.boolean, + } +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LoopTestComponent), + cv.Required(CONF_COMPONENTS): cv.ensure_list(COMPONENT_CONFIG_SCHEMA), + } +).extend(cv.COMPONENT_SCHEMA) + +# Define actions +EnableAction = loop_test_component_ns.class_("EnableAction", automation.Action) +DisableAction = loop_test_component_ns.class_("DisableAction", automation.Action) + + +@automation.register_action( + "loop_test_component.enable", + EnableAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(LoopTestComponent), + } + ), +) +async def enable_to_code(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + return var + + +@automation.register_action( + "loop_test_component.disable", + DisableAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(LoopTestComponent), + } + ), +) +async def disable_to_code(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + return var + + +async def to_code(config): + # The parent config doesn't actually create a component + # We just create each sub-component + for comp_config in config[CONF_COMPONENTS]: + var = cg.new_Pvariable(comp_config[CONF_ID]) + await cg.register_component(var, comp_config) + + cg.add(var.set_name(comp_config[CONF_NAME])) + cg.add(var.set_disable_after(comp_config[CONF_DISABLE_AFTER])) + cg.add( + var.set_test_redundant_operations( + comp_config[CONF_TEST_REDUNDANT_OPERATIONS] + ) + ) diff --git a/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.cpp b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.cpp new file mode 100644 index 0000000000..470740c534 --- /dev/null +++ b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.cpp @@ -0,0 +1,43 @@ +#include "loop_test_component.h" + +namespace esphome { +namespace loop_test_component { + +void LoopTestComponent::setup() { ESP_LOGI(TAG, "[%s] Setup called", this->name_.c_str()); } + +void LoopTestComponent::loop() { + this->loop_count_++; + ESP_LOGI(TAG, "[%s] Loop count: %d", this->name_.c_str(), this->loop_count_); + + // Test self-disable after specified count + if (this->disable_after_ > 0 && this->loop_count_ == this->disable_after_) { + ESP_LOGI(TAG, "[%s] Disabling self after %d loops", this->name_.c_str(), this->disable_after_); + this->disable_loop(); + } + + // Test redundant operations + if (this->test_redundant_operations_ && this->loop_count_ == 5) { + if (this->name_ == "redundant_enable") { + ESP_LOGI(TAG, "[%s] Testing enable when already enabled", this->name_.c_str()); + this->enable_loop(); + } else if (this->name_ == "redundant_disable") { + ESP_LOGI(TAG, "[%s] Testing disable when will be disabled", this->name_.c_str()); + // We'll disable at count 10, but try to disable again at 5 + this->disable_loop(); + ESP_LOGI(TAG, "[%s] First disable complete", this->name_.c_str()); + } + } +} + +void LoopTestComponent::service_enable() { + ESP_LOGI(TAG, "[%s] Service enable called", this->name_.c_str()); + this->enable_loop(); +} + +void LoopTestComponent::service_disable() { + ESP_LOGI(TAG, "[%s] Service disable called", this->name_.c_str()); + this->disable_loop(); +} + +} // namespace loop_test_component +} // namespace esphome diff --git a/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h new file mode 100644 index 0000000000..5c43dd4b43 --- /dev/null +++ b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h @@ -0,0 +1,58 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" +#include "esphome/core/automation.h" + +namespace esphome { +namespace loop_test_component { + +static const char *const TAG = "loop_test_component"; + +class LoopTestComponent : public Component { + public: + void set_name(const std::string &name) { this->name_ = name; } + void set_disable_after(int count) { this->disable_after_ = count; } + void set_test_redundant_operations(bool test) { this->test_redundant_operations_ = test; } + + void setup() override; + void loop() override; + + // Service methods for external control + void service_enable(); + void service_disable(); + + int get_loop_count() const { return this->loop_count_; } + + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + std::string name_; + int loop_count_{0}; + int disable_after_{0}; + bool test_redundant_operations_{false}; +}; + +template class EnableAction : public Action { + public: + EnableAction(LoopTestComponent *parent) : parent_(parent) {} + + void play(Ts... x) override { this->parent_->service_enable(); } + + protected: + LoopTestComponent *parent_; +}; + +template class DisableAction : public Action { + public: + DisableAction(LoopTestComponent *parent) : parent_(parent) {} + + void play(Ts... x) override { this->parent_->service_disable(); } + + protected: + LoopTestComponent *parent_; +}; + +} // namespace loop_test_component +} // namespace esphome diff --git a/tests/integration/fixtures/loop_disable_enable.yaml b/tests/integration/fixtures/loop_disable_enable.yaml new file mode 100644 index 0000000000..17010f7c34 --- /dev/null +++ b/tests/integration/fixtures/loop_disable_enable.yaml @@ -0,0 +1,48 @@ +esphome: + name: loop-test + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +loop_test_component: + components: + # Component that disables itself after 10 loops + - id: self_disable_10 + name: "self_disable_10" + disable_after: 10 + + # Component that never disables itself (for re-enable test) + - id: normal_component + name: "normal_component" + disable_after: 0 + + # Component that tests enable when already enabled + - id: redundant_enable + name: "redundant_enable" + test_redundant_operations: true + disable_after: 0 + + # Component that tests disable when already disabled + - id: redundant_disable + name: "redundant_disable" + test_redundant_operations: true + disable_after: 10 + +# Interval to re-enable the self_disable_10 component after some time +interval: + - interval: 0.5s + then: + - if: + condition: + lambda: 'return id(self_disable_10).get_loop_count() == 10;' + then: + - logger.log: "Re-enabling self_disable_10 via service" + - loop_test_component.enable: + id: self_disable_10 diff --git a/tests/integration/test_loop_disable_enable.py b/tests/integration/test_loop_disable_enable.py new file mode 100644 index 0000000000..84301c25d8 --- /dev/null +++ b/tests/integration/test_loop_disable_enable.py @@ -0,0 +1,150 @@ +"""Integration test for loop disable/enable functionality.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_disable_enable( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that components can disable and enable their loop() method.""" + # Get the absolute path to the external components directory + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + + # Replace the placeholder in the YAML config with the actual path + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + # Track log messages and events + log_messages: list[str] = [] + + # Event fired when self_disable_10 component disables itself after 10 loops + self_disable_10_disabled = asyncio.Event() + # Event fired when normal_component reaches 10 loops + normal_component_10_loops = asyncio.Event() + # Event fired when redundant_enable component tests enabling when already enabled + redundant_enable_tested = asyncio.Event() + # Event fired when redundant_disable component tests disabling when already disabled + redundant_disable_tested = asyncio.Event() + # Event fired when self_disable_10 component is re-enabled and runs again (count > 10) + self_disable_10_re_enabled = asyncio.Event() + + # Track loop counts for components + self_disable_10_counts: list[int] = [] + normal_component_counts: list[int] = [] + + def on_log_line(line: str) -> None: + """Process each log line from the process output.""" + # Strip ANSI color codes + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + + if "loop_test_component" not in clean_line: + return + + log_messages.append(clean_line) + + # Track specific events using the cleaned line + if "[self_disable_10]" in clean_line: + if "Loop count:" in clean_line: + # Extract loop count + try: + count = int(clean_line.split("Loop count: ")[1]) + self_disable_10_counts.append(count) + # Check if component was re-enabled (count > 10) + if count > 10: + self_disable_10_re_enabled.set() + except (IndexError, ValueError): + pass + elif "Disabling self after 10 loops" in clean_line: + self_disable_10_disabled.set() + + elif "[normal_component]" in clean_line and "Loop count:" in clean_line: + try: + count = int(clean_line.split("Loop count: ")[1]) + normal_component_counts.append(count) + if count >= 10: + normal_component_10_loops.set() + except (IndexError, ValueError): + pass + + elif ( + "[redundant_enable]" in clean_line + and "Testing enable when already enabled" in clean_line + ): + redundant_enable_tested.set() + + elif ( + "[redundant_disable]" in clean_line + and "Testing disable when will be disabled" in clean_line + ): + redundant_disable_tested.set() + + # Write, compile and run the ESPHome device with log callback + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # Verify we can connect and get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-test" + + # Wait for self_disable_10 to disable itself + try: + await asyncio.wait_for(self_disable_10_disabled.wait(), timeout=10.0) + except asyncio.TimeoutError: + pytest.fail("self_disable_10 did not disable itself within 10 seconds") + + # Verify it ran at least 10 times before disabling + assert len([c for c in self_disable_10_counts if c <= 10]) == 10, ( + f"Expected exactly 10 loops before disable, got {[c for c in self_disable_10_counts if c <= 10]}" + ) + assert self_disable_10_counts[:10] == list(range(1, 11)), ( + f"Expected first 10 counts to be 1-10, got {self_disable_10_counts[:10]}" + ) + + # Wait for normal_component to run at least 10 times + try: + await asyncio.wait_for(normal_component_10_loops.wait(), timeout=10.0) + except asyncio.TimeoutError: + pytest.fail( + f"normal_component did not reach 10 loops within timeout, got {len(normal_component_counts)}" + ) + + # Wait for redundant operation tests + try: + await asyncio.wait_for(redundant_enable_tested.wait(), timeout=10.0) + except asyncio.TimeoutError: + pytest.fail("redundant_enable did not test enabling when already enabled") + + try: + await asyncio.wait_for(redundant_disable_tested.wait(), timeout=10.0) + except asyncio.TimeoutError: + pytest.fail( + "redundant_disable did not test disabling when will be disabled" + ) + + # Wait to see if self_disable_10 gets re-enabled + try: + await asyncio.wait_for(self_disable_10_re_enabled.wait(), timeout=5.0) + except asyncio.TimeoutError: + pytest.fail("self_disable_10 was not re-enabled within 5 seconds") + + # Component was re-enabled - verify it ran more times + later_self_disable_counts = [c for c in self_disable_10_counts if c > 10] + assert later_self_disable_counts, ( + "self_disable_10 was re-enabled but did not run additional times" + ) diff --git a/tests/integration/types.py b/tests/integration/types.py index 6fc3e9435e..5e4bfaa29d 100644 --- a/tests/integration/types.py +++ b/tests/integration/types.py @@ -13,7 +13,19 @@ from aioesphomeapi import APIClient ConfigWriter = Callable[[str, str | None], Awaitable[Path]] CompileFunction = Callable[[Path], Awaitable[Path]] RunFunction = Callable[[Path], Awaitable[asyncio.subprocess.Process]] -RunCompiledFunction = Callable[[str, str | None], AbstractAsyncContextManager[None]] + + +class RunCompiledFunction(Protocol): + """Protocol for run_compiled function with optional line callback.""" + + def __call__( # noqa: E704 + self, + yaml_content: str, + filename: str | None = None, + line_callback: Callable[[str], None] | None = None, + ) -> AbstractAsyncContextManager[None]: ... + + WaitFunction = Callable[[APIClient, float], Awaitable[bool]] From c612985930be8a70f7fd1abc24070740cd147448 Mon Sep 17 00:00:00 2001 From: Severin von Wnuck-Lipinski Date: Wed, 18 Jun 2025 11:49:39 +0200 Subject: [PATCH 20/22] Add support for Xiaomi XMWSDJ04MMC (#8591) --- CODEOWNERS | 1 + esphome/components/xiaomi_ble/xiaomi_ble.cpp | 12 +++ esphome/components/xiaomi_ble/xiaomi_ble.h | 1 + .../components/xiaomi_xmwsdj04mmc/__init__.py | 0 .../components/xiaomi_xmwsdj04mmc/sensor.py | 77 +++++++++++++++++++ .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp | 77 +++++++++++++++++++ .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h | 37 +++++++++ .../components/xiaomi_xmwsdj04mmc/common.yaml | 12 +++ .../xiaomi_xmwsdj04mmc/test.esp32-ard.yaml | 1 + .../xiaomi_xmwsdj04mmc/test.esp32-c3-ard.yaml | 1 + .../xiaomi_xmwsdj04mmc/test.esp32-c3-idf.yaml | 1 + .../xiaomi_xmwsdj04mmc/test.esp32-idf.yaml | 1 + 12 files changed, 221 insertions(+) create mode 100644 esphome/components/xiaomi_xmwsdj04mmc/__init__.py create mode 100644 esphome/components/xiaomi_xmwsdj04mmc/sensor.py create mode 100644 esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp create mode 100644 esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h create mode 100644 tests/components/xiaomi_xmwsdj04mmc/common.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/test.esp32-ard.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/test.esp32-c3-ard.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/test.esp32-c3-idf.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 66ea80f8d6..ebbc8732ea 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -520,6 +520,7 @@ esphome/components/xiaomi_lywsd03mmc/* @ahpohl esphome/components/xiaomi_mhoc303/* @drug123 esphome/components/xiaomi_mhoc401/* @vevsvevs esphome/components/xiaomi_rtcgq02lm/* @jesserockz +esphome/components/xiaomi_xmwsdj04mmc/* @medusalix esphome/components/xl9535/* @mreditor97 esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 04e0724ba7..a80daa0b80 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -91,6 +91,13 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_ // MiaoMiaoce humidity, 1 byte, 8-bit unsigned integer, 1 % else if ((value_type == 0x4C02) && (value_length == 1)) { result.humidity = data[0]; + } + // XMWSDJ04MMC humidity, 4 bytes, float, 0.1 °C + else if ((value_type == 0x4C08) && (value_length == 4)) { + const uint32_t int_number = encode_uint32(data[3], data[2], data[1], data[0]); + float humidity; + std::memcpy(&humidity, &int_number, sizeof(humidity)); + result.humidity = humidity; } else { return false; } @@ -219,6 +226,11 @@ optional parse_xiaomi_header(const esp32_ble_tracker::Service } else if (device_uuid == 0x055b) { // small square body, segment LCD, encrypted result.type = XiaomiParseResult::TYPE_LYWSD03MMC; result.name = "LYWSD03MMC"; + } else if (device_uuid == 0x1203) { // small square body, e-ink display, encrypted + result.type = XiaomiParseResult::TYPE_XMWSDJ04MMC; + result.name = "XMWSDJ04MMC"; + if (raw.size() == 19) + result.raw_offset -= 6; } else if (device_uuid == 0x07f6) { // Xiaomi-Yeelight BLE nightlight result.type = XiaomiParseResult::TYPE_MJYD02YLA; result.name = "MJYD02YLA"; diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index 6978be97f4..77fb04fd78 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -20,6 +20,7 @@ struct XiaomiParseResult { TYPE_LYWSD02MMC, TYPE_CGG1, TYPE_LYWSD03MMC, + TYPE_XMWSDJ04MMC, TYPE_CGD1, TYPE_CGDK2, TYPE_JQJCY01YM, diff --git a/esphome/components/xiaomi_xmwsdj04mmc/__init__.py b/esphome/components/xiaomi_xmwsdj04mmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py new file mode 100644 index 0000000000..b41a775f35 --- /dev/null +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -0,0 +1,77 @@ +import esphome.codegen as cg +from esphome.components import esp32_ble_tracker, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_BINDKEY, + CONF_HUMIDITY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_TEMPERATURE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PERCENT, +) + +AUTO_LOAD = ["xiaomi_ble"] +CODEOWNERS = ["@medusalix"] +DEPENDENCIES = ["esp32_ble_tracker"] + +xiaomi_xmwsdj04mmc_ns = cg.esphome_ns.namespace("xiaomi_xmwsdj04mmc") +XiaomiXMWSDJ04MMC = xiaomi_xmwsdj04mmc_ns.class_( + "XiaomiXMWSDJ04MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XiaomiXMWSDJ04MMC), + cv.Required(CONF_BINDKEY): cv.bind_key, + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await esp32_ble_tracker.register_ble_device(var, config) + + cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) + cg.add(var.set_bindkey(config[CONF_BINDKEY])) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature(sens)) + if humidity_config := config.get(CONF_HUMIDITY): + sens = await sensor.new_sensor(humidity_config) + cg.add(var.set_humidity(sens)) + if battery_level_config := config.get(CONF_BATTERY_LEVEL): + sens = await sensor.new_sensor(battery_level_config) + cg.add(var.set_battery_level(sens)) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp new file mode 100644 index 0000000000..f8712e7fd4 --- /dev/null +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -0,0 +1,77 @@ +#include "xiaomi_xmwsdj04mmc.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +namespace esphome { +namespace xiaomi_xmwsdj04mmc { + +static const char *const TAG = "xiaomi_xmwsdj04mmc"; + +void XiaomiXMWSDJ04MMC::dump_config() { + ESP_LOGCONFIG(TAG, "Xiaomi XMWSDJ04MMC"); + ESP_LOGCONFIG(TAG, " Bindkey: %s", format_hex_pretty(this->bindkey_, 16).c_str()); + LOG_SENSOR(" ", "Temperature", this->temperature_); + LOG_SENSOR(" ", "Humidity", this->humidity_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_); +} + +bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) { + ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); + return false; + } + ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str().c_str()); + + bool success = false; + for (auto &service_data : device.get_service_datas()) { + auto res = xiaomi_ble::parse_xiaomi_header(service_data); + if (!res.has_value()) { + continue; + } + if (res->is_duplicate) { + continue; + } + if (res->has_encryption && + (!(xiaomi_ble::decrypt_xiaomi_payload(const_cast &>(service_data.data), this->bindkey_, + this->address_)))) { + continue; + } + if (!(xiaomi_ble::parse_xiaomi_message(service_data.data, *res))) { + continue; + } + if (res->humidity.has_value() && this->humidity_ != nullptr) { + // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 + *res->humidity = trunc(*res->humidity); + } + if (!(xiaomi_ble::report_xiaomi_results(res, device.address_str()))) { + continue; + } + if (res->temperature.has_value() && this->temperature_ != nullptr) + this->temperature_->publish_state(*res->temperature); + if (res->humidity.has_value() && this->humidity_ != nullptr) + this->humidity_->publish_state(*res->humidity); + if (res->battery_level.has_value() && this->battery_level_ != nullptr) + this->battery_level_->publish_state(*res->battery_level); + success = true; + } + + return success; +} + +void XiaomiXMWSDJ04MMC::set_bindkey(const std::string &bindkey) { + memset(this->bindkey_, 0, 16); + if (bindkey.size() != 32) { + return; + } + char temp[3] = {0}; + for (int i = 0; i < 16; i++) { + strncpy(temp, &(bindkey.c_str()[i * 2]), 2); + this->bindkey_[i] = std::strtoul(temp, nullptr, 16); + } +} + +} // namespace xiaomi_xmwsdj04mmc +} // namespace esphome + +#endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h new file mode 100644 index 0000000000..9ce02bb64e --- /dev/null +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/xiaomi_ble/xiaomi_ble.h" + +#ifdef USE_ESP32 + +namespace esphome { +namespace xiaomi_xmwsdj04mmc { + +class XiaomiXMWSDJ04MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { + public: + void set_address(uint64_t address) { this->address_ = address; } + void set_bindkey(const std::string &bindkey); + + bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } + void set_humidity(sensor::Sensor *humidity) { this->humidity_ = humidity; } + void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; } + + protected: + uint64_t address_; + uint8_t bindkey_[16]; + sensor::Sensor *temperature_{nullptr}; + sensor::Sensor *humidity_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; +}; + +} // namespace xiaomi_xmwsdj04mmc +} // namespace esphome + +#endif diff --git a/tests/components/xiaomi_xmwsdj04mmc/common.yaml b/tests/components/xiaomi_xmwsdj04mmc/common.yaml new file mode 100644 index 0000000000..fe7a11efc5 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/common.yaml @@ -0,0 +1,12 @@ +esp32_ble_tracker: + +sensor: + - platform: xiaomi_xmwsdj04mmc + mac_address: 84:B4:DB:5D:A3:8F + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: Xiaomi XMWSDJ04MMC Temperature + humidity: + name: Xiaomi XMWSDJ04MMC Humidity + battery_level: + name: Xiaomi XMWSDJ04MMC Battery Level diff --git a/tests/components/xiaomi_xmwsdj04mmc/test.esp32-ard.yaml b/tests/components/xiaomi_xmwsdj04mmc/test.esp32-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/test.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/xiaomi_xmwsdj04mmc/test.esp32-c3-ard.yaml b/tests/components/xiaomi_xmwsdj04mmc/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/test.esp32-c3-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/xiaomi_xmwsdj04mmc/test.esp32-c3-idf.yaml b/tests/components/xiaomi_xmwsdj04mmc/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/test.esp32-c3-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/xiaomi_xmwsdj04mmc/test.esp32-idf.yaml b/tests/components/xiaomi_xmwsdj04mmc/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 669ef7a0b132df86ec392bc22790e6a7c24c7cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Szczodrzy=C5=84ski?= Date: Wed, 18 Jun 2025 11:51:00 +0200 Subject: [PATCH 21/22] [web_server] Upgrade ESPAsync libraries (#8867) --- esphome/components/async_tcp/__init__.py | 8 ++-- .../captive_portal/captive_portal.cpp | 4 ++ .../captive_portal/captive_portal.h | 2 +- .../prometheus/prometheus_handler.h | 2 +- esphome/components/web_server/web_server.cpp | 37 +++++++++++++------ esphome/components/web_server/web_server.h | 6 +-- .../components/web_server_base/__init__.py | 6 ++- .../web_server_base/web_server_base.h | 8 ++-- .../web_server_idf/web_server_idf.h | 10 ++--- platformio.ini | 6 +-- 10 files changed, 54 insertions(+), 35 deletions(-) diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 99e250b6fc..eec6a0e327 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -21,8 +21,8 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(200.0) async def to_code(config): if CORE.is_esp32 or CORE.is_libretiny: - # https://github.com/esphome/AsyncTCP/blob/master/library.json - cg.add_library("esphome/AsyncTCP-esphome", "2.1.4") + # https://github.com/ESP32Async/AsyncTCP + cg.add_library("ESP32Async/AsyncTCP", "3.4.4") elif CORE.is_esp8266: - # https://github.com/esphome/ESPAsyncTCP - cg.add_library("esphome/ESPAsyncTCP-esphome", "2.0.0") + # https://github.com/ESP32Async/ESPAsyncTCP + cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0") diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 2c1ce17fb3..51e5cfc8ff 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -75,7 +75,11 @@ void CaptivePortal::start() { void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { if (req->url() == "/") { +#ifndef USE_ESP8266 + auto *response = req->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); +#else auto *response = req->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); +#endif response->addHeader("Content-Encoding", "gzip"); req->send(response); return; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 94db7fef50..c78fff824a 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,7 +40,7 @@ class CaptivePortal : public AsyncWebHandler, public Component { #endif } - bool canHandle(AsyncWebServerRequest *request) override { + bool canHandle(AsyncWebServerRequest *request) const override { if (!this->active_) return false; diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index bdc3d971ce..c4598f44b0 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -40,7 +40,7 @@ class PrometheusHandler : public AsyncWebHandler, public Component { */ void add_label_name(EntityBase *obj, const std::string &value) { relabel_map_name_.insert({obj, value}); } - bool canHandle(AsyncWebServerRequest *request) override { + bool canHandle(AsyncWebServerRequest *request) const override { if (request->method() == HTTP_GET) { if (request->url() == "/metrics") return true; diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 7ae30522f4..becb5bc2c7 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -91,7 +91,7 @@ void DeferredUpdateEventSource::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); std::string message = de.message_generator_(web_server_, de.source_); - if (this->try_send(message.c_str(), "state")) { + if (this->send(message.c_str(), "state") != DISCARDED) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -131,7 +131,7 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * deq_push_back_with_dedup_(source, message_generator); } else { std::string message = message_generator(web_server_, source); - if (!this->try_send(message.c_str(), "state")) { + if (this->send(message.c_str(), "state") == DISCARDED) { deq_push_back_with_dedup_(source, message_generator); } } @@ -171,8 +171,8 @@ void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServer ws->defer([this, ws, es]() { this->on_client_connect_(ws, es); }); }); - es->onDisconnect([this, ws](AsyncEventSource *source, AsyncEventSourceClient *client) { - ws->defer([this, source]() { this->on_client_disconnect_((DeferredUpdateEventSource *) source); }); + es->onDisconnect([this, ws, es](AsyncEventSourceClient *client) { + ws->defer([this, es]() { this->on_client_disconnect_((DeferredUpdateEventSource *) es); }); }); es->handleRequest(request); @@ -291,14 +291,23 @@ float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f #ifdef USE_WEBSERVER_LOCAL void WebServer::handle_index_request(AsyncWebServerRequest *request) { +#ifndef USE_ESP8266 + AsyncWebServerResponse *response = request->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); +#else AsyncWebServerResponse *response = request->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); +#endif response->addHeader("Content-Encoding", "gzip"); request->send(response); } #elif USE_WEBSERVER_VERSION >= 2 void WebServer::handle_index_request(AsyncWebServerRequest *request) { +#ifndef USE_ESP8266 + AsyncWebServerResponse *response = + request->beginResponse(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); +#else AsyncWebServerResponse *response = request->beginResponse_P(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); +#endif // No gzip header here because the HTML file is so small request->send(response); } @@ -317,8 +326,13 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_CSS_INCLUDE void WebServer::handle_css_request(AsyncWebServerRequest *request) { +#ifndef USE_ESP8266 + AsyncWebServerResponse *response = + request->beginResponse(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); +#else AsyncWebServerResponse *response = request->beginResponse_P(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); +#endif response->addHeader("Content-Encoding", "gzip"); request->send(response); } @@ -326,8 +340,13 @@ void WebServer::handle_css_request(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_JS_INCLUDE void WebServer::handle_js_request(AsyncWebServerRequest *request) { +#ifndef USE_ESP8266 + AsyncWebServerResponse *response = + request->beginResponse(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); +#else AsyncWebServerResponse *response = request->beginResponse_P(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); +#endif response->addHeader("Content-Encoding", "gzip"); request->send(response); } @@ -1837,7 +1856,7 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c } #endif -bool WebServer::canHandle(AsyncWebServerRequest *request) { +bool WebServer::canHandle(AsyncWebServerRequest *request) const { if (request->url() == "/") return true; @@ -1859,12 +1878,6 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS if (request->method() == HTTP_OPTIONS && request->hasHeader(HEADER_CORS_REQ_PNA)) { -#ifdef USE_ARDUINO - // Header needs to be added to interesting header list for it to not be - // nuked by the time we handle the request later. - // Only required in Arduino framework. - request->addInterestingHeader(HEADER_CORS_REQ_PNA); -#endif return true; } #endif @@ -2145,7 +2158,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { #endif } -bool WebServer::isRequestHandlerTrivial() { return false; } +bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_entity_config(EntityBase *entity, float weight, uint64_t group) { this->sorting_entitys_[entity] = SortingComponents{weight, group}; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index f4d6ad8e86..53ee4d1212 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -99,7 +99,7 @@ class DeferredUpdateEventSource : public AsyncEventSource { protected: // surface a couple methods from the base class using AsyncEventSource::handleRequest; - using AsyncEventSource::try_send; + using AsyncEventSource::send; ListEntitiesIterator entities_iterator_; // vector is used very specifically for its zero memory overhead even though items are popped from the front (memory @@ -468,11 +468,11 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { #endif /// Override the web handler's canHandle method. - bool canHandle(AsyncWebServerRequest *request) override; + bool canHandle(AsyncWebServerRequest *request) const override; /// Override the web handler's handleRequest method. void handleRequest(AsyncWebServerRequest *request) override; /// This web handle is not trivial. - bool isRequestHandlerTrivial() override; // NOLINT(readability-identifier-naming) + bool isRequestHandlerTrivial() const override; // NOLINT(readability-identifier-naming) void add_entity_config(EntityBase *entity, float weight, uint64_t group); void add_sorting_group(uint64_t group_id, const std::string &group_name, float weight); diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index f50ee59b9c..c17bab2128 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -36,5 +36,7 @@ async def to_code(config): cg.add_library("WiFi", None) cg.add_library("FS", None) cg.add_library("Update", None) - # https://github.com/esphome/ESPAsyncWebServer/blob/master/library.json - cg.add_library("esphome/ESPAsyncWebServer-esphome", "3.3.0") + if CORE.is_esp8266: + cg.add_library("ESP8266WiFi", None) + # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json + cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.8") diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index f876d163bc..641006cb99 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -23,7 +23,7 @@ class MiddlewareHandler : public AsyncWebHandler { public: MiddlewareHandler(AsyncWebHandler *next) : next_(next) {} - bool canHandle(AsyncWebServerRequest *request) override { return next_->canHandle(request); } + bool canHandle(AsyncWebServerRequest *request) const override { return next_->canHandle(request); } void handleRequest(AsyncWebServerRequest *request) override { next_->handleRequest(request); } void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final) override { @@ -32,7 +32,7 @@ class MiddlewareHandler : public AsyncWebHandler { void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override { next_->handleBody(request, data, len, index, total); } - bool isRequestHandlerTrivial() override { return next_->isRequestHandlerTrivial(); } + bool isRequestHandlerTrivial() const override { return next_->isRequestHandlerTrivial(); } protected: AsyncWebHandler *next_; @@ -131,12 +131,12 @@ class OTARequestHandler : public AsyncWebHandler { void handleRequest(AsyncWebServerRequest *request) override; void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final) override; - bool canHandle(AsyncWebServerRequest *request) override { + bool canHandle(AsyncWebServerRequest *request) const override { return request->url() == "/update" && request->method() == HTTP_POST; } // NOLINTNEXTLINE(readability-identifier-naming) - bool isRequestHandlerTrivial() override { return false; } + bool isRequestHandlerTrivial() const override { return false; } protected: uint32_t last_ota_progress_{0}; diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index d883c0ca9b..8dafdf11ef 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -135,8 +135,8 @@ class AsyncWebServerRequest { return res; } // NOLINTNEXTLINE(readability-identifier-naming) - AsyncWebServerResponse *beginResponse_P(int code, const char *content_type, const uint8_t *data, - const size_t data_size) { + AsyncWebServerResponse *beginResponse(int code, const char *content_type, const uint8_t *data, + const size_t data_size) { auto *res = new AsyncWebServerResponseProgmem(this, data, data_size); // NOLINT(cppcoreguidelines-owning-memory) this->init_response_(res, code, content_type); return res; @@ -211,7 +211,7 @@ class AsyncWebHandler { public: virtual ~AsyncWebHandler() {} // NOLINTNEXTLINE(readability-identifier-naming) - virtual bool canHandle(AsyncWebServerRequest *request) { return false; } + virtual bool canHandle(AsyncWebServerRequest *request) const { return false; } // NOLINTNEXTLINE(readability-identifier-naming) virtual void handleRequest(AsyncWebServerRequest *request) {} // NOLINTNEXTLINE(readability-identifier-naming) @@ -220,7 +220,7 @@ class AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) virtual void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {} // NOLINTNEXTLINE(readability-identifier-naming) - virtual bool isRequestHandlerTrivial() { return true; } + virtual bool isRequestHandlerTrivial() const { return true; } }; #ifdef USE_WEBSERVER @@ -290,7 +290,7 @@ class AsyncEventSource : public AsyncWebHandler { ~AsyncEventSource() override; // NOLINTNEXTLINE(readability-identifier-naming) - bool canHandle(AsyncWebServerRequest *request) override { + bool canHandle(AsyncWebServerRequest *request) const override { return request->method() == HTTP_GET && request->url() == this->url_; } // NOLINTNEXTLINE(readability-identifier-naming) diff --git a/platformio.ini b/platformio.ini index 58e0b77c07..96926eadd1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -65,7 +65,7 @@ lib_deps = SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt - esphome/ESPAsyncWebServer-esphome@3.3.0 ; web_server_base + ESP32Async/ESPAsyncWebServer@3.7.8 ; web_server_base fastled/FastLED@3.9.16 ; fastled_base mikalhart/TinyGPSPlus@1.1.0 ; gps freekode/TM1651@1.0.1 ; tm1651 @@ -100,7 +100,7 @@ lib_deps = ${common:arduino.lib_deps} ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) - esphome/ESPAsyncTCP-esphome@2.0.0 ; async_tcp + ESP32Async/ESPAsyncTCP@2.0.0 ; async_tcp ESP8266HTTPClient ; http_request (Arduino built-in) ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) @@ -130,7 +130,7 @@ lib_deps = WiFi ; wifi,web_server_base,ethernet (Arduino built-in) Update ; ota,web_server_base (Arduino built-in) ${common:arduino.lib_deps} - esphome/AsyncTCP-esphome@2.1.4 ; async_tcp + ESP32Async/AsyncTCP@3.4.4 ; async_tcp WiFiClientSecure ; http_request,nextion (Arduino built-in) HTTPClient ; http_request,nextion (Arduino built-in) ESPmDNS ; mdns (Arduino built-in) From 6667336bd89786f5546dd07dc120d6642beabb39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Jun 2025 11:57:49 +0200 Subject: [PATCH 22/22] Eliminate memory fragmentation with BLE event pool (#9101) --- esphome/components/esp32_ble/ble.cpp | 55 ++-- esphome/components/esp32_ble/ble.h | 2 + esphome/components/esp32_ble/ble_event.h | 275 +++++++++++------- esphome/components/esp32_ble/ble_event_pool.h | 72 +++++ esphome/components/esp32_ble/queue.h | 25 +- 5 files changed, 288 insertions(+), 141 deletions(-) create mode 100644 esphome/components/esp32_ble/ble_event_pool.h diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 8adef79d2f..5a66f11d0f 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "ble.h" +#include "ble_event_pool.h" #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -23,9 +24,6 @@ namespace esp32_ble { static const char *const TAG = "esp32_ble"; -static RAMAllocator EVENT_ALLOCATOR( // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - RAMAllocator::ALLOW_FAILURE | RAMAllocator::ALLOC_INTERNAL); - void ESP32BLE::setup() { global_ble = this; ESP_LOGCONFIG(TAG, "Running setup"); @@ -349,9 +347,8 @@ void ESP32BLE::loop() { default: break; } - // Destructor will clean up external allocations for GATTC/GATTS - ble_event->~BLEEvent(); - EVENT_ALLOCATOR.deallocate(ble_event, 1); + // Return the event to the pool + this->ble_event_pool_.release(ble_event); ble_event = this->ble_events_.pop(); } if (this->advertising_ != nullptr) { @@ -359,37 +356,41 @@ void ESP32BLE::loop() { } // Log dropped events periodically - size_t dropped = this->ble_events_.get_and_reset_dropped_count(); + uint16_t dropped = this->ble_events_.get_and_reset_dropped_count(); if (dropped > 0) { - ESP_LOGW(TAG, "Dropped %zu BLE events due to buffer overflow", dropped); + ESP_LOGW(TAG, "Dropped %u BLE events due to buffer overflow", dropped); } } +// 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); +} + +void load_ble_event(BLEEvent *event, esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { + event->load_gattc_event(e, i, p); +} + +void load_ble_event(BLEEvent *event, esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { + event->load_gatts_event(e, i, p); +} + template void enqueue_ble_event(Args... args) { - // Check if queue is full before allocating - if (global_ble->ble_events_.full()) { - // Queue is full, drop the event + // Allocate an event from the pool + BLEEvent *event = global_ble->ble_event_pool_.allocate(); + if (event == nullptr) { + // No events available - queue is full or we're out of memory global_ble->ble_events_.increment_dropped_count(); return; } - BLEEvent *new_event = EVENT_ALLOCATOR.allocate(1); - if (new_event == nullptr) { - // Memory too fragmented to allocate new event. Can only drop it until memory comes back - global_ble->ble_events_.increment_dropped_count(); - return; - } - new (new_event) BLEEvent(args...); + // Load new event data (replaces previous event) + load_ble_event(event, args...); - // Push the event - since we're the only producer and we checked full() above, - // this should always succeed unless we have a bug - if (!global_ble->ble_events_.push(new_event)) { - // This should not happen in SPSC queue with single producer - ESP_LOGE(TAG, "BLE queue push failed unexpectedly"); - new_event->~BLEEvent(); - EVENT_ALLOCATOR.deallocate(new_event, 1); - } -} // NOLINT(clang-analyzer-unix.Malloc) + // Push the event to the queue + global_ble->ble_events_.push(event); + // Push always succeeds because we're the only producer and the pool ensures we never exceed queue size +} // Explicit template instantiations for the friend function template void enqueue_ble_event(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 58c064a2ef..9fe996086e 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -12,6 +12,7 @@ #include "esphome/core/helpers.h" #include "ble_event.h" +#include "ble_event_pool.h" #include "queue.h" #ifdef USE_ESP32 @@ -148,6 +149,7 @@ class ESP32BLE : public Component { BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; LockFreeQueue ble_events_; + BLEEventPool ble_event_pool_; BLEAdvertising *advertising_{}; esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; uint32_t advertising_cycle_time_{}; diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index f51095effd..30118d2afd 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -51,6 +51,13 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, scan_stop_cmpl.status) == // - GATTC/GATTS events: We heap-allocate and copy the entire param struct, ensuring // the data remains valid even after the BLE callback returns. The original // param pointer from ESP-IDF is only valid during the callback. +// +// CRITICAL DESIGN NOTE: +// The heap allocations for GATTC/GATTS events are REQUIRED for memory safety. +// DO NOT attempt to optimize by removing these allocations or storing pointers +// to the original ESP-IDF data. The ESP-IDF callback data has a different lifetime +// than our event processing, and accessing it after the callback returns would +// result in use-after-free bugs and crashes. class BLEEvent { public: // NOLINTNEXTLINE(readability-identifier-naming) @@ -63,123 +70,72 @@ class BLEEvent { // Constructor for GAP events - no external allocations needed BLEEvent(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { this->type_ = GAP; - this->event_.gap.gap_event = e; - - if (p == nullptr) { - return; // Invalid event, but we can't log in header file - } - - // Only copy the data we actually use for each GAP event type - switch (e) { - case ESP_GAP_BLE_SCAN_RESULT_EVT: - // Copy only the fields we use from scan results - memcpy(this->event_.gap.scan_result.bda, p->scan_rst.bda, sizeof(esp_bd_addr_t)); - this->event_.gap.scan_result.ble_addr_type = p->scan_rst.ble_addr_type; - this->event_.gap.scan_result.rssi = p->scan_rst.rssi; - this->event_.gap.scan_result.adv_data_len = p->scan_rst.adv_data_len; - this->event_.gap.scan_result.scan_rsp_len = p->scan_rst.scan_rsp_len; - this->event_.gap.scan_result.search_evt = p->scan_rst.search_evt; - memcpy(this->event_.gap.scan_result.ble_adv, p->scan_rst.ble_adv, - ESP_BLE_ADV_DATA_LEN_MAX + ESP_BLE_SCAN_RSP_DATA_LEN_MAX); - break; - - case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: - this->event_.gap.scan_complete.status = p->scan_param_cmpl.status; - break; - - case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: - this->event_.gap.scan_complete.status = p->scan_start_cmpl.status; - break; - - case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT: - this->event_.gap.scan_complete.status = p->scan_stop_cmpl.status; - break; - - default: - // We only handle 4 GAP event types, others are dropped - break; - } + this->init_gap_data_(e, p); } // Constructor for GATTC events - uses heap allocation - // Creates a copy of the param struct since the original is only valid during the callback + // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization. + // The param pointer from ESP-IDF is only valid during the callback execution. + // Since BLE events are processed asynchronously in the main loop, we must create + // our own copy to ensure the data remains valid until the event is processed. BLEEvent(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { this->type_ = GATTC; - this->event_.gattc.gattc_event = e; - this->event_.gattc.gattc_if = i; - - if (p == nullptr) { - this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; - return; // Invalid event, but we can't log in header file - } - - // Heap-allocate param and data - // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) - // while GAP events (99%) are stored inline to minimize memory usage - this->event_.gattc.gattc_param = new esp_ble_gattc_cb_param_t(*p); - - // Copy data for events that need it - switch (e) { - case ESP_GATTC_NOTIFY_EVT: - this->event_.gattc.data = new std::vector(p->notify.value, p->notify.value + p->notify.value_len); - this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data->data(); - break; - case ESP_GATTC_READ_CHAR_EVT: - case ESP_GATTC_READ_DESCR_EVT: - this->event_.gattc.data = new std::vector(p->read.value, p->read.value + p->read.value_len); - this->event_.gattc.gattc_param->read.value = this->event_.gattc.data->data(); - break; - default: - this->event_.gattc.data = nullptr; - break; - } + this->init_gattc_data_(e, i, p); } // Constructor for GATTS events - uses heap allocation - // Creates a copy of the param struct since the original is only valid during the callback + // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization. + // The param pointer from ESP-IDF is only valid during the callback execution. + // Since BLE events are processed asynchronously in the main loop, we must create + // our own copy to ensure the data remains valid until the event is processed. BLEEvent(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { this->type_ = GATTS; - this->event_.gatts.gatts_event = e; - this->event_.gatts.gatts_if = i; - - if (p == nullptr) { - this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; - return; // Invalid event, but we can't log in header file - } - - // Heap-allocate param and data - // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) - // while GAP events (99%) are stored inline to minimize memory usage - this->event_.gatts.gatts_param = new esp_ble_gatts_cb_param_t(*p); - - // Copy data for events that need it - switch (e) { - case ESP_GATTS_WRITE_EVT: - this->event_.gatts.data = new std::vector(p->write.value, p->write.value + p->write.len); - this->event_.gatts.gatts_param->write.value = this->event_.gatts.data->data(); - break; - default: - this->event_.gatts.data = nullptr; - break; - } + this->init_gatts_data_(e, i, p); } // Destructor to clean up heap allocations - ~BLEEvent() { - switch (this->type_) { - case GATTC: - delete this->event_.gattc.gattc_param; - delete this->event_.gattc.data; - break; - case GATTS: - delete this->event_.gatts.gatts_param; - delete this->event_.gatts.data; - break; - default: - break; + ~BLEEvent() { this->cleanup_heap_data(); } + + // Default constructor for pre-allocation in pool + BLEEvent() : type_(GAP) {} + + // Clean up any heap-allocated data + void cleanup_heap_data() { + if (this->type_ == GAP) { + return; } + if (this->type_ == GATTC) { + delete this->event_.gattc.gattc_param; + delete this->event_.gattc.data; + this->event_.gattc.gattc_param = nullptr; + this->event_.gattc.data = nullptr; + return; + } + if (this->type_ == GATTS) { + delete this->event_.gatts.gatts_param; + delete this->event_.gatts.data; + this->event_.gatts.gatts_param = nullptr; + this->event_.gatts.data = nullptr; + } + } + + // Load new event data for reuse (replaces previous event data) + void load_gap_event(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { + this->cleanup_heap_data(); + this->type_ = GAP; + this->init_gap_data_(e, p); + } + + void load_gattc_event(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { + this->cleanup_heap_data(); + this->type_ = GATTC; + this->init_gattc_data_(e, i, p); + } + + void load_gatts_event(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { + this->cleanup_heap_data(); + this->type_ = GATTS; + this->init_gatts_data_(e, i, p); } // Disable copy to prevent double-delete @@ -224,6 +180,119 @@ class BLEEvent { esp_gap_ble_cb_event_t gap_event_type() const { return event_.gap.gap_event; } const BLEScanResult &scan_result() const { return event_.gap.scan_result; } esp_bt_status_t scan_complete_status() const { return event_.gap.scan_complete.status; } + + private: + // Initialize GAP event data + void init_gap_data_(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { + this->event_.gap.gap_event = e; + + if (p == nullptr) { + return; // Invalid event, but we can't log in header file + } + + // Copy data based on event type + switch (e) { + case ESP_GAP_BLE_SCAN_RESULT_EVT: + memcpy(this->event_.gap.scan_result.bda, p->scan_rst.bda, sizeof(esp_bd_addr_t)); + this->event_.gap.scan_result.ble_addr_type = p->scan_rst.ble_addr_type; + this->event_.gap.scan_result.rssi = p->scan_rst.rssi; + this->event_.gap.scan_result.adv_data_len = p->scan_rst.adv_data_len; + this->event_.gap.scan_result.scan_rsp_len = p->scan_rst.scan_rsp_len; + this->event_.gap.scan_result.search_evt = p->scan_rst.search_evt; + memcpy(this->event_.gap.scan_result.ble_adv, p->scan_rst.ble_adv, + ESP_BLE_ADV_DATA_LEN_MAX + ESP_BLE_SCAN_RSP_DATA_LEN_MAX); + break; + + case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: + this->event_.gap.scan_complete.status = p->scan_param_cmpl.status; + break; + + case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: + this->event_.gap.scan_complete.status = p->scan_start_cmpl.status; + break; + + case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT: + this->event_.gap.scan_complete.status = p->scan_stop_cmpl.status; + break; + + default: + // We only handle 4 GAP event types, others are dropped + break; + } + } + + // Initialize GATTC event data + void init_gattc_data_(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { + this->event_.gattc.gattc_event = e; + this->event_.gattc.gattc_if = i; + + if (p == nullptr) { + this->event_.gattc.gattc_param = nullptr; + this->event_.gattc.data = nullptr; + return; // Invalid event, but we can't log in header file + } + + // Heap-allocate param and data + // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) + // while GAP events (99%) are stored inline to minimize memory usage + // IMPORTANT: This heap allocation provides clear ownership semantics: + // - The BLEEvent owns the allocated memory for its lifetime + // - The data remains valid from the BLE callback context until processed in the main loop + // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory + this->event_.gattc.gattc_param = new esp_ble_gattc_cb_param_t(*p); + + // Copy data for events that need it + // The param struct contains pointers (e.g., notify.value) that point to temporary buffers. + // We must copy this data to ensure it remains valid when the event is processed later. + switch (e) { + case ESP_GATTC_NOTIFY_EVT: + this->event_.gattc.data = new std::vector(p->notify.value, p->notify.value + p->notify.value_len); + this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data->data(); + break; + case ESP_GATTC_READ_CHAR_EVT: + case ESP_GATTC_READ_DESCR_EVT: + this->event_.gattc.data = new std::vector(p->read.value, p->read.value + p->read.value_len); + this->event_.gattc.gattc_param->read.value = this->event_.gattc.data->data(); + break; + default: + this->event_.gattc.data = nullptr; + break; + } + } + + // Initialize GATTS event data + void init_gatts_data_(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { + this->event_.gatts.gatts_event = e; + this->event_.gatts.gatts_if = i; + + if (p == nullptr) { + this->event_.gatts.gatts_param = nullptr; + this->event_.gatts.data = nullptr; + return; // Invalid event, but we can't log in header file + } + + // Heap-allocate param and data + // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) + // while GAP events (99%) are stored inline to minimize memory usage + // IMPORTANT: This heap allocation provides clear ownership semantics: + // - The BLEEvent owns the allocated memory for its lifetime + // - The data remains valid from the BLE callback context until processed in the main loop + // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory + this->event_.gatts.gatts_param = new esp_ble_gatts_cb_param_t(*p); + + // Copy data for events that need it + // The param struct contains pointers (e.g., write.value) that point to temporary buffers. + // We must copy this data to ensure it remains valid when the event is processed later. + switch (e) { + case ESP_GATTS_WRITE_EVT: + this->event_.gatts.data = new std::vector(p->write.value, p->write.value + p->write.len); + this->event_.gatts.gatts_param->write.value = this->event_.gatts.data->data(); + break; + default: + this->event_.gatts.data = nullptr; + break; + } + } }; // BLEEvent total size: 84 bytes (80 byte union + 1 byte type + 3 bytes padding) diff --git a/esphome/components/esp32_ble/ble_event_pool.h b/esphome/components/esp32_ble/ble_event_pool.h new file mode 100644 index 0000000000..ef123b1325 --- /dev/null +++ b/esphome/components/esp32_ble/ble_event_pool.h @@ -0,0 +1,72 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "ble_event.h" +#include "queue.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace esp32_ble { + +// BLE Event Pool - On-demand pool of BLEEvent objects to avoid heap fragmentation +// Events are allocated on first use and reused thereafter, growing to peak usage +template class BLEEventPool { + public: + BLEEventPool() : total_created_(0) {} + + ~BLEEventPool() { + // Clean up any remaining events in the free list + BLEEvent *event; + while ((event = this->free_list_.pop()) != nullptr) { + delete event; + } + } + + // Allocate an event from the pool + // Returns nullptr if pool is full + BLEEvent *allocate() { + // Try to get from free list first + BLEEvent *event = this->free_list_.pop(); + if (event != nullptr) + return event; + + // Need to create a new event + if (this->total_created_ >= SIZE) { + // Pool is at capacity + return nullptr; + } + + // Use internal RAM for better performance + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + event = allocator.allocate(1); + + if (event == nullptr) { + // Memory allocation failed + return nullptr; + } + + // Placement new to construct the object + new (event) BLEEvent(); + this->total_created_++; + return event; + } + + // Return an event to the pool for reuse + void release(BLEEvent *event) { + if (event != nullptr) { + this->free_list_.push(event); + } + } + + private: + LockFreeQueue free_list_; // Free events ready for reuse + uint8_t total_created_; // Total events created (high water mark) +}; + +} // namespace esp32_ble +} // namespace esphome + +#endif diff --git a/esphome/components/esp32_ble/queue.h b/esphome/components/esp32_ble/queue.h index 56d2efd18b..75bf1eef25 100644 --- a/esphome/components/esp32_ble/queue.h +++ b/esphome/components/esp32_ble/queue.h @@ -18,7 +18,7 @@ namespace esphome { namespace esp32_ble { -template class LockFreeQueue { +template class LockFreeQueue { public: LockFreeQueue() : head_(0), tail_(0), dropped_count_(0) {} @@ -26,8 +26,8 @@ template class LockFreeQueue { if (element == nullptr) return false; - size_t current_tail = tail_.load(std::memory_order_relaxed); - size_t next_tail = (current_tail + 1) % SIZE; + uint8_t current_tail = tail_.load(std::memory_order_relaxed); + uint8_t next_tail = (current_tail + 1) % SIZE; if (next_tail == head_.load(std::memory_order_acquire)) { // Buffer full @@ -41,7 +41,7 @@ template class LockFreeQueue { } T *pop() { - size_t current_head = head_.load(std::memory_order_relaxed); + uint8_t current_head = head_.load(std::memory_order_relaxed); if (current_head == tail_.load(std::memory_order_acquire)) { return nullptr; // Empty @@ -53,27 +53,30 @@ template class LockFreeQueue { } size_t size() const { - size_t tail = tail_.load(std::memory_order_acquire); - size_t head = head_.load(std::memory_order_acquire); + uint8_t tail = tail_.load(std::memory_order_acquire); + uint8_t head = head_.load(std::memory_order_acquire); return (tail - head + SIZE) % SIZE; } - size_t get_and_reset_dropped_count() { return dropped_count_.exchange(0, std::memory_order_relaxed); } + uint16_t get_and_reset_dropped_count() { return dropped_count_.exchange(0, std::memory_order_relaxed); } void increment_dropped_count() { dropped_count_.fetch_add(1, std::memory_order_relaxed); } bool empty() const { return head_.load(std::memory_order_acquire) == tail_.load(std::memory_order_acquire); } bool full() const { - size_t next_tail = (tail_.load(std::memory_order_relaxed) + 1) % SIZE; + uint8_t next_tail = (tail_.load(std::memory_order_relaxed) + 1) % SIZE; return next_tail == head_.load(std::memory_order_acquire); } protected: T *buffer_[SIZE]; - std::atomic head_; - std::atomic tail_; - std::atomic dropped_count_; + // Atomic: written by producer (push/increment), read+reset by consumer (get_and_reset) + std::atomic dropped_count_; // 65535 max - more than enough for drop tracking + // Atomic: written by consumer (pop), read by producer (push) to check if full + std::atomic head_; + // Atomic: written by producer (push), read by consumer (pop) to check if empty + std::atomic tail_; }; } // namespace esp32_ble